From 1bcdf80f9140f63c528e514f858b9468c2055687 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Mon, 31 Aug 2026 23:27:49 -0400 Subject: [PATCH 01/31] Fix radius var fallback detection (#687) Strip closing var() parentheses before resolving fallback radius tokens, preserving on-scale values and actionable ignore values. AI-assisted change: implemented with Codex under @pbakaus direction. --- cli/engine/design-system.mjs | 4 +++- tests/design-system.test.mjs | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/cli/engine/design-system.mjs b/cli/engine/design-system.mjs index 28e01b1d2..7b7e588e9 100644 --- a/cli/engine/design-system.mjs +++ b/cli/engine/design-system.mjs @@ -995,7 +995,9 @@ function extractRadiusTokens(value) { return String(value || '') .replace(/\s*\/\s*/g, ' ') .split(/\s+/) - .map(token => token.trim()) + // var() fallbacks leave the closing parenthesis on the final token. Strip + // it before length resolution so `8px)` is not treated as unitless 8rem. + .map(token => token.trim().replace(/\)+$/, '')) .filter(Boolean); } diff --git a/tests/design-system.test.mjs b/tests/design-system.test.mjs index ebd83243f..e6deba854 100644 --- a/tests/design-system.test.mjs +++ b/tests/design-system.test.mjs @@ -389,6 +389,25 @@ describe('checkSourceDesignSystem()', () => { ); }); + it('judges var() radius fallbacks without keeping the closing parenthesis', () => { + const designSystem = normalizeDesignSystem({ + frontmatter: { rounded: { md: '8px' } }, + }); + const findings = checkSourceDesignSystem(` +.good { + border-radius: var(--radius-md, 8px); +} +.bad { + border-radius: var(--radius-custom, 18px); +} +`, '/tmp/radius-fallbacks.css', { designSystem }); + + assert.deepEqual( + findings.map((item) => [item.antipattern, item.ignoreValue]), + [['design-system-radius', '18px']], + ); + }); + it('strips CSS priority markers before checking font-family declarations', () => { const designSystem = sampleDesignSystem(); const findings = checkSourceDesignSystem(` From 40b51512377019202e28e534e2316017b84671b1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 03:28:21 +0000 Subject: [PATCH 02/31] Sync generated provider output --- .agents/skills/impeccable/scripts/detector/design-system.mjs | 4 +++- .claude/skills/impeccable/scripts/detector/design-system.mjs | 4 +++- .cursor/skills/impeccable/scripts/detector/design-system.mjs | 4 +++- .gemini/skills/impeccable/scripts/detector/design-system.mjs | 4 +++- .github/skills/impeccable/scripts/detector/design-system.mjs | 4 +++- .grok/skills/impeccable/scripts/detector/design-system.mjs | 4 +++- .hermes/skills/impeccable/scripts/detector/design-system.mjs | 4 +++- .kiro/skills/impeccable/scripts/detector/design-system.mjs | 4 +++- .../skills/impeccable/scripts/detector/design-system.mjs | 4 +++- .pi/skills/impeccable/scripts/detector/design-system.mjs | 4 +++- .qoder/skills/impeccable/scripts/detector/design-system.mjs | 4 +++- .rovodev/skills/impeccable/scripts/detector/design-system.mjs | 4 +++- .trae-cn/skills/impeccable/scripts/detector/design-system.mjs | 4 +++- .trae/skills/impeccable/scripts/detector/design-system.mjs | 4 +++- .vibe/skills/impeccable/scripts/detector/design-system.mjs | 4 +++- plugin/skills/impeccable/scripts/detector/design-system.mjs | 4 +++- 16 files changed, 48 insertions(+), 16 deletions(-) diff --git a/.agents/skills/impeccable/scripts/detector/design-system.mjs b/.agents/skills/impeccable/scripts/detector/design-system.mjs index 28e01b1d2..7b7e588e9 100644 --- a/.agents/skills/impeccable/scripts/detector/design-system.mjs +++ b/.agents/skills/impeccable/scripts/detector/design-system.mjs @@ -995,7 +995,9 @@ function extractRadiusTokens(value) { return String(value || '') .replace(/\s*\/\s*/g, ' ') .split(/\s+/) - .map(token => token.trim()) + // var() fallbacks leave the closing parenthesis on the final token. Strip + // it before length resolution so `8px)` is not treated as unitless 8rem. + .map(token => token.trim().replace(/\)+$/, '')) .filter(Boolean); } diff --git a/.claude/skills/impeccable/scripts/detector/design-system.mjs b/.claude/skills/impeccable/scripts/detector/design-system.mjs index 28e01b1d2..7b7e588e9 100644 --- a/.claude/skills/impeccable/scripts/detector/design-system.mjs +++ b/.claude/skills/impeccable/scripts/detector/design-system.mjs @@ -995,7 +995,9 @@ function extractRadiusTokens(value) { return String(value || '') .replace(/\s*\/\s*/g, ' ') .split(/\s+/) - .map(token => token.trim()) + // var() fallbacks leave the closing parenthesis on the final token. Strip + // it before length resolution so `8px)` is not treated as unitless 8rem. + .map(token => token.trim().replace(/\)+$/, '')) .filter(Boolean); } diff --git a/.cursor/skills/impeccable/scripts/detector/design-system.mjs b/.cursor/skills/impeccable/scripts/detector/design-system.mjs index 28e01b1d2..7b7e588e9 100644 --- a/.cursor/skills/impeccable/scripts/detector/design-system.mjs +++ b/.cursor/skills/impeccable/scripts/detector/design-system.mjs @@ -995,7 +995,9 @@ function extractRadiusTokens(value) { return String(value || '') .replace(/\s*\/\s*/g, ' ') .split(/\s+/) - .map(token => token.trim()) + // var() fallbacks leave the closing parenthesis on the final token. Strip + // it before length resolution so `8px)` is not treated as unitless 8rem. + .map(token => token.trim().replace(/\)+$/, '')) .filter(Boolean); } diff --git a/.gemini/skills/impeccable/scripts/detector/design-system.mjs b/.gemini/skills/impeccable/scripts/detector/design-system.mjs index 28e01b1d2..7b7e588e9 100644 --- a/.gemini/skills/impeccable/scripts/detector/design-system.mjs +++ b/.gemini/skills/impeccable/scripts/detector/design-system.mjs @@ -995,7 +995,9 @@ function extractRadiusTokens(value) { return String(value || '') .replace(/\s*\/\s*/g, ' ') .split(/\s+/) - .map(token => token.trim()) + // var() fallbacks leave the closing parenthesis on the final token. Strip + // it before length resolution so `8px)` is not treated as unitless 8rem. + .map(token => token.trim().replace(/\)+$/, '')) .filter(Boolean); } diff --git a/.github/skills/impeccable/scripts/detector/design-system.mjs b/.github/skills/impeccable/scripts/detector/design-system.mjs index 28e01b1d2..7b7e588e9 100644 --- a/.github/skills/impeccable/scripts/detector/design-system.mjs +++ b/.github/skills/impeccable/scripts/detector/design-system.mjs @@ -995,7 +995,9 @@ function extractRadiusTokens(value) { return String(value || '') .replace(/\s*\/\s*/g, ' ') .split(/\s+/) - .map(token => token.trim()) + // var() fallbacks leave the closing parenthesis on the final token. Strip + // it before length resolution so `8px)` is not treated as unitless 8rem. + .map(token => token.trim().replace(/\)+$/, '')) .filter(Boolean); } diff --git a/.grok/skills/impeccable/scripts/detector/design-system.mjs b/.grok/skills/impeccable/scripts/detector/design-system.mjs index 28e01b1d2..7b7e588e9 100644 --- a/.grok/skills/impeccable/scripts/detector/design-system.mjs +++ b/.grok/skills/impeccable/scripts/detector/design-system.mjs @@ -995,7 +995,9 @@ function extractRadiusTokens(value) { return String(value || '') .replace(/\s*\/\s*/g, ' ') .split(/\s+/) - .map(token => token.trim()) + // var() fallbacks leave the closing parenthesis on the final token. Strip + // it before length resolution so `8px)` is not treated as unitless 8rem. + .map(token => token.trim().replace(/\)+$/, '')) .filter(Boolean); } diff --git a/.hermes/skills/impeccable/scripts/detector/design-system.mjs b/.hermes/skills/impeccable/scripts/detector/design-system.mjs index 28e01b1d2..7b7e588e9 100644 --- a/.hermes/skills/impeccable/scripts/detector/design-system.mjs +++ b/.hermes/skills/impeccable/scripts/detector/design-system.mjs @@ -995,7 +995,9 @@ function extractRadiusTokens(value) { return String(value || '') .replace(/\s*\/\s*/g, ' ') .split(/\s+/) - .map(token => token.trim()) + // var() fallbacks leave the closing parenthesis on the final token. Strip + // it before length resolution so `8px)` is not treated as unitless 8rem. + .map(token => token.trim().replace(/\)+$/, '')) .filter(Boolean); } diff --git a/.kiro/skills/impeccable/scripts/detector/design-system.mjs b/.kiro/skills/impeccable/scripts/detector/design-system.mjs index 28e01b1d2..7b7e588e9 100644 --- a/.kiro/skills/impeccable/scripts/detector/design-system.mjs +++ b/.kiro/skills/impeccable/scripts/detector/design-system.mjs @@ -995,7 +995,9 @@ function extractRadiusTokens(value) { return String(value || '') .replace(/\s*\/\s*/g, ' ') .split(/\s+/) - .map(token => token.trim()) + // var() fallbacks leave the closing parenthesis on the final token. Strip + // it before length resolution so `8px)` is not treated as unitless 8rem. + .map(token => token.trim().replace(/\)+$/, '')) .filter(Boolean); } diff --git a/.opencode/skills/impeccable/scripts/detector/design-system.mjs b/.opencode/skills/impeccable/scripts/detector/design-system.mjs index 28e01b1d2..7b7e588e9 100644 --- a/.opencode/skills/impeccable/scripts/detector/design-system.mjs +++ b/.opencode/skills/impeccable/scripts/detector/design-system.mjs @@ -995,7 +995,9 @@ function extractRadiusTokens(value) { return String(value || '') .replace(/\s*\/\s*/g, ' ') .split(/\s+/) - .map(token => token.trim()) + // var() fallbacks leave the closing parenthesis on the final token. Strip + // it before length resolution so `8px)` is not treated as unitless 8rem. + .map(token => token.trim().replace(/\)+$/, '')) .filter(Boolean); } diff --git a/.pi/skills/impeccable/scripts/detector/design-system.mjs b/.pi/skills/impeccable/scripts/detector/design-system.mjs index 28e01b1d2..7b7e588e9 100644 --- a/.pi/skills/impeccable/scripts/detector/design-system.mjs +++ b/.pi/skills/impeccable/scripts/detector/design-system.mjs @@ -995,7 +995,9 @@ function extractRadiusTokens(value) { return String(value || '') .replace(/\s*\/\s*/g, ' ') .split(/\s+/) - .map(token => token.trim()) + // var() fallbacks leave the closing parenthesis on the final token. Strip + // it before length resolution so `8px)` is not treated as unitless 8rem. + .map(token => token.trim().replace(/\)+$/, '')) .filter(Boolean); } diff --git a/.qoder/skills/impeccable/scripts/detector/design-system.mjs b/.qoder/skills/impeccable/scripts/detector/design-system.mjs index 28e01b1d2..7b7e588e9 100644 --- a/.qoder/skills/impeccable/scripts/detector/design-system.mjs +++ b/.qoder/skills/impeccable/scripts/detector/design-system.mjs @@ -995,7 +995,9 @@ function extractRadiusTokens(value) { return String(value || '') .replace(/\s*\/\s*/g, ' ') .split(/\s+/) - .map(token => token.trim()) + // var() fallbacks leave the closing parenthesis on the final token. Strip + // it before length resolution so `8px)` is not treated as unitless 8rem. + .map(token => token.trim().replace(/\)+$/, '')) .filter(Boolean); } diff --git a/.rovodev/skills/impeccable/scripts/detector/design-system.mjs b/.rovodev/skills/impeccable/scripts/detector/design-system.mjs index 28e01b1d2..7b7e588e9 100644 --- a/.rovodev/skills/impeccable/scripts/detector/design-system.mjs +++ b/.rovodev/skills/impeccable/scripts/detector/design-system.mjs @@ -995,7 +995,9 @@ function extractRadiusTokens(value) { return String(value || '') .replace(/\s*\/\s*/g, ' ') .split(/\s+/) - .map(token => token.trim()) + // var() fallbacks leave the closing parenthesis on the final token. Strip + // it before length resolution so `8px)` is not treated as unitless 8rem. + .map(token => token.trim().replace(/\)+$/, '')) .filter(Boolean); } diff --git a/.trae-cn/skills/impeccable/scripts/detector/design-system.mjs b/.trae-cn/skills/impeccable/scripts/detector/design-system.mjs index 28e01b1d2..7b7e588e9 100644 --- a/.trae-cn/skills/impeccable/scripts/detector/design-system.mjs +++ b/.trae-cn/skills/impeccable/scripts/detector/design-system.mjs @@ -995,7 +995,9 @@ function extractRadiusTokens(value) { return String(value || '') .replace(/\s*\/\s*/g, ' ') .split(/\s+/) - .map(token => token.trim()) + // var() fallbacks leave the closing parenthesis on the final token. Strip + // it before length resolution so `8px)` is not treated as unitless 8rem. + .map(token => token.trim().replace(/\)+$/, '')) .filter(Boolean); } diff --git a/.trae/skills/impeccable/scripts/detector/design-system.mjs b/.trae/skills/impeccable/scripts/detector/design-system.mjs index 28e01b1d2..7b7e588e9 100644 --- a/.trae/skills/impeccable/scripts/detector/design-system.mjs +++ b/.trae/skills/impeccable/scripts/detector/design-system.mjs @@ -995,7 +995,9 @@ function extractRadiusTokens(value) { return String(value || '') .replace(/\s*\/\s*/g, ' ') .split(/\s+/) - .map(token => token.trim()) + // var() fallbacks leave the closing parenthesis on the final token. Strip + // it before length resolution so `8px)` is not treated as unitless 8rem. + .map(token => token.trim().replace(/\)+$/, '')) .filter(Boolean); } diff --git a/.vibe/skills/impeccable/scripts/detector/design-system.mjs b/.vibe/skills/impeccable/scripts/detector/design-system.mjs index 28e01b1d2..7b7e588e9 100644 --- a/.vibe/skills/impeccable/scripts/detector/design-system.mjs +++ b/.vibe/skills/impeccable/scripts/detector/design-system.mjs @@ -995,7 +995,9 @@ function extractRadiusTokens(value) { return String(value || '') .replace(/\s*\/\s*/g, ' ') .split(/\s+/) - .map(token => token.trim()) + // var() fallbacks leave the closing parenthesis on the final token. Strip + // it before length resolution so `8px)` is not treated as unitless 8rem. + .map(token => token.trim().replace(/\)+$/, '')) .filter(Boolean); } diff --git a/plugin/skills/impeccable/scripts/detector/design-system.mjs b/plugin/skills/impeccable/scripts/detector/design-system.mjs index 28e01b1d2..7b7e588e9 100644 --- a/plugin/skills/impeccable/scripts/detector/design-system.mjs +++ b/plugin/skills/impeccable/scripts/detector/design-system.mjs @@ -995,7 +995,9 @@ function extractRadiusTokens(value) { return String(value || '') .replace(/\s*\/\s*/g, ' ') .split(/\s+/) - .map(token => token.trim()) + // var() fallbacks leave the closing parenthesis on the final token. Strip + // it before length resolution so `8px)` is not treated as unitless 8rem. + .map(token => token.trim().replace(/\)+$/, '')) .filter(Boolean); } From 187790826da89c5c3c181a734695287f1511795a Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Mon, 31 Aug 2026 23:48:43 -0400 Subject: [PATCH 03/31] Fix concept seed under symlinked installs (#686) Resolve the CLI entry path through realpath and cover linked skill directories on Unix and Windows junctions. AI-assisted change: implemented with Codex under @pbakaus direction. --- skill/scripts/concept-seed.mjs | 25 ++++++++++++++++++++++-- tests/concept-seed.test.mjs | 35 ++++++++++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/skill/scripts/concept-seed.mjs b/skill/scripts/concept-seed.mjs index 991dc629b..8887f9b32 100644 --- a/skill/scripts/concept-seed.mjs +++ b/skill/scripts/concept-seed.mjs @@ -91,7 +91,7 @@ import crypto from 'node:crypto'; import { dirname, join, relative, resolve } from 'node:path'; -import { readFileSync } from 'node:fs'; +import { readFileSync, realpathSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { approvedPoolRevision, @@ -703,7 +703,28 @@ export function nextStepAfterChoice({ key, scope, cwd = process.cwd(), env = pro return `NEXT (comp-led, ${why}): the world is chosen; the composition is not. Run: node ${scripts}/build-phase.mjs start${seed} and follow its NEXT lines: it opens the comps phase (three comps under .impeccable/mocks/, one approved by the user through the decision page or structured question, sidecar "approved": true), then spec, plates, hero, sections, motion, responsive, review. Do not write page code before those gates close. Reference: reference/visualize.md for the comp round.\n`; } -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { +export function sameMainModulePath(left, right, platform = process.platform) { + if (platform !== 'win32') return left === right; + const normalizeDriveLetter = (value) => value.replace(/^([a-z]):/i, (_, drive) => `${drive.toUpperCase()}:`); + return normalizeDriveLetter(left) === normalizeDriveLetter(right); +} + +function isMainModule() { + if (!process.argv[1]) return false; + try { + // Node resolves import.meta.url through symlinks but leaves argv[1] as the + // invoked path. Compare real paths so a linked skill still runs its CLI, + // normalizing the drive-letter casing that Windows junctions can change. + return sameMainModulePath( + realpathSync(process.argv[1]), + realpathSync(fileURLToPath(import.meta.url)) + ); + } catch { + return false; + } +} + +if (isMainModule()) { const args = process.argv.slice(2); const fromIdx = args.indexOf('--from'); const scopeIdx = args.indexOf('--scope'); diff --git a/tests/concept-seed.test.mjs b/tests/concept-seed.test.mjs index 1e04ab613..40a225557 100644 --- a/tests/concept-seed.test.mjs +++ b/tests/concept-seed.test.mjs @@ -1,7 +1,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { spawn, spawnSync } from 'node:child_process'; -import { mkdtempSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { createServer } from 'node:http'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -12,7 +12,7 @@ import { validateConceptEntry, } from '../skill/scripts/lib/concept-catalog.mjs'; import { readCompositionCatalog } from '../skill/scripts/lib/composition-catalog.mjs'; -import { dealCompositions, pingChosen, renderChallenger, selectApprovedChallengers, selectApprovedComposition, selectApprovedCompositions } from '../skill/scripts/concept-seed.mjs'; +import { dealCompositions, pingChosen, renderChallenger, sameMainModulePath, selectApprovedChallengers, selectApprovedComposition, selectApprovedCompositions } from '../skill/scripts/concept-seed.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const SCRIPT = path.join(ROOT, 'skill', 'scripts', 'concept-seed.mjs'); @@ -40,6 +40,37 @@ function run(scope, extraArgs = [], env = {}) { } describe('concept seed scopes', () => { + it('normalizes Windows drive-letter casing for linked entry paths', () => { + assert.equal( + sameMainModulePath('C:\\repo\\skill\\scripts\\concept-seed.mjs', 'c:\\repo\\skill\\scripts\\concept-seed.mjs', 'win32'), + true + ); + assert.equal( + sameMainModulePath('/repo/Skill/scripts/concept-seed.mjs', '/repo/skill/scripts/concept-seed.mjs', 'linux'), + false + ); + }); + + it('runs through a symlinked skill directory', () => { + const dir = mkdtempSync(path.join(tmpdir(), 'concept-seed-symlink-')); + const linkedSkill = path.join(dir, 'skill'); + try { + symlinkSync(path.join(ROOT, 'skill'), linkedSkill, process.platform === 'win32' ? 'junction' : 'dir'); + const result = spawnSync(process.execPath, [ + path.join(linkedSkill, 'scripts', 'concept-seed.mjs'), + '--scope', 'surface', '--mode', 'persuade', '--from', 'symlink-test', + ], { + cwd: ROOT, + encoding: 'utf-8', + env: { ...process.env, IMPECCABLE_CATALOG_DIR: FIXTURE_DIR }, + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /SURFACE CONCEPT SEED/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it('keeps complete-direction and established-world surface rolls reproducible but independent', () => { const directionA = run('direction'); const directionB = run('direction'); From 5b585c0885a7c7d68523169b5b1934711794b80d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 03:49:18 +0000 Subject: [PATCH 04/31] Sync generated provider output --- .../impeccable/scripts/concept-seed.mjs | 25 +++++++++++++++++-- .../impeccable/scripts/concept-seed.mjs | 25 +++++++++++++++++-- .../impeccable/scripts/concept-seed.mjs | 25 +++++++++++++++++-- .../impeccable/scripts/concept-seed.mjs | 25 +++++++++++++++++-- .../impeccable/scripts/concept-seed.mjs | 25 +++++++++++++++++-- .../impeccable/scripts/concept-seed.mjs | 25 +++++++++++++++++-- .../impeccable/scripts/concept-seed.mjs | 25 +++++++++++++++++-- .../impeccable/scripts/concept-seed.mjs | 25 +++++++++++++++++-- .../impeccable/scripts/concept-seed.mjs | 25 +++++++++++++++++-- .../impeccable/scripts/concept-seed.mjs | 25 +++++++++++++++++-- .../impeccable/scripts/concept-seed.mjs | 25 +++++++++++++++++-- .../impeccable/scripts/concept-seed.mjs | 25 +++++++++++++++++-- .../impeccable/scripts/concept-seed.mjs | 25 +++++++++++++++++-- .../impeccable/scripts/concept-seed.mjs | 25 +++++++++++++++++-- .../impeccable/scripts/concept-seed.mjs | 25 +++++++++++++++++-- .../impeccable/scripts/concept-seed.mjs | 25 +++++++++++++++++-- 16 files changed, 368 insertions(+), 32 deletions(-) diff --git a/.agents/skills/impeccable/scripts/concept-seed.mjs b/.agents/skills/impeccable/scripts/concept-seed.mjs index 991dc629b..8887f9b32 100644 --- a/.agents/skills/impeccable/scripts/concept-seed.mjs +++ b/.agents/skills/impeccable/scripts/concept-seed.mjs @@ -91,7 +91,7 @@ import crypto from 'node:crypto'; import { dirname, join, relative, resolve } from 'node:path'; -import { readFileSync } from 'node:fs'; +import { readFileSync, realpathSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { approvedPoolRevision, @@ -703,7 +703,28 @@ export function nextStepAfterChoice({ key, scope, cwd = process.cwd(), env = pro return `NEXT (comp-led, ${why}): the world is chosen; the composition is not. Run: node ${scripts}/build-phase.mjs start${seed} and follow its NEXT lines: it opens the comps phase (three comps under .impeccable/mocks/, one approved by the user through the decision page or structured question, sidecar "approved": true), then spec, plates, hero, sections, motion, responsive, review. Do not write page code before those gates close. Reference: reference/visualize.md for the comp round.\n`; } -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { +export function sameMainModulePath(left, right, platform = process.platform) { + if (platform !== 'win32') return left === right; + const normalizeDriveLetter = (value) => value.replace(/^([a-z]):/i, (_, drive) => `${drive.toUpperCase()}:`); + return normalizeDriveLetter(left) === normalizeDriveLetter(right); +} + +function isMainModule() { + if (!process.argv[1]) return false; + try { + // Node resolves import.meta.url through symlinks but leaves argv[1] as the + // invoked path. Compare real paths so a linked skill still runs its CLI, + // normalizing the drive-letter casing that Windows junctions can change. + return sameMainModulePath( + realpathSync(process.argv[1]), + realpathSync(fileURLToPath(import.meta.url)) + ); + } catch { + return false; + } +} + +if (isMainModule()) { const args = process.argv.slice(2); const fromIdx = args.indexOf('--from'); const scopeIdx = args.indexOf('--scope'); diff --git a/.claude/skills/impeccable/scripts/concept-seed.mjs b/.claude/skills/impeccable/scripts/concept-seed.mjs index 991dc629b..8887f9b32 100644 --- a/.claude/skills/impeccable/scripts/concept-seed.mjs +++ b/.claude/skills/impeccable/scripts/concept-seed.mjs @@ -91,7 +91,7 @@ import crypto from 'node:crypto'; import { dirname, join, relative, resolve } from 'node:path'; -import { readFileSync } from 'node:fs'; +import { readFileSync, realpathSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { approvedPoolRevision, @@ -703,7 +703,28 @@ export function nextStepAfterChoice({ key, scope, cwd = process.cwd(), env = pro return `NEXT (comp-led, ${why}): the world is chosen; the composition is not. Run: node ${scripts}/build-phase.mjs start${seed} and follow its NEXT lines: it opens the comps phase (three comps under .impeccable/mocks/, one approved by the user through the decision page or structured question, sidecar "approved": true), then spec, plates, hero, sections, motion, responsive, review. Do not write page code before those gates close. Reference: reference/visualize.md for the comp round.\n`; } -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { +export function sameMainModulePath(left, right, platform = process.platform) { + if (platform !== 'win32') return left === right; + const normalizeDriveLetter = (value) => value.replace(/^([a-z]):/i, (_, drive) => `${drive.toUpperCase()}:`); + return normalizeDriveLetter(left) === normalizeDriveLetter(right); +} + +function isMainModule() { + if (!process.argv[1]) return false; + try { + // Node resolves import.meta.url through symlinks but leaves argv[1] as the + // invoked path. Compare real paths so a linked skill still runs its CLI, + // normalizing the drive-letter casing that Windows junctions can change. + return sameMainModulePath( + realpathSync(process.argv[1]), + realpathSync(fileURLToPath(import.meta.url)) + ); + } catch { + return false; + } +} + +if (isMainModule()) { const args = process.argv.slice(2); const fromIdx = args.indexOf('--from'); const scopeIdx = args.indexOf('--scope'); diff --git a/.cursor/skills/impeccable/scripts/concept-seed.mjs b/.cursor/skills/impeccable/scripts/concept-seed.mjs index 991dc629b..8887f9b32 100644 --- a/.cursor/skills/impeccable/scripts/concept-seed.mjs +++ b/.cursor/skills/impeccable/scripts/concept-seed.mjs @@ -91,7 +91,7 @@ import crypto from 'node:crypto'; import { dirname, join, relative, resolve } from 'node:path'; -import { readFileSync } from 'node:fs'; +import { readFileSync, realpathSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { approvedPoolRevision, @@ -703,7 +703,28 @@ export function nextStepAfterChoice({ key, scope, cwd = process.cwd(), env = pro return `NEXT (comp-led, ${why}): the world is chosen; the composition is not. Run: node ${scripts}/build-phase.mjs start${seed} and follow its NEXT lines: it opens the comps phase (three comps under .impeccable/mocks/, one approved by the user through the decision page or structured question, sidecar "approved": true), then spec, plates, hero, sections, motion, responsive, review. Do not write page code before those gates close. Reference: reference/visualize.md for the comp round.\n`; } -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { +export function sameMainModulePath(left, right, platform = process.platform) { + if (platform !== 'win32') return left === right; + const normalizeDriveLetter = (value) => value.replace(/^([a-z]):/i, (_, drive) => `${drive.toUpperCase()}:`); + return normalizeDriveLetter(left) === normalizeDriveLetter(right); +} + +function isMainModule() { + if (!process.argv[1]) return false; + try { + // Node resolves import.meta.url through symlinks but leaves argv[1] as the + // invoked path. Compare real paths so a linked skill still runs its CLI, + // normalizing the drive-letter casing that Windows junctions can change. + return sameMainModulePath( + realpathSync(process.argv[1]), + realpathSync(fileURLToPath(import.meta.url)) + ); + } catch { + return false; + } +} + +if (isMainModule()) { const args = process.argv.slice(2); const fromIdx = args.indexOf('--from'); const scopeIdx = args.indexOf('--scope'); diff --git a/.gemini/skills/impeccable/scripts/concept-seed.mjs b/.gemini/skills/impeccable/scripts/concept-seed.mjs index 991dc629b..8887f9b32 100644 --- a/.gemini/skills/impeccable/scripts/concept-seed.mjs +++ b/.gemini/skills/impeccable/scripts/concept-seed.mjs @@ -91,7 +91,7 @@ import crypto from 'node:crypto'; import { dirname, join, relative, resolve } from 'node:path'; -import { readFileSync } from 'node:fs'; +import { readFileSync, realpathSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { approvedPoolRevision, @@ -703,7 +703,28 @@ export function nextStepAfterChoice({ key, scope, cwd = process.cwd(), env = pro return `NEXT (comp-led, ${why}): the world is chosen; the composition is not. Run: node ${scripts}/build-phase.mjs start${seed} and follow its NEXT lines: it opens the comps phase (three comps under .impeccable/mocks/, one approved by the user through the decision page or structured question, sidecar "approved": true), then spec, plates, hero, sections, motion, responsive, review. Do not write page code before those gates close. Reference: reference/visualize.md for the comp round.\n`; } -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { +export function sameMainModulePath(left, right, platform = process.platform) { + if (platform !== 'win32') return left === right; + const normalizeDriveLetter = (value) => value.replace(/^([a-z]):/i, (_, drive) => `${drive.toUpperCase()}:`); + return normalizeDriveLetter(left) === normalizeDriveLetter(right); +} + +function isMainModule() { + if (!process.argv[1]) return false; + try { + // Node resolves import.meta.url through symlinks but leaves argv[1] as the + // invoked path. Compare real paths so a linked skill still runs its CLI, + // normalizing the drive-letter casing that Windows junctions can change. + return sameMainModulePath( + realpathSync(process.argv[1]), + realpathSync(fileURLToPath(import.meta.url)) + ); + } catch { + return false; + } +} + +if (isMainModule()) { const args = process.argv.slice(2); const fromIdx = args.indexOf('--from'); const scopeIdx = args.indexOf('--scope'); diff --git a/.github/skills/impeccable/scripts/concept-seed.mjs b/.github/skills/impeccable/scripts/concept-seed.mjs index 991dc629b..8887f9b32 100644 --- a/.github/skills/impeccable/scripts/concept-seed.mjs +++ b/.github/skills/impeccable/scripts/concept-seed.mjs @@ -91,7 +91,7 @@ import crypto from 'node:crypto'; import { dirname, join, relative, resolve } from 'node:path'; -import { readFileSync } from 'node:fs'; +import { readFileSync, realpathSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { approvedPoolRevision, @@ -703,7 +703,28 @@ export function nextStepAfterChoice({ key, scope, cwd = process.cwd(), env = pro return `NEXT (comp-led, ${why}): the world is chosen; the composition is not. Run: node ${scripts}/build-phase.mjs start${seed} and follow its NEXT lines: it opens the comps phase (three comps under .impeccable/mocks/, one approved by the user through the decision page or structured question, sidecar "approved": true), then spec, plates, hero, sections, motion, responsive, review. Do not write page code before those gates close. Reference: reference/visualize.md for the comp round.\n`; } -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { +export function sameMainModulePath(left, right, platform = process.platform) { + if (platform !== 'win32') return left === right; + const normalizeDriveLetter = (value) => value.replace(/^([a-z]):/i, (_, drive) => `${drive.toUpperCase()}:`); + return normalizeDriveLetter(left) === normalizeDriveLetter(right); +} + +function isMainModule() { + if (!process.argv[1]) return false; + try { + // Node resolves import.meta.url through symlinks but leaves argv[1] as the + // invoked path. Compare real paths so a linked skill still runs its CLI, + // normalizing the drive-letter casing that Windows junctions can change. + return sameMainModulePath( + realpathSync(process.argv[1]), + realpathSync(fileURLToPath(import.meta.url)) + ); + } catch { + return false; + } +} + +if (isMainModule()) { const args = process.argv.slice(2); const fromIdx = args.indexOf('--from'); const scopeIdx = args.indexOf('--scope'); diff --git a/.grok/skills/impeccable/scripts/concept-seed.mjs b/.grok/skills/impeccable/scripts/concept-seed.mjs index 991dc629b..8887f9b32 100644 --- a/.grok/skills/impeccable/scripts/concept-seed.mjs +++ b/.grok/skills/impeccable/scripts/concept-seed.mjs @@ -91,7 +91,7 @@ import crypto from 'node:crypto'; import { dirname, join, relative, resolve } from 'node:path'; -import { readFileSync } from 'node:fs'; +import { readFileSync, realpathSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { approvedPoolRevision, @@ -703,7 +703,28 @@ export function nextStepAfterChoice({ key, scope, cwd = process.cwd(), env = pro return `NEXT (comp-led, ${why}): the world is chosen; the composition is not. Run: node ${scripts}/build-phase.mjs start${seed} and follow its NEXT lines: it opens the comps phase (three comps under .impeccable/mocks/, one approved by the user through the decision page or structured question, sidecar "approved": true), then spec, plates, hero, sections, motion, responsive, review. Do not write page code before those gates close. Reference: reference/visualize.md for the comp round.\n`; } -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { +export function sameMainModulePath(left, right, platform = process.platform) { + if (platform !== 'win32') return left === right; + const normalizeDriveLetter = (value) => value.replace(/^([a-z]):/i, (_, drive) => `${drive.toUpperCase()}:`); + return normalizeDriveLetter(left) === normalizeDriveLetter(right); +} + +function isMainModule() { + if (!process.argv[1]) return false; + try { + // Node resolves import.meta.url through symlinks but leaves argv[1] as the + // invoked path. Compare real paths so a linked skill still runs its CLI, + // normalizing the drive-letter casing that Windows junctions can change. + return sameMainModulePath( + realpathSync(process.argv[1]), + realpathSync(fileURLToPath(import.meta.url)) + ); + } catch { + return false; + } +} + +if (isMainModule()) { const args = process.argv.slice(2); const fromIdx = args.indexOf('--from'); const scopeIdx = args.indexOf('--scope'); diff --git a/.hermes/skills/impeccable/scripts/concept-seed.mjs b/.hermes/skills/impeccable/scripts/concept-seed.mjs index 991dc629b..8887f9b32 100644 --- a/.hermes/skills/impeccable/scripts/concept-seed.mjs +++ b/.hermes/skills/impeccable/scripts/concept-seed.mjs @@ -91,7 +91,7 @@ import crypto from 'node:crypto'; import { dirname, join, relative, resolve } from 'node:path'; -import { readFileSync } from 'node:fs'; +import { readFileSync, realpathSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { approvedPoolRevision, @@ -703,7 +703,28 @@ export function nextStepAfterChoice({ key, scope, cwd = process.cwd(), env = pro return `NEXT (comp-led, ${why}): the world is chosen; the composition is not. Run: node ${scripts}/build-phase.mjs start${seed} and follow its NEXT lines: it opens the comps phase (three comps under .impeccable/mocks/, one approved by the user through the decision page or structured question, sidecar "approved": true), then spec, plates, hero, sections, motion, responsive, review. Do not write page code before those gates close. Reference: reference/visualize.md for the comp round.\n`; } -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { +export function sameMainModulePath(left, right, platform = process.platform) { + if (platform !== 'win32') return left === right; + const normalizeDriveLetter = (value) => value.replace(/^([a-z]):/i, (_, drive) => `${drive.toUpperCase()}:`); + return normalizeDriveLetter(left) === normalizeDriveLetter(right); +} + +function isMainModule() { + if (!process.argv[1]) return false; + try { + // Node resolves import.meta.url through symlinks but leaves argv[1] as the + // invoked path. Compare real paths so a linked skill still runs its CLI, + // normalizing the drive-letter casing that Windows junctions can change. + return sameMainModulePath( + realpathSync(process.argv[1]), + realpathSync(fileURLToPath(import.meta.url)) + ); + } catch { + return false; + } +} + +if (isMainModule()) { const args = process.argv.slice(2); const fromIdx = args.indexOf('--from'); const scopeIdx = args.indexOf('--scope'); diff --git a/.kiro/skills/impeccable/scripts/concept-seed.mjs b/.kiro/skills/impeccable/scripts/concept-seed.mjs index 991dc629b..8887f9b32 100644 --- a/.kiro/skills/impeccable/scripts/concept-seed.mjs +++ b/.kiro/skills/impeccable/scripts/concept-seed.mjs @@ -91,7 +91,7 @@ import crypto from 'node:crypto'; import { dirname, join, relative, resolve } from 'node:path'; -import { readFileSync } from 'node:fs'; +import { readFileSync, realpathSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { approvedPoolRevision, @@ -703,7 +703,28 @@ export function nextStepAfterChoice({ key, scope, cwd = process.cwd(), env = pro return `NEXT (comp-led, ${why}): the world is chosen; the composition is not. Run: node ${scripts}/build-phase.mjs start${seed} and follow its NEXT lines: it opens the comps phase (three comps under .impeccable/mocks/, one approved by the user through the decision page or structured question, sidecar "approved": true), then spec, plates, hero, sections, motion, responsive, review. Do not write page code before those gates close. Reference: reference/visualize.md for the comp round.\n`; } -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { +export function sameMainModulePath(left, right, platform = process.platform) { + if (platform !== 'win32') return left === right; + const normalizeDriveLetter = (value) => value.replace(/^([a-z]):/i, (_, drive) => `${drive.toUpperCase()}:`); + return normalizeDriveLetter(left) === normalizeDriveLetter(right); +} + +function isMainModule() { + if (!process.argv[1]) return false; + try { + // Node resolves import.meta.url through symlinks but leaves argv[1] as the + // invoked path. Compare real paths so a linked skill still runs its CLI, + // normalizing the drive-letter casing that Windows junctions can change. + return sameMainModulePath( + realpathSync(process.argv[1]), + realpathSync(fileURLToPath(import.meta.url)) + ); + } catch { + return false; + } +} + +if (isMainModule()) { const args = process.argv.slice(2); const fromIdx = args.indexOf('--from'); const scopeIdx = args.indexOf('--scope'); diff --git a/.opencode/skills/impeccable/scripts/concept-seed.mjs b/.opencode/skills/impeccable/scripts/concept-seed.mjs index 991dc629b..8887f9b32 100644 --- a/.opencode/skills/impeccable/scripts/concept-seed.mjs +++ b/.opencode/skills/impeccable/scripts/concept-seed.mjs @@ -91,7 +91,7 @@ import crypto from 'node:crypto'; import { dirname, join, relative, resolve } from 'node:path'; -import { readFileSync } from 'node:fs'; +import { readFileSync, realpathSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { approvedPoolRevision, @@ -703,7 +703,28 @@ export function nextStepAfterChoice({ key, scope, cwd = process.cwd(), env = pro return `NEXT (comp-led, ${why}): the world is chosen; the composition is not. Run: node ${scripts}/build-phase.mjs start${seed} and follow its NEXT lines: it opens the comps phase (three comps under .impeccable/mocks/, one approved by the user through the decision page or structured question, sidecar "approved": true), then spec, plates, hero, sections, motion, responsive, review. Do not write page code before those gates close. Reference: reference/visualize.md for the comp round.\n`; } -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { +export function sameMainModulePath(left, right, platform = process.platform) { + if (platform !== 'win32') return left === right; + const normalizeDriveLetter = (value) => value.replace(/^([a-z]):/i, (_, drive) => `${drive.toUpperCase()}:`); + return normalizeDriveLetter(left) === normalizeDriveLetter(right); +} + +function isMainModule() { + if (!process.argv[1]) return false; + try { + // Node resolves import.meta.url through symlinks but leaves argv[1] as the + // invoked path. Compare real paths so a linked skill still runs its CLI, + // normalizing the drive-letter casing that Windows junctions can change. + return sameMainModulePath( + realpathSync(process.argv[1]), + realpathSync(fileURLToPath(import.meta.url)) + ); + } catch { + return false; + } +} + +if (isMainModule()) { const args = process.argv.slice(2); const fromIdx = args.indexOf('--from'); const scopeIdx = args.indexOf('--scope'); diff --git a/.pi/skills/impeccable/scripts/concept-seed.mjs b/.pi/skills/impeccable/scripts/concept-seed.mjs index 991dc629b..8887f9b32 100644 --- a/.pi/skills/impeccable/scripts/concept-seed.mjs +++ b/.pi/skills/impeccable/scripts/concept-seed.mjs @@ -91,7 +91,7 @@ import crypto from 'node:crypto'; import { dirname, join, relative, resolve } from 'node:path'; -import { readFileSync } from 'node:fs'; +import { readFileSync, realpathSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { approvedPoolRevision, @@ -703,7 +703,28 @@ export function nextStepAfterChoice({ key, scope, cwd = process.cwd(), env = pro return `NEXT (comp-led, ${why}): the world is chosen; the composition is not. Run: node ${scripts}/build-phase.mjs start${seed} and follow its NEXT lines: it opens the comps phase (three comps under .impeccable/mocks/, one approved by the user through the decision page or structured question, sidecar "approved": true), then spec, plates, hero, sections, motion, responsive, review. Do not write page code before those gates close. Reference: reference/visualize.md for the comp round.\n`; } -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { +export function sameMainModulePath(left, right, platform = process.platform) { + if (platform !== 'win32') return left === right; + const normalizeDriveLetter = (value) => value.replace(/^([a-z]):/i, (_, drive) => `${drive.toUpperCase()}:`); + return normalizeDriveLetter(left) === normalizeDriveLetter(right); +} + +function isMainModule() { + if (!process.argv[1]) return false; + try { + // Node resolves import.meta.url through symlinks but leaves argv[1] as the + // invoked path. Compare real paths so a linked skill still runs its CLI, + // normalizing the drive-letter casing that Windows junctions can change. + return sameMainModulePath( + realpathSync(process.argv[1]), + realpathSync(fileURLToPath(import.meta.url)) + ); + } catch { + return false; + } +} + +if (isMainModule()) { const args = process.argv.slice(2); const fromIdx = args.indexOf('--from'); const scopeIdx = args.indexOf('--scope'); diff --git a/.qoder/skills/impeccable/scripts/concept-seed.mjs b/.qoder/skills/impeccable/scripts/concept-seed.mjs index 991dc629b..8887f9b32 100644 --- a/.qoder/skills/impeccable/scripts/concept-seed.mjs +++ b/.qoder/skills/impeccable/scripts/concept-seed.mjs @@ -91,7 +91,7 @@ import crypto from 'node:crypto'; import { dirname, join, relative, resolve } from 'node:path'; -import { readFileSync } from 'node:fs'; +import { readFileSync, realpathSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { approvedPoolRevision, @@ -703,7 +703,28 @@ export function nextStepAfterChoice({ key, scope, cwd = process.cwd(), env = pro return `NEXT (comp-led, ${why}): the world is chosen; the composition is not. Run: node ${scripts}/build-phase.mjs start${seed} and follow its NEXT lines: it opens the comps phase (three comps under .impeccable/mocks/, one approved by the user through the decision page or structured question, sidecar "approved": true), then spec, plates, hero, sections, motion, responsive, review. Do not write page code before those gates close. Reference: reference/visualize.md for the comp round.\n`; } -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { +export function sameMainModulePath(left, right, platform = process.platform) { + if (platform !== 'win32') return left === right; + const normalizeDriveLetter = (value) => value.replace(/^([a-z]):/i, (_, drive) => `${drive.toUpperCase()}:`); + return normalizeDriveLetter(left) === normalizeDriveLetter(right); +} + +function isMainModule() { + if (!process.argv[1]) return false; + try { + // Node resolves import.meta.url through symlinks but leaves argv[1] as the + // invoked path. Compare real paths so a linked skill still runs its CLI, + // normalizing the drive-letter casing that Windows junctions can change. + return sameMainModulePath( + realpathSync(process.argv[1]), + realpathSync(fileURLToPath(import.meta.url)) + ); + } catch { + return false; + } +} + +if (isMainModule()) { const args = process.argv.slice(2); const fromIdx = args.indexOf('--from'); const scopeIdx = args.indexOf('--scope'); diff --git a/.rovodev/skills/impeccable/scripts/concept-seed.mjs b/.rovodev/skills/impeccable/scripts/concept-seed.mjs index 991dc629b..8887f9b32 100644 --- a/.rovodev/skills/impeccable/scripts/concept-seed.mjs +++ b/.rovodev/skills/impeccable/scripts/concept-seed.mjs @@ -91,7 +91,7 @@ import crypto from 'node:crypto'; import { dirname, join, relative, resolve } from 'node:path'; -import { readFileSync } from 'node:fs'; +import { readFileSync, realpathSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { approvedPoolRevision, @@ -703,7 +703,28 @@ export function nextStepAfterChoice({ key, scope, cwd = process.cwd(), env = pro return `NEXT (comp-led, ${why}): the world is chosen; the composition is not. Run: node ${scripts}/build-phase.mjs start${seed} and follow its NEXT lines: it opens the comps phase (three comps under .impeccable/mocks/, one approved by the user through the decision page or structured question, sidecar "approved": true), then spec, plates, hero, sections, motion, responsive, review. Do not write page code before those gates close. Reference: reference/visualize.md for the comp round.\n`; } -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { +export function sameMainModulePath(left, right, platform = process.platform) { + if (platform !== 'win32') return left === right; + const normalizeDriveLetter = (value) => value.replace(/^([a-z]):/i, (_, drive) => `${drive.toUpperCase()}:`); + return normalizeDriveLetter(left) === normalizeDriveLetter(right); +} + +function isMainModule() { + if (!process.argv[1]) return false; + try { + // Node resolves import.meta.url through symlinks but leaves argv[1] as the + // invoked path. Compare real paths so a linked skill still runs its CLI, + // normalizing the drive-letter casing that Windows junctions can change. + return sameMainModulePath( + realpathSync(process.argv[1]), + realpathSync(fileURLToPath(import.meta.url)) + ); + } catch { + return false; + } +} + +if (isMainModule()) { const args = process.argv.slice(2); const fromIdx = args.indexOf('--from'); const scopeIdx = args.indexOf('--scope'); diff --git a/.trae-cn/skills/impeccable/scripts/concept-seed.mjs b/.trae-cn/skills/impeccable/scripts/concept-seed.mjs index 991dc629b..8887f9b32 100644 --- a/.trae-cn/skills/impeccable/scripts/concept-seed.mjs +++ b/.trae-cn/skills/impeccable/scripts/concept-seed.mjs @@ -91,7 +91,7 @@ import crypto from 'node:crypto'; import { dirname, join, relative, resolve } from 'node:path'; -import { readFileSync } from 'node:fs'; +import { readFileSync, realpathSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { approvedPoolRevision, @@ -703,7 +703,28 @@ export function nextStepAfterChoice({ key, scope, cwd = process.cwd(), env = pro return `NEXT (comp-led, ${why}): the world is chosen; the composition is not. Run: node ${scripts}/build-phase.mjs start${seed} and follow its NEXT lines: it opens the comps phase (three comps under .impeccable/mocks/, one approved by the user through the decision page or structured question, sidecar "approved": true), then spec, plates, hero, sections, motion, responsive, review. Do not write page code before those gates close. Reference: reference/visualize.md for the comp round.\n`; } -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { +export function sameMainModulePath(left, right, platform = process.platform) { + if (platform !== 'win32') return left === right; + const normalizeDriveLetter = (value) => value.replace(/^([a-z]):/i, (_, drive) => `${drive.toUpperCase()}:`); + return normalizeDriveLetter(left) === normalizeDriveLetter(right); +} + +function isMainModule() { + if (!process.argv[1]) return false; + try { + // Node resolves import.meta.url through symlinks but leaves argv[1] as the + // invoked path. Compare real paths so a linked skill still runs its CLI, + // normalizing the drive-letter casing that Windows junctions can change. + return sameMainModulePath( + realpathSync(process.argv[1]), + realpathSync(fileURLToPath(import.meta.url)) + ); + } catch { + return false; + } +} + +if (isMainModule()) { const args = process.argv.slice(2); const fromIdx = args.indexOf('--from'); const scopeIdx = args.indexOf('--scope'); diff --git a/.trae/skills/impeccable/scripts/concept-seed.mjs b/.trae/skills/impeccable/scripts/concept-seed.mjs index 991dc629b..8887f9b32 100644 --- a/.trae/skills/impeccable/scripts/concept-seed.mjs +++ b/.trae/skills/impeccable/scripts/concept-seed.mjs @@ -91,7 +91,7 @@ import crypto from 'node:crypto'; import { dirname, join, relative, resolve } from 'node:path'; -import { readFileSync } from 'node:fs'; +import { readFileSync, realpathSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { approvedPoolRevision, @@ -703,7 +703,28 @@ export function nextStepAfterChoice({ key, scope, cwd = process.cwd(), env = pro return `NEXT (comp-led, ${why}): the world is chosen; the composition is not. Run: node ${scripts}/build-phase.mjs start${seed} and follow its NEXT lines: it opens the comps phase (three comps under .impeccable/mocks/, one approved by the user through the decision page or structured question, sidecar "approved": true), then spec, plates, hero, sections, motion, responsive, review. Do not write page code before those gates close. Reference: reference/visualize.md for the comp round.\n`; } -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { +export function sameMainModulePath(left, right, platform = process.platform) { + if (platform !== 'win32') return left === right; + const normalizeDriveLetter = (value) => value.replace(/^([a-z]):/i, (_, drive) => `${drive.toUpperCase()}:`); + return normalizeDriveLetter(left) === normalizeDriveLetter(right); +} + +function isMainModule() { + if (!process.argv[1]) return false; + try { + // Node resolves import.meta.url through symlinks but leaves argv[1] as the + // invoked path. Compare real paths so a linked skill still runs its CLI, + // normalizing the drive-letter casing that Windows junctions can change. + return sameMainModulePath( + realpathSync(process.argv[1]), + realpathSync(fileURLToPath(import.meta.url)) + ); + } catch { + return false; + } +} + +if (isMainModule()) { const args = process.argv.slice(2); const fromIdx = args.indexOf('--from'); const scopeIdx = args.indexOf('--scope'); diff --git a/.vibe/skills/impeccable/scripts/concept-seed.mjs b/.vibe/skills/impeccable/scripts/concept-seed.mjs index 991dc629b..8887f9b32 100644 --- a/.vibe/skills/impeccable/scripts/concept-seed.mjs +++ b/.vibe/skills/impeccable/scripts/concept-seed.mjs @@ -91,7 +91,7 @@ import crypto from 'node:crypto'; import { dirname, join, relative, resolve } from 'node:path'; -import { readFileSync } from 'node:fs'; +import { readFileSync, realpathSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { approvedPoolRevision, @@ -703,7 +703,28 @@ export function nextStepAfterChoice({ key, scope, cwd = process.cwd(), env = pro return `NEXT (comp-led, ${why}): the world is chosen; the composition is not. Run: node ${scripts}/build-phase.mjs start${seed} and follow its NEXT lines: it opens the comps phase (three comps under .impeccable/mocks/, one approved by the user through the decision page or structured question, sidecar "approved": true), then spec, plates, hero, sections, motion, responsive, review. Do not write page code before those gates close. Reference: reference/visualize.md for the comp round.\n`; } -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { +export function sameMainModulePath(left, right, platform = process.platform) { + if (platform !== 'win32') return left === right; + const normalizeDriveLetter = (value) => value.replace(/^([a-z]):/i, (_, drive) => `${drive.toUpperCase()}:`); + return normalizeDriveLetter(left) === normalizeDriveLetter(right); +} + +function isMainModule() { + if (!process.argv[1]) return false; + try { + // Node resolves import.meta.url through symlinks but leaves argv[1] as the + // invoked path. Compare real paths so a linked skill still runs its CLI, + // normalizing the drive-letter casing that Windows junctions can change. + return sameMainModulePath( + realpathSync(process.argv[1]), + realpathSync(fileURLToPath(import.meta.url)) + ); + } catch { + return false; + } +} + +if (isMainModule()) { const args = process.argv.slice(2); const fromIdx = args.indexOf('--from'); const scopeIdx = args.indexOf('--scope'); diff --git a/plugin/skills/impeccable/scripts/concept-seed.mjs b/plugin/skills/impeccable/scripts/concept-seed.mjs index 991dc629b..8887f9b32 100644 --- a/plugin/skills/impeccable/scripts/concept-seed.mjs +++ b/plugin/skills/impeccable/scripts/concept-seed.mjs @@ -91,7 +91,7 @@ import crypto from 'node:crypto'; import { dirname, join, relative, resolve } from 'node:path'; -import { readFileSync } from 'node:fs'; +import { readFileSync, realpathSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { approvedPoolRevision, @@ -703,7 +703,28 @@ export function nextStepAfterChoice({ key, scope, cwd = process.cwd(), env = pro return `NEXT (comp-led, ${why}): the world is chosen; the composition is not. Run: node ${scripts}/build-phase.mjs start${seed} and follow its NEXT lines: it opens the comps phase (three comps under .impeccable/mocks/, one approved by the user through the decision page or structured question, sidecar "approved": true), then spec, plates, hero, sections, motion, responsive, review. Do not write page code before those gates close. Reference: reference/visualize.md for the comp round.\n`; } -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { +export function sameMainModulePath(left, right, platform = process.platform) { + if (platform !== 'win32') return left === right; + const normalizeDriveLetter = (value) => value.replace(/^([a-z]):/i, (_, drive) => `${drive.toUpperCase()}:`); + return normalizeDriveLetter(left) === normalizeDriveLetter(right); +} + +function isMainModule() { + if (!process.argv[1]) return false; + try { + // Node resolves import.meta.url through symlinks but leaves argv[1] as the + // invoked path. Compare real paths so a linked skill still runs its CLI, + // normalizing the drive-letter casing that Windows junctions can change. + return sameMainModulePath( + realpathSync(process.argv[1]), + realpathSync(fileURLToPath(import.meta.url)) + ); + } catch { + return false; + } +} + +if (isMainModule()) { const args = process.argv.slice(2); const fromIdx = args.indexOf('--from'); const scopeIdx = args.indexOf('--scope'); From 85d82c0afc4b6d18ebd35c2c1c478eefd36f52c1 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Tue, 1 Sep 2026 00:00:43 -0400 Subject: [PATCH 05/31] Fix PRODUCT schema drift (#688) Update the public init description and migrate the repository product record to the current stamped schema without changing its established product truths.\n\nAI-assisted: prepared with Codex under @pbakaus direction. --- PRODUCT.md | 35 +++++++++++++++++++++++++++++------ README.md | 6 +++--- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/PRODUCT.md b/PRODUCT.md index c2c55749f..6e5357321 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -1,8 +1,10 @@ # Product -## Register + -brand +## Platform + +web ## Users @@ -12,14 +14,27 @@ Designers, product managers, and engineers who use AI coding tools (Cursor, Clau Impeccable gives builders a shared design vocabulary with their AI, delivered as a plug-and-play skill that works in every major AI coding harness. Success is measured in two ways: (1) the user can steer AI output with design precision instead of vague prose, and (2) the AI produces interfaces that pass professional design review, not "looks like an AI made it" output. -## Brand Personality +## Positioning + +Impeccable combines an opinionated design skill, live browser iteration, and deterministic anti-pattern detection in one source-first system that is transformed for supported AI coding harnesses. The repository ships the same design vocabulary through harness-native distributions instead of maintaining unrelated prompts for each tool. + +## Operating Context + +Builders install Impeccable from the repository or package, run `/impeccable init` once to record product truth, then direct design work through the shared command vocabulary. They may iterate against a runnable interface in live mode and use the CLI or browser extension for deterministic checks. Maintainers author the canonical skill under `skill/`; build scripts derive provider distributions, site assets, and validation output. + +## Capabilities and Constraints + +- The skill exposes 23 design commands covering new work, critique, technical audit, refinement, hardening, adaptation, and live iteration. +- The CLI and browser extension run deterministic detector rules without an LLM or API key; LLM critique remains a separate judgment layer. +- Provider-specific root harness folders and `plugin/` are generated distribution artifacts. Source changes belong in `skill/`, `scripts/`, `cli/`, `site/`, `extension/`, `functions/`, or `tests/`. +- Product claims, testimonials, customers, benchmarks, pricing, licensing, and deployment facts must not be invented when evidence is absent. + +## Brand Commitments Expert, opinionated, refined. Impeccable speaks with an authoritative design voice: confident taste, editorial quality, zero hedging. It's the design director in the room who knows exactly what's wrong and how to fix it. The tone is **direct** (no "maybe consider"), **specific** (no "improve the vibe"), and **rooted in craft** (no hype, no hedging). Three-word personality: **expert, decisive, editorial**. -## Anti-references - The site and brand must be the antithesis of everything Impeccable critiques. Specifically, avoid: - **Generic AI tool marketing**: dark mode with purple gradients, neon accents, glassmorphism, glowing particles, cyan-on-black. @@ -28,7 +43,15 @@ The site and brand must be the antithesis of everything Impeccable critiques. Sp - **Educational framing**: this product is for people who already know they have a problem; we solve it, we don't teach it. - **Over-decoration**: every visual element must earn its place. No ornament for ornament's sake. -## Design Principles +## Evidence on Hand + +- `README.md` documents the public command set, supported installation paths, and deterministic detector behavior. +- `skill/` contains the canonical guidance and command references; `tests/` contains regression coverage for its build and runtime behavior. +- `site/` is the public product surface and a direct demonstration of the design standard Impeccable advocates. +- `cli/` and `extension/` are working detector implementations, not roadmap claims. +- The existing product record names no customer testimonials or quantified outcome studies. Future surfaces must not fabricate them. + +## Product Principles 1. **Practice what you preach.** The site must pass its own anti-pattern tests with flying colors. If we ship anything we'd flag in an audit, we've lost. 2. **Show, don't tell.** Demonstrate design quality through execution, not through words about design quality. The site IS the demo. diff --git a/README.md b/README.md index b32ce194a..b957b7e5b 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Anthropic's [frontend-design](https://github.com/anthropics/skills/tree/main/ski Every model trained on the same SaaS templates. Skip the guidance and you get the same handful of tells on every project: Inter for everything, purple-to-blue gradients, cards nested in cards, gray text on colored backgrounds, the rounded-square icon tile above every heading. Impeccable adds: -- **One setup flow.** `/impeccable init` writes `PRODUCT.md` and offers `DESIGN.md`, so later commands know the audience, brand/product lane, voice, anti-references, colors, type, and components. +- **One setup flow.** `/impeccable init` records durable product truth in `PRODUCT.md`, so later commands know the audience, purpose, operating context, constraints, voice, and evidence without confusing those facts with surface-level visual direction. - **23 commands.** A shared design vocabulary with your AI: `polish`, `audit`, `critique`, `distill`, `animate`, `bolder`, `quieter`, and more. - **61 deterministic detector rules** plus LLM-only critique checks. The CLI and browser extension run the deterministic rules with no LLM and no API key. @@ -31,7 +31,7 @@ Start every new project with: /impeccable init ``` -`init` asks whether the surface is brand (marketing, landing, portfolio) or product (app UI, dashboard, tool), then writes design context that every later command reads. +`init` inspects the project, asks only for material gaps in durable product truth, and writes `PRODUCT.md`. Visitor mode and visual direction are chosen later for each surface; incumbent or newly built visual systems are recorded separately in `DESIGN.md`. ### 23 Commands @@ -40,7 +40,7 @@ All commands are accessed through `/impeccable`: | Command | What it does | |---------|--------------| | `/impeccable craft` | Full shape-then-build flow with visual iteration | -| `/impeccable init` | One-time setup: gather design context, write PRODUCT.md and DESIGN.md, configure live mode, recommend next steps | +| `/impeccable init` | One-time setup: gather durable product context, write PRODUCT.md, configure live mode when applicable, recommend next steps | | `/impeccable document` | Generate root DESIGN.md from existing project code | | `/impeccable extract` | Pull reusable components and tokens into the design system | | `/impeccable shape` | Plan UX/UI before writing code | From 632912b5aeafed55c4fb61c7da9b38c6d99825e4 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Tue, 1 Sep 2026 00:00:51 -0400 Subject: [PATCH 06/31] Fix live script response encoding (#690) Declare UTF-8 on the generated live and detector JavaScript responses and cover both endpoints with integration assertions.\n\nAI-assisted: prepared with Codex under @pbakaus direction. --- skill/scripts/live-server.mjs | 4 ++-- tests/live-server.test.mjs | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/skill/scripts/live-server.mjs b/skill/scripts/live-server.mjs index ebdb8f4d8..546b668ad 100644 --- a/skill/scripts/live-server.mjs +++ b/skill/scripts/live-server.mjs @@ -768,7 +768,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) { }), }); res.writeHead(200, { - 'Content-Type': 'application/javascript', + 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', 'Pragma': 'no-cache', }); @@ -777,7 +777,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) { } if (p === '/detect.js' || p === '/') { if (!detectScript) { res.writeHead(404); res.end('Not available'); return; } - res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' }); res.end(detectScript); return; } diff --git a/tests/live-server.test.mjs b/tests/live-server.test.mjs index 034d20e02..be0a0c17d 100644 --- a/tests/live-server.test.mjs +++ b/tests/live-server.test.mjs @@ -350,7 +350,7 @@ describe('live-server integration', () => { it('/live.js serves script with token injected', async () => { const res = await fetch(`http://localhost:${server.port}/live.js?token=${server.token}`); assert.equal(res.status, 200); - assert.equal(res.headers.get('content-type'), 'application/javascript'); + assert.equal(res.headers.get('content-type'), 'application/javascript; charset=utf-8'); const text = await res.text(); assert.ok(text.includes('__IMPECCABLE_TOKEN__')); assert.ok(text.includes(server.token)); @@ -545,6 +545,9 @@ colors: {} const res = await fetch(`http://localhost:${server.port}/detect.js`); // May 404 if detect-antipatterns-browser.js hasn't been built assert.ok(res.status === 200 || res.status === 404); + if (res.status === 200) { + assert.equal(res.headers.get('content-type'), 'application/javascript; charset=utf-8'); + } }); it('/manual-edit-commit runs the batched AI apply path and clears successful entries', async () => { From 2c8816a3cec6a7a2a3b03aed2ab06f1b9385e5db Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 04:01:22 +0000 Subject: [PATCH 07/31] Sync generated provider output --- .agents/skills/impeccable/scripts/live-server.mjs | 4 ++-- .claude/skills/impeccable/scripts/live-server.mjs | 4 ++-- .cursor/skills/impeccable/scripts/live-server.mjs | 4 ++-- .gemini/skills/impeccable/scripts/live-server.mjs | 4 ++-- .github/skills/impeccable/scripts/live-server.mjs | 4 ++-- .grok/skills/impeccable/scripts/live-server.mjs | 4 ++-- .hermes/skills/impeccable/scripts/live-server.mjs | 4 ++-- .kiro/skills/impeccable/scripts/live-server.mjs | 4 ++-- .opencode/skills/impeccable/scripts/live-server.mjs | 4 ++-- .pi/skills/impeccable/scripts/live-server.mjs | 4 ++-- .qoder/skills/impeccable/scripts/live-server.mjs | 4 ++-- .rovodev/skills/impeccable/scripts/live-server.mjs | 4 ++-- .trae-cn/skills/impeccable/scripts/live-server.mjs | 4 ++-- .trae/skills/impeccable/scripts/live-server.mjs | 4 ++-- .vibe/skills/impeccable/scripts/live-server.mjs | 4 ++-- plugin/skills/impeccable/scripts/live-server.mjs | 4 ++-- 16 files changed, 32 insertions(+), 32 deletions(-) diff --git a/.agents/skills/impeccable/scripts/live-server.mjs b/.agents/skills/impeccable/scripts/live-server.mjs index ebdb8f4d8..546b668ad 100644 --- a/.agents/skills/impeccable/scripts/live-server.mjs +++ b/.agents/skills/impeccable/scripts/live-server.mjs @@ -768,7 +768,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) { }), }); res.writeHead(200, { - 'Content-Type': 'application/javascript', + 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', 'Pragma': 'no-cache', }); @@ -777,7 +777,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) { } if (p === '/detect.js' || p === '/') { if (!detectScript) { res.writeHead(404); res.end('Not available'); return; } - res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' }); res.end(detectScript); return; } diff --git a/.claude/skills/impeccable/scripts/live-server.mjs b/.claude/skills/impeccable/scripts/live-server.mjs index ebdb8f4d8..546b668ad 100644 --- a/.claude/skills/impeccable/scripts/live-server.mjs +++ b/.claude/skills/impeccable/scripts/live-server.mjs @@ -768,7 +768,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) { }), }); res.writeHead(200, { - 'Content-Type': 'application/javascript', + 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', 'Pragma': 'no-cache', }); @@ -777,7 +777,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) { } if (p === '/detect.js' || p === '/') { if (!detectScript) { res.writeHead(404); res.end('Not available'); return; } - res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' }); res.end(detectScript); return; } diff --git a/.cursor/skills/impeccable/scripts/live-server.mjs b/.cursor/skills/impeccable/scripts/live-server.mjs index ebdb8f4d8..546b668ad 100644 --- a/.cursor/skills/impeccable/scripts/live-server.mjs +++ b/.cursor/skills/impeccable/scripts/live-server.mjs @@ -768,7 +768,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) { }), }); res.writeHead(200, { - 'Content-Type': 'application/javascript', + 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', 'Pragma': 'no-cache', }); @@ -777,7 +777,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) { } if (p === '/detect.js' || p === '/') { if (!detectScript) { res.writeHead(404); res.end('Not available'); return; } - res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' }); res.end(detectScript); return; } diff --git a/.gemini/skills/impeccable/scripts/live-server.mjs b/.gemini/skills/impeccable/scripts/live-server.mjs index ebdb8f4d8..546b668ad 100644 --- a/.gemini/skills/impeccable/scripts/live-server.mjs +++ b/.gemini/skills/impeccable/scripts/live-server.mjs @@ -768,7 +768,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) { }), }); res.writeHead(200, { - 'Content-Type': 'application/javascript', + 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', 'Pragma': 'no-cache', }); @@ -777,7 +777,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) { } if (p === '/detect.js' || p === '/') { if (!detectScript) { res.writeHead(404); res.end('Not available'); return; } - res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' }); res.end(detectScript); return; } diff --git a/.github/skills/impeccable/scripts/live-server.mjs b/.github/skills/impeccable/scripts/live-server.mjs index ebdb8f4d8..546b668ad 100644 --- a/.github/skills/impeccable/scripts/live-server.mjs +++ b/.github/skills/impeccable/scripts/live-server.mjs @@ -768,7 +768,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) { }), }); res.writeHead(200, { - 'Content-Type': 'application/javascript', + 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', 'Pragma': 'no-cache', }); @@ -777,7 +777,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) { } if (p === '/detect.js' || p === '/') { if (!detectScript) { res.writeHead(404); res.end('Not available'); return; } - res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' }); res.end(detectScript); return; } diff --git a/.grok/skills/impeccable/scripts/live-server.mjs b/.grok/skills/impeccable/scripts/live-server.mjs index ebdb8f4d8..546b668ad 100644 --- a/.grok/skills/impeccable/scripts/live-server.mjs +++ b/.grok/skills/impeccable/scripts/live-server.mjs @@ -768,7 +768,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) { }), }); res.writeHead(200, { - 'Content-Type': 'application/javascript', + 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', 'Pragma': 'no-cache', }); @@ -777,7 +777,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) { } if (p === '/detect.js' || p === '/') { if (!detectScript) { res.writeHead(404); res.end('Not available'); return; } - res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' }); res.end(detectScript); return; } diff --git a/.hermes/skills/impeccable/scripts/live-server.mjs b/.hermes/skills/impeccable/scripts/live-server.mjs index ebdb8f4d8..546b668ad 100644 --- a/.hermes/skills/impeccable/scripts/live-server.mjs +++ b/.hermes/skills/impeccable/scripts/live-server.mjs @@ -768,7 +768,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) { }), }); res.writeHead(200, { - 'Content-Type': 'application/javascript', + 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', 'Pragma': 'no-cache', }); @@ -777,7 +777,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) { } if (p === '/detect.js' || p === '/') { if (!detectScript) { res.writeHead(404); res.end('Not available'); return; } - res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' }); res.end(detectScript); return; } diff --git a/.kiro/skills/impeccable/scripts/live-server.mjs b/.kiro/skills/impeccable/scripts/live-server.mjs index ebdb8f4d8..546b668ad 100644 --- a/.kiro/skills/impeccable/scripts/live-server.mjs +++ b/.kiro/skills/impeccable/scripts/live-server.mjs @@ -768,7 +768,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) { }), }); res.writeHead(200, { - 'Content-Type': 'application/javascript', + 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', 'Pragma': 'no-cache', }); @@ -777,7 +777,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) { } if (p === '/detect.js' || p === '/') { if (!detectScript) { res.writeHead(404); res.end('Not available'); return; } - res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' }); res.end(detectScript); return; } diff --git a/.opencode/skills/impeccable/scripts/live-server.mjs b/.opencode/skills/impeccable/scripts/live-server.mjs index ebdb8f4d8..546b668ad 100644 --- a/.opencode/skills/impeccable/scripts/live-server.mjs +++ b/.opencode/skills/impeccable/scripts/live-server.mjs @@ -768,7 +768,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) { }), }); res.writeHead(200, { - 'Content-Type': 'application/javascript', + 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', 'Pragma': 'no-cache', }); @@ -777,7 +777,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) { } if (p === '/detect.js' || p === '/') { if (!detectScript) { res.writeHead(404); res.end('Not available'); return; } - res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' }); res.end(detectScript); return; } diff --git a/.pi/skills/impeccable/scripts/live-server.mjs b/.pi/skills/impeccable/scripts/live-server.mjs index ebdb8f4d8..546b668ad 100644 --- a/.pi/skills/impeccable/scripts/live-server.mjs +++ b/.pi/skills/impeccable/scripts/live-server.mjs @@ -768,7 +768,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) { }), }); res.writeHead(200, { - 'Content-Type': 'application/javascript', + 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', 'Pragma': 'no-cache', }); @@ -777,7 +777,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) { } if (p === '/detect.js' || p === '/') { if (!detectScript) { res.writeHead(404); res.end('Not available'); return; } - res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' }); res.end(detectScript); return; } diff --git a/.qoder/skills/impeccable/scripts/live-server.mjs b/.qoder/skills/impeccable/scripts/live-server.mjs index ebdb8f4d8..546b668ad 100644 --- a/.qoder/skills/impeccable/scripts/live-server.mjs +++ b/.qoder/skills/impeccable/scripts/live-server.mjs @@ -768,7 +768,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) { }), }); res.writeHead(200, { - 'Content-Type': 'application/javascript', + 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', 'Pragma': 'no-cache', }); @@ -777,7 +777,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) { } if (p === '/detect.js' || p === '/') { if (!detectScript) { res.writeHead(404); res.end('Not available'); return; } - res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' }); res.end(detectScript); return; } diff --git a/.rovodev/skills/impeccable/scripts/live-server.mjs b/.rovodev/skills/impeccable/scripts/live-server.mjs index ebdb8f4d8..546b668ad 100644 --- a/.rovodev/skills/impeccable/scripts/live-server.mjs +++ b/.rovodev/skills/impeccable/scripts/live-server.mjs @@ -768,7 +768,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) { }), }); res.writeHead(200, { - 'Content-Type': 'application/javascript', + 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', 'Pragma': 'no-cache', }); @@ -777,7 +777,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) { } if (p === '/detect.js' || p === '/') { if (!detectScript) { res.writeHead(404); res.end('Not available'); return; } - res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' }); res.end(detectScript); return; } diff --git a/.trae-cn/skills/impeccable/scripts/live-server.mjs b/.trae-cn/skills/impeccable/scripts/live-server.mjs index ebdb8f4d8..546b668ad 100644 --- a/.trae-cn/skills/impeccable/scripts/live-server.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-server.mjs @@ -768,7 +768,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) { }), }); res.writeHead(200, { - 'Content-Type': 'application/javascript', + 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', 'Pragma': 'no-cache', }); @@ -777,7 +777,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) { } if (p === '/detect.js' || p === '/') { if (!detectScript) { res.writeHead(404); res.end('Not available'); return; } - res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' }); res.end(detectScript); return; } diff --git a/.trae/skills/impeccable/scripts/live-server.mjs b/.trae/skills/impeccable/scripts/live-server.mjs index ebdb8f4d8..546b668ad 100644 --- a/.trae/skills/impeccable/scripts/live-server.mjs +++ b/.trae/skills/impeccable/scripts/live-server.mjs @@ -768,7 +768,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) { }), }); res.writeHead(200, { - 'Content-Type': 'application/javascript', + 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', 'Pragma': 'no-cache', }); @@ -777,7 +777,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) { } if (p === '/detect.js' || p === '/') { if (!detectScript) { res.writeHead(404); res.end('Not available'); return; } - res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' }); res.end(detectScript); return; } diff --git a/.vibe/skills/impeccable/scripts/live-server.mjs b/.vibe/skills/impeccable/scripts/live-server.mjs index ebdb8f4d8..546b668ad 100644 --- a/.vibe/skills/impeccable/scripts/live-server.mjs +++ b/.vibe/skills/impeccable/scripts/live-server.mjs @@ -768,7 +768,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) { }), }); res.writeHead(200, { - 'Content-Type': 'application/javascript', + 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', 'Pragma': 'no-cache', }); @@ -777,7 +777,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) { } if (p === '/detect.js' || p === '/') { if (!detectScript) { res.writeHead(404); res.end('Not available'); return; } - res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' }); res.end(detectScript); return; } diff --git a/plugin/skills/impeccable/scripts/live-server.mjs b/plugin/skills/impeccable/scripts/live-server.mjs index ebdb8f4d8..546b668ad 100644 --- a/plugin/skills/impeccable/scripts/live-server.mjs +++ b/plugin/skills/impeccable/scripts/live-server.mjs @@ -768,7 +768,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) { }), }); res.writeHead(200, { - 'Content-Type': 'application/javascript', + 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', 'Pragma': 'no-cache', }); @@ -777,7 +777,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) { } if (p === '/detect.js' || p === '/') { if (!detectScript) { res.writeHead(404); res.end('Not available'); return; } - res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' }); res.end(detectScript); return; } From 6fe900dbb47a649ffd54044d4ac16ce04618ddbe Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Tue, 1 Sep 2026 00:22:49 -0400 Subject: [PATCH 08/31] Improve incumbent evidence and direction fusion (#689) Prefer committed visual goldens when the app cannot run and make assigned-system translation explicit when a pinned register conflicts with literal materials.\n\nAI-assisted: prepared with Codex under @pbakaus direction. --- skill/SKILL.src.md | 2 +- skill/reference/new-work.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/skill/SKILL.src.md b/skill/SKILL.src.md index 511f667d3..b962047c7 100644 --- a/skill/SKILL.src.md +++ b/skill/SKILL.src.md @@ -19,7 +19,7 @@ Core principles: ## Setup 1. Run `node /scripts/context.mjs` once per session, where `` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node {{scripts_path}}/...` command in this skill and its references, and `{{scripts_path}}` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target `. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it. -2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing. +2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures. 3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work. ## How to design diff --git a/skill/reference/new-work.md b/skill/reference/new-work.md index 9b325f745..a21e3ff26 100644 --- a/skill/reference/new-work.md +++ b/skill/reference/new-work.md @@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u 4. Run `node {{scripts_path}}/concept-seed.mjs --scope direction --mode ` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen. 5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLE’S PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register ` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel. -The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node {{scripts_path}}/serve-question.mjs --start --payload ` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from --reroll ` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key --payload `, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. +The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node {{scripts_path}}/serve-question.mjs --start --payload ` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from --reroll ` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key --payload `, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images. From 94b7f34f6e27b95bc32d8284a671601ed6ac1b2a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 04:23:23 +0000 Subject: [PATCH 09/31] Sync generated provider output --- .agents/skills/impeccable/SKILL.md | 2 +- .agents/skills/impeccable/reference/new-work.md | 2 +- .claude/skills/impeccable/SKILL.md | 2 +- .claude/skills/impeccable/reference/new-work.md | 2 +- .cursor/skills/impeccable/SKILL.md | 2 +- .cursor/skills/impeccable/reference/new-work.md | 2 +- .gemini/skills/impeccable/SKILL.md | 2 +- .gemini/skills/impeccable/reference/new-work.md | 2 +- .github/skills/impeccable/SKILL.md | 2 +- .github/skills/impeccable/reference/new-work.md | 2 +- .grok/skills/impeccable/SKILL.md | 2 +- .grok/skills/impeccable/reference/new-work.md | 2 +- .hermes/skills/impeccable/SKILL.md | 2 +- .hermes/skills/impeccable/reference/new-work.md | 2 +- .kiro/skills/impeccable/SKILL.md | 2 +- .kiro/skills/impeccable/reference/new-work.md | 2 +- .opencode/skills/impeccable/SKILL.md | 2 +- .opencode/skills/impeccable/reference/new-work.md | 2 +- .pi/skills/impeccable/SKILL.md | 2 +- .pi/skills/impeccable/reference/new-work.md | 2 +- .qoder/skills/impeccable/SKILL.md | 2 +- .qoder/skills/impeccable/reference/new-work.md | 2 +- .rovodev/skills/impeccable/SKILL.md | 2 +- .rovodev/skills/impeccable/reference/new-work.md | 2 +- .trae-cn/skills/impeccable/SKILL.md | 2 +- .trae-cn/skills/impeccable/reference/new-work.md | 2 +- .trae/skills/impeccable/SKILL.md | 2 +- .trae/skills/impeccable/reference/new-work.md | 2 +- .vibe/skills/impeccable/SKILL.md | 2 +- .vibe/skills/impeccable/reference/new-work.md | 2 +- plugin/skills/impeccable/SKILL.md | 2 +- plugin/skills/impeccable/reference/new-work.md | 2 +- 32 files changed, 32 insertions(+), 32 deletions(-) diff --git a/.agents/skills/impeccable/SKILL.md b/.agents/skills/impeccable/SKILL.md index 22095cfd3..ea13a0d42 100644 --- a/.agents/skills/impeccable/SKILL.md +++ b/.agents/skills/impeccable/SKILL.md @@ -14,7 +14,7 @@ Core principles: ## Setup 1. Run `node /scripts/context.mjs` once per session, where `` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .agents/skills/impeccable/scripts/...` command in this skill and its references, and `.agents/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target `. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it. -2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing. +2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures. 3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work. ## How to design diff --git a/.agents/skills/impeccable/reference/new-work.md b/.agents/skills/impeccable/reference/new-work.md index 430571d36..9e2587219 100644 --- a/.agents/skills/impeccable/reference/new-work.md +++ b/.agents/skills/impeccable/reference/new-work.md @@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u 4. Run `node .agents/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode ` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen. 5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLE’S PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register ` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel. -The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .agents/skills/impeccable/scripts/serve-question.mjs --start --payload ` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from --reroll ` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key --payload `, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. +The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .agents/skills/impeccable/scripts/serve-question.mjs --start --payload ` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from --reroll ` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key --payload `, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images. diff --git a/.claude/skills/impeccable/SKILL.md b/.claude/skills/impeccable/SKILL.md index 8ef6786d6..e6a2dba17 100644 --- a/.claude/skills/impeccable/SKILL.md +++ b/.claude/skills/impeccable/SKILL.md @@ -20,7 +20,7 @@ Core principles: ## Setup 1. Run `node /scripts/context.mjs` once per session, where `` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .claude/skills/impeccable/scripts/...` command in this skill and its references, and `.claude/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target `. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it. -2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing. +2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures. 3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work. ## How to design diff --git a/.claude/skills/impeccable/reference/new-work.md b/.claude/skills/impeccable/reference/new-work.md index 8c5284643..13fc88f06 100644 --- a/.claude/skills/impeccable/reference/new-work.md +++ b/.claude/skills/impeccable/reference/new-work.md @@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u 4. Run `node .claude/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode ` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen. 5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLE’S PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register ` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel. -The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .claude/skills/impeccable/scripts/serve-question.mjs --start --payload ` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from --reroll ` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key --payload `, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. +The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .claude/skills/impeccable/scripts/serve-question.mjs --start --payload ` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from --reroll ` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key --payload `, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images. diff --git a/.cursor/skills/impeccable/SKILL.md b/.cursor/skills/impeccable/SKILL.md index ec811be28..351592969 100644 --- a/.cursor/skills/impeccable/SKILL.md +++ b/.cursor/skills/impeccable/SKILL.md @@ -15,7 +15,7 @@ Core principles: ## Setup 1. Run `node /scripts/context.mjs` once per session, where `` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .cursor/skills/impeccable/scripts/...` command in this skill and its references, and `.cursor/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target `. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it. -2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing. +2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures. 3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work. ## How to design diff --git a/.cursor/skills/impeccable/reference/new-work.md b/.cursor/skills/impeccable/reference/new-work.md index 4913fb013..bc7fc9150 100644 --- a/.cursor/skills/impeccable/reference/new-work.md +++ b/.cursor/skills/impeccable/reference/new-work.md @@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u 4. Run `node .cursor/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode ` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen. 5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLE’S PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register ` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel. -The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .cursor/skills/impeccable/scripts/serve-question.mjs --start --payload ` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from --reroll ` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key --payload `, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. +The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .cursor/skills/impeccable/scripts/serve-question.mjs --start --payload ` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from --reroll ` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key --payload `, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images. diff --git a/.gemini/skills/impeccable/SKILL.md b/.gemini/skills/impeccable/SKILL.md index 372a832e4..bce20bac2 100644 --- a/.gemini/skills/impeccable/SKILL.md +++ b/.gemini/skills/impeccable/SKILL.md @@ -14,7 +14,7 @@ Core principles: ## Setup 1. Run `node /scripts/context.mjs` once per session, where `` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .gemini/skills/impeccable/scripts/...` command in this skill and its references, and `.gemini/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target `. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it. -2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing. +2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures. 3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work. ## How to design diff --git a/.gemini/skills/impeccable/reference/new-work.md b/.gemini/skills/impeccable/reference/new-work.md index 47d49d814..a960f0b73 100644 --- a/.gemini/skills/impeccable/reference/new-work.md +++ b/.gemini/skills/impeccable/reference/new-work.md @@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u 4. Run `node .gemini/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode ` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen. 5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLE’S PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register ` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel. -The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .gemini/skills/impeccable/scripts/serve-question.mjs --start --payload ` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from --reroll ` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key --payload `, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. +The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .gemini/skills/impeccable/scripts/serve-question.mjs --start --payload ` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from --reroll ` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key --payload `, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images. diff --git a/.github/skills/impeccable/SKILL.md b/.github/skills/impeccable/SKILL.md index 6874f63a1..eb9699ff3 100644 --- a/.github/skills/impeccable/SKILL.md +++ b/.github/skills/impeccable/SKILL.md @@ -17,7 +17,7 @@ Core principles: ## Setup 1. Run `node /scripts/context.mjs` once per session, where `` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .github/skills/impeccable/scripts/...` command in this skill and its references, and `.github/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target `. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it. -2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing. +2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures. 3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work. ## How to design diff --git a/.github/skills/impeccable/reference/new-work.md b/.github/skills/impeccable/reference/new-work.md index 05d44a581..2c9477c95 100644 --- a/.github/skills/impeccable/reference/new-work.md +++ b/.github/skills/impeccable/reference/new-work.md @@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u 4. Run `node .github/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode ` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen. 5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLE’S PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register ` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel. -The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .github/skills/impeccable/scripts/serve-question.mjs --start --payload ` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from --reroll ` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key --payload `, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. +The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .github/skills/impeccable/scripts/serve-question.mjs --start --payload ` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from --reroll ` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key --payload `, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images. diff --git a/.grok/skills/impeccable/SKILL.md b/.grok/skills/impeccable/SKILL.md index 099b30292..6cba9ce6d 100644 --- a/.grok/skills/impeccable/SKILL.md +++ b/.grok/skills/impeccable/SKILL.md @@ -20,7 +20,7 @@ Core principles: ## Setup 1. Run `node /scripts/context.mjs` once per session, where `` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .grok/skills/impeccable/scripts/...` command in this skill and its references, and `.grok/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target `. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it. -2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing. +2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures. 3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work. ## How to design diff --git a/.grok/skills/impeccable/reference/new-work.md b/.grok/skills/impeccable/reference/new-work.md index cc7afc31a..cdc08edb2 100644 --- a/.grok/skills/impeccable/reference/new-work.md +++ b/.grok/skills/impeccable/reference/new-work.md @@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u 4. Run `node .grok/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode ` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen. 5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLE’S PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register ` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel. -The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .grok/skills/impeccable/scripts/serve-question.mjs --start --payload ` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from --reroll ` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key --payload `, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. +The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .grok/skills/impeccable/scripts/serve-question.mjs --start --payload ` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from --reroll ` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key --payload `, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images. diff --git a/.hermes/skills/impeccable/SKILL.md b/.hermes/skills/impeccable/SKILL.md index 99036154e..9cdd59f8a 100644 --- a/.hermes/skills/impeccable/SKILL.md +++ b/.hermes/skills/impeccable/SKILL.md @@ -15,7 +15,7 @@ Core principles: ## Setup 1. Run `node /scripts/context.mjs` once per session, where `` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .hermes/skills/impeccable/scripts/...` command in this skill and its references, and `.hermes/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target `. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it. -2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing. +2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures. 3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work. ## How to design diff --git a/.hermes/skills/impeccable/reference/new-work.md b/.hermes/skills/impeccable/reference/new-work.md index 0e4d69a3d..615f7b404 100644 --- a/.hermes/skills/impeccable/reference/new-work.md +++ b/.hermes/skills/impeccable/reference/new-work.md @@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u 4. Run `node .hermes/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode ` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen. 5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLE’S PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register ` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel. -The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .hermes/skills/impeccable/scripts/serve-question.mjs --start --payload ` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from --reroll ` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key --payload `, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. +The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .hermes/skills/impeccable/scripts/serve-question.mjs --start --payload ` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from --reroll ` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key --payload `, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images. diff --git a/.kiro/skills/impeccable/SKILL.md b/.kiro/skills/impeccable/SKILL.md index 4fb28722a..bb1154bd9 100644 --- a/.kiro/skills/impeccable/SKILL.md +++ b/.kiro/skills/impeccable/SKILL.md @@ -15,7 +15,7 @@ Core principles: ## Setup 1. Run `node /scripts/context.mjs` once per session, where `` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .kiro/skills/impeccable/scripts/...` command in this skill and its references, and `.kiro/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target `. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it. -2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing. +2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures. 3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work. ## How to design diff --git a/.kiro/skills/impeccable/reference/new-work.md b/.kiro/skills/impeccable/reference/new-work.md index a2e8d01a4..ad25e44a0 100644 --- a/.kiro/skills/impeccable/reference/new-work.md +++ b/.kiro/skills/impeccable/reference/new-work.md @@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u 4. Run `node .kiro/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode ` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen. 5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLE’S PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register ` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel. -The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .kiro/skills/impeccable/scripts/serve-question.mjs --start --payload ` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from --reroll ` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key --payload `, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. +The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .kiro/skills/impeccable/scripts/serve-question.mjs --start --payload ` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from --reroll ` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key --payload `, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images. diff --git a/.opencode/skills/impeccable/SKILL.md b/.opencode/skills/impeccable/SKILL.md index 70b0c0074..7f19ae465 100644 --- a/.opencode/skills/impeccable/SKILL.md +++ b/.opencode/skills/impeccable/SKILL.md @@ -20,7 +20,7 @@ Core principles: ## Setup 1. Run `node /scripts/context.mjs` once per session, where `` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .opencode/skills/impeccable/scripts/...` command in this skill and its references, and `.opencode/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target `. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it. -2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing. +2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures. 3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work. ## How to design diff --git a/.opencode/skills/impeccable/reference/new-work.md b/.opencode/skills/impeccable/reference/new-work.md index a99f2d018..8750165c2 100644 --- a/.opencode/skills/impeccable/reference/new-work.md +++ b/.opencode/skills/impeccable/reference/new-work.md @@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u 4. Run `node .opencode/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode ` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen. 5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLE’S PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register ` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel. -The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .opencode/skills/impeccable/scripts/serve-question.mjs --start --payload ` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from --reroll ` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key --payload `, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. +The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .opencode/skills/impeccable/scripts/serve-question.mjs --start --payload ` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from --reroll ` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key --payload `, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images. diff --git a/.pi/skills/impeccable/SKILL.md b/.pi/skills/impeccable/SKILL.md index e1f38d2e6..e0b8674cb 100644 --- a/.pi/skills/impeccable/SKILL.md +++ b/.pi/skills/impeccable/SKILL.md @@ -18,7 +18,7 @@ Core principles: ## Setup 1. Run `node /scripts/context.mjs` once per session, where `` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .pi/skills/impeccable/scripts/...` command in this skill and its references, and `.pi/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target `. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it. -2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing. +2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures. 3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work. ## How to design diff --git a/.pi/skills/impeccable/reference/new-work.md b/.pi/skills/impeccable/reference/new-work.md index 91b7f3ad8..fe374aab6 100644 --- a/.pi/skills/impeccable/reference/new-work.md +++ b/.pi/skills/impeccable/reference/new-work.md @@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u 4. Run `node .pi/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode ` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen. 5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLE’S PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register ` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel. -The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .pi/skills/impeccable/scripts/serve-question.mjs --start --payload ` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from --reroll ` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key --payload `, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. +The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .pi/skills/impeccable/scripts/serve-question.mjs --start --payload ` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from --reroll ` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key --payload `, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images. diff --git a/.qoder/skills/impeccable/SKILL.md b/.qoder/skills/impeccable/SKILL.md index a50252c0f..e4cfcd677 100644 --- a/.qoder/skills/impeccable/SKILL.md +++ b/.qoder/skills/impeccable/SKILL.md @@ -20,7 +20,7 @@ Core principles: ## Setup 1. Run `node /scripts/context.mjs` once per session, where `` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .qoder/skills/impeccable/scripts/...` command in this skill and its references, and `.qoder/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target `. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it. -2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing. +2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures. 3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work. ## How to design diff --git a/.qoder/skills/impeccable/reference/new-work.md b/.qoder/skills/impeccable/reference/new-work.md index 527ac01f5..1a0518353 100644 --- a/.qoder/skills/impeccable/reference/new-work.md +++ b/.qoder/skills/impeccable/reference/new-work.md @@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u 4. Run `node .qoder/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode ` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen. 5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLE’S PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register ` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel. -The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .qoder/skills/impeccable/scripts/serve-question.mjs --start --payload ` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from --reroll ` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key --payload `, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. +The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .qoder/skills/impeccable/scripts/serve-question.mjs --start --payload ` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from --reroll ` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key --payload `, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images. diff --git a/.rovodev/skills/impeccable/SKILL.md b/.rovodev/skills/impeccable/SKILL.md index f212202b4..e316cb635 100644 --- a/.rovodev/skills/impeccable/SKILL.md +++ b/.rovodev/skills/impeccable/SKILL.md @@ -20,7 +20,7 @@ Core principles: ## Setup 1. Run `node /scripts/context.mjs` once per session, where `` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .rovodev/skills/impeccable/scripts/...` command in this skill and its references, and `.rovodev/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target `. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it. -2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing. +2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures. 3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work. ## How to design diff --git a/.rovodev/skills/impeccable/reference/new-work.md b/.rovodev/skills/impeccable/reference/new-work.md index d4540378f..3c7a80362 100644 --- a/.rovodev/skills/impeccable/reference/new-work.md +++ b/.rovodev/skills/impeccable/reference/new-work.md @@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u 4. Run `node .rovodev/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode ` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen. 5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLE’S PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register ` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel. -The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .rovodev/skills/impeccable/scripts/serve-question.mjs --start --payload ` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from --reroll ` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key --payload `, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. +The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .rovodev/skills/impeccable/scripts/serve-question.mjs --start --payload ` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from --reroll ` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key --payload `, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images. diff --git a/.trae-cn/skills/impeccable/SKILL.md b/.trae-cn/skills/impeccable/SKILL.md index d9515a873..6210d87c6 100644 --- a/.trae-cn/skills/impeccable/SKILL.md +++ b/.trae-cn/skills/impeccable/SKILL.md @@ -17,7 +17,7 @@ Core principles: ## Setup 1. Run `node /scripts/context.mjs` once per session, where `` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .trae-cn/skills/impeccable/scripts/...` command in this skill and its references, and `.trae-cn/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target `. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it. -2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing. +2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures. 3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work. ## How to design diff --git a/.trae-cn/skills/impeccable/reference/new-work.md b/.trae-cn/skills/impeccable/reference/new-work.md index 4adca89fe..4e904b67f 100644 --- a/.trae-cn/skills/impeccable/reference/new-work.md +++ b/.trae-cn/skills/impeccable/reference/new-work.md @@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u 4. Run `node .trae-cn/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode ` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen. 5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLE’S PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register ` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel. -The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .trae-cn/skills/impeccable/scripts/serve-question.mjs --start --payload ` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from --reroll ` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key --payload `, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. +The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .trae-cn/skills/impeccable/scripts/serve-question.mjs --start --payload ` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from --reroll ` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key --payload `, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images. diff --git a/.trae/skills/impeccable/SKILL.md b/.trae/skills/impeccable/SKILL.md index 57ac7c309..3b2e144b6 100644 --- a/.trae/skills/impeccable/SKILL.md +++ b/.trae/skills/impeccable/SKILL.md @@ -17,7 +17,7 @@ Core principles: ## Setup 1. Run `node /scripts/context.mjs` once per session, where `` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .trae/skills/impeccable/scripts/...` command in this skill and its references, and `.trae/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target `. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it. -2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing. +2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures. 3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work. ## How to design diff --git a/.trae/skills/impeccable/reference/new-work.md b/.trae/skills/impeccable/reference/new-work.md index 53f5c2cd7..201ccef37 100644 --- a/.trae/skills/impeccable/reference/new-work.md +++ b/.trae/skills/impeccable/reference/new-work.md @@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u 4. Run `node .trae/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode ` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen. 5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLE’S PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register ` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel. -The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .trae/skills/impeccable/scripts/serve-question.mjs --start --payload ` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from --reroll ` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key --payload `, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. +The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .trae/skills/impeccable/scripts/serve-question.mjs --start --payload ` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from --reroll ` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key --payload `, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images. diff --git a/.vibe/skills/impeccable/SKILL.md b/.vibe/skills/impeccable/SKILL.md index d198ec867..6351681a7 100644 --- a/.vibe/skills/impeccable/SKILL.md +++ b/.vibe/skills/impeccable/SKILL.md @@ -19,7 +19,7 @@ Core principles: ## Setup 1. Run `node /scripts/context.mjs` once per session, where `` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .vibe/skills/impeccable/scripts/...` command in this skill and its references, and `.vibe/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target `. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it. -2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing. +2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures. 3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work. ## How to design diff --git a/.vibe/skills/impeccable/reference/new-work.md b/.vibe/skills/impeccable/reference/new-work.md index 4201b7ef7..4547058fd 100644 --- a/.vibe/skills/impeccable/reference/new-work.md +++ b/.vibe/skills/impeccable/reference/new-work.md @@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u 4. Run `node .vibe/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode ` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen. 5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLE’S PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register ` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel. -The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .vibe/skills/impeccable/scripts/serve-question.mjs --start --payload ` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from --reroll ` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key --payload `, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. +The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .vibe/skills/impeccable/scripts/serve-question.mjs --start --payload ` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from --reroll ` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key --payload `, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images. diff --git a/plugin/skills/impeccable/SKILL.md b/plugin/skills/impeccable/SKILL.md index 84bb660ff..6db7c25e0 100644 --- a/plugin/skills/impeccable/SKILL.md +++ b/plugin/skills/impeccable/SKILL.md @@ -19,7 +19,7 @@ Core principles: ## Setup 1. Run `node "/scripts/context.mjs"` once per session, where `` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. Every `node "/scripts/..."` command in this skill and its references resolves against that base directory. Pass a named source file or route as `--target `. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it. -2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing. +2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures. 3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work. ## How to design diff --git a/plugin/skills/impeccable/reference/new-work.md b/plugin/skills/impeccable/reference/new-work.md index e59227be6..5a3aaf40b 100644 --- a/plugin/skills/impeccable/reference/new-work.md +++ b/plugin/skills/impeccable/reference/new-work.md @@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u 4. Run `node "/scripts/concept-seed.mjs" --scope direction --mode ` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen. 5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLE’S PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register ` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel. -The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node "/scripts/serve-question.mjs" --start --payload ` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from --reroll ` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key --payload `, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. +The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node "/scripts/serve-question.mjs" --start --payload ` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from --reroll ` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key --payload `, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images. From 6bc4f242c79b3b380f962c3733dd112097cbd8b9 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Tue, 1 Sep 2026 17:09:25 -0400 Subject: [PATCH 10/31] Fix live cleanup races with framework HMR (#695) Guard delayed accept and discard DOM fallbacks when framework/HMR ownership is present, while preserving static-page cleanup. Add unit/source regressions for both paths and refresh stale Setup wording assertions from #689. AI-assisted: prepared with Codex under @pbakaus direction. --- skill/scripts/live-browser-dom.js | 21 ++ skill/scripts/live-browser-session.js | 29 ++- skill/scripts/live-browser.js | 315 +++++++++++++++++++++---- tests/live-browser-dom.test.mjs | 36 +++ tests/live-browser-regression.test.mjs | 2 +- tests/live-browser-session.test.mjs | 20 ++ tests/live-browser-source.test.mjs | 171 +++++++++++++- tests/live-e2e/agent.mjs | 15 +- tests/live-reference.test.mjs | 6 +- 9 files changed, 563 insertions(+), 52 deletions(-) diff --git a/skill/scripts/live-browser-dom.js b/skill/scripts/live-browser-dom.js index ad6a794b3..e85c95c43 100644 --- a/skill/scripts/live-browser-dom.js +++ b/skill/scripts/live-browser-dom.js @@ -64,6 +64,26 @@ }; } + function hasFrameworkHmrOwnership(el) { + for (let node = el; node; node = node.parentElement) { + let keys = []; + try { keys = Object.getOwnPropertyNames(node); } catch {} + if (keys.some((key) => ( + key.startsWith('__reactFiber$') + || key.startsWith('__reactProps$') + || key.startsWith('__reactContainer$') + || key === '_reactRootContainer' + || key === '__vueParentComponent' + || key === '__vue_app__' + || key === '__vnode' + || key === '__svelte_meta' + ))) { + return true; + } + } + return false; + } + function id8() { if (crypto?.randomUUID) return crypto.randomUUID().replace(/-/g, '').slice(0, 8); return (Math.random().toString(16).slice(2) + Date.now().toString(16)).slice(0, 8); @@ -128,6 +148,7 @@ desc, rectIsUsableAnchor, makeFrozenAnchor, + hasFrameworkHmrOwnership, id8, cssId, liveUiRoot, diff --git a/skill/scripts/live-browser-session.js b/skill/scripts/live-browser-session.js index 0e362d6be..1514bf472 100644 --- a/skill/scripts/live-browser-session.js +++ b/skill/scripts/live-browser-session.js @@ -71,17 +71,38 @@ return checkpointRevision; } + function readHandledIds() { + const raw = safeRead(handledKey); + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed.filter(id => typeof id === 'string' && id); + } + if (typeof parsed === 'string' && parsed) return [parsed]; + } catch { /* legacy values were stored as a plain session id */ } + return [raw]; + } + function markHandled(id) { if (!id) return; - safeWrite(handledKey, id); + const ids = readHandledIds().filter(existing => existing !== id); + ids.push(id); + safeWrite(handledKey, JSON.stringify(ids.slice(-8))); } function isHandled(id) { - return !!id && safeRead(handledKey) === id; + return !!id && readHandledIds().includes(id); } - function clearHandled() { - safeRemove(handledKey); + function clearHandled(id) { + if (!id) { + safeRemove(handledKey); + return; + } + const remaining = readHandledIds().filter(existing => existing !== id); + if (remaining.length > 0) safeWrite(handledKey, JSON.stringify(remaining)); + else safeRemove(handledKey); } function writeScrollY(y) { diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index 2b38ec62e..1f373a894 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -121,6 +121,10 @@ let hoveredElement = null; let selectedElement = null; let currentSessionId = null; + // Advances when the user begins configuring a fresh edit, before that edit + // has a server session id. Deferred recovery captures this revision so an + // older accept/discard can never reload over a replacement configuration. + let liveInteractionRevision = 0; let expectedVariants = 0; let arrivedVariants = 0; let visibleVariant = 0; @@ -188,6 +192,9 @@ // when the real accept result arrives or a new session starts. let awaitingAcceptResult = null; let variantObserver = null; + const discardedFrameworkWrapperWatchers = new Map(); + const handledRuntimeWrapperWatchers = new Map(); + const handledRuntimeWrapperReloadSessions = new Set(); let variantSelectionInFlight = false; let variantSelectionPromise = null; let recoveringEmptyCycling = false; @@ -208,6 +215,7 @@ const SCROLL_ANCHOR_LOCK_ID = 'impeccable-scroll-anchor-lock'; const VARIANT_STATE_STYLE_ID = 'impeccable-variant-state'; const DISCARD_STATE_STYLE_ID = 'impeccable-discard-state'; + const HANDLED_WRAPPER_RELOAD_KEY = PREFIX + '-handled-wrapper-reload'; // Dedicated key for scroll position - SEPARATE from LS_KEY so that // saveSession's state updates don't clobber a carefully-captured scrollY. @@ -270,6 +278,7 @@ desc, rectIsUsableAnchor, makeFrozenAnchor, + hasFrameworkHmrOwnership, id8, cssId, liveUiRoot, @@ -2034,6 +2043,16 @@ syncSteerQueueHint(); } + function beginNewLiveConfiguration() { + liveInteractionRevision += 1; + setLiveState('CONFIGURING'); + } + + function deferredRecoverySuperseded(sessionId, recoveryRevision) { + return liveInteractionRevision !== recoveryRevision + || !!(currentSessionId && currentSessionId !== sessionId); + } + /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { @@ -5036,7 +5055,7 @@ && el.parentElement && document.body.contains(el) && !own(el) - && !el.closest?.('[data-impeccable-variants]'); + && !el.closest?.('[data-impeccable-variants],[data-impeccable-carbonize]'); } function elementMatchesOriginalMarkup(liveEl, origContent) { @@ -6608,19 +6627,78 @@ document.getElementById(VARIANT_STATE_STYLE_ID)?.remove(); } + function discardStateStyleId(sessionId) { + return DISCARD_STATE_STYLE_ID + '-' + sessionId; + } + function showOriginalDuringDiscard(sessionId) { if (!sessionId) return; - let styleEl = document.getElementById(DISCARD_STATE_STYLE_ID); + let styleEl = document.getElementById(discardStateStyleId(sessionId)); if (!styleEl) { styleEl = document.createElement('style'); - styleEl.id = DISCARD_STATE_STYLE_ID; + styleEl.id = discardStateStyleId(sessionId); (document.head || document.documentElement).appendChild(styleEl); } + styleEl.dataset.impeccableDiscardSession = sessionId; const wrapper = '[data-impeccable-variants="' + sessionId + '"]'; styleEl.textContent = wrapper + ' > [data-impeccable-variant]:not([data-impeccable-variant="original"]) { display:none !important; }\n' + wrapper + ' > [data-impeccable-variant="original"] { display:block !important; }'; } + function removeDiscardStateStylesheet(sessionId) { + if (!sessionId) return; + document.getElementById(discardStateStyleId(sessionId))?.remove(); + } + + function releaseDiscardedStaticWrapper(wrapper, sessionId) { + removeDiscardStateStylesheet(sessionId); + if (!wrapper) return; + const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); + const content = orig?.firstElementChild; + if (content && wrapper.parentElement) { + wrapper.parentElement.replaceChild(content, wrapper); + return; + } + wrapper.remove(); + } + + function watchForDiscardedFrameworkWrapperRemoval(sessionId) { + if (!sessionId || !document.body) return; + if (discardedFrameworkWrapperWatchers.has(sessionId)) return; + const selector = '[data-impeccable-variants="' + sessionId + '"]'; + let observer = null; + let timer = null; + const stopWatching = function() { + observer?.disconnect(); + if (timer) clearTimeout(timer); + discardedFrameworkWrapperWatchers.delete(sessionId); + }; + const finishIfGone = function() { + if (document.querySelector(selector)) return false; + removeDiscardStateStylesheet(sessionId); + stopWatching(); + return true; + }; + if (finishIfGone()) return; + observer = new MutationObserver(finishIfGone); + observer.observe(document.body, { childList: true, subtree: true }); + const resolveStillMounted = function() { + if (finishIfGone()) return; + const replacementActive = !!currentSessionId + || (state !== 'IDLE' && state !== 'PICKING'); + if (replacementActive) { + timer = setTimeout(resolveStillMounted, 12000); + discardedFrameworkWrapperWatchers.get(sessionId).timer = timer; + return; + } + removeDiscardStateStylesheet(sessionId); + stopWatching(); + location.reload(); + }; + timer = setTimeout(resolveStillMounted, 12000); + discardedFrameworkWrapperWatchers.set(sessionId, { observer, timer }); + } + function resolveScrollLockAnchorTop() { const anchor = resolveBarAnchor(); if (!anchor?.isConnected) return null; @@ -7335,7 +7413,7 @@ hideInsertLine(); configureKind = 'insert'; selectedElement = placeholder; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); hideHighlight(); clearAnnotations(); showAnnotOverlay(placeholder); @@ -7353,7 +7431,7 @@ e.preventDefault(); e.stopPropagation(); selectedElement = hoveredElement; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -7530,7 +7608,7 @@ } else if (e.key === 'Enter') { e.preventDefault(); selectedElement = hoveredElement; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -8535,6 +8613,7 @@ void main() { } function scheduleAcceptCleanup(accepted) { + const recoveryRevision = liveInteractionRevision; queueMicrotask(function() { if (pendingAcceptedSession?.id !== accepted?.id) return; // Svelte previews live in an adapter-owned mount rather than in source @@ -8551,8 +8630,10 @@ void main() { // races. Static servers still need a fallback, but it must not keep Live // in SAVING or block the user's next pick. if (!accepted?.isSvelteComponent) { + watchForHandledRuntimeWrapper(accepted?.id, recoveryRevision); setTimeout(function() { - if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted); + if (deferredRecoverySuperseded(accepted?.id, recoveryRevision)) return; + if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted, recoveryRevision); }, 1200); } } @@ -8585,13 +8666,27 @@ void main() { && matches.every((el) => !el.closest('[data-impeccable-variants],[data-impeccable-variant],[data-impeccable-carbonize]')); } - function ensureAcceptedDomClean(pending) { + function ensureAcceptedDomClean(pending, recoveryRevision) { + // Background cleanup for an accepted session must never mutate or reload + // a newer comparison the user has already started. + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; if (acceptedDomAlreadyClean(pending)) return; const sessionId = pending?.id; const variantId = pending?.variant; const wrappers = findAcceptedRuntimeWrappers(sessionId); + if (hasFrameworkHmrOwnership(wrappers[0] || pending?.parentElement)) { + // Vite can coalesce rapid scaffold/carbonize writes and leave the last + // framework-owned preview tree mounted even though source is clean. Give + // HMR another grace window, then reload from clean source rather than + // violating reconciler ownership with a manual DOM mutation. + setTimeout(function() { + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; + if (!acceptedDomAlreadyClean(pending)) location.reload(); + }, 2000); + return; + } if (wrappers.length === 0) { - restoreAcceptedDomFromSnapshot(pending); + restoreAcceptedDomFromSnapshot(pending, recoveryRevision); return; } for (const wrapper of wrappers) { @@ -8608,7 +8703,7 @@ void main() { } wrapper.remove(); } - if (!acceptedDomAlreadyClean(pending)) restoreAcceptedDomFromSnapshot(pending); + if (!acceptedDomAlreadyClean(pending)) restoreAcceptedDomFromSnapshot(pending, recoveryRevision); } function findAcceptedRuntimeWrappers(sessionId) { @@ -8619,17 +8714,17 @@ void main() { ])]; } - function restoreAcceptedDomFromSnapshot(pending) { + function restoreAcceptedDomFromSnapshot(pending, recoveryRevision) { if (acceptedDomAlreadyClean(pending)) return; if (!pending?.acceptedHtml) { - reloadAfterMissingAcceptedDom(pending); + reloadAfterMissingAcceptedDom(pending, recoveryRevision); return; } const parent = pending.parentElement?.isConnected ? pending.parentElement : (pending.parentSelector ? document.querySelector(pending.parentSelector) : null); if (!parent) { - reloadAfterMissingAcceptedDom(pending); + reloadAfterMissingAcceptedDom(pending, recoveryRevision); return; } const template = document.createElement('template'); @@ -8638,10 +8733,11 @@ void main() { ? pending.nextSibling : null; parent.insertBefore(template.content, anchor); - if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending); + if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending, recoveryRevision); } - function reloadAfterMissingAcceptedDom(pending) { + function reloadAfterMissingAcceptedDom(pending, recoveryRevision) { + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; if (acceptedDomAlreadyClean(pending)) return; if (pending?.id && document.querySelector('[data-impeccable-variants="' + pending.id + '"]')) return; location.reload(); @@ -8991,14 +9087,15 @@ void main() { return sessionState.isHandled(id); } - function clearHandled() { - sessionState.clearHandled(); + function clearHandled(sessionId) { + sessionState.clearHandled(sessionId); } function cleanup(options) { const restoreOriginal = options?.restoreOriginal === true; const instantChrome = options?.instantChrome === true; const cleanupSessionId = currentSessionId; + const cleanupRevision = liveInteractionRevision; clearMountErrorCard(); lastReportedMountFailure = null; if (svelteComponentSession?.sessionId === cleanupSessionId) { @@ -9016,19 +9113,41 @@ void main() { else wrapper.style.display = 'none'; } setTimeout(function() { - document.getElementById(DISCARD_STATE_STYLE_ID)?.remove(); - if (!cleanupSessionId) return; - const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!lateWrapper) return; - const orig = lateWrapper.querySelector('[data-impeccable-variant="original"]'); - if (orig) { - const content = orig.firstElementChild; - if (content) { - lateWrapper.parentElement.replaceChild(content, lateWrapper); - return; - } + const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); + if (!cleanupSessionId) { + removeDiscardStateStylesheet(); + return; } - lateWrapper.remove(); + const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (!lateWrapper) { + removeDiscardStateStylesheet(cleanupSessionId); + return; + } + if (recoverySuperseded) { + if (hasFrameworkHmrOwnership(lateWrapper)) { + watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); + } else { + releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + } + return; + } + if (hasFrameworkHmrOwnership(lateWrapper)) { + // As on accept, never restructure framework-owned DOM. If HMR missed + // the final source rewrite, reload once after a grace window so the + // discarded source becomes authoritative without a reconciler race. + setTimeout(function() { + const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { + if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId); + else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); + return; + } + removeDiscardStateStylesheet(cleanupSessionId); + if (staleWrapper) location.reload(); + }, 2000); + return; + } + releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); }, 2000); } hideBar(instantChrome); @@ -9111,12 +9230,122 @@ void main() { // Resume an active variant session after HMR/page reload. // If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote // variants before HMR fired. Pick up where we left off. - function resumeSession() { + function handledWrapperReloadKey(sessionId) { + return HANDLED_WRAPPER_RELOAD_KEY + ':' + sessionId; + } + + function clearHandledWrapperReloadStamp(sessionId) { + try { + if (sessionId) { + sessionStorage.removeItem(handledWrapperReloadKey(sessionId)); + const legacy = sessionStorage.getItem(HANDLED_WRAPPER_RELOAD_KEY) || ''; + if (legacy === sessionId || legacy.startsWith(sessionId + ':')) { + sessionStorage.removeItem(HANDLED_WRAPPER_RELOAD_KEY); + } + return; + } + sessionStorage.removeItem(HANDLED_WRAPPER_RELOAD_KEY); + for (let i = sessionStorage.length - 1; i >= 0; i--) { + const key = sessionStorage.key(i); + if (key?.startsWith(HANDLED_WRAPPER_RELOAD_KEY + ':')) sessionStorage.removeItem(key); + } + } catch {} + } + + function scheduleHandledRuntimeWrapperReload(wrapper, recoveryRevision = liveInteractionRevision) { + const sessionId = wrapper?.dataset?.impeccableVariants + || wrapper?.dataset?.impeccableCarbonize; + if (!sessionId || !isSessionHandled(sessionId)) return false; + if (deferredRecoverySuperseded(sessionId, recoveryRevision)) return true; + + if (handledRuntimeWrapperReloadSessions.has(sessionId)) return true; + let reloadAttempts = 0; + try { + reloadAttempts = Number(sessionStorage.getItem(handledWrapperReloadKey(sessionId))) || 0; + if (reloadAttempts >= 2) return true; + sessionStorage.setItem(handledWrapperReloadKey(sessionId), String(reloadAttempts + 1)); + } catch {} + handledRuntimeWrapperReloadSessions.add(sessionId); + + // A framework refresh can replace the variants tree with an intermediate + // carbonize tree and reload the page, cancelling the original accept timer. + // Let the file-side cleanup settle, then reload once from authoritative + // source. The sessionStorage stamp prevents a stale dev-server response + // from turning this recovery into a reload loop. + setTimeout(function() { + if (deferredRecoverySuperseded(sessionId, recoveryRevision)) { + clearHandledWrapperReloadStamp(sessionId); + handledRuntimeWrapperReloadSessions.delete(sessionId); + return; + } + const staleWrapper = document.querySelector( + '[data-impeccable-variants="' + sessionId + '"],' + + '[data-impeccable-carbonize="' + sessionId + '"]', + ); + if (staleWrapper) location.reload(); + else { + clearHandledWrapperReloadStamp(sessionId); + handledRuntimeWrapperReloadSessions.delete(sessionId); + } + }, 3000); + return true; + } + + function watchForHandledRuntimeWrapper(sessionId, recoveryRevision = liveInteractionRevision) { + if (!sessionId || !document.body) return; + const existing = handledRuntimeWrapperWatchers.get(sessionId); + existing?.observer.disconnect(); + if (existing?.timer) clearTimeout(existing.timer); + + const findHandledWrapper = function() { + const wrapper = document.querySelector( + '[data-impeccable-variants="' + sessionId + '"],' + + '[data-impeccable-carbonize="' + sessionId + '"]', + ); + if (wrapper) scheduleHandledRuntimeWrapperReload(wrapper, recoveryRevision); + }; + + // Vite can briefly render the clean accepted tree, then apply a delayed + // carbonize refresh after the one-shot accept fallback has already passed. + // Keep a bounded scout alive through that refresh window so a late stale + // framework tree still reloads from the now-authoritative source. + const observer = new MutationObserver(findHandledWrapper); + observer.observe(document.body, { childList: true, subtree: true }); + const timer = setTimeout(function() { + if (handledRuntimeWrapperWatchers.get(sessionId)?.observer !== observer) return; + observer.disconnect(); + handledRuntimeWrapperWatchers.delete(sessionId); + }, 12000); + handledRuntimeWrapperWatchers.set(sessionId, { observer, timer }); + findHandledWrapper(); + } + + function restoreSessionSupersedingHandledWrapper(runtimeWrapper) { + const handledSessionId = runtimeWrapper?.dataset?.impeccableVariants + || runtimeWrapper?.dataset?.impeccableCarbonize; + if (!handledSessionId || !isSessionHandled(handledSessionId)) return false; + + // Accept releases the picker before carbonize finishes, so a replacement + // generation can already be durable while the prior handled wrapper is + // still mounted. Restore that newer session before the stale-wrapper + // recovery path gets a chance to reload or consume its retry budget. + const saved = loadSession(); + if (!saved?.id || saved.id === handledSessionId || isSessionHandled(saved.id)) return false; + if (currentSessionId === saved.id) return true; + return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); + } + + function resumeSession(recoveryRevision = liveInteractionRevision) { const wrapper = document.querySelector('[data-impeccable-variants]'); + const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); + if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; + if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; if (!wrapper) { if (restoreSessionWithoutWrapper('browser_resumed_without_wrapper')) return true; clearSession(); - clearHandled(); + // Keep the bounded handled-id history durable. A framework can hydrate a + // completed wrapper well after initialization, and a later reload must + // still recognize that wrapper as recovery work rather than resume it. return false; } @@ -9136,7 +9365,7 @@ void main() { wrapper.remove(); if (restoreSessionWithoutWrapper('browser_resumed_svelte_orphan_wrapper')) return true; clearSession(); - clearHandled(); + clearHandled(sessionId); return false; } @@ -12520,22 +12749,28 @@ void main() { connectSSE(); // Check for an active session to resume (variant wrapper already in DOM after HMR) - if (!resumeSession()) { + const resumed = resumeSession(); + if (!resumed) { console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.'); - // SvelteKit (and any framework that hydrates after HTML parse) may add - // the variant wrapper AFTER init runs. Watch for it and retry resume - // once it appears. Disconnect on first hit. + } else { + console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); + } + + // SvelteKit, React, and other frameworks may restore a durable session + // before hydration adds its variant wrapper. Keep a deferred-wrapper scout + // whenever init did not see a runtime wrapper, even if local/server state + // was already restored successfully. Disconnect on the first wrapper hit. + if (!resumed || !document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]')) { + const deferredResumeRevision = liveInteractionRevision; const scout = new MutationObserver(() => { - const wrapper = document.querySelector('[data-impeccable-variants]'); + const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); if (!wrapper) return; scout.disconnect(); - if (resumeSession()) { + if (resumeSession(deferredResumeRevision)) { console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); } }); scout.observe(document.body, { childList: true, subtree: true }); - } else { - console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); } if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING'); diff --git a/tests/live-browser-dom.test.mjs b/tests/live-browser-dom.test.mjs index f537df2ab..597801108 100644 --- a/tests/live-browser-dom.test.mjs +++ b/tests/live-browser-dom.test.mjs @@ -53,6 +53,7 @@ function createDocument() { activeElement: null, elementsById, getElementById(id) { return elementsById.get(id) || null; }, + querySelectorAll() { return []; }, }; } @@ -155,4 +156,39 @@ describe('live-browser-dom helpers', () => { root.listeners.focusin({ stopPropagation: () => { stopped += 1; } }); assert.equal(stopped, 3); }); + + it('detects framework HMR ownership while leaving static DOM eligible for cleanup', () => { + const staticDoc = createDocument(); + const { context, createHelpers } = loadFactory(staticDoc); + const helpers = createHelpers({ prefix: 'impeccable-live', document: staticDoc }); + const staticWrapper = createElement(); + + assert.equal(helpers.hasFrameworkHmrOwnership(staticWrapper), false); + + staticDoc.querySelectorAll = () => [{ getAttribute: () => '/app/@vite/client' }]; + assert.equal(helpers.hasFrameworkHmrOwnership(staticWrapper), false); + + staticDoc.querySelectorAll = () => []; + context.$RefreshReg$ = () => {}; + assert.equal(helpers.hasFrameworkHmrOwnership(staticWrapper), false); + delete context.$RefreshReg$; + + context.__VUE_HMR_RUNTIME__ = {}; + assert.equal(helpers.hasFrameworkHmrOwnership(staticWrapper), false); + delete context.__VUE_HMR_RUNTIME__; + + const reactWrapper = createElement(); + reactWrapper.__reactFiber$impeccable = {}; + assert.equal(helpers.hasFrameworkHmrOwnership(reactWrapper), true); + + const vueParent = createElement(); + vueParent.__vueParentComponent = {}; + const nestedWrapper = createElement(); + nestedWrapper.parentElement = vueParent; + assert.equal(helpers.hasFrameworkHmrOwnership(nestedWrapper), true); + + const svelteWrapper = createElement(); + svelteWrapper.__svelte_meta = {}; + assert.equal(helpers.hasFrameworkHmrOwnership(svelteWrapper), true); + }); }); diff --git a/tests/live-browser-regression.test.mjs b/tests/live-browser-regression.test.mjs index 7796404e3..ae583eb32 100644 --- a/tests/live-browser-regression.test.mjs +++ b/tests/live-browser-regression.test.mjs @@ -1057,7 +1057,7 @@ describe('live-browser.js regression guards', () => { it('promotes an early-accepted Svelte preview before releasing the picker', () => { assert.match( SOURCE, - /function scheduleAcceptCleanup\(accepted\) \{[\s\S]{0,420}?if \(accepted\?\.isSvelteComponent\) \{[\s\S]{0,120}?commitAcceptedSvelteComponentToDom\(accepted\.id\);[\s\S]{0,120}?cleanupAcceptedSession\(\);/, + /function scheduleAcceptCleanup\(accepted\) \{[\s\S]{0,650}?if \(accepted\?\.isSvelteComponent\) \{[\s\S]{0,120}?commitAcceptedSvelteComponentToDom\(accepted\.id\);[\s\S]{0,120}?cleanupAcceptedSession\(\);/, 'Svelte early accept must tear down its adapter mount before the next picking session starts', ); }); diff --git a/tests/live-browser-session.test.mjs b/tests/live-browser-session.test.mjs index 1b712de08..8a7c2e8d1 100644 --- a/tests/live-browser-session.test.mjs +++ b/tests/live-browser-session.test.mjs @@ -61,4 +61,24 @@ describe('live-browser-session state helper', () => { 'event=live_browser_session.revision_resume actor=browser operation=reload_checkpoint risk=durable_store_ignores_stale_checkpoint expected=3 actual=' + second.currentCheckpointRevision(), ); }); + + it('retains overlapping handled sessions and reads the legacy single-id format', () => { + const createState = loadFactory(); + const storage = createMemoryStorage(); + const first = createState({ prefix: 'impeccable-live', storage, idFactory: () => 'owner-a' }); + + first.markHandled('session-a'); + first.markHandled('session-b'); + assert.equal(first.isHandled('session-a'), true); + assert.equal(first.isHandled('session-b'), true); + + const second = createState({ prefix: 'impeccable-live', storage, idFactory: () => 'owner-b' }); + assert.equal(second.isHandled('session-a'), true, 'handled sessions survive reload-equivalent helpers'); + second.clearHandled('session-a'); + assert.equal(second.isHandled('session-a'), false); + assert.equal(second.isHandled('session-b'), true, 'clearing one recovery must preserve overlapping sessions'); + + storage.setItem(second.handledKey, 'legacy-session'); + assert.equal(second.isHandled('legacy-session'), true); + }); }); diff --git a/tests/live-browser-source.test.mjs b/tests/live-browser-source.test.mjs index af03f023d..235bd866d 100644 --- a/tests/live-browser-source.test.mjs +++ b/tests/live-browser-source.test.mjs @@ -353,12 +353,12 @@ describe('live-browser source contracts', () => { ); assert.match( SOURCE, - /function scheduleAcceptCleanup\(accepted\)[\s\S]*?queueMicrotask\(function\(\) \{[\s\S]*?cleanupAcceptedSession\(\);[\s\S]*?setTimeout\(function\(\) \{[\s\S]*?ensureAcceptedDomClean\(accepted\);[\s\S]*?\}, 1200\);/, + /function scheduleAcceptCleanup\(accepted\)[\s\S]*?queueMicrotask\(function\(\) \{[\s\S]*?cleanupAcceptedSession\(\);[\s\S]*?setTimeout\(function\(\) \{[\s\S]*?ensureAcceptedDomClean\(accepted, recoveryRevision\);[\s\S]*?\}, 1200\);/, 'foreground cleanup should be immediate while the no-HMR DOM fallback stays deferred', ); assert.match( SOURCE, - /function ensureAcceptedDomClean\(pending\)[\s\S]*?acceptedDomAlreadyClean\(pending\)[\s\S]*?findAcceptedRuntimeWrappers\(sessionId\)[\s\S]*?for \(const wrapper of wrappers\)[\s\S]*?parent\.insertBefore\(accepted\.firstChild, wrapper\);[\s\S]*?wrapper\.remove\(\);[\s\S]*?acceptedDomAlreadyClean\(pending\)/, + /function ensureAcceptedDomClean\(pending, recoveryRevision\)[\s\S]*?acceptedDomAlreadyClean\(pending\)[\s\S]*?findAcceptedRuntimeWrappers\(sessionId\)[\s\S]*?for \(const wrapper of wrappers\)[\s\S]*?parent\.insertBefore\(accepted\.firstChild, wrapper\);[\s\S]*?wrapper\.remove\(\);[\s\S]*?acceptedDomAlreadyClean\(pending\)/, 'post-cleanup fallback should unwrap the accepted variant instead of preserving live runtime wrappers', ); assert.match( @@ -383,9 +383,174 @@ describe('live-browser source contracts', () => { ); assert.match( SOURCE, - /function reloadAfterMissingAcceptedDom\(pending\)[\s\S]*?location\.reload\(\);/, + /function reloadAfterMissingAcceptedDom\(pending, recoveryRevision\)[\s\S]*?location\.reload\(\);/, 'missing accepted DOM after clean source should recover by reloading the clean page', ); + assert.match( + SOURCE, + /restoreAcceptedDomFromSnapshot\(pending, recoveryRevision\)[\s\S]*?function restoreAcceptedDomFromSnapshot\(pending, recoveryRevision\)[\s\S]*?reloadAfterMissingAcceptedDom\(pending, recoveryRevision\)/, + 'snapshot restoration must carry the originating recovery revision into its reload fallback', + ); + assert.match( + SOURCE, + /function ensureAcceptedDomClean\(pending, recoveryRevision\) \{[\s\S]{0,250}?deferredRecoverySuperseded\(pending\?\.id, recoveryRevision\)[\s\S]*?setTimeout\(function\(\) \{[\s\S]{0,180}?deferredRecoverySuperseded\(pending\?\.id, recoveryRevision\)[\s\S]{0,180}?location\.reload\(\);/, + 'accepted-session cleanup and its reload fallback must yield to a newer Live session', + ); + }); + + it('never runs accept or discard structural fallbacks inside framework-owned HMR DOM', () => { + assert.match( + SOURCE, + /if \(hasFrameworkHmrOwnership\(wrappers\[0\] \|\| pending\?\.parentElement\)\) \{[\s\S]{0,500}?acceptedDomAlreadyClean\(pending\)[\s\S]{0,80}?location\.reload\(\);[\s\S]{0,80}?return;[\s\S]{0,120}?if \(wrappers\.length === 0\)/, + 'accept cleanup must use a reload grace fallback before any framework-owned structural mutation', + ); + assert.match( + SOURCE, + /if \(hasFrameworkHmrOwnership\(lateWrapper\)\) \{[\s\S]{0,900}?location\.reload\(\);[\s\S]{0,100}?return;[\s\S]{0,150}?releaseDiscardedStaticWrapper\(lateWrapper, cleanupSessionId\)/, + 'discard cleanup must use a reload grace fallback before replacing a framework-owned wrapper', + ); + assert.match( + SOURCE, + /function releaseDiscardedStaticWrapper\(wrapper, sessionId\)[\s\S]{0,400}?replaceChild\(content, wrapper\)/, + 'only the static-wrapper release helper may structurally restore discarded DOM', + ); + assert.match( + SOURCE, + /if \(hasFrameworkHmrOwnership\(lateWrapper\)\) \{[\s\S]{0,700}?removeDiscardStateStylesheet\(cleanupSessionId\);[\s\S]{0,120}?location\.reload\(\);/, + 'discard must keep its original-visibility stylesheet until the HMR grace window ends', + ); + assert.match( + SOURCE, + /const recoverySuperseded = deferredRecoverySuperseded\(cleanupSessionId, cleanupRevision\);[\s\S]{0,500}?if \(recoverySuperseded\) \{[\s\S]{0,250}?watchForDiscardedFrameworkWrapperRemoval\(cleanupSessionId\)[\s\S]{0,150}?releaseDiscardedStaticWrapper\(lateWrapper, cleanupSessionId\)[\s\S]{0,80}?return;/, + 'discard cleanup and its reload grace callback must yield to a newer Live session', + ); + assert.match( + SOURCE, + /function discardStateStyleId\(sessionId\)[\s\S]{0,100}?DISCARD_STATE_STYLE_ID \+ '-' \+ sessionId[\s\S]{0,400}?getElementById\(discardStateStyleId\(sessionId\)\)/, + 'concurrent discard sessions must retain independent visibility stylesheets', + ); + assert.match( + SOURCE, + /function removeDiscardStateStylesheet\(sessionId\)[\s\S]{0,100}?if \(!sessionId\) return;[\s\S]{0,100}?getElementById\(discardStateStyleId\(sessionId\)\)\?\.remove\(\);/, + 'an older discard callback must remove only its own session stylesheet', + ); + assert.match( + SOURCE, + /setTimeout\(function\(\) \{[\s\S]{0,300}?const staleWrapper = document\.querySelector[\s\S]{0,250}?deferredRecoverySuperseded\(cleanupSessionId, cleanupRevision\)[\s\S]{0,250}?watchForDiscardedFrameworkWrapperRemoval\(cleanupSessionId\)[\s\S]{0,100}?return;[\s\S]{0,100}?removeDiscardStateStylesheet\(cleanupSessionId\);[\s\S]{0,100}?location\.reload\(\);/, + 'framework discard recovery may observe safe HMR cleanup but must not reload replacement work', + ); + assert.match( + SOURCE, + /const discardedFrameworkWrapperWatchers = new Map\(\);[\s\S]*?function watchForDiscardedFrameworkWrapperRemoval\(sessionId\)[\s\S]{0,1500}?const replacementActive = !!currentSessionId[\s\S]{0,180}?state !== 'IDLE' && state !== 'PICKING'[\s\S]{0,300}?setTimeout\(resolveStillMounted, 12000\)[\s\S]{0,300}?location\.reload\(\);/, + 'discard recovery must keep observing during replacement work and reload stale framework DOM once Live is idle', + ); + }); + + it('recovers a handled variant or carbonize wrapper after HMR cancels the original cleanup timer', () => { + const start = SOURCE.indexOf('function scheduleHandledRuntimeWrapperReload(wrapper,'); + const end = SOURCE.indexOf('\n function resumeSession(', start); + const recovery = SOURCE.slice(start, end); + assert.match(recovery, /impeccableCarbonize/); + assert.match(recovery, /sessionStorage\.getItem\(handledWrapperReloadKey\(sessionId\)\)/); + assert.match(recovery, /sessionStorage\.setItem\(handledWrapperReloadKey\(sessionId\), String\(reloadAttempts \+ 1\)\)/); + assert.match(recovery, /if \(reloadAttempts >= 2\) return true;/); + assert.match(recovery, /handledRuntimeWrapperReloadSessions\.has\(sessionId\)/); + assert.match(recovery, /handledRuntimeWrapperReloadSessions\.add\(sessionId\)/); + assert.match( + recovery, + /deferredRecoverySuperseded\(sessionId, recoveryRevision\)[\s\S]*?return true;[\s\S]*?setTimeout\(function\(\) \{[\s\S]{0,180}?deferredRecoverySuperseded\(sessionId, recoveryRevision\)[\s\S]{0,220}?handledRuntimeWrapperReloadSessions\.delete\(sessionId\);[\s\S]{0,80}?return;/, + 'handled-wrapper recovery must never reload a newer Live session', + ); + assert.match( + SOURCE, + /const handledRuntimeWrapperReloadSessions = new Set\(\);[\s\S]*?function handledWrapperReloadKey\(sessionId\)[\s\S]{0,100}?HANDLED_WRAPPER_RELOAD_KEY \+ ':' \+ sessionId/, + 'overlapping handled sessions must have independent timers and retry budgets', + ); + assert.match(recovery, /\[data-impeccable-variants=.+\[data-impeccable-carbonize=/s); + assert.match(recovery, /if \(staleWrapper\) location\.reload\(\);/); + assert.match( + SOURCE, + /function resumeSession\(recoveryRevision = liveInteractionRevision\)[\s\S]{0,250}?\[data-impeccable-carbonize\][\s\S]{0,180}?scheduleHandledRuntimeWrapperReload\(runtimeWrapper, recoveryRevision\)/, + 'resume must inspect handled carbonize wrappers before clearing handled state', + ); + assert.match( + SOURCE, + /function restoreSessionSupersedingHandledWrapper\(runtimeWrapper\)[\s\S]{0,900}?saved\.id === handledSessionId[\s\S]{0,300}?restoreSessionWithoutWrapper\('browser_resumed_over_handled_wrapper'\)/, + 'handled-wrapper recovery must recognize a different durable session as newer work', + ); + assert.match( + SOURCE, + /function isUsableInjectionAnchor\(el\)[\s\S]{0,250}?closest\?\.\('\[data-impeccable-variants\],\[data-impeccable-carbonize\]'\)/, + 'a newer restored session must wait for a real page anchor outside stale handled wrappers', + ); + const resumeStart = SOURCE.indexOf('function resumeSession('); + const resumeEnd = SOURCE.indexOf('\n //', resumeStart); + const resume = SOURCE.slice(resumeStart, resumeEnd); + assert.ok( + resume.indexOf('restoreSessionSupersedingHandledWrapper(runtimeWrapper)') + < resume.indexOf('scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)'), + 'a newer durable session must restore before a stale handled wrapper can schedule another reload', + ); + assert.doesNotMatch( + resume, + /clearHandled\(\);/, + 'bounded handled state must survive arbitrarily late wrapper hydration and later reloads', + ); + assert.match( + resume, + /browser_resumed_svelte_orphan_wrapper[\s\S]{0,150}?clearHandled\(sessionId\);/, + 'orphan cleanup must clear only its own handled id', + ); + assert.match( + SOURCE, + /if \(!accepted\?\.isSvelteComponent\) \{[\s\S]{0,100}?watchForHandledRuntimeWrapper\(accepted\?\.id, recoveryRevision\);/, + 'accept cleanup should watch for a carbonize wrapper mounted by a delayed framework refresh', + ); + assert.match( + recovery, + /function watchForHandledRuntimeWrapper\(sessionId, recoveryRevision = liveInteractionRevision\)[\s\S]*?handledRuntimeWrapperWatchers\.get\(sessionId\)[\s\S]*?observer\.observe\(document\.body, \{ childList: true, subtree: true \}\)[\s\S]*?handledRuntimeWrapperWatchers\.set\(sessionId, \{ observer, timer \}\);/, + 'late handled-wrapper recovery should remain bounded while covering slow HMR updates', + ); + assert.match( + SOURCE, + /const handledRuntimeWrapperWatchers = new Map\(\);[\s\S]*?handledRuntimeWrapperWatchers\.delete\(sessionId\);/, + 'overlapping handled-wrapper scouts must retain independent observer state per session', + ); + }); + + it('keeps watching for a framework wrapper when session restore wins the hydration race', () => { + assert.match( + SOURCE, + /const resumed = resumeSession\(\);[\s\S]{0,1200}?if \(!resumed \|\| !document\.querySelector\('\[data-impeccable-variants\],\[data-impeccable-carbonize\]'\)\) \{[\s\S]{0,500}?const scout = new MutationObserver/, + 'restoring durable session state before hydration must still install the deferred-wrapper scout', + ); + assert.match( + SOURCE, + /const deferredResumeRevision = liveInteractionRevision;[\s\S]{0,350}?const scout = new MutationObserver[\s\S]{0,350}?resumeSession\(deferredResumeRevision\)/, + 'the deferred-wrapper scout must retain its originating interaction revision', + ); + }); + + it('invalidates nullable deferred recovery as soon as a replacement edit starts configuring', () => { + assert.match( + SOURCE, + /function beginNewLiveConfiguration\(\) \{[\s\S]{0,100}?liveInteractionRevision \+= 1;[\s\S]{0,80}?setLiveState\('CONFIGURING'\);/, + ); + assert.equal( + SOURCE.match(/beginNewLiveConfiguration\(\);/g)?.length || 0, + 3, + 'mouse replace, mouse insert, and keyboard configuration must all supersede older recovery timers', + ); + assert.match( + SOURCE, + /function deferredRecoverySuperseded\(sessionId, recoveryRevision\) \{[\s\S]{0,160}?liveInteractionRevision !== recoveryRevision[\s\S]{0,100}?currentSessionId !== sessionId/, + 'recovery must be fenced before a replacement configuration has a non-null session id', + ); + assert.match( + SOURCE, + /function scheduleAcceptCleanup\(accepted\) \{[\s\S]{0,100}?const recoveryRevision = liveInteractionRevision;[\s\S]*?watchForHandledRuntimeWrapper\(accepted\?\.id, recoveryRevision\)/, + 'accept and handled-wrapper recovery must share the originating interaction revision', + ); }); it('normalizes generated JSX source before source-fallback DOM parsing', () => { diff --git a/tests/live-e2e/agent.mjs b/tests/live-e2e/agent.mjs index aac83e985..ca2f2d4c6 100644 --- a/tests/live-e2e/agent.mjs +++ b/tests/live-e2e/agent.mjs @@ -29,6 +29,7 @@ import { promisify } from 'node:util'; import { completionTypeForAcceptResult } from '../../skill/scripts/live/completion.mjs'; const execFileP = promisify(execFile); +const CARBONIZE_HMR_BOUNDARY_MS = 250; export const STEER_MARKER_ATTR = 'data-impeccable-steer'; export const STEER_MARKER_VALUE = 'e2e'; @@ -2172,6 +2173,13 @@ export async function runAgentLoop({ const post = await fs.readFile(path.join(tmp, acceptResult.file), 'utf-8'); log(`--- post-accept (pre-carbonize) ---\n${post}`); } + // live-accept writes the intermediate carbonize tree, then a real + // agent reads its cleanup instructions before writing the final + // source. The deterministic agent otherwise collapses both writes + // into the same filesystem watcher tick, so Vite can observe the + // carbonize state but miss the clean state entirely. Preserve the + // real protocol boundary instead of relying on browser DOM cleanup. + await new Promise((resolve) => setTimeout(resolve, CARBONIZE_HMR_BOUNDARY_MS)); await runCarbonizeCleanup({ tmp, file: acceptResult.file, sessionId: event.id, variant: event.variantId }); log(`carbonize cleanup done on ${acceptResult.file}`); } @@ -2455,7 +2463,12 @@ async function runCarbonizeCleanup({ tmp, file, sessionId /* , variant */ }) { // element is now dead weight. body = body.replace(/\s+data-impeccable-hoist-id="[^"]*"/g, ''); - await fs.writeFile(filePath, body, 'utf-8'); + // Publish the clean source atomically. A direct write briefly exposes a + // zero-byte file, which can make the harness (and Vite) observe cleanup as + // complete before the accepted source has actually landed. + const temporaryPath = `${filePath}.impeccable-carbonize-${sessionId}.tmp`; + await fs.writeFile(temporaryPath, body, 'utf-8'); + await fs.rename(temporaryPath, filePath); } function unwrapDivAttributeWrapper(body, attrName, { expandSingleLineContainer = false } = {}) { diff --git a/tests/live-reference.test.mjs b/tests/live-reference.test.mjs index e9df0e261..25fc58719 100644 --- a/tests/live-reference.test.mjs +++ b/tests/live-reference.test.mjs @@ -11,8 +11,8 @@ describe('live reference authoring contract', () => { const skillSrc = readFileSync(join(ROOT, 'skill/SKILL.src.md'), 'utf-8'); const liveMd = readFileSync(join(ROOT, 'skill/reference/live.md'), 'utf-8'); - assert.match(skillSrc, /load the one playbook that owns the request/); - assert.match(skillSrc, /Commands table's reference for an explicit or clearly implied sub-command/); + assert.match(skillSrc, /Load the request's playbook/); + assert.match(skillSrc, /Commands-table reference for an explicit\/implied sub-command/); assert.doesNotMatch(skillSrc, /Use this same scripts directory for all Impeccable helper commands/); assert.doesNotMatch(skillSrc, /walk upward for the nearest project `\.agents`, `\.claude`, or `\.cursor` skill/); assert.doesNotMatch(skillSrc, /## Context diagnostics/); @@ -23,7 +23,7 @@ describe('live reference authoring contract', () => { const skillSrc = readFileSync(join(ROOT, 'skill/SKILL.src.md'), 'utf-8'); const liveMd = readFileSync(join(ROOT, 'skill/reference/live.md'), 'utf-8'); - assert.match(skillSrc, /load the one playbook that owns the request/); + assert.match(skillSrc, /Load the request's playbook/); assert.doesNotMatch(skillSrc, /TARGET_SELECTION_REQUIRED/); assert.doesNotMatch(skillSrc, /productStatus/); assert.doesNotMatch(skillSrc, /designStatus/); From a70acd2823a100bb854ce84b938cdb7bbb5de44f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:10:03 +0000 Subject: [PATCH 11/31] Sync generated provider output --- .../impeccable/scripts/live-browser-dom.js | 21 ++ .../scripts/live-browser-session.js | 29 +- .../skills/impeccable/scripts/live-browser.js | 315 +++++++++++++++--- .../impeccable/scripts/live-browser-dom.js | 21 ++ .../scripts/live-browser-session.js | 29 +- .../skills/impeccable/scripts/live-browser.js | 315 +++++++++++++++--- .../impeccable/scripts/live-browser-dom.js | 21 ++ .../scripts/live-browser-session.js | 29 +- .../skills/impeccable/scripts/live-browser.js | 315 +++++++++++++++--- .../impeccable/scripts/live-browser-dom.js | 21 ++ .../scripts/live-browser-session.js | 29 +- .../skills/impeccable/scripts/live-browser.js | 315 +++++++++++++++--- .../impeccable/scripts/live-browser-dom.js | 21 ++ .../scripts/live-browser-session.js | 29 +- .../skills/impeccable/scripts/live-browser.js | 315 +++++++++++++++--- .../impeccable/scripts/live-browser-dom.js | 21 ++ .../scripts/live-browser-session.js | 29 +- .../skills/impeccable/scripts/live-browser.js | 315 +++++++++++++++--- .../impeccable/scripts/live-browser-dom.js | 21 ++ .../scripts/live-browser-session.js | 29 +- .../skills/impeccable/scripts/live-browser.js | 315 +++++++++++++++--- .../impeccable/scripts/live-browser-dom.js | 21 ++ .../scripts/live-browser-session.js | 29 +- .../skills/impeccable/scripts/live-browser.js | 315 +++++++++++++++--- .../impeccable/scripts/live-browser-dom.js | 21 ++ .../scripts/live-browser-session.js | 29 +- .../skills/impeccable/scripts/live-browser.js | 315 +++++++++++++++--- .../impeccable/scripts/live-browser-dom.js | 21 ++ .../scripts/live-browser-session.js | 29 +- .pi/skills/impeccable/scripts/live-browser.js | 315 +++++++++++++++--- .../impeccable/scripts/live-browser-dom.js | 21 ++ .../scripts/live-browser-session.js | 29 +- .../skills/impeccable/scripts/live-browser.js | 315 +++++++++++++++--- .../impeccable/scripts/live-browser-dom.js | 21 ++ .../scripts/live-browser-session.js | 29 +- .../skills/impeccable/scripts/live-browser.js | 315 +++++++++++++++--- .../impeccable/scripts/live-browser-dom.js | 21 ++ .../scripts/live-browser-session.js | 29 +- .../skills/impeccable/scripts/live-browser.js | 315 +++++++++++++++--- .../impeccable/scripts/live-browser-dom.js | 21 ++ .../scripts/live-browser-session.js | 29 +- .../skills/impeccable/scripts/live-browser.js | 315 +++++++++++++++--- .../impeccable/scripts/live-browser-dom.js | 21 ++ .../scripts/live-browser-session.js | 29 +- .../skills/impeccable/scripts/live-browser.js | 315 +++++++++++++++--- .../impeccable/scripts/live-browser-dom.js | 21 ++ .../scripts/live-browser-session.js | 29 +- .../skills/impeccable/scripts/live-browser.js | 315 +++++++++++++++--- 48 files changed, 5136 insertions(+), 704 deletions(-) diff --git a/.agents/skills/impeccable/scripts/live-browser-dom.js b/.agents/skills/impeccable/scripts/live-browser-dom.js index ad6a794b3..e85c95c43 100644 --- a/.agents/skills/impeccable/scripts/live-browser-dom.js +++ b/.agents/skills/impeccable/scripts/live-browser-dom.js @@ -64,6 +64,26 @@ }; } + function hasFrameworkHmrOwnership(el) { + for (let node = el; node; node = node.parentElement) { + let keys = []; + try { keys = Object.getOwnPropertyNames(node); } catch {} + if (keys.some((key) => ( + key.startsWith('__reactFiber$') + || key.startsWith('__reactProps$') + || key.startsWith('__reactContainer$') + || key === '_reactRootContainer' + || key === '__vueParentComponent' + || key === '__vue_app__' + || key === '__vnode' + || key === '__svelte_meta' + ))) { + return true; + } + } + return false; + } + function id8() { if (crypto?.randomUUID) return crypto.randomUUID().replace(/-/g, '').slice(0, 8); return (Math.random().toString(16).slice(2) + Date.now().toString(16)).slice(0, 8); @@ -128,6 +148,7 @@ desc, rectIsUsableAnchor, makeFrozenAnchor, + hasFrameworkHmrOwnership, id8, cssId, liveUiRoot, diff --git a/.agents/skills/impeccable/scripts/live-browser-session.js b/.agents/skills/impeccable/scripts/live-browser-session.js index 0e362d6be..1514bf472 100644 --- a/.agents/skills/impeccable/scripts/live-browser-session.js +++ b/.agents/skills/impeccable/scripts/live-browser-session.js @@ -71,17 +71,38 @@ return checkpointRevision; } + function readHandledIds() { + const raw = safeRead(handledKey); + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed.filter(id => typeof id === 'string' && id); + } + if (typeof parsed === 'string' && parsed) return [parsed]; + } catch { /* legacy values were stored as a plain session id */ } + return [raw]; + } + function markHandled(id) { if (!id) return; - safeWrite(handledKey, id); + const ids = readHandledIds().filter(existing => existing !== id); + ids.push(id); + safeWrite(handledKey, JSON.stringify(ids.slice(-8))); } function isHandled(id) { - return !!id && safeRead(handledKey) === id; + return !!id && readHandledIds().includes(id); } - function clearHandled() { - safeRemove(handledKey); + function clearHandled(id) { + if (!id) { + safeRemove(handledKey); + return; + } + const remaining = readHandledIds().filter(existing => existing !== id); + if (remaining.length > 0) safeWrite(handledKey, JSON.stringify(remaining)); + else safeRemove(handledKey); } function writeScrollY(y) { diff --git a/.agents/skills/impeccable/scripts/live-browser.js b/.agents/skills/impeccable/scripts/live-browser.js index 2b38ec62e..1f373a894 100644 --- a/.agents/skills/impeccable/scripts/live-browser.js +++ b/.agents/skills/impeccable/scripts/live-browser.js @@ -121,6 +121,10 @@ let hoveredElement = null; let selectedElement = null; let currentSessionId = null; + // Advances when the user begins configuring a fresh edit, before that edit + // has a server session id. Deferred recovery captures this revision so an + // older accept/discard can never reload over a replacement configuration. + let liveInteractionRevision = 0; let expectedVariants = 0; let arrivedVariants = 0; let visibleVariant = 0; @@ -188,6 +192,9 @@ // when the real accept result arrives or a new session starts. let awaitingAcceptResult = null; let variantObserver = null; + const discardedFrameworkWrapperWatchers = new Map(); + const handledRuntimeWrapperWatchers = new Map(); + const handledRuntimeWrapperReloadSessions = new Set(); let variantSelectionInFlight = false; let variantSelectionPromise = null; let recoveringEmptyCycling = false; @@ -208,6 +215,7 @@ const SCROLL_ANCHOR_LOCK_ID = 'impeccable-scroll-anchor-lock'; const VARIANT_STATE_STYLE_ID = 'impeccable-variant-state'; const DISCARD_STATE_STYLE_ID = 'impeccable-discard-state'; + const HANDLED_WRAPPER_RELOAD_KEY = PREFIX + '-handled-wrapper-reload'; // Dedicated key for scroll position - SEPARATE from LS_KEY so that // saveSession's state updates don't clobber a carefully-captured scrollY. @@ -270,6 +278,7 @@ desc, rectIsUsableAnchor, makeFrozenAnchor, + hasFrameworkHmrOwnership, id8, cssId, liveUiRoot, @@ -2034,6 +2043,16 @@ syncSteerQueueHint(); } + function beginNewLiveConfiguration() { + liveInteractionRevision += 1; + setLiveState('CONFIGURING'); + } + + function deferredRecoverySuperseded(sessionId, recoveryRevision) { + return liveInteractionRevision !== recoveryRevision + || !!(currentSessionId && currentSessionId !== sessionId); + } + /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { @@ -5036,7 +5055,7 @@ && el.parentElement && document.body.contains(el) && !own(el) - && !el.closest?.('[data-impeccable-variants]'); + && !el.closest?.('[data-impeccable-variants],[data-impeccable-carbonize]'); } function elementMatchesOriginalMarkup(liveEl, origContent) { @@ -6608,19 +6627,78 @@ document.getElementById(VARIANT_STATE_STYLE_ID)?.remove(); } + function discardStateStyleId(sessionId) { + return DISCARD_STATE_STYLE_ID + '-' + sessionId; + } + function showOriginalDuringDiscard(sessionId) { if (!sessionId) return; - let styleEl = document.getElementById(DISCARD_STATE_STYLE_ID); + let styleEl = document.getElementById(discardStateStyleId(sessionId)); if (!styleEl) { styleEl = document.createElement('style'); - styleEl.id = DISCARD_STATE_STYLE_ID; + styleEl.id = discardStateStyleId(sessionId); (document.head || document.documentElement).appendChild(styleEl); } + styleEl.dataset.impeccableDiscardSession = sessionId; const wrapper = '[data-impeccable-variants="' + sessionId + '"]'; styleEl.textContent = wrapper + ' > [data-impeccable-variant]:not([data-impeccable-variant="original"]) { display:none !important; }\n' + wrapper + ' > [data-impeccable-variant="original"] { display:block !important; }'; } + function removeDiscardStateStylesheet(sessionId) { + if (!sessionId) return; + document.getElementById(discardStateStyleId(sessionId))?.remove(); + } + + function releaseDiscardedStaticWrapper(wrapper, sessionId) { + removeDiscardStateStylesheet(sessionId); + if (!wrapper) return; + const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); + const content = orig?.firstElementChild; + if (content && wrapper.parentElement) { + wrapper.parentElement.replaceChild(content, wrapper); + return; + } + wrapper.remove(); + } + + function watchForDiscardedFrameworkWrapperRemoval(sessionId) { + if (!sessionId || !document.body) return; + if (discardedFrameworkWrapperWatchers.has(sessionId)) return; + const selector = '[data-impeccable-variants="' + sessionId + '"]'; + let observer = null; + let timer = null; + const stopWatching = function() { + observer?.disconnect(); + if (timer) clearTimeout(timer); + discardedFrameworkWrapperWatchers.delete(sessionId); + }; + const finishIfGone = function() { + if (document.querySelector(selector)) return false; + removeDiscardStateStylesheet(sessionId); + stopWatching(); + return true; + }; + if (finishIfGone()) return; + observer = new MutationObserver(finishIfGone); + observer.observe(document.body, { childList: true, subtree: true }); + const resolveStillMounted = function() { + if (finishIfGone()) return; + const replacementActive = !!currentSessionId + || (state !== 'IDLE' && state !== 'PICKING'); + if (replacementActive) { + timer = setTimeout(resolveStillMounted, 12000); + discardedFrameworkWrapperWatchers.get(sessionId).timer = timer; + return; + } + removeDiscardStateStylesheet(sessionId); + stopWatching(); + location.reload(); + }; + timer = setTimeout(resolveStillMounted, 12000); + discardedFrameworkWrapperWatchers.set(sessionId, { observer, timer }); + } + function resolveScrollLockAnchorTop() { const anchor = resolveBarAnchor(); if (!anchor?.isConnected) return null; @@ -7335,7 +7413,7 @@ hideInsertLine(); configureKind = 'insert'; selectedElement = placeholder; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); hideHighlight(); clearAnnotations(); showAnnotOverlay(placeholder); @@ -7353,7 +7431,7 @@ e.preventDefault(); e.stopPropagation(); selectedElement = hoveredElement; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -7530,7 +7608,7 @@ } else if (e.key === 'Enter') { e.preventDefault(); selectedElement = hoveredElement; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -8535,6 +8613,7 @@ void main() { } function scheduleAcceptCleanup(accepted) { + const recoveryRevision = liveInteractionRevision; queueMicrotask(function() { if (pendingAcceptedSession?.id !== accepted?.id) return; // Svelte previews live in an adapter-owned mount rather than in source @@ -8551,8 +8630,10 @@ void main() { // races. Static servers still need a fallback, but it must not keep Live // in SAVING or block the user's next pick. if (!accepted?.isSvelteComponent) { + watchForHandledRuntimeWrapper(accepted?.id, recoveryRevision); setTimeout(function() { - if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted); + if (deferredRecoverySuperseded(accepted?.id, recoveryRevision)) return; + if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted, recoveryRevision); }, 1200); } } @@ -8585,13 +8666,27 @@ void main() { && matches.every((el) => !el.closest('[data-impeccable-variants],[data-impeccable-variant],[data-impeccable-carbonize]')); } - function ensureAcceptedDomClean(pending) { + function ensureAcceptedDomClean(pending, recoveryRevision) { + // Background cleanup for an accepted session must never mutate or reload + // a newer comparison the user has already started. + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; if (acceptedDomAlreadyClean(pending)) return; const sessionId = pending?.id; const variantId = pending?.variant; const wrappers = findAcceptedRuntimeWrappers(sessionId); + if (hasFrameworkHmrOwnership(wrappers[0] || pending?.parentElement)) { + // Vite can coalesce rapid scaffold/carbonize writes and leave the last + // framework-owned preview tree mounted even though source is clean. Give + // HMR another grace window, then reload from clean source rather than + // violating reconciler ownership with a manual DOM mutation. + setTimeout(function() { + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; + if (!acceptedDomAlreadyClean(pending)) location.reload(); + }, 2000); + return; + } if (wrappers.length === 0) { - restoreAcceptedDomFromSnapshot(pending); + restoreAcceptedDomFromSnapshot(pending, recoveryRevision); return; } for (const wrapper of wrappers) { @@ -8608,7 +8703,7 @@ void main() { } wrapper.remove(); } - if (!acceptedDomAlreadyClean(pending)) restoreAcceptedDomFromSnapshot(pending); + if (!acceptedDomAlreadyClean(pending)) restoreAcceptedDomFromSnapshot(pending, recoveryRevision); } function findAcceptedRuntimeWrappers(sessionId) { @@ -8619,17 +8714,17 @@ void main() { ])]; } - function restoreAcceptedDomFromSnapshot(pending) { + function restoreAcceptedDomFromSnapshot(pending, recoveryRevision) { if (acceptedDomAlreadyClean(pending)) return; if (!pending?.acceptedHtml) { - reloadAfterMissingAcceptedDom(pending); + reloadAfterMissingAcceptedDom(pending, recoveryRevision); return; } const parent = pending.parentElement?.isConnected ? pending.parentElement : (pending.parentSelector ? document.querySelector(pending.parentSelector) : null); if (!parent) { - reloadAfterMissingAcceptedDom(pending); + reloadAfterMissingAcceptedDom(pending, recoveryRevision); return; } const template = document.createElement('template'); @@ -8638,10 +8733,11 @@ void main() { ? pending.nextSibling : null; parent.insertBefore(template.content, anchor); - if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending); + if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending, recoveryRevision); } - function reloadAfterMissingAcceptedDom(pending) { + function reloadAfterMissingAcceptedDom(pending, recoveryRevision) { + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; if (acceptedDomAlreadyClean(pending)) return; if (pending?.id && document.querySelector('[data-impeccable-variants="' + pending.id + '"]')) return; location.reload(); @@ -8991,14 +9087,15 @@ void main() { return sessionState.isHandled(id); } - function clearHandled() { - sessionState.clearHandled(); + function clearHandled(sessionId) { + sessionState.clearHandled(sessionId); } function cleanup(options) { const restoreOriginal = options?.restoreOriginal === true; const instantChrome = options?.instantChrome === true; const cleanupSessionId = currentSessionId; + const cleanupRevision = liveInteractionRevision; clearMountErrorCard(); lastReportedMountFailure = null; if (svelteComponentSession?.sessionId === cleanupSessionId) { @@ -9016,19 +9113,41 @@ void main() { else wrapper.style.display = 'none'; } setTimeout(function() { - document.getElementById(DISCARD_STATE_STYLE_ID)?.remove(); - if (!cleanupSessionId) return; - const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!lateWrapper) return; - const orig = lateWrapper.querySelector('[data-impeccable-variant="original"]'); - if (orig) { - const content = orig.firstElementChild; - if (content) { - lateWrapper.parentElement.replaceChild(content, lateWrapper); - return; - } + const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); + if (!cleanupSessionId) { + removeDiscardStateStylesheet(); + return; } - lateWrapper.remove(); + const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (!lateWrapper) { + removeDiscardStateStylesheet(cleanupSessionId); + return; + } + if (recoverySuperseded) { + if (hasFrameworkHmrOwnership(lateWrapper)) { + watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); + } else { + releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + } + return; + } + if (hasFrameworkHmrOwnership(lateWrapper)) { + // As on accept, never restructure framework-owned DOM. If HMR missed + // the final source rewrite, reload once after a grace window so the + // discarded source becomes authoritative without a reconciler race. + setTimeout(function() { + const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { + if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId); + else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); + return; + } + removeDiscardStateStylesheet(cleanupSessionId); + if (staleWrapper) location.reload(); + }, 2000); + return; + } + releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); }, 2000); } hideBar(instantChrome); @@ -9111,12 +9230,122 @@ void main() { // Resume an active variant session after HMR/page reload. // If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote // variants before HMR fired. Pick up where we left off. - function resumeSession() { + function handledWrapperReloadKey(sessionId) { + return HANDLED_WRAPPER_RELOAD_KEY + ':' + sessionId; + } + + function clearHandledWrapperReloadStamp(sessionId) { + try { + if (sessionId) { + sessionStorage.removeItem(handledWrapperReloadKey(sessionId)); + const legacy = sessionStorage.getItem(HANDLED_WRAPPER_RELOAD_KEY) || ''; + if (legacy === sessionId || legacy.startsWith(sessionId + ':')) { + sessionStorage.removeItem(HANDLED_WRAPPER_RELOAD_KEY); + } + return; + } + sessionStorage.removeItem(HANDLED_WRAPPER_RELOAD_KEY); + for (let i = sessionStorage.length - 1; i >= 0; i--) { + const key = sessionStorage.key(i); + if (key?.startsWith(HANDLED_WRAPPER_RELOAD_KEY + ':')) sessionStorage.removeItem(key); + } + } catch {} + } + + function scheduleHandledRuntimeWrapperReload(wrapper, recoveryRevision = liveInteractionRevision) { + const sessionId = wrapper?.dataset?.impeccableVariants + || wrapper?.dataset?.impeccableCarbonize; + if (!sessionId || !isSessionHandled(sessionId)) return false; + if (deferredRecoverySuperseded(sessionId, recoveryRevision)) return true; + + if (handledRuntimeWrapperReloadSessions.has(sessionId)) return true; + let reloadAttempts = 0; + try { + reloadAttempts = Number(sessionStorage.getItem(handledWrapperReloadKey(sessionId))) || 0; + if (reloadAttempts >= 2) return true; + sessionStorage.setItem(handledWrapperReloadKey(sessionId), String(reloadAttempts + 1)); + } catch {} + handledRuntimeWrapperReloadSessions.add(sessionId); + + // A framework refresh can replace the variants tree with an intermediate + // carbonize tree and reload the page, cancelling the original accept timer. + // Let the file-side cleanup settle, then reload once from authoritative + // source. The sessionStorage stamp prevents a stale dev-server response + // from turning this recovery into a reload loop. + setTimeout(function() { + if (deferredRecoverySuperseded(sessionId, recoveryRevision)) { + clearHandledWrapperReloadStamp(sessionId); + handledRuntimeWrapperReloadSessions.delete(sessionId); + return; + } + const staleWrapper = document.querySelector( + '[data-impeccable-variants="' + sessionId + '"],' + + '[data-impeccable-carbonize="' + sessionId + '"]', + ); + if (staleWrapper) location.reload(); + else { + clearHandledWrapperReloadStamp(sessionId); + handledRuntimeWrapperReloadSessions.delete(sessionId); + } + }, 3000); + return true; + } + + function watchForHandledRuntimeWrapper(sessionId, recoveryRevision = liveInteractionRevision) { + if (!sessionId || !document.body) return; + const existing = handledRuntimeWrapperWatchers.get(sessionId); + existing?.observer.disconnect(); + if (existing?.timer) clearTimeout(existing.timer); + + const findHandledWrapper = function() { + const wrapper = document.querySelector( + '[data-impeccable-variants="' + sessionId + '"],' + + '[data-impeccable-carbonize="' + sessionId + '"]', + ); + if (wrapper) scheduleHandledRuntimeWrapperReload(wrapper, recoveryRevision); + }; + + // Vite can briefly render the clean accepted tree, then apply a delayed + // carbonize refresh after the one-shot accept fallback has already passed. + // Keep a bounded scout alive through that refresh window so a late stale + // framework tree still reloads from the now-authoritative source. + const observer = new MutationObserver(findHandledWrapper); + observer.observe(document.body, { childList: true, subtree: true }); + const timer = setTimeout(function() { + if (handledRuntimeWrapperWatchers.get(sessionId)?.observer !== observer) return; + observer.disconnect(); + handledRuntimeWrapperWatchers.delete(sessionId); + }, 12000); + handledRuntimeWrapperWatchers.set(sessionId, { observer, timer }); + findHandledWrapper(); + } + + function restoreSessionSupersedingHandledWrapper(runtimeWrapper) { + const handledSessionId = runtimeWrapper?.dataset?.impeccableVariants + || runtimeWrapper?.dataset?.impeccableCarbonize; + if (!handledSessionId || !isSessionHandled(handledSessionId)) return false; + + // Accept releases the picker before carbonize finishes, so a replacement + // generation can already be durable while the prior handled wrapper is + // still mounted. Restore that newer session before the stale-wrapper + // recovery path gets a chance to reload or consume its retry budget. + const saved = loadSession(); + if (!saved?.id || saved.id === handledSessionId || isSessionHandled(saved.id)) return false; + if (currentSessionId === saved.id) return true; + return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); + } + + function resumeSession(recoveryRevision = liveInteractionRevision) { const wrapper = document.querySelector('[data-impeccable-variants]'); + const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); + if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; + if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; if (!wrapper) { if (restoreSessionWithoutWrapper('browser_resumed_without_wrapper')) return true; clearSession(); - clearHandled(); + // Keep the bounded handled-id history durable. A framework can hydrate a + // completed wrapper well after initialization, and a later reload must + // still recognize that wrapper as recovery work rather than resume it. return false; } @@ -9136,7 +9365,7 @@ void main() { wrapper.remove(); if (restoreSessionWithoutWrapper('browser_resumed_svelte_orphan_wrapper')) return true; clearSession(); - clearHandled(); + clearHandled(sessionId); return false; } @@ -12520,22 +12749,28 @@ void main() { connectSSE(); // Check for an active session to resume (variant wrapper already in DOM after HMR) - if (!resumeSession()) { + const resumed = resumeSession(); + if (!resumed) { console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.'); - // SvelteKit (and any framework that hydrates after HTML parse) may add - // the variant wrapper AFTER init runs. Watch for it and retry resume - // once it appears. Disconnect on first hit. + } else { + console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); + } + + // SvelteKit, React, and other frameworks may restore a durable session + // before hydration adds its variant wrapper. Keep a deferred-wrapper scout + // whenever init did not see a runtime wrapper, even if local/server state + // was already restored successfully. Disconnect on the first wrapper hit. + if (!resumed || !document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]')) { + const deferredResumeRevision = liveInteractionRevision; const scout = new MutationObserver(() => { - const wrapper = document.querySelector('[data-impeccable-variants]'); + const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); if (!wrapper) return; scout.disconnect(); - if (resumeSession()) { + if (resumeSession(deferredResumeRevision)) { console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); } }); scout.observe(document.body, { childList: true, subtree: true }); - } else { - console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); } if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING'); diff --git a/.claude/skills/impeccable/scripts/live-browser-dom.js b/.claude/skills/impeccable/scripts/live-browser-dom.js index ad6a794b3..e85c95c43 100644 --- a/.claude/skills/impeccable/scripts/live-browser-dom.js +++ b/.claude/skills/impeccable/scripts/live-browser-dom.js @@ -64,6 +64,26 @@ }; } + function hasFrameworkHmrOwnership(el) { + for (let node = el; node; node = node.parentElement) { + let keys = []; + try { keys = Object.getOwnPropertyNames(node); } catch {} + if (keys.some((key) => ( + key.startsWith('__reactFiber$') + || key.startsWith('__reactProps$') + || key.startsWith('__reactContainer$') + || key === '_reactRootContainer' + || key === '__vueParentComponent' + || key === '__vue_app__' + || key === '__vnode' + || key === '__svelte_meta' + ))) { + return true; + } + } + return false; + } + function id8() { if (crypto?.randomUUID) return crypto.randomUUID().replace(/-/g, '').slice(0, 8); return (Math.random().toString(16).slice(2) + Date.now().toString(16)).slice(0, 8); @@ -128,6 +148,7 @@ desc, rectIsUsableAnchor, makeFrozenAnchor, + hasFrameworkHmrOwnership, id8, cssId, liveUiRoot, diff --git a/.claude/skills/impeccable/scripts/live-browser-session.js b/.claude/skills/impeccable/scripts/live-browser-session.js index 0e362d6be..1514bf472 100644 --- a/.claude/skills/impeccable/scripts/live-browser-session.js +++ b/.claude/skills/impeccable/scripts/live-browser-session.js @@ -71,17 +71,38 @@ return checkpointRevision; } + function readHandledIds() { + const raw = safeRead(handledKey); + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed.filter(id => typeof id === 'string' && id); + } + if (typeof parsed === 'string' && parsed) return [parsed]; + } catch { /* legacy values were stored as a plain session id */ } + return [raw]; + } + function markHandled(id) { if (!id) return; - safeWrite(handledKey, id); + const ids = readHandledIds().filter(existing => existing !== id); + ids.push(id); + safeWrite(handledKey, JSON.stringify(ids.slice(-8))); } function isHandled(id) { - return !!id && safeRead(handledKey) === id; + return !!id && readHandledIds().includes(id); } - function clearHandled() { - safeRemove(handledKey); + function clearHandled(id) { + if (!id) { + safeRemove(handledKey); + return; + } + const remaining = readHandledIds().filter(existing => existing !== id); + if (remaining.length > 0) safeWrite(handledKey, JSON.stringify(remaining)); + else safeRemove(handledKey); } function writeScrollY(y) { diff --git a/.claude/skills/impeccable/scripts/live-browser.js b/.claude/skills/impeccable/scripts/live-browser.js index 2b38ec62e..1f373a894 100644 --- a/.claude/skills/impeccable/scripts/live-browser.js +++ b/.claude/skills/impeccable/scripts/live-browser.js @@ -121,6 +121,10 @@ let hoveredElement = null; let selectedElement = null; let currentSessionId = null; + // Advances when the user begins configuring a fresh edit, before that edit + // has a server session id. Deferred recovery captures this revision so an + // older accept/discard can never reload over a replacement configuration. + let liveInteractionRevision = 0; let expectedVariants = 0; let arrivedVariants = 0; let visibleVariant = 0; @@ -188,6 +192,9 @@ // when the real accept result arrives or a new session starts. let awaitingAcceptResult = null; let variantObserver = null; + const discardedFrameworkWrapperWatchers = new Map(); + const handledRuntimeWrapperWatchers = new Map(); + const handledRuntimeWrapperReloadSessions = new Set(); let variantSelectionInFlight = false; let variantSelectionPromise = null; let recoveringEmptyCycling = false; @@ -208,6 +215,7 @@ const SCROLL_ANCHOR_LOCK_ID = 'impeccable-scroll-anchor-lock'; const VARIANT_STATE_STYLE_ID = 'impeccable-variant-state'; const DISCARD_STATE_STYLE_ID = 'impeccable-discard-state'; + const HANDLED_WRAPPER_RELOAD_KEY = PREFIX + '-handled-wrapper-reload'; // Dedicated key for scroll position - SEPARATE from LS_KEY so that // saveSession's state updates don't clobber a carefully-captured scrollY. @@ -270,6 +278,7 @@ desc, rectIsUsableAnchor, makeFrozenAnchor, + hasFrameworkHmrOwnership, id8, cssId, liveUiRoot, @@ -2034,6 +2043,16 @@ syncSteerQueueHint(); } + function beginNewLiveConfiguration() { + liveInteractionRevision += 1; + setLiveState('CONFIGURING'); + } + + function deferredRecoverySuperseded(sessionId, recoveryRevision) { + return liveInteractionRevision !== recoveryRevision + || !!(currentSessionId && currentSessionId !== sessionId); + } + /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { @@ -5036,7 +5055,7 @@ && el.parentElement && document.body.contains(el) && !own(el) - && !el.closest?.('[data-impeccable-variants]'); + && !el.closest?.('[data-impeccable-variants],[data-impeccable-carbonize]'); } function elementMatchesOriginalMarkup(liveEl, origContent) { @@ -6608,19 +6627,78 @@ document.getElementById(VARIANT_STATE_STYLE_ID)?.remove(); } + function discardStateStyleId(sessionId) { + return DISCARD_STATE_STYLE_ID + '-' + sessionId; + } + function showOriginalDuringDiscard(sessionId) { if (!sessionId) return; - let styleEl = document.getElementById(DISCARD_STATE_STYLE_ID); + let styleEl = document.getElementById(discardStateStyleId(sessionId)); if (!styleEl) { styleEl = document.createElement('style'); - styleEl.id = DISCARD_STATE_STYLE_ID; + styleEl.id = discardStateStyleId(sessionId); (document.head || document.documentElement).appendChild(styleEl); } + styleEl.dataset.impeccableDiscardSession = sessionId; const wrapper = '[data-impeccable-variants="' + sessionId + '"]'; styleEl.textContent = wrapper + ' > [data-impeccable-variant]:not([data-impeccable-variant="original"]) { display:none !important; }\n' + wrapper + ' > [data-impeccable-variant="original"] { display:block !important; }'; } + function removeDiscardStateStylesheet(sessionId) { + if (!sessionId) return; + document.getElementById(discardStateStyleId(sessionId))?.remove(); + } + + function releaseDiscardedStaticWrapper(wrapper, sessionId) { + removeDiscardStateStylesheet(sessionId); + if (!wrapper) return; + const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); + const content = orig?.firstElementChild; + if (content && wrapper.parentElement) { + wrapper.parentElement.replaceChild(content, wrapper); + return; + } + wrapper.remove(); + } + + function watchForDiscardedFrameworkWrapperRemoval(sessionId) { + if (!sessionId || !document.body) return; + if (discardedFrameworkWrapperWatchers.has(sessionId)) return; + const selector = '[data-impeccable-variants="' + sessionId + '"]'; + let observer = null; + let timer = null; + const stopWatching = function() { + observer?.disconnect(); + if (timer) clearTimeout(timer); + discardedFrameworkWrapperWatchers.delete(sessionId); + }; + const finishIfGone = function() { + if (document.querySelector(selector)) return false; + removeDiscardStateStylesheet(sessionId); + stopWatching(); + return true; + }; + if (finishIfGone()) return; + observer = new MutationObserver(finishIfGone); + observer.observe(document.body, { childList: true, subtree: true }); + const resolveStillMounted = function() { + if (finishIfGone()) return; + const replacementActive = !!currentSessionId + || (state !== 'IDLE' && state !== 'PICKING'); + if (replacementActive) { + timer = setTimeout(resolveStillMounted, 12000); + discardedFrameworkWrapperWatchers.get(sessionId).timer = timer; + return; + } + removeDiscardStateStylesheet(sessionId); + stopWatching(); + location.reload(); + }; + timer = setTimeout(resolveStillMounted, 12000); + discardedFrameworkWrapperWatchers.set(sessionId, { observer, timer }); + } + function resolveScrollLockAnchorTop() { const anchor = resolveBarAnchor(); if (!anchor?.isConnected) return null; @@ -7335,7 +7413,7 @@ hideInsertLine(); configureKind = 'insert'; selectedElement = placeholder; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); hideHighlight(); clearAnnotations(); showAnnotOverlay(placeholder); @@ -7353,7 +7431,7 @@ e.preventDefault(); e.stopPropagation(); selectedElement = hoveredElement; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -7530,7 +7608,7 @@ } else if (e.key === 'Enter') { e.preventDefault(); selectedElement = hoveredElement; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -8535,6 +8613,7 @@ void main() { } function scheduleAcceptCleanup(accepted) { + const recoveryRevision = liveInteractionRevision; queueMicrotask(function() { if (pendingAcceptedSession?.id !== accepted?.id) return; // Svelte previews live in an adapter-owned mount rather than in source @@ -8551,8 +8630,10 @@ void main() { // races. Static servers still need a fallback, but it must not keep Live // in SAVING or block the user's next pick. if (!accepted?.isSvelteComponent) { + watchForHandledRuntimeWrapper(accepted?.id, recoveryRevision); setTimeout(function() { - if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted); + if (deferredRecoverySuperseded(accepted?.id, recoveryRevision)) return; + if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted, recoveryRevision); }, 1200); } } @@ -8585,13 +8666,27 @@ void main() { && matches.every((el) => !el.closest('[data-impeccable-variants],[data-impeccable-variant],[data-impeccable-carbonize]')); } - function ensureAcceptedDomClean(pending) { + function ensureAcceptedDomClean(pending, recoveryRevision) { + // Background cleanup for an accepted session must never mutate or reload + // a newer comparison the user has already started. + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; if (acceptedDomAlreadyClean(pending)) return; const sessionId = pending?.id; const variantId = pending?.variant; const wrappers = findAcceptedRuntimeWrappers(sessionId); + if (hasFrameworkHmrOwnership(wrappers[0] || pending?.parentElement)) { + // Vite can coalesce rapid scaffold/carbonize writes and leave the last + // framework-owned preview tree mounted even though source is clean. Give + // HMR another grace window, then reload from clean source rather than + // violating reconciler ownership with a manual DOM mutation. + setTimeout(function() { + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; + if (!acceptedDomAlreadyClean(pending)) location.reload(); + }, 2000); + return; + } if (wrappers.length === 0) { - restoreAcceptedDomFromSnapshot(pending); + restoreAcceptedDomFromSnapshot(pending, recoveryRevision); return; } for (const wrapper of wrappers) { @@ -8608,7 +8703,7 @@ void main() { } wrapper.remove(); } - if (!acceptedDomAlreadyClean(pending)) restoreAcceptedDomFromSnapshot(pending); + if (!acceptedDomAlreadyClean(pending)) restoreAcceptedDomFromSnapshot(pending, recoveryRevision); } function findAcceptedRuntimeWrappers(sessionId) { @@ -8619,17 +8714,17 @@ void main() { ])]; } - function restoreAcceptedDomFromSnapshot(pending) { + function restoreAcceptedDomFromSnapshot(pending, recoveryRevision) { if (acceptedDomAlreadyClean(pending)) return; if (!pending?.acceptedHtml) { - reloadAfterMissingAcceptedDom(pending); + reloadAfterMissingAcceptedDom(pending, recoveryRevision); return; } const parent = pending.parentElement?.isConnected ? pending.parentElement : (pending.parentSelector ? document.querySelector(pending.parentSelector) : null); if (!parent) { - reloadAfterMissingAcceptedDom(pending); + reloadAfterMissingAcceptedDom(pending, recoveryRevision); return; } const template = document.createElement('template'); @@ -8638,10 +8733,11 @@ void main() { ? pending.nextSibling : null; parent.insertBefore(template.content, anchor); - if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending); + if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending, recoveryRevision); } - function reloadAfterMissingAcceptedDom(pending) { + function reloadAfterMissingAcceptedDom(pending, recoveryRevision) { + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; if (acceptedDomAlreadyClean(pending)) return; if (pending?.id && document.querySelector('[data-impeccable-variants="' + pending.id + '"]')) return; location.reload(); @@ -8991,14 +9087,15 @@ void main() { return sessionState.isHandled(id); } - function clearHandled() { - sessionState.clearHandled(); + function clearHandled(sessionId) { + sessionState.clearHandled(sessionId); } function cleanup(options) { const restoreOriginal = options?.restoreOriginal === true; const instantChrome = options?.instantChrome === true; const cleanupSessionId = currentSessionId; + const cleanupRevision = liveInteractionRevision; clearMountErrorCard(); lastReportedMountFailure = null; if (svelteComponentSession?.sessionId === cleanupSessionId) { @@ -9016,19 +9113,41 @@ void main() { else wrapper.style.display = 'none'; } setTimeout(function() { - document.getElementById(DISCARD_STATE_STYLE_ID)?.remove(); - if (!cleanupSessionId) return; - const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!lateWrapper) return; - const orig = lateWrapper.querySelector('[data-impeccable-variant="original"]'); - if (orig) { - const content = orig.firstElementChild; - if (content) { - lateWrapper.parentElement.replaceChild(content, lateWrapper); - return; - } + const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); + if (!cleanupSessionId) { + removeDiscardStateStylesheet(); + return; } - lateWrapper.remove(); + const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (!lateWrapper) { + removeDiscardStateStylesheet(cleanupSessionId); + return; + } + if (recoverySuperseded) { + if (hasFrameworkHmrOwnership(lateWrapper)) { + watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); + } else { + releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + } + return; + } + if (hasFrameworkHmrOwnership(lateWrapper)) { + // As on accept, never restructure framework-owned DOM. If HMR missed + // the final source rewrite, reload once after a grace window so the + // discarded source becomes authoritative without a reconciler race. + setTimeout(function() { + const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { + if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId); + else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); + return; + } + removeDiscardStateStylesheet(cleanupSessionId); + if (staleWrapper) location.reload(); + }, 2000); + return; + } + releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); }, 2000); } hideBar(instantChrome); @@ -9111,12 +9230,122 @@ void main() { // Resume an active variant session after HMR/page reload. // If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote // variants before HMR fired. Pick up where we left off. - function resumeSession() { + function handledWrapperReloadKey(sessionId) { + return HANDLED_WRAPPER_RELOAD_KEY + ':' + sessionId; + } + + function clearHandledWrapperReloadStamp(sessionId) { + try { + if (sessionId) { + sessionStorage.removeItem(handledWrapperReloadKey(sessionId)); + const legacy = sessionStorage.getItem(HANDLED_WRAPPER_RELOAD_KEY) || ''; + if (legacy === sessionId || legacy.startsWith(sessionId + ':')) { + sessionStorage.removeItem(HANDLED_WRAPPER_RELOAD_KEY); + } + return; + } + sessionStorage.removeItem(HANDLED_WRAPPER_RELOAD_KEY); + for (let i = sessionStorage.length - 1; i >= 0; i--) { + const key = sessionStorage.key(i); + if (key?.startsWith(HANDLED_WRAPPER_RELOAD_KEY + ':')) sessionStorage.removeItem(key); + } + } catch {} + } + + function scheduleHandledRuntimeWrapperReload(wrapper, recoveryRevision = liveInteractionRevision) { + const sessionId = wrapper?.dataset?.impeccableVariants + || wrapper?.dataset?.impeccableCarbonize; + if (!sessionId || !isSessionHandled(sessionId)) return false; + if (deferredRecoverySuperseded(sessionId, recoveryRevision)) return true; + + if (handledRuntimeWrapperReloadSessions.has(sessionId)) return true; + let reloadAttempts = 0; + try { + reloadAttempts = Number(sessionStorage.getItem(handledWrapperReloadKey(sessionId))) || 0; + if (reloadAttempts >= 2) return true; + sessionStorage.setItem(handledWrapperReloadKey(sessionId), String(reloadAttempts + 1)); + } catch {} + handledRuntimeWrapperReloadSessions.add(sessionId); + + // A framework refresh can replace the variants tree with an intermediate + // carbonize tree and reload the page, cancelling the original accept timer. + // Let the file-side cleanup settle, then reload once from authoritative + // source. The sessionStorage stamp prevents a stale dev-server response + // from turning this recovery into a reload loop. + setTimeout(function() { + if (deferredRecoverySuperseded(sessionId, recoveryRevision)) { + clearHandledWrapperReloadStamp(sessionId); + handledRuntimeWrapperReloadSessions.delete(sessionId); + return; + } + const staleWrapper = document.querySelector( + '[data-impeccable-variants="' + sessionId + '"],' + + '[data-impeccable-carbonize="' + sessionId + '"]', + ); + if (staleWrapper) location.reload(); + else { + clearHandledWrapperReloadStamp(sessionId); + handledRuntimeWrapperReloadSessions.delete(sessionId); + } + }, 3000); + return true; + } + + function watchForHandledRuntimeWrapper(sessionId, recoveryRevision = liveInteractionRevision) { + if (!sessionId || !document.body) return; + const existing = handledRuntimeWrapperWatchers.get(sessionId); + existing?.observer.disconnect(); + if (existing?.timer) clearTimeout(existing.timer); + + const findHandledWrapper = function() { + const wrapper = document.querySelector( + '[data-impeccable-variants="' + sessionId + '"],' + + '[data-impeccable-carbonize="' + sessionId + '"]', + ); + if (wrapper) scheduleHandledRuntimeWrapperReload(wrapper, recoveryRevision); + }; + + // Vite can briefly render the clean accepted tree, then apply a delayed + // carbonize refresh after the one-shot accept fallback has already passed. + // Keep a bounded scout alive through that refresh window so a late stale + // framework tree still reloads from the now-authoritative source. + const observer = new MutationObserver(findHandledWrapper); + observer.observe(document.body, { childList: true, subtree: true }); + const timer = setTimeout(function() { + if (handledRuntimeWrapperWatchers.get(sessionId)?.observer !== observer) return; + observer.disconnect(); + handledRuntimeWrapperWatchers.delete(sessionId); + }, 12000); + handledRuntimeWrapperWatchers.set(sessionId, { observer, timer }); + findHandledWrapper(); + } + + function restoreSessionSupersedingHandledWrapper(runtimeWrapper) { + const handledSessionId = runtimeWrapper?.dataset?.impeccableVariants + || runtimeWrapper?.dataset?.impeccableCarbonize; + if (!handledSessionId || !isSessionHandled(handledSessionId)) return false; + + // Accept releases the picker before carbonize finishes, so a replacement + // generation can already be durable while the prior handled wrapper is + // still mounted. Restore that newer session before the stale-wrapper + // recovery path gets a chance to reload or consume its retry budget. + const saved = loadSession(); + if (!saved?.id || saved.id === handledSessionId || isSessionHandled(saved.id)) return false; + if (currentSessionId === saved.id) return true; + return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); + } + + function resumeSession(recoveryRevision = liveInteractionRevision) { const wrapper = document.querySelector('[data-impeccable-variants]'); + const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); + if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; + if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; if (!wrapper) { if (restoreSessionWithoutWrapper('browser_resumed_without_wrapper')) return true; clearSession(); - clearHandled(); + // Keep the bounded handled-id history durable. A framework can hydrate a + // completed wrapper well after initialization, and a later reload must + // still recognize that wrapper as recovery work rather than resume it. return false; } @@ -9136,7 +9365,7 @@ void main() { wrapper.remove(); if (restoreSessionWithoutWrapper('browser_resumed_svelte_orphan_wrapper')) return true; clearSession(); - clearHandled(); + clearHandled(sessionId); return false; } @@ -12520,22 +12749,28 @@ void main() { connectSSE(); // Check for an active session to resume (variant wrapper already in DOM after HMR) - if (!resumeSession()) { + const resumed = resumeSession(); + if (!resumed) { console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.'); - // SvelteKit (and any framework that hydrates after HTML parse) may add - // the variant wrapper AFTER init runs. Watch for it and retry resume - // once it appears. Disconnect on first hit. + } else { + console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); + } + + // SvelteKit, React, and other frameworks may restore a durable session + // before hydration adds its variant wrapper. Keep a deferred-wrapper scout + // whenever init did not see a runtime wrapper, even if local/server state + // was already restored successfully. Disconnect on the first wrapper hit. + if (!resumed || !document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]')) { + const deferredResumeRevision = liveInteractionRevision; const scout = new MutationObserver(() => { - const wrapper = document.querySelector('[data-impeccable-variants]'); + const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); if (!wrapper) return; scout.disconnect(); - if (resumeSession()) { + if (resumeSession(deferredResumeRevision)) { console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); } }); scout.observe(document.body, { childList: true, subtree: true }); - } else { - console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); } if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING'); diff --git a/.cursor/skills/impeccable/scripts/live-browser-dom.js b/.cursor/skills/impeccable/scripts/live-browser-dom.js index ad6a794b3..e85c95c43 100644 --- a/.cursor/skills/impeccable/scripts/live-browser-dom.js +++ b/.cursor/skills/impeccable/scripts/live-browser-dom.js @@ -64,6 +64,26 @@ }; } + function hasFrameworkHmrOwnership(el) { + for (let node = el; node; node = node.parentElement) { + let keys = []; + try { keys = Object.getOwnPropertyNames(node); } catch {} + if (keys.some((key) => ( + key.startsWith('__reactFiber$') + || key.startsWith('__reactProps$') + || key.startsWith('__reactContainer$') + || key === '_reactRootContainer' + || key === '__vueParentComponent' + || key === '__vue_app__' + || key === '__vnode' + || key === '__svelte_meta' + ))) { + return true; + } + } + return false; + } + function id8() { if (crypto?.randomUUID) return crypto.randomUUID().replace(/-/g, '').slice(0, 8); return (Math.random().toString(16).slice(2) + Date.now().toString(16)).slice(0, 8); @@ -128,6 +148,7 @@ desc, rectIsUsableAnchor, makeFrozenAnchor, + hasFrameworkHmrOwnership, id8, cssId, liveUiRoot, diff --git a/.cursor/skills/impeccable/scripts/live-browser-session.js b/.cursor/skills/impeccable/scripts/live-browser-session.js index 0e362d6be..1514bf472 100644 --- a/.cursor/skills/impeccable/scripts/live-browser-session.js +++ b/.cursor/skills/impeccable/scripts/live-browser-session.js @@ -71,17 +71,38 @@ return checkpointRevision; } + function readHandledIds() { + const raw = safeRead(handledKey); + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed.filter(id => typeof id === 'string' && id); + } + if (typeof parsed === 'string' && parsed) return [parsed]; + } catch { /* legacy values were stored as a plain session id */ } + return [raw]; + } + function markHandled(id) { if (!id) return; - safeWrite(handledKey, id); + const ids = readHandledIds().filter(existing => existing !== id); + ids.push(id); + safeWrite(handledKey, JSON.stringify(ids.slice(-8))); } function isHandled(id) { - return !!id && safeRead(handledKey) === id; + return !!id && readHandledIds().includes(id); } - function clearHandled() { - safeRemove(handledKey); + function clearHandled(id) { + if (!id) { + safeRemove(handledKey); + return; + } + const remaining = readHandledIds().filter(existing => existing !== id); + if (remaining.length > 0) safeWrite(handledKey, JSON.stringify(remaining)); + else safeRemove(handledKey); } function writeScrollY(y) { diff --git a/.cursor/skills/impeccable/scripts/live-browser.js b/.cursor/skills/impeccable/scripts/live-browser.js index 2b38ec62e..1f373a894 100644 --- a/.cursor/skills/impeccable/scripts/live-browser.js +++ b/.cursor/skills/impeccable/scripts/live-browser.js @@ -121,6 +121,10 @@ let hoveredElement = null; let selectedElement = null; let currentSessionId = null; + // Advances when the user begins configuring a fresh edit, before that edit + // has a server session id. Deferred recovery captures this revision so an + // older accept/discard can never reload over a replacement configuration. + let liveInteractionRevision = 0; let expectedVariants = 0; let arrivedVariants = 0; let visibleVariant = 0; @@ -188,6 +192,9 @@ // when the real accept result arrives or a new session starts. let awaitingAcceptResult = null; let variantObserver = null; + const discardedFrameworkWrapperWatchers = new Map(); + const handledRuntimeWrapperWatchers = new Map(); + const handledRuntimeWrapperReloadSessions = new Set(); let variantSelectionInFlight = false; let variantSelectionPromise = null; let recoveringEmptyCycling = false; @@ -208,6 +215,7 @@ const SCROLL_ANCHOR_LOCK_ID = 'impeccable-scroll-anchor-lock'; const VARIANT_STATE_STYLE_ID = 'impeccable-variant-state'; const DISCARD_STATE_STYLE_ID = 'impeccable-discard-state'; + const HANDLED_WRAPPER_RELOAD_KEY = PREFIX + '-handled-wrapper-reload'; // Dedicated key for scroll position - SEPARATE from LS_KEY so that // saveSession's state updates don't clobber a carefully-captured scrollY. @@ -270,6 +278,7 @@ desc, rectIsUsableAnchor, makeFrozenAnchor, + hasFrameworkHmrOwnership, id8, cssId, liveUiRoot, @@ -2034,6 +2043,16 @@ syncSteerQueueHint(); } + function beginNewLiveConfiguration() { + liveInteractionRevision += 1; + setLiveState('CONFIGURING'); + } + + function deferredRecoverySuperseded(sessionId, recoveryRevision) { + return liveInteractionRevision !== recoveryRevision + || !!(currentSessionId && currentSessionId !== sessionId); + } + /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { @@ -5036,7 +5055,7 @@ && el.parentElement && document.body.contains(el) && !own(el) - && !el.closest?.('[data-impeccable-variants]'); + && !el.closest?.('[data-impeccable-variants],[data-impeccable-carbonize]'); } function elementMatchesOriginalMarkup(liveEl, origContent) { @@ -6608,19 +6627,78 @@ document.getElementById(VARIANT_STATE_STYLE_ID)?.remove(); } + function discardStateStyleId(sessionId) { + return DISCARD_STATE_STYLE_ID + '-' + sessionId; + } + function showOriginalDuringDiscard(sessionId) { if (!sessionId) return; - let styleEl = document.getElementById(DISCARD_STATE_STYLE_ID); + let styleEl = document.getElementById(discardStateStyleId(sessionId)); if (!styleEl) { styleEl = document.createElement('style'); - styleEl.id = DISCARD_STATE_STYLE_ID; + styleEl.id = discardStateStyleId(sessionId); (document.head || document.documentElement).appendChild(styleEl); } + styleEl.dataset.impeccableDiscardSession = sessionId; const wrapper = '[data-impeccable-variants="' + sessionId + '"]'; styleEl.textContent = wrapper + ' > [data-impeccable-variant]:not([data-impeccable-variant="original"]) { display:none !important; }\n' + wrapper + ' > [data-impeccable-variant="original"] { display:block !important; }'; } + function removeDiscardStateStylesheet(sessionId) { + if (!sessionId) return; + document.getElementById(discardStateStyleId(sessionId))?.remove(); + } + + function releaseDiscardedStaticWrapper(wrapper, sessionId) { + removeDiscardStateStylesheet(sessionId); + if (!wrapper) return; + const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); + const content = orig?.firstElementChild; + if (content && wrapper.parentElement) { + wrapper.parentElement.replaceChild(content, wrapper); + return; + } + wrapper.remove(); + } + + function watchForDiscardedFrameworkWrapperRemoval(sessionId) { + if (!sessionId || !document.body) return; + if (discardedFrameworkWrapperWatchers.has(sessionId)) return; + const selector = '[data-impeccable-variants="' + sessionId + '"]'; + let observer = null; + let timer = null; + const stopWatching = function() { + observer?.disconnect(); + if (timer) clearTimeout(timer); + discardedFrameworkWrapperWatchers.delete(sessionId); + }; + const finishIfGone = function() { + if (document.querySelector(selector)) return false; + removeDiscardStateStylesheet(sessionId); + stopWatching(); + return true; + }; + if (finishIfGone()) return; + observer = new MutationObserver(finishIfGone); + observer.observe(document.body, { childList: true, subtree: true }); + const resolveStillMounted = function() { + if (finishIfGone()) return; + const replacementActive = !!currentSessionId + || (state !== 'IDLE' && state !== 'PICKING'); + if (replacementActive) { + timer = setTimeout(resolveStillMounted, 12000); + discardedFrameworkWrapperWatchers.get(sessionId).timer = timer; + return; + } + removeDiscardStateStylesheet(sessionId); + stopWatching(); + location.reload(); + }; + timer = setTimeout(resolveStillMounted, 12000); + discardedFrameworkWrapperWatchers.set(sessionId, { observer, timer }); + } + function resolveScrollLockAnchorTop() { const anchor = resolveBarAnchor(); if (!anchor?.isConnected) return null; @@ -7335,7 +7413,7 @@ hideInsertLine(); configureKind = 'insert'; selectedElement = placeholder; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); hideHighlight(); clearAnnotations(); showAnnotOverlay(placeholder); @@ -7353,7 +7431,7 @@ e.preventDefault(); e.stopPropagation(); selectedElement = hoveredElement; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -7530,7 +7608,7 @@ } else if (e.key === 'Enter') { e.preventDefault(); selectedElement = hoveredElement; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -8535,6 +8613,7 @@ void main() { } function scheduleAcceptCleanup(accepted) { + const recoveryRevision = liveInteractionRevision; queueMicrotask(function() { if (pendingAcceptedSession?.id !== accepted?.id) return; // Svelte previews live in an adapter-owned mount rather than in source @@ -8551,8 +8630,10 @@ void main() { // races. Static servers still need a fallback, but it must not keep Live // in SAVING or block the user's next pick. if (!accepted?.isSvelteComponent) { + watchForHandledRuntimeWrapper(accepted?.id, recoveryRevision); setTimeout(function() { - if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted); + if (deferredRecoverySuperseded(accepted?.id, recoveryRevision)) return; + if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted, recoveryRevision); }, 1200); } } @@ -8585,13 +8666,27 @@ void main() { && matches.every((el) => !el.closest('[data-impeccable-variants],[data-impeccable-variant],[data-impeccable-carbonize]')); } - function ensureAcceptedDomClean(pending) { + function ensureAcceptedDomClean(pending, recoveryRevision) { + // Background cleanup for an accepted session must never mutate or reload + // a newer comparison the user has already started. + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; if (acceptedDomAlreadyClean(pending)) return; const sessionId = pending?.id; const variantId = pending?.variant; const wrappers = findAcceptedRuntimeWrappers(sessionId); + if (hasFrameworkHmrOwnership(wrappers[0] || pending?.parentElement)) { + // Vite can coalesce rapid scaffold/carbonize writes and leave the last + // framework-owned preview tree mounted even though source is clean. Give + // HMR another grace window, then reload from clean source rather than + // violating reconciler ownership with a manual DOM mutation. + setTimeout(function() { + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; + if (!acceptedDomAlreadyClean(pending)) location.reload(); + }, 2000); + return; + } if (wrappers.length === 0) { - restoreAcceptedDomFromSnapshot(pending); + restoreAcceptedDomFromSnapshot(pending, recoveryRevision); return; } for (const wrapper of wrappers) { @@ -8608,7 +8703,7 @@ void main() { } wrapper.remove(); } - if (!acceptedDomAlreadyClean(pending)) restoreAcceptedDomFromSnapshot(pending); + if (!acceptedDomAlreadyClean(pending)) restoreAcceptedDomFromSnapshot(pending, recoveryRevision); } function findAcceptedRuntimeWrappers(sessionId) { @@ -8619,17 +8714,17 @@ void main() { ])]; } - function restoreAcceptedDomFromSnapshot(pending) { + function restoreAcceptedDomFromSnapshot(pending, recoveryRevision) { if (acceptedDomAlreadyClean(pending)) return; if (!pending?.acceptedHtml) { - reloadAfterMissingAcceptedDom(pending); + reloadAfterMissingAcceptedDom(pending, recoveryRevision); return; } const parent = pending.parentElement?.isConnected ? pending.parentElement : (pending.parentSelector ? document.querySelector(pending.parentSelector) : null); if (!parent) { - reloadAfterMissingAcceptedDom(pending); + reloadAfterMissingAcceptedDom(pending, recoveryRevision); return; } const template = document.createElement('template'); @@ -8638,10 +8733,11 @@ void main() { ? pending.nextSibling : null; parent.insertBefore(template.content, anchor); - if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending); + if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending, recoveryRevision); } - function reloadAfterMissingAcceptedDom(pending) { + function reloadAfterMissingAcceptedDom(pending, recoveryRevision) { + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; if (acceptedDomAlreadyClean(pending)) return; if (pending?.id && document.querySelector('[data-impeccable-variants="' + pending.id + '"]')) return; location.reload(); @@ -8991,14 +9087,15 @@ void main() { return sessionState.isHandled(id); } - function clearHandled() { - sessionState.clearHandled(); + function clearHandled(sessionId) { + sessionState.clearHandled(sessionId); } function cleanup(options) { const restoreOriginal = options?.restoreOriginal === true; const instantChrome = options?.instantChrome === true; const cleanupSessionId = currentSessionId; + const cleanupRevision = liveInteractionRevision; clearMountErrorCard(); lastReportedMountFailure = null; if (svelteComponentSession?.sessionId === cleanupSessionId) { @@ -9016,19 +9113,41 @@ void main() { else wrapper.style.display = 'none'; } setTimeout(function() { - document.getElementById(DISCARD_STATE_STYLE_ID)?.remove(); - if (!cleanupSessionId) return; - const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!lateWrapper) return; - const orig = lateWrapper.querySelector('[data-impeccable-variant="original"]'); - if (orig) { - const content = orig.firstElementChild; - if (content) { - lateWrapper.parentElement.replaceChild(content, lateWrapper); - return; - } + const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); + if (!cleanupSessionId) { + removeDiscardStateStylesheet(); + return; } - lateWrapper.remove(); + const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (!lateWrapper) { + removeDiscardStateStylesheet(cleanupSessionId); + return; + } + if (recoverySuperseded) { + if (hasFrameworkHmrOwnership(lateWrapper)) { + watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); + } else { + releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + } + return; + } + if (hasFrameworkHmrOwnership(lateWrapper)) { + // As on accept, never restructure framework-owned DOM. If HMR missed + // the final source rewrite, reload once after a grace window so the + // discarded source becomes authoritative without a reconciler race. + setTimeout(function() { + const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { + if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId); + else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); + return; + } + removeDiscardStateStylesheet(cleanupSessionId); + if (staleWrapper) location.reload(); + }, 2000); + return; + } + releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); }, 2000); } hideBar(instantChrome); @@ -9111,12 +9230,122 @@ void main() { // Resume an active variant session after HMR/page reload. // If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote // variants before HMR fired. Pick up where we left off. - function resumeSession() { + function handledWrapperReloadKey(sessionId) { + return HANDLED_WRAPPER_RELOAD_KEY + ':' + sessionId; + } + + function clearHandledWrapperReloadStamp(sessionId) { + try { + if (sessionId) { + sessionStorage.removeItem(handledWrapperReloadKey(sessionId)); + const legacy = sessionStorage.getItem(HANDLED_WRAPPER_RELOAD_KEY) || ''; + if (legacy === sessionId || legacy.startsWith(sessionId + ':')) { + sessionStorage.removeItem(HANDLED_WRAPPER_RELOAD_KEY); + } + return; + } + sessionStorage.removeItem(HANDLED_WRAPPER_RELOAD_KEY); + for (let i = sessionStorage.length - 1; i >= 0; i--) { + const key = sessionStorage.key(i); + if (key?.startsWith(HANDLED_WRAPPER_RELOAD_KEY + ':')) sessionStorage.removeItem(key); + } + } catch {} + } + + function scheduleHandledRuntimeWrapperReload(wrapper, recoveryRevision = liveInteractionRevision) { + const sessionId = wrapper?.dataset?.impeccableVariants + || wrapper?.dataset?.impeccableCarbonize; + if (!sessionId || !isSessionHandled(sessionId)) return false; + if (deferredRecoverySuperseded(sessionId, recoveryRevision)) return true; + + if (handledRuntimeWrapperReloadSessions.has(sessionId)) return true; + let reloadAttempts = 0; + try { + reloadAttempts = Number(sessionStorage.getItem(handledWrapperReloadKey(sessionId))) || 0; + if (reloadAttempts >= 2) return true; + sessionStorage.setItem(handledWrapperReloadKey(sessionId), String(reloadAttempts + 1)); + } catch {} + handledRuntimeWrapperReloadSessions.add(sessionId); + + // A framework refresh can replace the variants tree with an intermediate + // carbonize tree and reload the page, cancelling the original accept timer. + // Let the file-side cleanup settle, then reload once from authoritative + // source. The sessionStorage stamp prevents a stale dev-server response + // from turning this recovery into a reload loop. + setTimeout(function() { + if (deferredRecoverySuperseded(sessionId, recoveryRevision)) { + clearHandledWrapperReloadStamp(sessionId); + handledRuntimeWrapperReloadSessions.delete(sessionId); + return; + } + const staleWrapper = document.querySelector( + '[data-impeccable-variants="' + sessionId + '"],' + + '[data-impeccable-carbonize="' + sessionId + '"]', + ); + if (staleWrapper) location.reload(); + else { + clearHandledWrapperReloadStamp(sessionId); + handledRuntimeWrapperReloadSessions.delete(sessionId); + } + }, 3000); + return true; + } + + function watchForHandledRuntimeWrapper(sessionId, recoveryRevision = liveInteractionRevision) { + if (!sessionId || !document.body) return; + const existing = handledRuntimeWrapperWatchers.get(sessionId); + existing?.observer.disconnect(); + if (existing?.timer) clearTimeout(existing.timer); + + const findHandledWrapper = function() { + const wrapper = document.querySelector( + '[data-impeccable-variants="' + sessionId + '"],' + + '[data-impeccable-carbonize="' + sessionId + '"]', + ); + if (wrapper) scheduleHandledRuntimeWrapperReload(wrapper, recoveryRevision); + }; + + // Vite can briefly render the clean accepted tree, then apply a delayed + // carbonize refresh after the one-shot accept fallback has already passed. + // Keep a bounded scout alive through that refresh window so a late stale + // framework tree still reloads from the now-authoritative source. + const observer = new MutationObserver(findHandledWrapper); + observer.observe(document.body, { childList: true, subtree: true }); + const timer = setTimeout(function() { + if (handledRuntimeWrapperWatchers.get(sessionId)?.observer !== observer) return; + observer.disconnect(); + handledRuntimeWrapperWatchers.delete(sessionId); + }, 12000); + handledRuntimeWrapperWatchers.set(sessionId, { observer, timer }); + findHandledWrapper(); + } + + function restoreSessionSupersedingHandledWrapper(runtimeWrapper) { + const handledSessionId = runtimeWrapper?.dataset?.impeccableVariants + || runtimeWrapper?.dataset?.impeccableCarbonize; + if (!handledSessionId || !isSessionHandled(handledSessionId)) return false; + + // Accept releases the picker before carbonize finishes, so a replacement + // generation can already be durable while the prior handled wrapper is + // still mounted. Restore that newer session before the stale-wrapper + // recovery path gets a chance to reload or consume its retry budget. + const saved = loadSession(); + if (!saved?.id || saved.id === handledSessionId || isSessionHandled(saved.id)) return false; + if (currentSessionId === saved.id) return true; + return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); + } + + function resumeSession(recoveryRevision = liveInteractionRevision) { const wrapper = document.querySelector('[data-impeccable-variants]'); + const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); + if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; + if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; if (!wrapper) { if (restoreSessionWithoutWrapper('browser_resumed_without_wrapper')) return true; clearSession(); - clearHandled(); + // Keep the bounded handled-id history durable. A framework can hydrate a + // completed wrapper well after initialization, and a later reload must + // still recognize that wrapper as recovery work rather than resume it. return false; } @@ -9136,7 +9365,7 @@ void main() { wrapper.remove(); if (restoreSessionWithoutWrapper('browser_resumed_svelte_orphan_wrapper')) return true; clearSession(); - clearHandled(); + clearHandled(sessionId); return false; } @@ -12520,22 +12749,28 @@ void main() { connectSSE(); // Check for an active session to resume (variant wrapper already in DOM after HMR) - if (!resumeSession()) { + const resumed = resumeSession(); + if (!resumed) { console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.'); - // SvelteKit (and any framework that hydrates after HTML parse) may add - // the variant wrapper AFTER init runs. Watch for it and retry resume - // once it appears. Disconnect on first hit. + } else { + console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); + } + + // SvelteKit, React, and other frameworks may restore a durable session + // before hydration adds its variant wrapper. Keep a deferred-wrapper scout + // whenever init did not see a runtime wrapper, even if local/server state + // was already restored successfully. Disconnect on the first wrapper hit. + if (!resumed || !document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]')) { + const deferredResumeRevision = liveInteractionRevision; const scout = new MutationObserver(() => { - const wrapper = document.querySelector('[data-impeccable-variants]'); + const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); if (!wrapper) return; scout.disconnect(); - if (resumeSession()) { + if (resumeSession(deferredResumeRevision)) { console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); } }); scout.observe(document.body, { childList: true, subtree: true }); - } else { - console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); } if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING'); diff --git a/.gemini/skills/impeccable/scripts/live-browser-dom.js b/.gemini/skills/impeccable/scripts/live-browser-dom.js index ad6a794b3..e85c95c43 100644 --- a/.gemini/skills/impeccable/scripts/live-browser-dom.js +++ b/.gemini/skills/impeccable/scripts/live-browser-dom.js @@ -64,6 +64,26 @@ }; } + function hasFrameworkHmrOwnership(el) { + for (let node = el; node; node = node.parentElement) { + let keys = []; + try { keys = Object.getOwnPropertyNames(node); } catch {} + if (keys.some((key) => ( + key.startsWith('__reactFiber$') + || key.startsWith('__reactProps$') + || key.startsWith('__reactContainer$') + || key === '_reactRootContainer' + || key === '__vueParentComponent' + || key === '__vue_app__' + || key === '__vnode' + || key === '__svelte_meta' + ))) { + return true; + } + } + return false; + } + function id8() { if (crypto?.randomUUID) return crypto.randomUUID().replace(/-/g, '').slice(0, 8); return (Math.random().toString(16).slice(2) + Date.now().toString(16)).slice(0, 8); @@ -128,6 +148,7 @@ desc, rectIsUsableAnchor, makeFrozenAnchor, + hasFrameworkHmrOwnership, id8, cssId, liveUiRoot, diff --git a/.gemini/skills/impeccable/scripts/live-browser-session.js b/.gemini/skills/impeccable/scripts/live-browser-session.js index 0e362d6be..1514bf472 100644 --- a/.gemini/skills/impeccable/scripts/live-browser-session.js +++ b/.gemini/skills/impeccable/scripts/live-browser-session.js @@ -71,17 +71,38 @@ return checkpointRevision; } + function readHandledIds() { + const raw = safeRead(handledKey); + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed.filter(id => typeof id === 'string' && id); + } + if (typeof parsed === 'string' && parsed) return [parsed]; + } catch { /* legacy values were stored as a plain session id */ } + return [raw]; + } + function markHandled(id) { if (!id) return; - safeWrite(handledKey, id); + const ids = readHandledIds().filter(existing => existing !== id); + ids.push(id); + safeWrite(handledKey, JSON.stringify(ids.slice(-8))); } function isHandled(id) { - return !!id && safeRead(handledKey) === id; + return !!id && readHandledIds().includes(id); } - function clearHandled() { - safeRemove(handledKey); + function clearHandled(id) { + if (!id) { + safeRemove(handledKey); + return; + } + const remaining = readHandledIds().filter(existing => existing !== id); + if (remaining.length > 0) safeWrite(handledKey, JSON.stringify(remaining)); + else safeRemove(handledKey); } function writeScrollY(y) { diff --git a/.gemini/skills/impeccable/scripts/live-browser.js b/.gemini/skills/impeccable/scripts/live-browser.js index 2b38ec62e..1f373a894 100644 --- a/.gemini/skills/impeccable/scripts/live-browser.js +++ b/.gemini/skills/impeccable/scripts/live-browser.js @@ -121,6 +121,10 @@ let hoveredElement = null; let selectedElement = null; let currentSessionId = null; + // Advances when the user begins configuring a fresh edit, before that edit + // has a server session id. Deferred recovery captures this revision so an + // older accept/discard can never reload over a replacement configuration. + let liveInteractionRevision = 0; let expectedVariants = 0; let arrivedVariants = 0; let visibleVariant = 0; @@ -188,6 +192,9 @@ // when the real accept result arrives or a new session starts. let awaitingAcceptResult = null; let variantObserver = null; + const discardedFrameworkWrapperWatchers = new Map(); + const handledRuntimeWrapperWatchers = new Map(); + const handledRuntimeWrapperReloadSessions = new Set(); let variantSelectionInFlight = false; let variantSelectionPromise = null; let recoveringEmptyCycling = false; @@ -208,6 +215,7 @@ const SCROLL_ANCHOR_LOCK_ID = 'impeccable-scroll-anchor-lock'; const VARIANT_STATE_STYLE_ID = 'impeccable-variant-state'; const DISCARD_STATE_STYLE_ID = 'impeccable-discard-state'; + const HANDLED_WRAPPER_RELOAD_KEY = PREFIX + '-handled-wrapper-reload'; // Dedicated key for scroll position - SEPARATE from LS_KEY so that // saveSession's state updates don't clobber a carefully-captured scrollY. @@ -270,6 +278,7 @@ desc, rectIsUsableAnchor, makeFrozenAnchor, + hasFrameworkHmrOwnership, id8, cssId, liveUiRoot, @@ -2034,6 +2043,16 @@ syncSteerQueueHint(); } + function beginNewLiveConfiguration() { + liveInteractionRevision += 1; + setLiveState('CONFIGURING'); + } + + function deferredRecoverySuperseded(sessionId, recoveryRevision) { + return liveInteractionRevision !== recoveryRevision + || !!(currentSessionId && currentSessionId !== sessionId); + } + /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { @@ -5036,7 +5055,7 @@ && el.parentElement && document.body.contains(el) && !own(el) - && !el.closest?.('[data-impeccable-variants]'); + && !el.closest?.('[data-impeccable-variants],[data-impeccable-carbonize]'); } function elementMatchesOriginalMarkup(liveEl, origContent) { @@ -6608,19 +6627,78 @@ document.getElementById(VARIANT_STATE_STYLE_ID)?.remove(); } + function discardStateStyleId(sessionId) { + return DISCARD_STATE_STYLE_ID + '-' + sessionId; + } + function showOriginalDuringDiscard(sessionId) { if (!sessionId) return; - let styleEl = document.getElementById(DISCARD_STATE_STYLE_ID); + let styleEl = document.getElementById(discardStateStyleId(sessionId)); if (!styleEl) { styleEl = document.createElement('style'); - styleEl.id = DISCARD_STATE_STYLE_ID; + styleEl.id = discardStateStyleId(sessionId); (document.head || document.documentElement).appendChild(styleEl); } + styleEl.dataset.impeccableDiscardSession = sessionId; const wrapper = '[data-impeccable-variants="' + sessionId + '"]'; styleEl.textContent = wrapper + ' > [data-impeccable-variant]:not([data-impeccable-variant="original"]) { display:none !important; }\n' + wrapper + ' > [data-impeccable-variant="original"] { display:block !important; }'; } + function removeDiscardStateStylesheet(sessionId) { + if (!sessionId) return; + document.getElementById(discardStateStyleId(sessionId))?.remove(); + } + + function releaseDiscardedStaticWrapper(wrapper, sessionId) { + removeDiscardStateStylesheet(sessionId); + if (!wrapper) return; + const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); + const content = orig?.firstElementChild; + if (content && wrapper.parentElement) { + wrapper.parentElement.replaceChild(content, wrapper); + return; + } + wrapper.remove(); + } + + function watchForDiscardedFrameworkWrapperRemoval(sessionId) { + if (!sessionId || !document.body) return; + if (discardedFrameworkWrapperWatchers.has(sessionId)) return; + const selector = '[data-impeccable-variants="' + sessionId + '"]'; + let observer = null; + let timer = null; + const stopWatching = function() { + observer?.disconnect(); + if (timer) clearTimeout(timer); + discardedFrameworkWrapperWatchers.delete(sessionId); + }; + const finishIfGone = function() { + if (document.querySelector(selector)) return false; + removeDiscardStateStylesheet(sessionId); + stopWatching(); + return true; + }; + if (finishIfGone()) return; + observer = new MutationObserver(finishIfGone); + observer.observe(document.body, { childList: true, subtree: true }); + const resolveStillMounted = function() { + if (finishIfGone()) return; + const replacementActive = !!currentSessionId + || (state !== 'IDLE' && state !== 'PICKING'); + if (replacementActive) { + timer = setTimeout(resolveStillMounted, 12000); + discardedFrameworkWrapperWatchers.get(sessionId).timer = timer; + return; + } + removeDiscardStateStylesheet(sessionId); + stopWatching(); + location.reload(); + }; + timer = setTimeout(resolveStillMounted, 12000); + discardedFrameworkWrapperWatchers.set(sessionId, { observer, timer }); + } + function resolveScrollLockAnchorTop() { const anchor = resolveBarAnchor(); if (!anchor?.isConnected) return null; @@ -7335,7 +7413,7 @@ hideInsertLine(); configureKind = 'insert'; selectedElement = placeholder; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); hideHighlight(); clearAnnotations(); showAnnotOverlay(placeholder); @@ -7353,7 +7431,7 @@ e.preventDefault(); e.stopPropagation(); selectedElement = hoveredElement; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -7530,7 +7608,7 @@ } else if (e.key === 'Enter') { e.preventDefault(); selectedElement = hoveredElement; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -8535,6 +8613,7 @@ void main() { } function scheduleAcceptCleanup(accepted) { + const recoveryRevision = liveInteractionRevision; queueMicrotask(function() { if (pendingAcceptedSession?.id !== accepted?.id) return; // Svelte previews live in an adapter-owned mount rather than in source @@ -8551,8 +8630,10 @@ void main() { // races. Static servers still need a fallback, but it must not keep Live // in SAVING or block the user's next pick. if (!accepted?.isSvelteComponent) { + watchForHandledRuntimeWrapper(accepted?.id, recoveryRevision); setTimeout(function() { - if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted); + if (deferredRecoverySuperseded(accepted?.id, recoveryRevision)) return; + if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted, recoveryRevision); }, 1200); } } @@ -8585,13 +8666,27 @@ void main() { && matches.every((el) => !el.closest('[data-impeccable-variants],[data-impeccable-variant],[data-impeccable-carbonize]')); } - function ensureAcceptedDomClean(pending) { + function ensureAcceptedDomClean(pending, recoveryRevision) { + // Background cleanup for an accepted session must never mutate or reload + // a newer comparison the user has already started. + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; if (acceptedDomAlreadyClean(pending)) return; const sessionId = pending?.id; const variantId = pending?.variant; const wrappers = findAcceptedRuntimeWrappers(sessionId); + if (hasFrameworkHmrOwnership(wrappers[0] || pending?.parentElement)) { + // Vite can coalesce rapid scaffold/carbonize writes and leave the last + // framework-owned preview tree mounted even though source is clean. Give + // HMR another grace window, then reload from clean source rather than + // violating reconciler ownership with a manual DOM mutation. + setTimeout(function() { + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; + if (!acceptedDomAlreadyClean(pending)) location.reload(); + }, 2000); + return; + } if (wrappers.length === 0) { - restoreAcceptedDomFromSnapshot(pending); + restoreAcceptedDomFromSnapshot(pending, recoveryRevision); return; } for (const wrapper of wrappers) { @@ -8608,7 +8703,7 @@ void main() { } wrapper.remove(); } - if (!acceptedDomAlreadyClean(pending)) restoreAcceptedDomFromSnapshot(pending); + if (!acceptedDomAlreadyClean(pending)) restoreAcceptedDomFromSnapshot(pending, recoveryRevision); } function findAcceptedRuntimeWrappers(sessionId) { @@ -8619,17 +8714,17 @@ void main() { ])]; } - function restoreAcceptedDomFromSnapshot(pending) { + function restoreAcceptedDomFromSnapshot(pending, recoveryRevision) { if (acceptedDomAlreadyClean(pending)) return; if (!pending?.acceptedHtml) { - reloadAfterMissingAcceptedDom(pending); + reloadAfterMissingAcceptedDom(pending, recoveryRevision); return; } const parent = pending.parentElement?.isConnected ? pending.parentElement : (pending.parentSelector ? document.querySelector(pending.parentSelector) : null); if (!parent) { - reloadAfterMissingAcceptedDom(pending); + reloadAfterMissingAcceptedDom(pending, recoveryRevision); return; } const template = document.createElement('template'); @@ -8638,10 +8733,11 @@ void main() { ? pending.nextSibling : null; parent.insertBefore(template.content, anchor); - if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending); + if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending, recoveryRevision); } - function reloadAfterMissingAcceptedDom(pending) { + function reloadAfterMissingAcceptedDom(pending, recoveryRevision) { + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; if (acceptedDomAlreadyClean(pending)) return; if (pending?.id && document.querySelector('[data-impeccable-variants="' + pending.id + '"]')) return; location.reload(); @@ -8991,14 +9087,15 @@ void main() { return sessionState.isHandled(id); } - function clearHandled() { - sessionState.clearHandled(); + function clearHandled(sessionId) { + sessionState.clearHandled(sessionId); } function cleanup(options) { const restoreOriginal = options?.restoreOriginal === true; const instantChrome = options?.instantChrome === true; const cleanupSessionId = currentSessionId; + const cleanupRevision = liveInteractionRevision; clearMountErrorCard(); lastReportedMountFailure = null; if (svelteComponentSession?.sessionId === cleanupSessionId) { @@ -9016,19 +9113,41 @@ void main() { else wrapper.style.display = 'none'; } setTimeout(function() { - document.getElementById(DISCARD_STATE_STYLE_ID)?.remove(); - if (!cleanupSessionId) return; - const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!lateWrapper) return; - const orig = lateWrapper.querySelector('[data-impeccable-variant="original"]'); - if (orig) { - const content = orig.firstElementChild; - if (content) { - lateWrapper.parentElement.replaceChild(content, lateWrapper); - return; - } + const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); + if (!cleanupSessionId) { + removeDiscardStateStylesheet(); + return; } - lateWrapper.remove(); + const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (!lateWrapper) { + removeDiscardStateStylesheet(cleanupSessionId); + return; + } + if (recoverySuperseded) { + if (hasFrameworkHmrOwnership(lateWrapper)) { + watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); + } else { + releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + } + return; + } + if (hasFrameworkHmrOwnership(lateWrapper)) { + // As on accept, never restructure framework-owned DOM. If HMR missed + // the final source rewrite, reload once after a grace window so the + // discarded source becomes authoritative without a reconciler race. + setTimeout(function() { + const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { + if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId); + else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); + return; + } + removeDiscardStateStylesheet(cleanupSessionId); + if (staleWrapper) location.reload(); + }, 2000); + return; + } + releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); }, 2000); } hideBar(instantChrome); @@ -9111,12 +9230,122 @@ void main() { // Resume an active variant session after HMR/page reload. // If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote // variants before HMR fired. Pick up where we left off. - function resumeSession() { + function handledWrapperReloadKey(sessionId) { + return HANDLED_WRAPPER_RELOAD_KEY + ':' + sessionId; + } + + function clearHandledWrapperReloadStamp(sessionId) { + try { + if (sessionId) { + sessionStorage.removeItem(handledWrapperReloadKey(sessionId)); + const legacy = sessionStorage.getItem(HANDLED_WRAPPER_RELOAD_KEY) || ''; + if (legacy === sessionId || legacy.startsWith(sessionId + ':')) { + sessionStorage.removeItem(HANDLED_WRAPPER_RELOAD_KEY); + } + return; + } + sessionStorage.removeItem(HANDLED_WRAPPER_RELOAD_KEY); + for (let i = sessionStorage.length - 1; i >= 0; i--) { + const key = sessionStorage.key(i); + if (key?.startsWith(HANDLED_WRAPPER_RELOAD_KEY + ':')) sessionStorage.removeItem(key); + } + } catch {} + } + + function scheduleHandledRuntimeWrapperReload(wrapper, recoveryRevision = liveInteractionRevision) { + const sessionId = wrapper?.dataset?.impeccableVariants + || wrapper?.dataset?.impeccableCarbonize; + if (!sessionId || !isSessionHandled(sessionId)) return false; + if (deferredRecoverySuperseded(sessionId, recoveryRevision)) return true; + + if (handledRuntimeWrapperReloadSessions.has(sessionId)) return true; + let reloadAttempts = 0; + try { + reloadAttempts = Number(sessionStorage.getItem(handledWrapperReloadKey(sessionId))) || 0; + if (reloadAttempts >= 2) return true; + sessionStorage.setItem(handledWrapperReloadKey(sessionId), String(reloadAttempts + 1)); + } catch {} + handledRuntimeWrapperReloadSessions.add(sessionId); + + // A framework refresh can replace the variants tree with an intermediate + // carbonize tree and reload the page, cancelling the original accept timer. + // Let the file-side cleanup settle, then reload once from authoritative + // source. The sessionStorage stamp prevents a stale dev-server response + // from turning this recovery into a reload loop. + setTimeout(function() { + if (deferredRecoverySuperseded(sessionId, recoveryRevision)) { + clearHandledWrapperReloadStamp(sessionId); + handledRuntimeWrapperReloadSessions.delete(sessionId); + return; + } + const staleWrapper = document.querySelector( + '[data-impeccable-variants="' + sessionId + '"],' + + '[data-impeccable-carbonize="' + sessionId + '"]', + ); + if (staleWrapper) location.reload(); + else { + clearHandledWrapperReloadStamp(sessionId); + handledRuntimeWrapperReloadSessions.delete(sessionId); + } + }, 3000); + return true; + } + + function watchForHandledRuntimeWrapper(sessionId, recoveryRevision = liveInteractionRevision) { + if (!sessionId || !document.body) return; + const existing = handledRuntimeWrapperWatchers.get(sessionId); + existing?.observer.disconnect(); + if (existing?.timer) clearTimeout(existing.timer); + + const findHandledWrapper = function() { + const wrapper = document.querySelector( + '[data-impeccable-variants="' + sessionId + '"],' + + '[data-impeccable-carbonize="' + sessionId + '"]', + ); + if (wrapper) scheduleHandledRuntimeWrapperReload(wrapper, recoveryRevision); + }; + + // Vite can briefly render the clean accepted tree, then apply a delayed + // carbonize refresh after the one-shot accept fallback has already passed. + // Keep a bounded scout alive through that refresh window so a late stale + // framework tree still reloads from the now-authoritative source. + const observer = new MutationObserver(findHandledWrapper); + observer.observe(document.body, { childList: true, subtree: true }); + const timer = setTimeout(function() { + if (handledRuntimeWrapperWatchers.get(sessionId)?.observer !== observer) return; + observer.disconnect(); + handledRuntimeWrapperWatchers.delete(sessionId); + }, 12000); + handledRuntimeWrapperWatchers.set(sessionId, { observer, timer }); + findHandledWrapper(); + } + + function restoreSessionSupersedingHandledWrapper(runtimeWrapper) { + const handledSessionId = runtimeWrapper?.dataset?.impeccableVariants + || runtimeWrapper?.dataset?.impeccableCarbonize; + if (!handledSessionId || !isSessionHandled(handledSessionId)) return false; + + // Accept releases the picker before carbonize finishes, so a replacement + // generation can already be durable while the prior handled wrapper is + // still mounted. Restore that newer session before the stale-wrapper + // recovery path gets a chance to reload or consume its retry budget. + const saved = loadSession(); + if (!saved?.id || saved.id === handledSessionId || isSessionHandled(saved.id)) return false; + if (currentSessionId === saved.id) return true; + return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); + } + + function resumeSession(recoveryRevision = liveInteractionRevision) { const wrapper = document.querySelector('[data-impeccable-variants]'); + const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); + if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; + if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; if (!wrapper) { if (restoreSessionWithoutWrapper('browser_resumed_without_wrapper')) return true; clearSession(); - clearHandled(); + // Keep the bounded handled-id history durable. A framework can hydrate a + // completed wrapper well after initialization, and a later reload must + // still recognize that wrapper as recovery work rather than resume it. return false; } @@ -9136,7 +9365,7 @@ void main() { wrapper.remove(); if (restoreSessionWithoutWrapper('browser_resumed_svelte_orphan_wrapper')) return true; clearSession(); - clearHandled(); + clearHandled(sessionId); return false; } @@ -12520,22 +12749,28 @@ void main() { connectSSE(); // Check for an active session to resume (variant wrapper already in DOM after HMR) - if (!resumeSession()) { + const resumed = resumeSession(); + if (!resumed) { console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.'); - // SvelteKit (and any framework that hydrates after HTML parse) may add - // the variant wrapper AFTER init runs. Watch for it and retry resume - // once it appears. Disconnect on first hit. + } else { + console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); + } + + // SvelteKit, React, and other frameworks may restore a durable session + // before hydration adds its variant wrapper. Keep a deferred-wrapper scout + // whenever init did not see a runtime wrapper, even if local/server state + // was already restored successfully. Disconnect on the first wrapper hit. + if (!resumed || !document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]')) { + const deferredResumeRevision = liveInteractionRevision; const scout = new MutationObserver(() => { - const wrapper = document.querySelector('[data-impeccable-variants]'); + const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); if (!wrapper) return; scout.disconnect(); - if (resumeSession()) { + if (resumeSession(deferredResumeRevision)) { console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); } }); scout.observe(document.body, { childList: true, subtree: true }); - } else { - console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); } if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING'); diff --git a/.github/skills/impeccable/scripts/live-browser-dom.js b/.github/skills/impeccable/scripts/live-browser-dom.js index ad6a794b3..e85c95c43 100644 --- a/.github/skills/impeccable/scripts/live-browser-dom.js +++ b/.github/skills/impeccable/scripts/live-browser-dom.js @@ -64,6 +64,26 @@ }; } + function hasFrameworkHmrOwnership(el) { + for (let node = el; node; node = node.parentElement) { + let keys = []; + try { keys = Object.getOwnPropertyNames(node); } catch {} + if (keys.some((key) => ( + key.startsWith('__reactFiber$') + || key.startsWith('__reactProps$') + || key.startsWith('__reactContainer$') + || key === '_reactRootContainer' + || key === '__vueParentComponent' + || key === '__vue_app__' + || key === '__vnode' + || key === '__svelte_meta' + ))) { + return true; + } + } + return false; + } + function id8() { if (crypto?.randomUUID) return crypto.randomUUID().replace(/-/g, '').slice(0, 8); return (Math.random().toString(16).slice(2) + Date.now().toString(16)).slice(0, 8); @@ -128,6 +148,7 @@ desc, rectIsUsableAnchor, makeFrozenAnchor, + hasFrameworkHmrOwnership, id8, cssId, liveUiRoot, diff --git a/.github/skills/impeccable/scripts/live-browser-session.js b/.github/skills/impeccable/scripts/live-browser-session.js index 0e362d6be..1514bf472 100644 --- a/.github/skills/impeccable/scripts/live-browser-session.js +++ b/.github/skills/impeccable/scripts/live-browser-session.js @@ -71,17 +71,38 @@ return checkpointRevision; } + function readHandledIds() { + const raw = safeRead(handledKey); + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed.filter(id => typeof id === 'string' && id); + } + if (typeof parsed === 'string' && parsed) return [parsed]; + } catch { /* legacy values were stored as a plain session id */ } + return [raw]; + } + function markHandled(id) { if (!id) return; - safeWrite(handledKey, id); + const ids = readHandledIds().filter(existing => existing !== id); + ids.push(id); + safeWrite(handledKey, JSON.stringify(ids.slice(-8))); } function isHandled(id) { - return !!id && safeRead(handledKey) === id; + return !!id && readHandledIds().includes(id); } - function clearHandled() { - safeRemove(handledKey); + function clearHandled(id) { + if (!id) { + safeRemove(handledKey); + return; + } + const remaining = readHandledIds().filter(existing => existing !== id); + if (remaining.length > 0) safeWrite(handledKey, JSON.stringify(remaining)); + else safeRemove(handledKey); } function writeScrollY(y) { diff --git a/.github/skills/impeccable/scripts/live-browser.js b/.github/skills/impeccable/scripts/live-browser.js index 2b38ec62e..1f373a894 100644 --- a/.github/skills/impeccable/scripts/live-browser.js +++ b/.github/skills/impeccable/scripts/live-browser.js @@ -121,6 +121,10 @@ let hoveredElement = null; let selectedElement = null; let currentSessionId = null; + // Advances when the user begins configuring a fresh edit, before that edit + // has a server session id. Deferred recovery captures this revision so an + // older accept/discard can never reload over a replacement configuration. + let liveInteractionRevision = 0; let expectedVariants = 0; let arrivedVariants = 0; let visibleVariant = 0; @@ -188,6 +192,9 @@ // when the real accept result arrives or a new session starts. let awaitingAcceptResult = null; let variantObserver = null; + const discardedFrameworkWrapperWatchers = new Map(); + const handledRuntimeWrapperWatchers = new Map(); + const handledRuntimeWrapperReloadSessions = new Set(); let variantSelectionInFlight = false; let variantSelectionPromise = null; let recoveringEmptyCycling = false; @@ -208,6 +215,7 @@ const SCROLL_ANCHOR_LOCK_ID = 'impeccable-scroll-anchor-lock'; const VARIANT_STATE_STYLE_ID = 'impeccable-variant-state'; const DISCARD_STATE_STYLE_ID = 'impeccable-discard-state'; + const HANDLED_WRAPPER_RELOAD_KEY = PREFIX + '-handled-wrapper-reload'; // Dedicated key for scroll position - SEPARATE from LS_KEY so that // saveSession's state updates don't clobber a carefully-captured scrollY. @@ -270,6 +278,7 @@ desc, rectIsUsableAnchor, makeFrozenAnchor, + hasFrameworkHmrOwnership, id8, cssId, liveUiRoot, @@ -2034,6 +2043,16 @@ syncSteerQueueHint(); } + function beginNewLiveConfiguration() { + liveInteractionRevision += 1; + setLiveState('CONFIGURING'); + } + + function deferredRecoverySuperseded(sessionId, recoveryRevision) { + return liveInteractionRevision !== recoveryRevision + || !!(currentSessionId && currentSessionId !== sessionId); + } + /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { @@ -5036,7 +5055,7 @@ && el.parentElement && document.body.contains(el) && !own(el) - && !el.closest?.('[data-impeccable-variants]'); + && !el.closest?.('[data-impeccable-variants],[data-impeccable-carbonize]'); } function elementMatchesOriginalMarkup(liveEl, origContent) { @@ -6608,19 +6627,78 @@ document.getElementById(VARIANT_STATE_STYLE_ID)?.remove(); } + function discardStateStyleId(sessionId) { + return DISCARD_STATE_STYLE_ID + '-' + sessionId; + } + function showOriginalDuringDiscard(sessionId) { if (!sessionId) return; - let styleEl = document.getElementById(DISCARD_STATE_STYLE_ID); + let styleEl = document.getElementById(discardStateStyleId(sessionId)); if (!styleEl) { styleEl = document.createElement('style'); - styleEl.id = DISCARD_STATE_STYLE_ID; + styleEl.id = discardStateStyleId(sessionId); (document.head || document.documentElement).appendChild(styleEl); } + styleEl.dataset.impeccableDiscardSession = sessionId; const wrapper = '[data-impeccable-variants="' + sessionId + '"]'; styleEl.textContent = wrapper + ' > [data-impeccable-variant]:not([data-impeccable-variant="original"]) { display:none !important; }\n' + wrapper + ' > [data-impeccable-variant="original"] { display:block !important; }'; } + function removeDiscardStateStylesheet(sessionId) { + if (!sessionId) return; + document.getElementById(discardStateStyleId(sessionId))?.remove(); + } + + function releaseDiscardedStaticWrapper(wrapper, sessionId) { + removeDiscardStateStylesheet(sessionId); + if (!wrapper) return; + const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); + const content = orig?.firstElementChild; + if (content && wrapper.parentElement) { + wrapper.parentElement.replaceChild(content, wrapper); + return; + } + wrapper.remove(); + } + + function watchForDiscardedFrameworkWrapperRemoval(sessionId) { + if (!sessionId || !document.body) return; + if (discardedFrameworkWrapperWatchers.has(sessionId)) return; + const selector = '[data-impeccable-variants="' + sessionId + '"]'; + let observer = null; + let timer = null; + const stopWatching = function() { + observer?.disconnect(); + if (timer) clearTimeout(timer); + discardedFrameworkWrapperWatchers.delete(sessionId); + }; + const finishIfGone = function() { + if (document.querySelector(selector)) return false; + removeDiscardStateStylesheet(sessionId); + stopWatching(); + return true; + }; + if (finishIfGone()) return; + observer = new MutationObserver(finishIfGone); + observer.observe(document.body, { childList: true, subtree: true }); + const resolveStillMounted = function() { + if (finishIfGone()) return; + const replacementActive = !!currentSessionId + || (state !== 'IDLE' && state !== 'PICKING'); + if (replacementActive) { + timer = setTimeout(resolveStillMounted, 12000); + discardedFrameworkWrapperWatchers.get(sessionId).timer = timer; + return; + } + removeDiscardStateStylesheet(sessionId); + stopWatching(); + location.reload(); + }; + timer = setTimeout(resolveStillMounted, 12000); + discardedFrameworkWrapperWatchers.set(sessionId, { observer, timer }); + } + function resolveScrollLockAnchorTop() { const anchor = resolveBarAnchor(); if (!anchor?.isConnected) return null; @@ -7335,7 +7413,7 @@ hideInsertLine(); configureKind = 'insert'; selectedElement = placeholder; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); hideHighlight(); clearAnnotations(); showAnnotOverlay(placeholder); @@ -7353,7 +7431,7 @@ e.preventDefault(); e.stopPropagation(); selectedElement = hoveredElement; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -7530,7 +7608,7 @@ } else if (e.key === 'Enter') { e.preventDefault(); selectedElement = hoveredElement; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -8535,6 +8613,7 @@ void main() { } function scheduleAcceptCleanup(accepted) { + const recoveryRevision = liveInteractionRevision; queueMicrotask(function() { if (pendingAcceptedSession?.id !== accepted?.id) return; // Svelte previews live in an adapter-owned mount rather than in source @@ -8551,8 +8630,10 @@ void main() { // races. Static servers still need a fallback, but it must not keep Live // in SAVING or block the user's next pick. if (!accepted?.isSvelteComponent) { + watchForHandledRuntimeWrapper(accepted?.id, recoveryRevision); setTimeout(function() { - if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted); + if (deferredRecoverySuperseded(accepted?.id, recoveryRevision)) return; + if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted, recoveryRevision); }, 1200); } } @@ -8585,13 +8666,27 @@ void main() { && matches.every((el) => !el.closest('[data-impeccable-variants],[data-impeccable-variant],[data-impeccable-carbonize]')); } - function ensureAcceptedDomClean(pending) { + function ensureAcceptedDomClean(pending, recoveryRevision) { + // Background cleanup for an accepted session must never mutate or reload + // a newer comparison the user has already started. + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; if (acceptedDomAlreadyClean(pending)) return; const sessionId = pending?.id; const variantId = pending?.variant; const wrappers = findAcceptedRuntimeWrappers(sessionId); + if (hasFrameworkHmrOwnership(wrappers[0] || pending?.parentElement)) { + // Vite can coalesce rapid scaffold/carbonize writes and leave the last + // framework-owned preview tree mounted even though source is clean. Give + // HMR another grace window, then reload from clean source rather than + // violating reconciler ownership with a manual DOM mutation. + setTimeout(function() { + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; + if (!acceptedDomAlreadyClean(pending)) location.reload(); + }, 2000); + return; + } if (wrappers.length === 0) { - restoreAcceptedDomFromSnapshot(pending); + restoreAcceptedDomFromSnapshot(pending, recoveryRevision); return; } for (const wrapper of wrappers) { @@ -8608,7 +8703,7 @@ void main() { } wrapper.remove(); } - if (!acceptedDomAlreadyClean(pending)) restoreAcceptedDomFromSnapshot(pending); + if (!acceptedDomAlreadyClean(pending)) restoreAcceptedDomFromSnapshot(pending, recoveryRevision); } function findAcceptedRuntimeWrappers(sessionId) { @@ -8619,17 +8714,17 @@ void main() { ])]; } - function restoreAcceptedDomFromSnapshot(pending) { + function restoreAcceptedDomFromSnapshot(pending, recoveryRevision) { if (acceptedDomAlreadyClean(pending)) return; if (!pending?.acceptedHtml) { - reloadAfterMissingAcceptedDom(pending); + reloadAfterMissingAcceptedDom(pending, recoveryRevision); return; } const parent = pending.parentElement?.isConnected ? pending.parentElement : (pending.parentSelector ? document.querySelector(pending.parentSelector) : null); if (!parent) { - reloadAfterMissingAcceptedDom(pending); + reloadAfterMissingAcceptedDom(pending, recoveryRevision); return; } const template = document.createElement('template'); @@ -8638,10 +8733,11 @@ void main() { ? pending.nextSibling : null; parent.insertBefore(template.content, anchor); - if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending); + if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending, recoveryRevision); } - function reloadAfterMissingAcceptedDom(pending) { + function reloadAfterMissingAcceptedDom(pending, recoveryRevision) { + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; if (acceptedDomAlreadyClean(pending)) return; if (pending?.id && document.querySelector('[data-impeccable-variants="' + pending.id + '"]')) return; location.reload(); @@ -8991,14 +9087,15 @@ void main() { return sessionState.isHandled(id); } - function clearHandled() { - sessionState.clearHandled(); + function clearHandled(sessionId) { + sessionState.clearHandled(sessionId); } function cleanup(options) { const restoreOriginal = options?.restoreOriginal === true; const instantChrome = options?.instantChrome === true; const cleanupSessionId = currentSessionId; + const cleanupRevision = liveInteractionRevision; clearMountErrorCard(); lastReportedMountFailure = null; if (svelteComponentSession?.sessionId === cleanupSessionId) { @@ -9016,19 +9113,41 @@ void main() { else wrapper.style.display = 'none'; } setTimeout(function() { - document.getElementById(DISCARD_STATE_STYLE_ID)?.remove(); - if (!cleanupSessionId) return; - const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!lateWrapper) return; - const orig = lateWrapper.querySelector('[data-impeccable-variant="original"]'); - if (orig) { - const content = orig.firstElementChild; - if (content) { - lateWrapper.parentElement.replaceChild(content, lateWrapper); - return; - } + const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); + if (!cleanupSessionId) { + removeDiscardStateStylesheet(); + return; } - lateWrapper.remove(); + const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (!lateWrapper) { + removeDiscardStateStylesheet(cleanupSessionId); + return; + } + if (recoverySuperseded) { + if (hasFrameworkHmrOwnership(lateWrapper)) { + watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); + } else { + releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + } + return; + } + if (hasFrameworkHmrOwnership(lateWrapper)) { + // As on accept, never restructure framework-owned DOM. If HMR missed + // the final source rewrite, reload once after a grace window so the + // discarded source becomes authoritative without a reconciler race. + setTimeout(function() { + const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { + if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId); + else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); + return; + } + removeDiscardStateStylesheet(cleanupSessionId); + if (staleWrapper) location.reload(); + }, 2000); + return; + } + releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); }, 2000); } hideBar(instantChrome); @@ -9111,12 +9230,122 @@ void main() { // Resume an active variant session after HMR/page reload. // If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote // variants before HMR fired. Pick up where we left off. - function resumeSession() { + function handledWrapperReloadKey(sessionId) { + return HANDLED_WRAPPER_RELOAD_KEY + ':' + sessionId; + } + + function clearHandledWrapperReloadStamp(sessionId) { + try { + if (sessionId) { + sessionStorage.removeItem(handledWrapperReloadKey(sessionId)); + const legacy = sessionStorage.getItem(HANDLED_WRAPPER_RELOAD_KEY) || ''; + if (legacy === sessionId || legacy.startsWith(sessionId + ':')) { + sessionStorage.removeItem(HANDLED_WRAPPER_RELOAD_KEY); + } + return; + } + sessionStorage.removeItem(HANDLED_WRAPPER_RELOAD_KEY); + for (let i = sessionStorage.length - 1; i >= 0; i--) { + const key = sessionStorage.key(i); + if (key?.startsWith(HANDLED_WRAPPER_RELOAD_KEY + ':')) sessionStorage.removeItem(key); + } + } catch {} + } + + function scheduleHandledRuntimeWrapperReload(wrapper, recoveryRevision = liveInteractionRevision) { + const sessionId = wrapper?.dataset?.impeccableVariants + || wrapper?.dataset?.impeccableCarbonize; + if (!sessionId || !isSessionHandled(sessionId)) return false; + if (deferredRecoverySuperseded(sessionId, recoveryRevision)) return true; + + if (handledRuntimeWrapperReloadSessions.has(sessionId)) return true; + let reloadAttempts = 0; + try { + reloadAttempts = Number(sessionStorage.getItem(handledWrapperReloadKey(sessionId))) || 0; + if (reloadAttempts >= 2) return true; + sessionStorage.setItem(handledWrapperReloadKey(sessionId), String(reloadAttempts + 1)); + } catch {} + handledRuntimeWrapperReloadSessions.add(sessionId); + + // A framework refresh can replace the variants tree with an intermediate + // carbonize tree and reload the page, cancelling the original accept timer. + // Let the file-side cleanup settle, then reload once from authoritative + // source. The sessionStorage stamp prevents a stale dev-server response + // from turning this recovery into a reload loop. + setTimeout(function() { + if (deferredRecoverySuperseded(sessionId, recoveryRevision)) { + clearHandledWrapperReloadStamp(sessionId); + handledRuntimeWrapperReloadSessions.delete(sessionId); + return; + } + const staleWrapper = document.querySelector( + '[data-impeccable-variants="' + sessionId + '"],' + + '[data-impeccable-carbonize="' + sessionId + '"]', + ); + if (staleWrapper) location.reload(); + else { + clearHandledWrapperReloadStamp(sessionId); + handledRuntimeWrapperReloadSessions.delete(sessionId); + } + }, 3000); + return true; + } + + function watchForHandledRuntimeWrapper(sessionId, recoveryRevision = liveInteractionRevision) { + if (!sessionId || !document.body) return; + const existing = handledRuntimeWrapperWatchers.get(sessionId); + existing?.observer.disconnect(); + if (existing?.timer) clearTimeout(existing.timer); + + const findHandledWrapper = function() { + const wrapper = document.querySelector( + '[data-impeccable-variants="' + sessionId + '"],' + + '[data-impeccable-carbonize="' + sessionId + '"]', + ); + if (wrapper) scheduleHandledRuntimeWrapperReload(wrapper, recoveryRevision); + }; + + // Vite can briefly render the clean accepted tree, then apply a delayed + // carbonize refresh after the one-shot accept fallback has already passed. + // Keep a bounded scout alive through that refresh window so a late stale + // framework tree still reloads from the now-authoritative source. + const observer = new MutationObserver(findHandledWrapper); + observer.observe(document.body, { childList: true, subtree: true }); + const timer = setTimeout(function() { + if (handledRuntimeWrapperWatchers.get(sessionId)?.observer !== observer) return; + observer.disconnect(); + handledRuntimeWrapperWatchers.delete(sessionId); + }, 12000); + handledRuntimeWrapperWatchers.set(sessionId, { observer, timer }); + findHandledWrapper(); + } + + function restoreSessionSupersedingHandledWrapper(runtimeWrapper) { + const handledSessionId = runtimeWrapper?.dataset?.impeccableVariants + || runtimeWrapper?.dataset?.impeccableCarbonize; + if (!handledSessionId || !isSessionHandled(handledSessionId)) return false; + + // Accept releases the picker before carbonize finishes, so a replacement + // generation can already be durable while the prior handled wrapper is + // still mounted. Restore that newer session before the stale-wrapper + // recovery path gets a chance to reload or consume its retry budget. + const saved = loadSession(); + if (!saved?.id || saved.id === handledSessionId || isSessionHandled(saved.id)) return false; + if (currentSessionId === saved.id) return true; + return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); + } + + function resumeSession(recoveryRevision = liveInteractionRevision) { const wrapper = document.querySelector('[data-impeccable-variants]'); + const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); + if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; + if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; if (!wrapper) { if (restoreSessionWithoutWrapper('browser_resumed_without_wrapper')) return true; clearSession(); - clearHandled(); + // Keep the bounded handled-id history durable. A framework can hydrate a + // completed wrapper well after initialization, and a later reload must + // still recognize that wrapper as recovery work rather than resume it. return false; } @@ -9136,7 +9365,7 @@ void main() { wrapper.remove(); if (restoreSessionWithoutWrapper('browser_resumed_svelte_orphan_wrapper')) return true; clearSession(); - clearHandled(); + clearHandled(sessionId); return false; } @@ -12520,22 +12749,28 @@ void main() { connectSSE(); // Check for an active session to resume (variant wrapper already in DOM after HMR) - if (!resumeSession()) { + const resumed = resumeSession(); + if (!resumed) { console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.'); - // SvelteKit (and any framework that hydrates after HTML parse) may add - // the variant wrapper AFTER init runs. Watch for it and retry resume - // once it appears. Disconnect on first hit. + } else { + console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); + } + + // SvelteKit, React, and other frameworks may restore a durable session + // before hydration adds its variant wrapper. Keep a deferred-wrapper scout + // whenever init did not see a runtime wrapper, even if local/server state + // was already restored successfully. Disconnect on the first wrapper hit. + if (!resumed || !document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]')) { + const deferredResumeRevision = liveInteractionRevision; const scout = new MutationObserver(() => { - const wrapper = document.querySelector('[data-impeccable-variants]'); + const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); if (!wrapper) return; scout.disconnect(); - if (resumeSession()) { + if (resumeSession(deferredResumeRevision)) { console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); } }); scout.observe(document.body, { childList: true, subtree: true }); - } else { - console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); } if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING'); diff --git a/.grok/skills/impeccable/scripts/live-browser-dom.js b/.grok/skills/impeccable/scripts/live-browser-dom.js index ad6a794b3..e85c95c43 100644 --- a/.grok/skills/impeccable/scripts/live-browser-dom.js +++ b/.grok/skills/impeccable/scripts/live-browser-dom.js @@ -64,6 +64,26 @@ }; } + function hasFrameworkHmrOwnership(el) { + for (let node = el; node; node = node.parentElement) { + let keys = []; + try { keys = Object.getOwnPropertyNames(node); } catch {} + if (keys.some((key) => ( + key.startsWith('__reactFiber$') + || key.startsWith('__reactProps$') + || key.startsWith('__reactContainer$') + || key === '_reactRootContainer' + || key === '__vueParentComponent' + || key === '__vue_app__' + || key === '__vnode' + || key === '__svelte_meta' + ))) { + return true; + } + } + return false; + } + function id8() { if (crypto?.randomUUID) return crypto.randomUUID().replace(/-/g, '').slice(0, 8); return (Math.random().toString(16).slice(2) + Date.now().toString(16)).slice(0, 8); @@ -128,6 +148,7 @@ desc, rectIsUsableAnchor, makeFrozenAnchor, + hasFrameworkHmrOwnership, id8, cssId, liveUiRoot, diff --git a/.grok/skills/impeccable/scripts/live-browser-session.js b/.grok/skills/impeccable/scripts/live-browser-session.js index 0e362d6be..1514bf472 100644 --- a/.grok/skills/impeccable/scripts/live-browser-session.js +++ b/.grok/skills/impeccable/scripts/live-browser-session.js @@ -71,17 +71,38 @@ return checkpointRevision; } + function readHandledIds() { + const raw = safeRead(handledKey); + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed.filter(id => typeof id === 'string' && id); + } + if (typeof parsed === 'string' && parsed) return [parsed]; + } catch { /* legacy values were stored as a plain session id */ } + return [raw]; + } + function markHandled(id) { if (!id) return; - safeWrite(handledKey, id); + const ids = readHandledIds().filter(existing => existing !== id); + ids.push(id); + safeWrite(handledKey, JSON.stringify(ids.slice(-8))); } function isHandled(id) { - return !!id && safeRead(handledKey) === id; + return !!id && readHandledIds().includes(id); } - function clearHandled() { - safeRemove(handledKey); + function clearHandled(id) { + if (!id) { + safeRemove(handledKey); + return; + } + const remaining = readHandledIds().filter(existing => existing !== id); + if (remaining.length > 0) safeWrite(handledKey, JSON.stringify(remaining)); + else safeRemove(handledKey); } function writeScrollY(y) { diff --git a/.grok/skills/impeccable/scripts/live-browser.js b/.grok/skills/impeccable/scripts/live-browser.js index 2b38ec62e..1f373a894 100644 --- a/.grok/skills/impeccable/scripts/live-browser.js +++ b/.grok/skills/impeccable/scripts/live-browser.js @@ -121,6 +121,10 @@ let hoveredElement = null; let selectedElement = null; let currentSessionId = null; + // Advances when the user begins configuring a fresh edit, before that edit + // has a server session id. Deferred recovery captures this revision so an + // older accept/discard can never reload over a replacement configuration. + let liveInteractionRevision = 0; let expectedVariants = 0; let arrivedVariants = 0; let visibleVariant = 0; @@ -188,6 +192,9 @@ // when the real accept result arrives or a new session starts. let awaitingAcceptResult = null; let variantObserver = null; + const discardedFrameworkWrapperWatchers = new Map(); + const handledRuntimeWrapperWatchers = new Map(); + const handledRuntimeWrapperReloadSessions = new Set(); let variantSelectionInFlight = false; let variantSelectionPromise = null; let recoveringEmptyCycling = false; @@ -208,6 +215,7 @@ const SCROLL_ANCHOR_LOCK_ID = 'impeccable-scroll-anchor-lock'; const VARIANT_STATE_STYLE_ID = 'impeccable-variant-state'; const DISCARD_STATE_STYLE_ID = 'impeccable-discard-state'; + const HANDLED_WRAPPER_RELOAD_KEY = PREFIX + '-handled-wrapper-reload'; // Dedicated key for scroll position - SEPARATE from LS_KEY so that // saveSession's state updates don't clobber a carefully-captured scrollY. @@ -270,6 +278,7 @@ desc, rectIsUsableAnchor, makeFrozenAnchor, + hasFrameworkHmrOwnership, id8, cssId, liveUiRoot, @@ -2034,6 +2043,16 @@ syncSteerQueueHint(); } + function beginNewLiveConfiguration() { + liveInteractionRevision += 1; + setLiveState('CONFIGURING'); + } + + function deferredRecoverySuperseded(sessionId, recoveryRevision) { + return liveInteractionRevision !== recoveryRevision + || !!(currentSessionId && currentSessionId !== sessionId); + } + /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { @@ -5036,7 +5055,7 @@ && el.parentElement && document.body.contains(el) && !own(el) - && !el.closest?.('[data-impeccable-variants]'); + && !el.closest?.('[data-impeccable-variants],[data-impeccable-carbonize]'); } function elementMatchesOriginalMarkup(liveEl, origContent) { @@ -6608,19 +6627,78 @@ document.getElementById(VARIANT_STATE_STYLE_ID)?.remove(); } + function discardStateStyleId(sessionId) { + return DISCARD_STATE_STYLE_ID + '-' + sessionId; + } + function showOriginalDuringDiscard(sessionId) { if (!sessionId) return; - let styleEl = document.getElementById(DISCARD_STATE_STYLE_ID); + let styleEl = document.getElementById(discardStateStyleId(sessionId)); if (!styleEl) { styleEl = document.createElement('style'); - styleEl.id = DISCARD_STATE_STYLE_ID; + styleEl.id = discardStateStyleId(sessionId); (document.head || document.documentElement).appendChild(styleEl); } + styleEl.dataset.impeccableDiscardSession = sessionId; const wrapper = '[data-impeccable-variants="' + sessionId + '"]'; styleEl.textContent = wrapper + ' > [data-impeccable-variant]:not([data-impeccable-variant="original"]) { display:none !important; }\n' + wrapper + ' > [data-impeccable-variant="original"] { display:block !important; }'; } + function removeDiscardStateStylesheet(sessionId) { + if (!sessionId) return; + document.getElementById(discardStateStyleId(sessionId))?.remove(); + } + + function releaseDiscardedStaticWrapper(wrapper, sessionId) { + removeDiscardStateStylesheet(sessionId); + if (!wrapper) return; + const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); + const content = orig?.firstElementChild; + if (content && wrapper.parentElement) { + wrapper.parentElement.replaceChild(content, wrapper); + return; + } + wrapper.remove(); + } + + function watchForDiscardedFrameworkWrapperRemoval(sessionId) { + if (!sessionId || !document.body) return; + if (discardedFrameworkWrapperWatchers.has(sessionId)) return; + const selector = '[data-impeccable-variants="' + sessionId + '"]'; + let observer = null; + let timer = null; + const stopWatching = function() { + observer?.disconnect(); + if (timer) clearTimeout(timer); + discardedFrameworkWrapperWatchers.delete(sessionId); + }; + const finishIfGone = function() { + if (document.querySelector(selector)) return false; + removeDiscardStateStylesheet(sessionId); + stopWatching(); + return true; + }; + if (finishIfGone()) return; + observer = new MutationObserver(finishIfGone); + observer.observe(document.body, { childList: true, subtree: true }); + const resolveStillMounted = function() { + if (finishIfGone()) return; + const replacementActive = !!currentSessionId + || (state !== 'IDLE' && state !== 'PICKING'); + if (replacementActive) { + timer = setTimeout(resolveStillMounted, 12000); + discardedFrameworkWrapperWatchers.get(sessionId).timer = timer; + return; + } + removeDiscardStateStylesheet(sessionId); + stopWatching(); + location.reload(); + }; + timer = setTimeout(resolveStillMounted, 12000); + discardedFrameworkWrapperWatchers.set(sessionId, { observer, timer }); + } + function resolveScrollLockAnchorTop() { const anchor = resolveBarAnchor(); if (!anchor?.isConnected) return null; @@ -7335,7 +7413,7 @@ hideInsertLine(); configureKind = 'insert'; selectedElement = placeholder; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); hideHighlight(); clearAnnotations(); showAnnotOverlay(placeholder); @@ -7353,7 +7431,7 @@ e.preventDefault(); e.stopPropagation(); selectedElement = hoveredElement; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -7530,7 +7608,7 @@ } else if (e.key === 'Enter') { e.preventDefault(); selectedElement = hoveredElement; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -8535,6 +8613,7 @@ void main() { } function scheduleAcceptCleanup(accepted) { + const recoveryRevision = liveInteractionRevision; queueMicrotask(function() { if (pendingAcceptedSession?.id !== accepted?.id) return; // Svelte previews live in an adapter-owned mount rather than in source @@ -8551,8 +8630,10 @@ void main() { // races. Static servers still need a fallback, but it must not keep Live // in SAVING or block the user's next pick. if (!accepted?.isSvelteComponent) { + watchForHandledRuntimeWrapper(accepted?.id, recoveryRevision); setTimeout(function() { - if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted); + if (deferredRecoverySuperseded(accepted?.id, recoveryRevision)) return; + if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted, recoveryRevision); }, 1200); } } @@ -8585,13 +8666,27 @@ void main() { && matches.every((el) => !el.closest('[data-impeccable-variants],[data-impeccable-variant],[data-impeccable-carbonize]')); } - function ensureAcceptedDomClean(pending) { + function ensureAcceptedDomClean(pending, recoveryRevision) { + // Background cleanup for an accepted session must never mutate or reload + // a newer comparison the user has already started. + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; if (acceptedDomAlreadyClean(pending)) return; const sessionId = pending?.id; const variantId = pending?.variant; const wrappers = findAcceptedRuntimeWrappers(sessionId); + if (hasFrameworkHmrOwnership(wrappers[0] || pending?.parentElement)) { + // Vite can coalesce rapid scaffold/carbonize writes and leave the last + // framework-owned preview tree mounted even though source is clean. Give + // HMR another grace window, then reload from clean source rather than + // violating reconciler ownership with a manual DOM mutation. + setTimeout(function() { + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; + if (!acceptedDomAlreadyClean(pending)) location.reload(); + }, 2000); + return; + } if (wrappers.length === 0) { - restoreAcceptedDomFromSnapshot(pending); + restoreAcceptedDomFromSnapshot(pending, recoveryRevision); return; } for (const wrapper of wrappers) { @@ -8608,7 +8703,7 @@ void main() { } wrapper.remove(); } - if (!acceptedDomAlreadyClean(pending)) restoreAcceptedDomFromSnapshot(pending); + if (!acceptedDomAlreadyClean(pending)) restoreAcceptedDomFromSnapshot(pending, recoveryRevision); } function findAcceptedRuntimeWrappers(sessionId) { @@ -8619,17 +8714,17 @@ void main() { ])]; } - function restoreAcceptedDomFromSnapshot(pending) { + function restoreAcceptedDomFromSnapshot(pending, recoveryRevision) { if (acceptedDomAlreadyClean(pending)) return; if (!pending?.acceptedHtml) { - reloadAfterMissingAcceptedDom(pending); + reloadAfterMissingAcceptedDom(pending, recoveryRevision); return; } const parent = pending.parentElement?.isConnected ? pending.parentElement : (pending.parentSelector ? document.querySelector(pending.parentSelector) : null); if (!parent) { - reloadAfterMissingAcceptedDom(pending); + reloadAfterMissingAcceptedDom(pending, recoveryRevision); return; } const template = document.createElement('template'); @@ -8638,10 +8733,11 @@ void main() { ? pending.nextSibling : null; parent.insertBefore(template.content, anchor); - if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending); + if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending, recoveryRevision); } - function reloadAfterMissingAcceptedDom(pending) { + function reloadAfterMissingAcceptedDom(pending, recoveryRevision) { + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; if (acceptedDomAlreadyClean(pending)) return; if (pending?.id && document.querySelector('[data-impeccable-variants="' + pending.id + '"]')) return; location.reload(); @@ -8991,14 +9087,15 @@ void main() { return sessionState.isHandled(id); } - function clearHandled() { - sessionState.clearHandled(); + function clearHandled(sessionId) { + sessionState.clearHandled(sessionId); } function cleanup(options) { const restoreOriginal = options?.restoreOriginal === true; const instantChrome = options?.instantChrome === true; const cleanupSessionId = currentSessionId; + const cleanupRevision = liveInteractionRevision; clearMountErrorCard(); lastReportedMountFailure = null; if (svelteComponentSession?.sessionId === cleanupSessionId) { @@ -9016,19 +9113,41 @@ void main() { else wrapper.style.display = 'none'; } setTimeout(function() { - document.getElementById(DISCARD_STATE_STYLE_ID)?.remove(); - if (!cleanupSessionId) return; - const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!lateWrapper) return; - const orig = lateWrapper.querySelector('[data-impeccable-variant="original"]'); - if (orig) { - const content = orig.firstElementChild; - if (content) { - lateWrapper.parentElement.replaceChild(content, lateWrapper); - return; - } + const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); + if (!cleanupSessionId) { + removeDiscardStateStylesheet(); + return; } - lateWrapper.remove(); + const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (!lateWrapper) { + removeDiscardStateStylesheet(cleanupSessionId); + return; + } + if (recoverySuperseded) { + if (hasFrameworkHmrOwnership(lateWrapper)) { + watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); + } else { + releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + } + return; + } + if (hasFrameworkHmrOwnership(lateWrapper)) { + // As on accept, never restructure framework-owned DOM. If HMR missed + // the final source rewrite, reload once after a grace window so the + // discarded source becomes authoritative without a reconciler race. + setTimeout(function() { + const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { + if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId); + else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); + return; + } + removeDiscardStateStylesheet(cleanupSessionId); + if (staleWrapper) location.reload(); + }, 2000); + return; + } + releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); }, 2000); } hideBar(instantChrome); @@ -9111,12 +9230,122 @@ void main() { // Resume an active variant session after HMR/page reload. // If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote // variants before HMR fired. Pick up where we left off. - function resumeSession() { + function handledWrapperReloadKey(sessionId) { + return HANDLED_WRAPPER_RELOAD_KEY + ':' + sessionId; + } + + function clearHandledWrapperReloadStamp(sessionId) { + try { + if (sessionId) { + sessionStorage.removeItem(handledWrapperReloadKey(sessionId)); + const legacy = sessionStorage.getItem(HANDLED_WRAPPER_RELOAD_KEY) || ''; + if (legacy === sessionId || legacy.startsWith(sessionId + ':')) { + sessionStorage.removeItem(HANDLED_WRAPPER_RELOAD_KEY); + } + return; + } + sessionStorage.removeItem(HANDLED_WRAPPER_RELOAD_KEY); + for (let i = sessionStorage.length - 1; i >= 0; i--) { + const key = sessionStorage.key(i); + if (key?.startsWith(HANDLED_WRAPPER_RELOAD_KEY + ':')) sessionStorage.removeItem(key); + } + } catch {} + } + + function scheduleHandledRuntimeWrapperReload(wrapper, recoveryRevision = liveInteractionRevision) { + const sessionId = wrapper?.dataset?.impeccableVariants + || wrapper?.dataset?.impeccableCarbonize; + if (!sessionId || !isSessionHandled(sessionId)) return false; + if (deferredRecoverySuperseded(sessionId, recoveryRevision)) return true; + + if (handledRuntimeWrapperReloadSessions.has(sessionId)) return true; + let reloadAttempts = 0; + try { + reloadAttempts = Number(sessionStorage.getItem(handledWrapperReloadKey(sessionId))) || 0; + if (reloadAttempts >= 2) return true; + sessionStorage.setItem(handledWrapperReloadKey(sessionId), String(reloadAttempts + 1)); + } catch {} + handledRuntimeWrapperReloadSessions.add(sessionId); + + // A framework refresh can replace the variants tree with an intermediate + // carbonize tree and reload the page, cancelling the original accept timer. + // Let the file-side cleanup settle, then reload once from authoritative + // source. The sessionStorage stamp prevents a stale dev-server response + // from turning this recovery into a reload loop. + setTimeout(function() { + if (deferredRecoverySuperseded(sessionId, recoveryRevision)) { + clearHandledWrapperReloadStamp(sessionId); + handledRuntimeWrapperReloadSessions.delete(sessionId); + return; + } + const staleWrapper = document.querySelector( + '[data-impeccable-variants="' + sessionId + '"],' + + '[data-impeccable-carbonize="' + sessionId + '"]', + ); + if (staleWrapper) location.reload(); + else { + clearHandledWrapperReloadStamp(sessionId); + handledRuntimeWrapperReloadSessions.delete(sessionId); + } + }, 3000); + return true; + } + + function watchForHandledRuntimeWrapper(sessionId, recoveryRevision = liveInteractionRevision) { + if (!sessionId || !document.body) return; + const existing = handledRuntimeWrapperWatchers.get(sessionId); + existing?.observer.disconnect(); + if (existing?.timer) clearTimeout(existing.timer); + + const findHandledWrapper = function() { + const wrapper = document.querySelector( + '[data-impeccable-variants="' + sessionId + '"],' + + '[data-impeccable-carbonize="' + sessionId + '"]', + ); + if (wrapper) scheduleHandledRuntimeWrapperReload(wrapper, recoveryRevision); + }; + + // Vite can briefly render the clean accepted tree, then apply a delayed + // carbonize refresh after the one-shot accept fallback has already passed. + // Keep a bounded scout alive through that refresh window so a late stale + // framework tree still reloads from the now-authoritative source. + const observer = new MutationObserver(findHandledWrapper); + observer.observe(document.body, { childList: true, subtree: true }); + const timer = setTimeout(function() { + if (handledRuntimeWrapperWatchers.get(sessionId)?.observer !== observer) return; + observer.disconnect(); + handledRuntimeWrapperWatchers.delete(sessionId); + }, 12000); + handledRuntimeWrapperWatchers.set(sessionId, { observer, timer }); + findHandledWrapper(); + } + + function restoreSessionSupersedingHandledWrapper(runtimeWrapper) { + const handledSessionId = runtimeWrapper?.dataset?.impeccableVariants + || runtimeWrapper?.dataset?.impeccableCarbonize; + if (!handledSessionId || !isSessionHandled(handledSessionId)) return false; + + // Accept releases the picker before carbonize finishes, so a replacement + // generation can already be durable while the prior handled wrapper is + // still mounted. Restore that newer session before the stale-wrapper + // recovery path gets a chance to reload or consume its retry budget. + const saved = loadSession(); + if (!saved?.id || saved.id === handledSessionId || isSessionHandled(saved.id)) return false; + if (currentSessionId === saved.id) return true; + return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); + } + + function resumeSession(recoveryRevision = liveInteractionRevision) { const wrapper = document.querySelector('[data-impeccable-variants]'); + const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); + if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; + if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; if (!wrapper) { if (restoreSessionWithoutWrapper('browser_resumed_without_wrapper')) return true; clearSession(); - clearHandled(); + // Keep the bounded handled-id history durable. A framework can hydrate a + // completed wrapper well after initialization, and a later reload must + // still recognize that wrapper as recovery work rather than resume it. return false; } @@ -9136,7 +9365,7 @@ void main() { wrapper.remove(); if (restoreSessionWithoutWrapper('browser_resumed_svelte_orphan_wrapper')) return true; clearSession(); - clearHandled(); + clearHandled(sessionId); return false; } @@ -12520,22 +12749,28 @@ void main() { connectSSE(); // Check for an active session to resume (variant wrapper already in DOM after HMR) - if (!resumeSession()) { + const resumed = resumeSession(); + if (!resumed) { console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.'); - // SvelteKit (and any framework that hydrates after HTML parse) may add - // the variant wrapper AFTER init runs. Watch for it and retry resume - // once it appears. Disconnect on first hit. + } else { + console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); + } + + // SvelteKit, React, and other frameworks may restore a durable session + // before hydration adds its variant wrapper. Keep a deferred-wrapper scout + // whenever init did not see a runtime wrapper, even if local/server state + // was already restored successfully. Disconnect on the first wrapper hit. + if (!resumed || !document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]')) { + const deferredResumeRevision = liveInteractionRevision; const scout = new MutationObserver(() => { - const wrapper = document.querySelector('[data-impeccable-variants]'); + const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); if (!wrapper) return; scout.disconnect(); - if (resumeSession()) { + if (resumeSession(deferredResumeRevision)) { console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); } }); scout.observe(document.body, { childList: true, subtree: true }); - } else { - console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); } if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING'); diff --git a/.hermes/skills/impeccable/scripts/live-browser-dom.js b/.hermes/skills/impeccable/scripts/live-browser-dom.js index ad6a794b3..e85c95c43 100644 --- a/.hermes/skills/impeccable/scripts/live-browser-dom.js +++ b/.hermes/skills/impeccable/scripts/live-browser-dom.js @@ -64,6 +64,26 @@ }; } + function hasFrameworkHmrOwnership(el) { + for (let node = el; node; node = node.parentElement) { + let keys = []; + try { keys = Object.getOwnPropertyNames(node); } catch {} + if (keys.some((key) => ( + key.startsWith('__reactFiber$') + || key.startsWith('__reactProps$') + || key.startsWith('__reactContainer$') + || key === '_reactRootContainer' + || key === '__vueParentComponent' + || key === '__vue_app__' + || key === '__vnode' + || key === '__svelte_meta' + ))) { + return true; + } + } + return false; + } + function id8() { if (crypto?.randomUUID) return crypto.randomUUID().replace(/-/g, '').slice(0, 8); return (Math.random().toString(16).slice(2) + Date.now().toString(16)).slice(0, 8); @@ -128,6 +148,7 @@ desc, rectIsUsableAnchor, makeFrozenAnchor, + hasFrameworkHmrOwnership, id8, cssId, liveUiRoot, diff --git a/.hermes/skills/impeccable/scripts/live-browser-session.js b/.hermes/skills/impeccable/scripts/live-browser-session.js index 0e362d6be..1514bf472 100644 --- a/.hermes/skills/impeccable/scripts/live-browser-session.js +++ b/.hermes/skills/impeccable/scripts/live-browser-session.js @@ -71,17 +71,38 @@ return checkpointRevision; } + function readHandledIds() { + const raw = safeRead(handledKey); + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed.filter(id => typeof id === 'string' && id); + } + if (typeof parsed === 'string' && parsed) return [parsed]; + } catch { /* legacy values were stored as a plain session id */ } + return [raw]; + } + function markHandled(id) { if (!id) return; - safeWrite(handledKey, id); + const ids = readHandledIds().filter(existing => existing !== id); + ids.push(id); + safeWrite(handledKey, JSON.stringify(ids.slice(-8))); } function isHandled(id) { - return !!id && safeRead(handledKey) === id; + return !!id && readHandledIds().includes(id); } - function clearHandled() { - safeRemove(handledKey); + function clearHandled(id) { + if (!id) { + safeRemove(handledKey); + return; + } + const remaining = readHandledIds().filter(existing => existing !== id); + if (remaining.length > 0) safeWrite(handledKey, JSON.stringify(remaining)); + else safeRemove(handledKey); } function writeScrollY(y) { diff --git a/.hermes/skills/impeccable/scripts/live-browser.js b/.hermes/skills/impeccable/scripts/live-browser.js index 2b38ec62e..1f373a894 100644 --- a/.hermes/skills/impeccable/scripts/live-browser.js +++ b/.hermes/skills/impeccable/scripts/live-browser.js @@ -121,6 +121,10 @@ let hoveredElement = null; let selectedElement = null; let currentSessionId = null; + // Advances when the user begins configuring a fresh edit, before that edit + // has a server session id. Deferred recovery captures this revision so an + // older accept/discard can never reload over a replacement configuration. + let liveInteractionRevision = 0; let expectedVariants = 0; let arrivedVariants = 0; let visibleVariant = 0; @@ -188,6 +192,9 @@ // when the real accept result arrives or a new session starts. let awaitingAcceptResult = null; let variantObserver = null; + const discardedFrameworkWrapperWatchers = new Map(); + const handledRuntimeWrapperWatchers = new Map(); + const handledRuntimeWrapperReloadSessions = new Set(); let variantSelectionInFlight = false; let variantSelectionPromise = null; let recoveringEmptyCycling = false; @@ -208,6 +215,7 @@ const SCROLL_ANCHOR_LOCK_ID = 'impeccable-scroll-anchor-lock'; const VARIANT_STATE_STYLE_ID = 'impeccable-variant-state'; const DISCARD_STATE_STYLE_ID = 'impeccable-discard-state'; + const HANDLED_WRAPPER_RELOAD_KEY = PREFIX + '-handled-wrapper-reload'; // Dedicated key for scroll position - SEPARATE from LS_KEY so that // saveSession's state updates don't clobber a carefully-captured scrollY. @@ -270,6 +278,7 @@ desc, rectIsUsableAnchor, makeFrozenAnchor, + hasFrameworkHmrOwnership, id8, cssId, liveUiRoot, @@ -2034,6 +2043,16 @@ syncSteerQueueHint(); } + function beginNewLiveConfiguration() { + liveInteractionRevision += 1; + setLiveState('CONFIGURING'); + } + + function deferredRecoverySuperseded(sessionId, recoveryRevision) { + return liveInteractionRevision !== recoveryRevision + || !!(currentSessionId && currentSessionId !== sessionId); + } + /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { @@ -5036,7 +5055,7 @@ && el.parentElement && document.body.contains(el) && !own(el) - && !el.closest?.('[data-impeccable-variants]'); + && !el.closest?.('[data-impeccable-variants],[data-impeccable-carbonize]'); } function elementMatchesOriginalMarkup(liveEl, origContent) { @@ -6608,19 +6627,78 @@ document.getElementById(VARIANT_STATE_STYLE_ID)?.remove(); } + function discardStateStyleId(sessionId) { + return DISCARD_STATE_STYLE_ID + '-' + sessionId; + } + function showOriginalDuringDiscard(sessionId) { if (!sessionId) return; - let styleEl = document.getElementById(DISCARD_STATE_STYLE_ID); + let styleEl = document.getElementById(discardStateStyleId(sessionId)); if (!styleEl) { styleEl = document.createElement('style'); - styleEl.id = DISCARD_STATE_STYLE_ID; + styleEl.id = discardStateStyleId(sessionId); (document.head || document.documentElement).appendChild(styleEl); } + styleEl.dataset.impeccableDiscardSession = sessionId; const wrapper = '[data-impeccable-variants="' + sessionId + '"]'; styleEl.textContent = wrapper + ' > [data-impeccable-variant]:not([data-impeccable-variant="original"]) { display:none !important; }\n' + wrapper + ' > [data-impeccable-variant="original"] { display:block !important; }'; } + function removeDiscardStateStylesheet(sessionId) { + if (!sessionId) return; + document.getElementById(discardStateStyleId(sessionId))?.remove(); + } + + function releaseDiscardedStaticWrapper(wrapper, sessionId) { + removeDiscardStateStylesheet(sessionId); + if (!wrapper) return; + const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); + const content = orig?.firstElementChild; + if (content && wrapper.parentElement) { + wrapper.parentElement.replaceChild(content, wrapper); + return; + } + wrapper.remove(); + } + + function watchForDiscardedFrameworkWrapperRemoval(sessionId) { + if (!sessionId || !document.body) return; + if (discardedFrameworkWrapperWatchers.has(sessionId)) return; + const selector = '[data-impeccable-variants="' + sessionId + '"]'; + let observer = null; + let timer = null; + const stopWatching = function() { + observer?.disconnect(); + if (timer) clearTimeout(timer); + discardedFrameworkWrapperWatchers.delete(sessionId); + }; + const finishIfGone = function() { + if (document.querySelector(selector)) return false; + removeDiscardStateStylesheet(sessionId); + stopWatching(); + return true; + }; + if (finishIfGone()) return; + observer = new MutationObserver(finishIfGone); + observer.observe(document.body, { childList: true, subtree: true }); + const resolveStillMounted = function() { + if (finishIfGone()) return; + const replacementActive = !!currentSessionId + || (state !== 'IDLE' && state !== 'PICKING'); + if (replacementActive) { + timer = setTimeout(resolveStillMounted, 12000); + discardedFrameworkWrapperWatchers.get(sessionId).timer = timer; + return; + } + removeDiscardStateStylesheet(sessionId); + stopWatching(); + location.reload(); + }; + timer = setTimeout(resolveStillMounted, 12000); + discardedFrameworkWrapperWatchers.set(sessionId, { observer, timer }); + } + function resolveScrollLockAnchorTop() { const anchor = resolveBarAnchor(); if (!anchor?.isConnected) return null; @@ -7335,7 +7413,7 @@ hideInsertLine(); configureKind = 'insert'; selectedElement = placeholder; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); hideHighlight(); clearAnnotations(); showAnnotOverlay(placeholder); @@ -7353,7 +7431,7 @@ e.preventDefault(); e.stopPropagation(); selectedElement = hoveredElement; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -7530,7 +7608,7 @@ } else if (e.key === 'Enter') { e.preventDefault(); selectedElement = hoveredElement; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -8535,6 +8613,7 @@ void main() { } function scheduleAcceptCleanup(accepted) { + const recoveryRevision = liveInteractionRevision; queueMicrotask(function() { if (pendingAcceptedSession?.id !== accepted?.id) return; // Svelte previews live in an adapter-owned mount rather than in source @@ -8551,8 +8630,10 @@ void main() { // races. Static servers still need a fallback, but it must not keep Live // in SAVING or block the user's next pick. if (!accepted?.isSvelteComponent) { + watchForHandledRuntimeWrapper(accepted?.id, recoveryRevision); setTimeout(function() { - if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted); + if (deferredRecoverySuperseded(accepted?.id, recoveryRevision)) return; + if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted, recoveryRevision); }, 1200); } } @@ -8585,13 +8666,27 @@ void main() { && matches.every((el) => !el.closest('[data-impeccable-variants],[data-impeccable-variant],[data-impeccable-carbonize]')); } - function ensureAcceptedDomClean(pending) { + function ensureAcceptedDomClean(pending, recoveryRevision) { + // Background cleanup for an accepted session must never mutate or reload + // a newer comparison the user has already started. + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; if (acceptedDomAlreadyClean(pending)) return; const sessionId = pending?.id; const variantId = pending?.variant; const wrappers = findAcceptedRuntimeWrappers(sessionId); + if (hasFrameworkHmrOwnership(wrappers[0] || pending?.parentElement)) { + // Vite can coalesce rapid scaffold/carbonize writes and leave the last + // framework-owned preview tree mounted even though source is clean. Give + // HMR another grace window, then reload from clean source rather than + // violating reconciler ownership with a manual DOM mutation. + setTimeout(function() { + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; + if (!acceptedDomAlreadyClean(pending)) location.reload(); + }, 2000); + return; + } if (wrappers.length === 0) { - restoreAcceptedDomFromSnapshot(pending); + restoreAcceptedDomFromSnapshot(pending, recoveryRevision); return; } for (const wrapper of wrappers) { @@ -8608,7 +8703,7 @@ void main() { } wrapper.remove(); } - if (!acceptedDomAlreadyClean(pending)) restoreAcceptedDomFromSnapshot(pending); + if (!acceptedDomAlreadyClean(pending)) restoreAcceptedDomFromSnapshot(pending, recoveryRevision); } function findAcceptedRuntimeWrappers(sessionId) { @@ -8619,17 +8714,17 @@ void main() { ])]; } - function restoreAcceptedDomFromSnapshot(pending) { + function restoreAcceptedDomFromSnapshot(pending, recoveryRevision) { if (acceptedDomAlreadyClean(pending)) return; if (!pending?.acceptedHtml) { - reloadAfterMissingAcceptedDom(pending); + reloadAfterMissingAcceptedDom(pending, recoveryRevision); return; } const parent = pending.parentElement?.isConnected ? pending.parentElement : (pending.parentSelector ? document.querySelector(pending.parentSelector) : null); if (!parent) { - reloadAfterMissingAcceptedDom(pending); + reloadAfterMissingAcceptedDom(pending, recoveryRevision); return; } const template = document.createElement('template'); @@ -8638,10 +8733,11 @@ void main() { ? pending.nextSibling : null; parent.insertBefore(template.content, anchor); - if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending); + if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending, recoveryRevision); } - function reloadAfterMissingAcceptedDom(pending) { + function reloadAfterMissingAcceptedDom(pending, recoveryRevision) { + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; if (acceptedDomAlreadyClean(pending)) return; if (pending?.id && document.querySelector('[data-impeccable-variants="' + pending.id + '"]')) return; location.reload(); @@ -8991,14 +9087,15 @@ void main() { return sessionState.isHandled(id); } - function clearHandled() { - sessionState.clearHandled(); + function clearHandled(sessionId) { + sessionState.clearHandled(sessionId); } function cleanup(options) { const restoreOriginal = options?.restoreOriginal === true; const instantChrome = options?.instantChrome === true; const cleanupSessionId = currentSessionId; + const cleanupRevision = liveInteractionRevision; clearMountErrorCard(); lastReportedMountFailure = null; if (svelteComponentSession?.sessionId === cleanupSessionId) { @@ -9016,19 +9113,41 @@ void main() { else wrapper.style.display = 'none'; } setTimeout(function() { - document.getElementById(DISCARD_STATE_STYLE_ID)?.remove(); - if (!cleanupSessionId) return; - const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!lateWrapper) return; - const orig = lateWrapper.querySelector('[data-impeccable-variant="original"]'); - if (orig) { - const content = orig.firstElementChild; - if (content) { - lateWrapper.parentElement.replaceChild(content, lateWrapper); - return; - } + const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); + if (!cleanupSessionId) { + removeDiscardStateStylesheet(); + return; } - lateWrapper.remove(); + const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (!lateWrapper) { + removeDiscardStateStylesheet(cleanupSessionId); + return; + } + if (recoverySuperseded) { + if (hasFrameworkHmrOwnership(lateWrapper)) { + watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); + } else { + releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + } + return; + } + if (hasFrameworkHmrOwnership(lateWrapper)) { + // As on accept, never restructure framework-owned DOM. If HMR missed + // the final source rewrite, reload once after a grace window so the + // discarded source becomes authoritative without a reconciler race. + setTimeout(function() { + const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { + if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId); + else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); + return; + } + removeDiscardStateStylesheet(cleanupSessionId); + if (staleWrapper) location.reload(); + }, 2000); + return; + } + releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); }, 2000); } hideBar(instantChrome); @@ -9111,12 +9230,122 @@ void main() { // Resume an active variant session after HMR/page reload. // If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote // variants before HMR fired. Pick up where we left off. - function resumeSession() { + function handledWrapperReloadKey(sessionId) { + return HANDLED_WRAPPER_RELOAD_KEY + ':' + sessionId; + } + + function clearHandledWrapperReloadStamp(sessionId) { + try { + if (sessionId) { + sessionStorage.removeItem(handledWrapperReloadKey(sessionId)); + const legacy = sessionStorage.getItem(HANDLED_WRAPPER_RELOAD_KEY) || ''; + if (legacy === sessionId || legacy.startsWith(sessionId + ':')) { + sessionStorage.removeItem(HANDLED_WRAPPER_RELOAD_KEY); + } + return; + } + sessionStorage.removeItem(HANDLED_WRAPPER_RELOAD_KEY); + for (let i = sessionStorage.length - 1; i >= 0; i--) { + const key = sessionStorage.key(i); + if (key?.startsWith(HANDLED_WRAPPER_RELOAD_KEY + ':')) sessionStorage.removeItem(key); + } + } catch {} + } + + function scheduleHandledRuntimeWrapperReload(wrapper, recoveryRevision = liveInteractionRevision) { + const sessionId = wrapper?.dataset?.impeccableVariants + || wrapper?.dataset?.impeccableCarbonize; + if (!sessionId || !isSessionHandled(sessionId)) return false; + if (deferredRecoverySuperseded(sessionId, recoveryRevision)) return true; + + if (handledRuntimeWrapperReloadSessions.has(sessionId)) return true; + let reloadAttempts = 0; + try { + reloadAttempts = Number(sessionStorage.getItem(handledWrapperReloadKey(sessionId))) || 0; + if (reloadAttempts >= 2) return true; + sessionStorage.setItem(handledWrapperReloadKey(sessionId), String(reloadAttempts + 1)); + } catch {} + handledRuntimeWrapperReloadSessions.add(sessionId); + + // A framework refresh can replace the variants tree with an intermediate + // carbonize tree and reload the page, cancelling the original accept timer. + // Let the file-side cleanup settle, then reload once from authoritative + // source. The sessionStorage stamp prevents a stale dev-server response + // from turning this recovery into a reload loop. + setTimeout(function() { + if (deferredRecoverySuperseded(sessionId, recoveryRevision)) { + clearHandledWrapperReloadStamp(sessionId); + handledRuntimeWrapperReloadSessions.delete(sessionId); + return; + } + const staleWrapper = document.querySelector( + '[data-impeccable-variants="' + sessionId + '"],' + + '[data-impeccable-carbonize="' + sessionId + '"]', + ); + if (staleWrapper) location.reload(); + else { + clearHandledWrapperReloadStamp(sessionId); + handledRuntimeWrapperReloadSessions.delete(sessionId); + } + }, 3000); + return true; + } + + function watchForHandledRuntimeWrapper(sessionId, recoveryRevision = liveInteractionRevision) { + if (!sessionId || !document.body) return; + const existing = handledRuntimeWrapperWatchers.get(sessionId); + existing?.observer.disconnect(); + if (existing?.timer) clearTimeout(existing.timer); + + const findHandledWrapper = function() { + const wrapper = document.querySelector( + '[data-impeccable-variants="' + sessionId + '"],' + + '[data-impeccable-carbonize="' + sessionId + '"]', + ); + if (wrapper) scheduleHandledRuntimeWrapperReload(wrapper, recoveryRevision); + }; + + // Vite can briefly render the clean accepted tree, then apply a delayed + // carbonize refresh after the one-shot accept fallback has already passed. + // Keep a bounded scout alive through that refresh window so a late stale + // framework tree still reloads from the now-authoritative source. + const observer = new MutationObserver(findHandledWrapper); + observer.observe(document.body, { childList: true, subtree: true }); + const timer = setTimeout(function() { + if (handledRuntimeWrapperWatchers.get(sessionId)?.observer !== observer) return; + observer.disconnect(); + handledRuntimeWrapperWatchers.delete(sessionId); + }, 12000); + handledRuntimeWrapperWatchers.set(sessionId, { observer, timer }); + findHandledWrapper(); + } + + function restoreSessionSupersedingHandledWrapper(runtimeWrapper) { + const handledSessionId = runtimeWrapper?.dataset?.impeccableVariants + || runtimeWrapper?.dataset?.impeccableCarbonize; + if (!handledSessionId || !isSessionHandled(handledSessionId)) return false; + + // Accept releases the picker before carbonize finishes, so a replacement + // generation can already be durable while the prior handled wrapper is + // still mounted. Restore that newer session before the stale-wrapper + // recovery path gets a chance to reload or consume its retry budget. + const saved = loadSession(); + if (!saved?.id || saved.id === handledSessionId || isSessionHandled(saved.id)) return false; + if (currentSessionId === saved.id) return true; + return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); + } + + function resumeSession(recoveryRevision = liveInteractionRevision) { const wrapper = document.querySelector('[data-impeccable-variants]'); + const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); + if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; + if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; if (!wrapper) { if (restoreSessionWithoutWrapper('browser_resumed_without_wrapper')) return true; clearSession(); - clearHandled(); + // Keep the bounded handled-id history durable. A framework can hydrate a + // completed wrapper well after initialization, and a later reload must + // still recognize that wrapper as recovery work rather than resume it. return false; } @@ -9136,7 +9365,7 @@ void main() { wrapper.remove(); if (restoreSessionWithoutWrapper('browser_resumed_svelte_orphan_wrapper')) return true; clearSession(); - clearHandled(); + clearHandled(sessionId); return false; } @@ -12520,22 +12749,28 @@ void main() { connectSSE(); // Check for an active session to resume (variant wrapper already in DOM after HMR) - if (!resumeSession()) { + const resumed = resumeSession(); + if (!resumed) { console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.'); - // SvelteKit (and any framework that hydrates after HTML parse) may add - // the variant wrapper AFTER init runs. Watch for it and retry resume - // once it appears. Disconnect on first hit. + } else { + console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); + } + + // SvelteKit, React, and other frameworks may restore a durable session + // before hydration adds its variant wrapper. Keep a deferred-wrapper scout + // whenever init did not see a runtime wrapper, even if local/server state + // was already restored successfully. Disconnect on the first wrapper hit. + if (!resumed || !document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]')) { + const deferredResumeRevision = liveInteractionRevision; const scout = new MutationObserver(() => { - const wrapper = document.querySelector('[data-impeccable-variants]'); + const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); if (!wrapper) return; scout.disconnect(); - if (resumeSession()) { + if (resumeSession(deferredResumeRevision)) { console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); } }); scout.observe(document.body, { childList: true, subtree: true }); - } else { - console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); } if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING'); diff --git a/.kiro/skills/impeccable/scripts/live-browser-dom.js b/.kiro/skills/impeccable/scripts/live-browser-dom.js index ad6a794b3..e85c95c43 100644 --- a/.kiro/skills/impeccable/scripts/live-browser-dom.js +++ b/.kiro/skills/impeccable/scripts/live-browser-dom.js @@ -64,6 +64,26 @@ }; } + function hasFrameworkHmrOwnership(el) { + for (let node = el; node; node = node.parentElement) { + let keys = []; + try { keys = Object.getOwnPropertyNames(node); } catch {} + if (keys.some((key) => ( + key.startsWith('__reactFiber$') + || key.startsWith('__reactProps$') + || key.startsWith('__reactContainer$') + || key === '_reactRootContainer' + || key === '__vueParentComponent' + || key === '__vue_app__' + || key === '__vnode' + || key === '__svelte_meta' + ))) { + return true; + } + } + return false; + } + function id8() { if (crypto?.randomUUID) return crypto.randomUUID().replace(/-/g, '').slice(0, 8); return (Math.random().toString(16).slice(2) + Date.now().toString(16)).slice(0, 8); @@ -128,6 +148,7 @@ desc, rectIsUsableAnchor, makeFrozenAnchor, + hasFrameworkHmrOwnership, id8, cssId, liveUiRoot, diff --git a/.kiro/skills/impeccable/scripts/live-browser-session.js b/.kiro/skills/impeccable/scripts/live-browser-session.js index 0e362d6be..1514bf472 100644 --- a/.kiro/skills/impeccable/scripts/live-browser-session.js +++ b/.kiro/skills/impeccable/scripts/live-browser-session.js @@ -71,17 +71,38 @@ return checkpointRevision; } + function readHandledIds() { + const raw = safeRead(handledKey); + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed.filter(id => typeof id === 'string' && id); + } + if (typeof parsed === 'string' && parsed) return [parsed]; + } catch { /* legacy values were stored as a plain session id */ } + return [raw]; + } + function markHandled(id) { if (!id) return; - safeWrite(handledKey, id); + const ids = readHandledIds().filter(existing => existing !== id); + ids.push(id); + safeWrite(handledKey, JSON.stringify(ids.slice(-8))); } function isHandled(id) { - return !!id && safeRead(handledKey) === id; + return !!id && readHandledIds().includes(id); } - function clearHandled() { - safeRemove(handledKey); + function clearHandled(id) { + if (!id) { + safeRemove(handledKey); + return; + } + const remaining = readHandledIds().filter(existing => existing !== id); + if (remaining.length > 0) safeWrite(handledKey, JSON.stringify(remaining)); + else safeRemove(handledKey); } function writeScrollY(y) { diff --git a/.kiro/skills/impeccable/scripts/live-browser.js b/.kiro/skills/impeccable/scripts/live-browser.js index 2b38ec62e..1f373a894 100644 --- a/.kiro/skills/impeccable/scripts/live-browser.js +++ b/.kiro/skills/impeccable/scripts/live-browser.js @@ -121,6 +121,10 @@ let hoveredElement = null; let selectedElement = null; let currentSessionId = null; + // Advances when the user begins configuring a fresh edit, before that edit + // has a server session id. Deferred recovery captures this revision so an + // older accept/discard can never reload over a replacement configuration. + let liveInteractionRevision = 0; let expectedVariants = 0; let arrivedVariants = 0; let visibleVariant = 0; @@ -188,6 +192,9 @@ // when the real accept result arrives or a new session starts. let awaitingAcceptResult = null; let variantObserver = null; + const discardedFrameworkWrapperWatchers = new Map(); + const handledRuntimeWrapperWatchers = new Map(); + const handledRuntimeWrapperReloadSessions = new Set(); let variantSelectionInFlight = false; let variantSelectionPromise = null; let recoveringEmptyCycling = false; @@ -208,6 +215,7 @@ const SCROLL_ANCHOR_LOCK_ID = 'impeccable-scroll-anchor-lock'; const VARIANT_STATE_STYLE_ID = 'impeccable-variant-state'; const DISCARD_STATE_STYLE_ID = 'impeccable-discard-state'; + const HANDLED_WRAPPER_RELOAD_KEY = PREFIX + '-handled-wrapper-reload'; // Dedicated key for scroll position - SEPARATE from LS_KEY so that // saveSession's state updates don't clobber a carefully-captured scrollY. @@ -270,6 +278,7 @@ desc, rectIsUsableAnchor, makeFrozenAnchor, + hasFrameworkHmrOwnership, id8, cssId, liveUiRoot, @@ -2034,6 +2043,16 @@ syncSteerQueueHint(); } + function beginNewLiveConfiguration() { + liveInteractionRevision += 1; + setLiveState('CONFIGURING'); + } + + function deferredRecoverySuperseded(sessionId, recoveryRevision) { + return liveInteractionRevision !== recoveryRevision + || !!(currentSessionId && currentSessionId !== sessionId); + } + /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { @@ -5036,7 +5055,7 @@ && el.parentElement && document.body.contains(el) && !own(el) - && !el.closest?.('[data-impeccable-variants]'); + && !el.closest?.('[data-impeccable-variants],[data-impeccable-carbonize]'); } function elementMatchesOriginalMarkup(liveEl, origContent) { @@ -6608,19 +6627,78 @@ document.getElementById(VARIANT_STATE_STYLE_ID)?.remove(); } + function discardStateStyleId(sessionId) { + return DISCARD_STATE_STYLE_ID + '-' + sessionId; + } + function showOriginalDuringDiscard(sessionId) { if (!sessionId) return; - let styleEl = document.getElementById(DISCARD_STATE_STYLE_ID); + let styleEl = document.getElementById(discardStateStyleId(sessionId)); if (!styleEl) { styleEl = document.createElement('style'); - styleEl.id = DISCARD_STATE_STYLE_ID; + styleEl.id = discardStateStyleId(sessionId); (document.head || document.documentElement).appendChild(styleEl); } + styleEl.dataset.impeccableDiscardSession = sessionId; const wrapper = '[data-impeccable-variants="' + sessionId + '"]'; styleEl.textContent = wrapper + ' > [data-impeccable-variant]:not([data-impeccable-variant="original"]) { display:none !important; }\n' + wrapper + ' > [data-impeccable-variant="original"] { display:block !important; }'; } + function removeDiscardStateStylesheet(sessionId) { + if (!sessionId) return; + document.getElementById(discardStateStyleId(sessionId))?.remove(); + } + + function releaseDiscardedStaticWrapper(wrapper, sessionId) { + removeDiscardStateStylesheet(sessionId); + if (!wrapper) return; + const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); + const content = orig?.firstElementChild; + if (content && wrapper.parentElement) { + wrapper.parentElement.replaceChild(content, wrapper); + return; + } + wrapper.remove(); + } + + function watchForDiscardedFrameworkWrapperRemoval(sessionId) { + if (!sessionId || !document.body) return; + if (discardedFrameworkWrapperWatchers.has(sessionId)) return; + const selector = '[data-impeccable-variants="' + sessionId + '"]'; + let observer = null; + let timer = null; + const stopWatching = function() { + observer?.disconnect(); + if (timer) clearTimeout(timer); + discardedFrameworkWrapperWatchers.delete(sessionId); + }; + const finishIfGone = function() { + if (document.querySelector(selector)) return false; + removeDiscardStateStylesheet(sessionId); + stopWatching(); + return true; + }; + if (finishIfGone()) return; + observer = new MutationObserver(finishIfGone); + observer.observe(document.body, { childList: true, subtree: true }); + const resolveStillMounted = function() { + if (finishIfGone()) return; + const replacementActive = !!currentSessionId + || (state !== 'IDLE' && state !== 'PICKING'); + if (replacementActive) { + timer = setTimeout(resolveStillMounted, 12000); + discardedFrameworkWrapperWatchers.get(sessionId).timer = timer; + return; + } + removeDiscardStateStylesheet(sessionId); + stopWatching(); + location.reload(); + }; + timer = setTimeout(resolveStillMounted, 12000); + discardedFrameworkWrapperWatchers.set(sessionId, { observer, timer }); + } + function resolveScrollLockAnchorTop() { const anchor = resolveBarAnchor(); if (!anchor?.isConnected) return null; @@ -7335,7 +7413,7 @@ hideInsertLine(); configureKind = 'insert'; selectedElement = placeholder; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); hideHighlight(); clearAnnotations(); showAnnotOverlay(placeholder); @@ -7353,7 +7431,7 @@ e.preventDefault(); e.stopPropagation(); selectedElement = hoveredElement; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -7530,7 +7608,7 @@ } else if (e.key === 'Enter') { e.preventDefault(); selectedElement = hoveredElement; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -8535,6 +8613,7 @@ void main() { } function scheduleAcceptCleanup(accepted) { + const recoveryRevision = liveInteractionRevision; queueMicrotask(function() { if (pendingAcceptedSession?.id !== accepted?.id) return; // Svelte previews live in an adapter-owned mount rather than in source @@ -8551,8 +8630,10 @@ void main() { // races. Static servers still need a fallback, but it must not keep Live // in SAVING or block the user's next pick. if (!accepted?.isSvelteComponent) { + watchForHandledRuntimeWrapper(accepted?.id, recoveryRevision); setTimeout(function() { - if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted); + if (deferredRecoverySuperseded(accepted?.id, recoveryRevision)) return; + if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted, recoveryRevision); }, 1200); } } @@ -8585,13 +8666,27 @@ void main() { && matches.every((el) => !el.closest('[data-impeccable-variants],[data-impeccable-variant],[data-impeccable-carbonize]')); } - function ensureAcceptedDomClean(pending) { + function ensureAcceptedDomClean(pending, recoveryRevision) { + // Background cleanup for an accepted session must never mutate or reload + // a newer comparison the user has already started. + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; if (acceptedDomAlreadyClean(pending)) return; const sessionId = pending?.id; const variantId = pending?.variant; const wrappers = findAcceptedRuntimeWrappers(sessionId); + if (hasFrameworkHmrOwnership(wrappers[0] || pending?.parentElement)) { + // Vite can coalesce rapid scaffold/carbonize writes and leave the last + // framework-owned preview tree mounted even though source is clean. Give + // HMR another grace window, then reload from clean source rather than + // violating reconciler ownership with a manual DOM mutation. + setTimeout(function() { + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; + if (!acceptedDomAlreadyClean(pending)) location.reload(); + }, 2000); + return; + } if (wrappers.length === 0) { - restoreAcceptedDomFromSnapshot(pending); + restoreAcceptedDomFromSnapshot(pending, recoveryRevision); return; } for (const wrapper of wrappers) { @@ -8608,7 +8703,7 @@ void main() { } wrapper.remove(); } - if (!acceptedDomAlreadyClean(pending)) restoreAcceptedDomFromSnapshot(pending); + if (!acceptedDomAlreadyClean(pending)) restoreAcceptedDomFromSnapshot(pending, recoveryRevision); } function findAcceptedRuntimeWrappers(sessionId) { @@ -8619,17 +8714,17 @@ void main() { ])]; } - function restoreAcceptedDomFromSnapshot(pending) { + function restoreAcceptedDomFromSnapshot(pending, recoveryRevision) { if (acceptedDomAlreadyClean(pending)) return; if (!pending?.acceptedHtml) { - reloadAfterMissingAcceptedDom(pending); + reloadAfterMissingAcceptedDom(pending, recoveryRevision); return; } const parent = pending.parentElement?.isConnected ? pending.parentElement : (pending.parentSelector ? document.querySelector(pending.parentSelector) : null); if (!parent) { - reloadAfterMissingAcceptedDom(pending); + reloadAfterMissingAcceptedDom(pending, recoveryRevision); return; } const template = document.createElement('template'); @@ -8638,10 +8733,11 @@ void main() { ? pending.nextSibling : null; parent.insertBefore(template.content, anchor); - if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending); + if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending, recoveryRevision); } - function reloadAfterMissingAcceptedDom(pending) { + function reloadAfterMissingAcceptedDom(pending, recoveryRevision) { + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; if (acceptedDomAlreadyClean(pending)) return; if (pending?.id && document.querySelector('[data-impeccable-variants="' + pending.id + '"]')) return; location.reload(); @@ -8991,14 +9087,15 @@ void main() { return sessionState.isHandled(id); } - function clearHandled() { - sessionState.clearHandled(); + function clearHandled(sessionId) { + sessionState.clearHandled(sessionId); } function cleanup(options) { const restoreOriginal = options?.restoreOriginal === true; const instantChrome = options?.instantChrome === true; const cleanupSessionId = currentSessionId; + const cleanupRevision = liveInteractionRevision; clearMountErrorCard(); lastReportedMountFailure = null; if (svelteComponentSession?.sessionId === cleanupSessionId) { @@ -9016,19 +9113,41 @@ void main() { else wrapper.style.display = 'none'; } setTimeout(function() { - document.getElementById(DISCARD_STATE_STYLE_ID)?.remove(); - if (!cleanupSessionId) return; - const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!lateWrapper) return; - const orig = lateWrapper.querySelector('[data-impeccable-variant="original"]'); - if (orig) { - const content = orig.firstElementChild; - if (content) { - lateWrapper.parentElement.replaceChild(content, lateWrapper); - return; - } + const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); + if (!cleanupSessionId) { + removeDiscardStateStylesheet(); + return; } - lateWrapper.remove(); + const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (!lateWrapper) { + removeDiscardStateStylesheet(cleanupSessionId); + return; + } + if (recoverySuperseded) { + if (hasFrameworkHmrOwnership(lateWrapper)) { + watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); + } else { + releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + } + return; + } + if (hasFrameworkHmrOwnership(lateWrapper)) { + // As on accept, never restructure framework-owned DOM. If HMR missed + // the final source rewrite, reload once after a grace window so the + // discarded source becomes authoritative without a reconciler race. + setTimeout(function() { + const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { + if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId); + else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); + return; + } + removeDiscardStateStylesheet(cleanupSessionId); + if (staleWrapper) location.reload(); + }, 2000); + return; + } + releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); }, 2000); } hideBar(instantChrome); @@ -9111,12 +9230,122 @@ void main() { // Resume an active variant session after HMR/page reload. // If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote // variants before HMR fired. Pick up where we left off. - function resumeSession() { + function handledWrapperReloadKey(sessionId) { + return HANDLED_WRAPPER_RELOAD_KEY + ':' + sessionId; + } + + function clearHandledWrapperReloadStamp(sessionId) { + try { + if (sessionId) { + sessionStorage.removeItem(handledWrapperReloadKey(sessionId)); + const legacy = sessionStorage.getItem(HANDLED_WRAPPER_RELOAD_KEY) || ''; + if (legacy === sessionId || legacy.startsWith(sessionId + ':')) { + sessionStorage.removeItem(HANDLED_WRAPPER_RELOAD_KEY); + } + return; + } + sessionStorage.removeItem(HANDLED_WRAPPER_RELOAD_KEY); + for (let i = sessionStorage.length - 1; i >= 0; i--) { + const key = sessionStorage.key(i); + if (key?.startsWith(HANDLED_WRAPPER_RELOAD_KEY + ':')) sessionStorage.removeItem(key); + } + } catch {} + } + + function scheduleHandledRuntimeWrapperReload(wrapper, recoveryRevision = liveInteractionRevision) { + const sessionId = wrapper?.dataset?.impeccableVariants + || wrapper?.dataset?.impeccableCarbonize; + if (!sessionId || !isSessionHandled(sessionId)) return false; + if (deferredRecoverySuperseded(sessionId, recoveryRevision)) return true; + + if (handledRuntimeWrapperReloadSessions.has(sessionId)) return true; + let reloadAttempts = 0; + try { + reloadAttempts = Number(sessionStorage.getItem(handledWrapperReloadKey(sessionId))) || 0; + if (reloadAttempts >= 2) return true; + sessionStorage.setItem(handledWrapperReloadKey(sessionId), String(reloadAttempts + 1)); + } catch {} + handledRuntimeWrapperReloadSessions.add(sessionId); + + // A framework refresh can replace the variants tree with an intermediate + // carbonize tree and reload the page, cancelling the original accept timer. + // Let the file-side cleanup settle, then reload once from authoritative + // source. The sessionStorage stamp prevents a stale dev-server response + // from turning this recovery into a reload loop. + setTimeout(function() { + if (deferredRecoverySuperseded(sessionId, recoveryRevision)) { + clearHandledWrapperReloadStamp(sessionId); + handledRuntimeWrapperReloadSessions.delete(sessionId); + return; + } + const staleWrapper = document.querySelector( + '[data-impeccable-variants="' + sessionId + '"],' + + '[data-impeccable-carbonize="' + sessionId + '"]', + ); + if (staleWrapper) location.reload(); + else { + clearHandledWrapperReloadStamp(sessionId); + handledRuntimeWrapperReloadSessions.delete(sessionId); + } + }, 3000); + return true; + } + + function watchForHandledRuntimeWrapper(sessionId, recoveryRevision = liveInteractionRevision) { + if (!sessionId || !document.body) return; + const existing = handledRuntimeWrapperWatchers.get(sessionId); + existing?.observer.disconnect(); + if (existing?.timer) clearTimeout(existing.timer); + + const findHandledWrapper = function() { + const wrapper = document.querySelector( + '[data-impeccable-variants="' + sessionId + '"],' + + '[data-impeccable-carbonize="' + sessionId + '"]', + ); + if (wrapper) scheduleHandledRuntimeWrapperReload(wrapper, recoveryRevision); + }; + + // Vite can briefly render the clean accepted tree, then apply a delayed + // carbonize refresh after the one-shot accept fallback has already passed. + // Keep a bounded scout alive through that refresh window so a late stale + // framework tree still reloads from the now-authoritative source. + const observer = new MutationObserver(findHandledWrapper); + observer.observe(document.body, { childList: true, subtree: true }); + const timer = setTimeout(function() { + if (handledRuntimeWrapperWatchers.get(sessionId)?.observer !== observer) return; + observer.disconnect(); + handledRuntimeWrapperWatchers.delete(sessionId); + }, 12000); + handledRuntimeWrapperWatchers.set(sessionId, { observer, timer }); + findHandledWrapper(); + } + + function restoreSessionSupersedingHandledWrapper(runtimeWrapper) { + const handledSessionId = runtimeWrapper?.dataset?.impeccableVariants + || runtimeWrapper?.dataset?.impeccableCarbonize; + if (!handledSessionId || !isSessionHandled(handledSessionId)) return false; + + // Accept releases the picker before carbonize finishes, so a replacement + // generation can already be durable while the prior handled wrapper is + // still mounted. Restore that newer session before the stale-wrapper + // recovery path gets a chance to reload or consume its retry budget. + const saved = loadSession(); + if (!saved?.id || saved.id === handledSessionId || isSessionHandled(saved.id)) return false; + if (currentSessionId === saved.id) return true; + return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); + } + + function resumeSession(recoveryRevision = liveInteractionRevision) { const wrapper = document.querySelector('[data-impeccable-variants]'); + const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); + if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; + if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; if (!wrapper) { if (restoreSessionWithoutWrapper('browser_resumed_without_wrapper')) return true; clearSession(); - clearHandled(); + // Keep the bounded handled-id history durable. A framework can hydrate a + // completed wrapper well after initialization, and a later reload must + // still recognize that wrapper as recovery work rather than resume it. return false; } @@ -9136,7 +9365,7 @@ void main() { wrapper.remove(); if (restoreSessionWithoutWrapper('browser_resumed_svelte_orphan_wrapper')) return true; clearSession(); - clearHandled(); + clearHandled(sessionId); return false; } @@ -12520,22 +12749,28 @@ void main() { connectSSE(); // Check for an active session to resume (variant wrapper already in DOM after HMR) - if (!resumeSession()) { + const resumed = resumeSession(); + if (!resumed) { console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.'); - // SvelteKit (and any framework that hydrates after HTML parse) may add - // the variant wrapper AFTER init runs. Watch for it and retry resume - // once it appears. Disconnect on first hit. + } else { + console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); + } + + // SvelteKit, React, and other frameworks may restore a durable session + // before hydration adds its variant wrapper. Keep a deferred-wrapper scout + // whenever init did not see a runtime wrapper, even if local/server state + // was already restored successfully. Disconnect on the first wrapper hit. + if (!resumed || !document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]')) { + const deferredResumeRevision = liveInteractionRevision; const scout = new MutationObserver(() => { - const wrapper = document.querySelector('[data-impeccable-variants]'); + const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); if (!wrapper) return; scout.disconnect(); - if (resumeSession()) { + if (resumeSession(deferredResumeRevision)) { console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); } }); scout.observe(document.body, { childList: true, subtree: true }); - } else { - console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); } if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING'); diff --git a/.opencode/skills/impeccable/scripts/live-browser-dom.js b/.opencode/skills/impeccable/scripts/live-browser-dom.js index ad6a794b3..e85c95c43 100644 --- a/.opencode/skills/impeccable/scripts/live-browser-dom.js +++ b/.opencode/skills/impeccable/scripts/live-browser-dom.js @@ -64,6 +64,26 @@ }; } + function hasFrameworkHmrOwnership(el) { + for (let node = el; node; node = node.parentElement) { + let keys = []; + try { keys = Object.getOwnPropertyNames(node); } catch {} + if (keys.some((key) => ( + key.startsWith('__reactFiber$') + || key.startsWith('__reactProps$') + || key.startsWith('__reactContainer$') + || key === '_reactRootContainer' + || key === '__vueParentComponent' + || key === '__vue_app__' + || key === '__vnode' + || key === '__svelte_meta' + ))) { + return true; + } + } + return false; + } + function id8() { if (crypto?.randomUUID) return crypto.randomUUID().replace(/-/g, '').slice(0, 8); return (Math.random().toString(16).slice(2) + Date.now().toString(16)).slice(0, 8); @@ -128,6 +148,7 @@ desc, rectIsUsableAnchor, makeFrozenAnchor, + hasFrameworkHmrOwnership, id8, cssId, liveUiRoot, diff --git a/.opencode/skills/impeccable/scripts/live-browser-session.js b/.opencode/skills/impeccable/scripts/live-browser-session.js index 0e362d6be..1514bf472 100644 --- a/.opencode/skills/impeccable/scripts/live-browser-session.js +++ b/.opencode/skills/impeccable/scripts/live-browser-session.js @@ -71,17 +71,38 @@ return checkpointRevision; } + function readHandledIds() { + const raw = safeRead(handledKey); + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed.filter(id => typeof id === 'string' && id); + } + if (typeof parsed === 'string' && parsed) return [parsed]; + } catch { /* legacy values were stored as a plain session id */ } + return [raw]; + } + function markHandled(id) { if (!id) return; - safeWrite(handledKey, id); + const ids = readHandledIds().filter(existing => existing !== id); + ids.push(id); + safeWrite(handledKey, JSON.stringify(ids.slice(-8))); } function isHandled(id) { - return !!id && safeRead(handledKey) === id; + return !!id && readHandledIds().includes(id); } - function clearHandled() { - safeRemove(handledKey); + function clearHandled(id) { + if (!id) { + safeRemove(handledKey); + return; + } + const remaining = readHandledIds().filter(existing => existing !== id); + if (remaining.length > 0) safeWrite(handledKey, JSON.stringify(remaining)); + else safeRemove(handledKey); } function writeScrollY(y) { diff --git a/.opencode/skills/impeccable/scripts/live-browser.js b/.opencode/skills/impeccable/scripts/live-browser.js index 2b38ec62e..1f373a894 100644 --- a/.opencode/skills/impeccable/scripts/live-browser.js +++ b/.opencode/skills/impeccable/scripts/live-browser.js @@ -121,6 +121,10 @@ let hoveredElement = null; let selectedElement = null; let currentSessionId = null; + // Advances when the user begins configuring a fresh edit, before that edit + // has a server session id. Deferred recovery captures this revision so an + // older accept/discard can never reload over a replacement configuration. + let liveInteractionRevision = 0; let expectedVariants = 0; let arrivedVariants = 0; let visibleVariant = 0; @@ -188,6 +192,9 @@ // when the real accept result arrives or a new session starts. let awaitingAcceptResult = null; let variantObserver = null; + const discardedFrameworkWrapperWatchers = new Map(); + const handledRuntimeWrapperWatchers = new Map(); + const handledRuntimeWrapperReloadSessions = new Set(); let variantSelectionInFlight = false; let variantSelectionPromise = null; let recoveringEmptyCycling = false; @@ -208,6 +215,7 @@ const SCROLL_ANCHOR_LOCK_ID = 'impeccable-scroll-anchor-lock'; const VARIANT_STATE_STYLE_ID = 'impeccable-variant-state'; const DISCARD_STATE_STYLE_ID = 'impeccable-discard-state'; + const HANDLED_WRAPPER_RELOAD_KEY = PREFIX + '-handled-wrapper-reload'; // Dedicated key for scroll position - SEPARATE from LS_KEY so that // saveSession's state updates don't clobber a carefully-captured scrollY. @@ -270,6 +278,7 @@ desc, rectIsUsableAnchor, makeFrozenAnchor, + hasFrameworkHmrOwnership, id8, cssId, liveUiRoot, @@ -2034,6 +2043,16 @@ syncSteerQueueHint(); } + function beginNewLiveConfiguration() { + liveInteractionRevision += 1; + setLiveState('CONFIGURING'); + } + + function deferredRecoverySuperseded(sessionId, recoveryRevision) { + return liveInteractionRevision !== recoveryRevision + || !!(currentSessionId && currentSessionId !== sessionId); + } + /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { @@ -5036,7 +5055,7 @@ && el.parentElement && document.body.contains(el) && !own(el) - && !el.closest?.('[data-impeccable-variants]'); + && !el.closest?.('[data-impeccable-variants],[data-impeccable-carbonize]'); } function elementMatchesOriginalMarkup(liveEl, origContent) { @@ -6608,19 +6627,78 @@ document.getElementById(VARIANT_STATE_STYLE_ID)?.remove(); } + function discardStateStyleId(sessionId) { + return DISCARD_STATE_STYLE_ID + '-' + sessionId; + } + function showOriginalDuringDiscard(sessionId) { if (!sessionId) return; - let styleEl = document.getElementById(DISCARD_STATE_STYLE_ID); + let styleEl = document.getElementById(discardStateStyleId(sessionId)); if (!styleEl) { styleEl = document.createElement('style'); - styleEl.id = DISCARD_STATE_STYLE_ID; + styleEl.id = discardStateStyleId(sessionId); (document.head || document.documentElement).appendChild(styleEl); } + styleEl.dataset.impeccableDiscardSession = sessionId; const wrapper = '[data-impeccable-variants="' + sessionId + '"]'; styleEl.textContent = wrapper + ' > [data-impeccable-variant]:not([data-impeccable-variant="original"]) { display:none !important; }\n' + wrapper + ' > [data-impeccable-variant="original"] { display:block !important; }'; } + function removeDiscardStateStylesheet(sessionId) { + if (!sessionId) return; + document.getElementById(discardStateStyleId(sessionId))?.remove(); + } + + function releaseDiscardedStaticWrapper(wrapper, sessionId) { + removeDiscardStateStylesheet(sessionId); + if (!wrapper) return; + const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); + const content = orig?.firstElementChild; + if (content && wrapper.parentElement) { + wrapper.parentElement.replaceChild(content, wrapper); + return; + } + wrapper.remove(); + } + + function watchForDiscardedFrameworkWrapperRemoval(sessionId) { + if (!sessionId || !document.body) return; + if (discardedFrameworkWrapperWatchers.has(sessionId)) return; + const selector = '[data-impeccable-variants="' + sessionId + '"]'; + let observer = null; + let timer = null; + const stopWatching = function() { + observer?.disconnect(); + if (timer) clearTimeout(timer); + discardedFrameworkWrapperWatchers.delete(sessionId); + }; + const finishIfGone = function() { + if (document.querySelector(selector)) return false; + removeDiscardStateStylesheet(sessionId); + stopWatching(); + return true; + }; + if (finishIfGone()) return; + observer = new MutationObserver(finishIfGone); + observer.observe(document.body, { childList: true, subtree: true }); + const resolveStillMounted = function() { + if (finishIfGone()) return; + const replacementActive = !!currentSessionId + || (state !== 'IDLE' && state !== 'PICKING'); + if (replacementActive) { + timer = setTimeout(resolveStillMounted, 12000); + discardedFrameworkWrapperWatchers.get(sessionId).timer = timer; + return; + } + removeDiscardStateStylesheet(sessionId); + stopWatching(); + location.reload(); + }; + timer = setTimeout(resolveStillMounted, 12000); + discardedFrameworkWrapperWatchers.set(sessionId, { observer, timer }); + } + function resolveScrollLockAnchorTop() { const anchor = resolveBarAnchor(); if (!anchor?.isConnected) return null; @@ -7335,7 +7413,7 @@ hideInsertLine(); configureKind = 'insert'; selectedElement = placeholder; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); hideHighlight(); clearAnnotations(); showAnnotOverlay(placeholder); @@ -7353,7 +7431,7 @@ e.preventDefault(); e.stopPropagation(); selectedElement = hoveredElement; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -7530,7 +7608,7 @@ } else if (e.key === 'Enter') { e.preventDefault(); selectedElement = hoveredElement; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -8535,6 +8613,7 @@ void main() { } function scheduleAcceptCleanup(accepted) { + const recoveryRevision = liveInteractionRevision; queueMicrotask(function() { if (pendingAcceptedSession?.id !== accepted?.id) return; // Svelte previews live in an adapter-owned mount rather than in source @@ -8551,8 +8630,10 @@ void main() { // races. Static servers still need a fallback, but it must not keep Live // in SAVING or block the user's next pick. if (!accepted?.isSvelteComponent) { + watchForHandledRuntimeWrapper(accepted?.id, recoveryRevision); setTimeout(function() { - if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted); + if (deferredRecoverySuperseded(accepted?.id, recoveryRevision)) return; + if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted, recoveryRevision); }, 1200); } } @@ -8585,13 +8666,27 @@ void main() { && matches.every((el) => !el.closest('[data-impeccable-variants],[data-impeccable-variant],[data-impeccable-carbonize]')); } - function ensureAcceptedDomClean(pending) { + function ensureAcceptedDomClean(pending, recoveryRevision) { + // Background cleanup for an accepted session must never mutate or reload + // a newer comparison the user has already started. + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; if (acceptedDomAlreadyClean(pending)) return; const sessionId = pending?.id; const variantId = pending?.variant; const wrappers = findAcceptedRuntimeWrappers(sessionId); + if (hasFrameworkHmrOwnership(wrappers[0] || pending?.parentElement)) { + // Vite can coalesce rapid scaffold/carbonize writes and leave the last + // framework-owned preview tree mounted even though source is clean. Give + // HMR another grace window, then reload from clean source rather than + // violating reconciler ownership with a manual DOM mutation. + setTimeout(function() { + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; + if (!acceptedDomAlreadyClean(pending)) location.reload(); + }, 2000); + return; + } if (wrappers.length === 0) { - restoreAcceptedDomFromSnapshot(pending); + restoreAcceptedDomFromSnapshot(pending, recoveryRevision); return; } for (const wrapper of wrappers) { @@ -8608,7 +8703,7 @@ void main() { } wrapper.remove(); } - if (!acceptedDomAlreadyClean(pending)) restoreAcceptedDomFromSnapshot(pending); + if (!acceptedDomAlreadyClean(pending)) restoreAcceptedDomFromSnapshot(pending, recoveryRevision); } function findAcceptedRuntimeWrappers(sessionId) { @@ -8619,17 +8714,17 @@ void main() { ])]; } - function restoreAcceptedDomFromSnapshot(pending) { + function restoreAcceptedDomFromSnapshot(pending, recoveryRevision) { if (acceptedDomAlreadyClean(pending)) return; if (!pending?.acceptedHtml) { - reloadAfterMissingAcceptedDom(pending); + reloadAfterMissingAcceptedDom(pending, recoveryRevision); return; } const parent = pending.parentElement?.isConnected ? pending.parentElement : (pending.parentSelector ? document.querySelector(pending.parentSelector) : null); if (!parent) { - reloadAfterMissingAcceptedDom(pending); + reloadAfterMissingAcceptedDom(pending, recoveryRevision); return; } const template = document.createElement('template'); @@ -8638,10 +8733,11 @@ void main() { ? pending.nextSibling : null; parent.insertBefore(template.content, anchor); - if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending); + if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending, recoveryRevision); } - function reloadAfterMissingAcceptedDom(pending) { + function reloadAfterMissingAcceptedDom(pending, recoveryRevision) { + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; if (acceptedDomAlreadyClean(pending)) return; if (pending?.id && document.querySelector('[data-impeccable-variants="' + pending.id + '"]')) return; location.reload(); @@ -8991,14 +9087,15 @@ void main() { return sessionState.isHandled(id); } - function clearHandled() { - sessionState.clearHandled(); + function clearHandled(sessionId) { + sessionState.clearHandled(sessionId); } function cleanup(options) { const restoreOriginal = options?.restoreOriginal === true; const instantChrome = options?.instantChrome === true; const cleanupSessionId = currentSessionId; + const cleanupRevision = liveInteractionRevision; clearMountErrorCard(); lastReportedMountFailure = null; if (svelteComponentSession?.sessionId === cleanupSessionId) { @@ -9016,19 +9113,41 @@ void main() { else wrapper.style.display = 'none'; } setTimeout(function() { - document.getElementById(DISCARD_STATE_STYLE_ID)?.remove(); - if (!cleanupSessionId) return; - const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!lateWrapper) return; - const orig = lateWrapper.querySelector('[data-impeccable-variant="original"]'); - if (orig) { - const content = orig.firstElementChild; - if (content) { - lateWrapper.parentElement.replaceChild(content, lateWrapper); - return; - } + const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); + if (!cleanupSessionId) { + removeDiscardStateStylesheet(); + return; } - lateWrapper.remove(); + const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (!lateWrapper) { + removeDiscardStateStylesheet(cleanupSessionId); + return; + } + if (recoverySuperseded) { + if (hasFrameworkHmrOwnership(lateWrapper)) { + watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); + } else { + releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + } + return; + } + if (hasFrameworkHmrOwnership(lateWrapper)) { + // As on accept, never restructure framework-owned DOM. If HMR missed + // the final source rewrite, reload once after a grace window so the + // discarded source becomes authoritative without a reconciler race. + setTimeout(function() { + const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { + if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId); + else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); + return; + } + removeDiscardStateStylesheet(cleanupSessionId); + if (staleWrapper) location.reload(); + }, 2000); + return; + } + releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); }, 2000); } hideBar(instantChrome); @@ -9111,12 +9230,122 @@ void main() { // Resume an active variant session after HMR/page reload. // If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote // variants before HMR fired. Pick up where we left off. - function resumeSession() { + function handledWrapperReloadKey(sessionId) { + return HANDLED_WRAPPER_RELOAD_KEY + ':' + sessionId; + } + + function clearHandledWrapperReloadStamp(sessionId) { + try { + if (sessionId) { + sessionStorage.removeItem(handledWrapperReloadKey(sessionId)); + const legacy = sessionStorage.getItem(HANDLED_WRAPPER_RELOAD_KEY) || ''; + if (legacy === sessionId || legacy.startsWith(sessionId + ':')) { + sessionStorage.removeItem(HANDLED_WRAPPER_RELOAD_KEY); + } + return; + } + sessionStorage.removeItem(HANDLED_WRAPPER_RELOAD_KEY); + for (let i = sessionStorage.length - 1; i >= 0; i--) { + const key = sessionStorage.key(i); + if (key?.startsWith(HANDLED_WRAPPER_RELOAD_KEY + ':')) sessionStorage.removeItem(key); + } + } catch {} + } + + function scheduleHandledRuntimeWrapperReload(wrapper, recoveryRevision = liveInteractionRevision) { + const sessionId = wrapper?.dataset?.impeccableVariants + || wrapper?.dataset?.impeccableCarbonize; + if (!sessionId || !isSessionHandled(sessionId)) return false; + if (deferredRecoverySuperseded(sessionId, recoveryRevision)) return true; + + if (handledRuntimeWrapperReloadSessions.has(sessionId)) return true; + let reloadAttempts = 0; + try { + reloadAttempts = Number(sessionStorage.getItem(handledWrapperReloadKey(sessionId))) || 0; + if (reloadAttempts >= 2) return true; + sessionStorage.setItem(handledWrapperReloadKey(sessionId), String(reloadAttempts + 1)); + } catch {} + handledRuntimeWrapperReloadSessions.add(sessionId); + + // A framework refresh can replace the variants tree with an intermediate + // carbonize tree and reload the page, cancelling the original accept timer. + // Let the file-side cleanup settle, then reload once from authoritative + // source. The sessionStorage stamp prevents a stale dev-server response + // from turning this recovery into a reload loop. + setTimeout(function() { + if (deferredRecoverySuperseded(sessionId, recoveryRevision)) { + clearHandledWrapperReloadStamp(sessionId); + handledRuntimeWrapperReloadSessions.delete(sessionId); + return; + } + const staleWrapper = document.querySelector( + '[data-impeccable-variants="' + sessionId + '"],' + + '[data-impeccable-carbonize="' + sessionId + '"]', + ); + if (staleWrapper) location.reload(); + else { + clearHandledWrapperReloadStamp(sessionId); + handledRuntimeWrapperReloadSessions.delete(sessionId); + } + }, 3000); + return true; + } + + function watchForHandledRuntimeWrapper(sessionId, recoveryRevision = liveInteractionRevision) { + if (!sessionId || !document.body) return; + const existing = handledRuntimeWrapperWatchers.get(sessionId); + existing?.observer.disconnect(); + if (existing?.timer) clearTimeout(existing.timer); + + const findHandledWrapper = function() { + const wrapper = document.querySelector( + '[data-impeccable-variants="' + sessionId + '"],' + + '[data-impeccable-carbonize="' + sessionId + '"]', + ); + if (wrapper) scheduleHandledRuntimeWrapperReload(wrapper, recoveryRevision); + }; + + // Vite can briefly render the clean accepted tree, then apply a delayed + // carbonize refresh after the one-shot accept fallback has already passed. + // Keep a bounded scout alive through that refresh window so a late stale + // framework tree still reloads from the now-authoritative source. + const observer = new MutationObserver(findHandledWrapper); + observer.observe(document.body, { childList: true, subtree: true }); + const timer = setTimeout(function() { + if (handledRuntimeWrapperWatchers.get(sessionId)?.observer !== observer) return; + observer.disconnect(); + handledRuntimeWrapperWatchers.delete(sessionId); + }, 12000); + handledRuntimeWrapperWatchers.set(sessionId, { observer, timer }); + findHandledWrapper(); + } + + function restoreSessionSupersedingHandledWrapper(runtimeWrapper) { + const handledSessionId = runtimeWrapper?.dataset?.impeccableVariants + || runtimeWrapper?.dataset?.impeccableCarbonize; + if (!handledSessionId || !isSessionHandled(handledSessionId)) return false; + + // Accept releases the picker before carbonize finishes, so a replacement + // generation can already be durable while the prior handled wrapper is + // still mounted. Restore that newer session before the stale-wrapper + // recovery path gets a chance to reload or consume its retry budget. + const saved = loadSession(); + if (!saved?.id || saved.id === handledSessionId || isSessionHandled(saved.id)) return false; + if (currentSessionId === saved.id) return true; + return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); + } + + function resumeSession(recoveryRevision = liveInteractionRevision) { const wrapper = document.querySelector('[data-impeccable-variants]'); + const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); + if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; + if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; if (!wrapper) { if (restoreSessionWithoutWrapper('browser_resumed_without_wrapper')) return true; clearSession(); - clearHandled(); + // Keep the bounded handled-id history durable. A framework can hydrate a + // completed wrapper well after initialization, and a later reload must + // still recognize that wrapper as recovery work rather than resume it. return false; } @@ -9136,7 +9365,7 @@ void main() { wrapper.remove(); if (restoreSessionWithoutWrapper('browser_resumed_svelte_orphan_wrapper')) return true; clearSession(); - clearHandled(); + clearHandled(sessionId); return false; } @@ -12520,22 +12749,28 @@ void main() { connectSSE(); // Check for an active session to resume (variant wrapper already in DOM after HMR) - if (!resumeSession()) { + const resumed = resumeSession(); + if (!resumed) { console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.'); - // SvelteKit (and any framework that hydrates after HTML parse) may add - // the variant wrapper AFTER init runs. Watch for it and retry resume - // once it appears. Disconnect on first hit. + } else { + console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); + } + + // SvelteKit, React, and other frameworks may restore a durable session + // before hydration adds its variant wrapper. Keep a deferred-wrapper scout + // whenever init did not see a runtime wrapper, even if local/server state + // was already restored successfully. Disconnect on the first wrapper hit. + if (!resumed || !document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]')) { + const deferredResumeRevision = liveInteractionRevision; const scout = new MutationObserver(() => { - const wrapper = document.querySelector('[data-impeccable-variants]'); + const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); if (!wrapper) return; scout.disconnect(); - if (resumeSession()) { + if (resumeSession(deferredResumeRevision)) { console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); } }); scout.observe(document.body, { childList: true, subtree: true }); - } else { - console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); } if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING'); diff --git a/.pi/skills/impeccable/scripts/live-browser-dom.js b/.pi/skills/impeccable/scripts/live-browser-dom.js index ad6a794b3..e85c95c43 100644 --- a/.pi/skills/impeccable/scripts/live-browser-dom.js +++ b/.pi/skills/impeccable/scripts/live-browser-dom.js @@ -64,6 +64,26 @@ }; } + function hasFrameworkHmrOwnership(el) { + for (let node = el; node; node = node.parentElement) { + let keys = []; + try { keys = Object.getOwnPropertyNames(node); } catch {} + if (keys.some((key) => ( + key.startsWith('__reactFiber$') + || key.startsWith('__reactProps$') + || key.startsWith('__reactContainer$') + || key === '_reactRootContainer' + || key === '__vueParentComponent' + || key === '__vue_app__' + || key === '__vnode' + || key === '__svelte_meta' + ))) { + return true; + } + } + return false; + } + function id8() { if (crypto?.randomUUID) return crypto.randomUUID().replace(/-/g, '').slice(0, 8); return (Math.random().toString(16).slice(2) + Date.now().toString(16)).slice(0, 8); @@ -128,6 +148,7 @@ desc, rectIsUsableAnchor, makeFrozenAnchor, + hasFrameworkHmrOwnership, id8, cssId, liveUiRoot, diff --git a/.pi/skills/impeccable/scripts/live-browser-session.js b/.pi/skills/impeccable/scripts/live-browser-session.js index 0e362d6be..1514bf472 100644 --- a/.pi/skills/impeccable/scripts/live-browser-session.js +++ b/.pi/skills/impeccable/scripts/live-browser-session.js @@ -71,17 +71,38 @@ return checkpointRevision; } + function readHandledIds() { + const raw = safeRead(handledKey); + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed.filter(id => typeof id === 'string' && id); + } + if (typeof parsed === 'string' && parsed) return [parsed]; + } catch { /* legacy values were stored as a plain session id */ } + return [raw]; + } + function markHandled(id) { if (!id) return; - safeWrite(handledKey, id); + const ids = readHandledIds().filter(existing => existing !== id); + ids.push(id); + safeWrite(handledKey, JSON.stringify(ids.slice(-8))); } function isHandled(id) { - return !!id && safeRead(handledKey) === id; + return !!id && readHandledIds().includes(id); } - function clearHandled() { - safeRemove(handledKey); + function clearHandled(id) { + if (!id) { + safeRemove(handledKey); + return; + } + const remaining = readHandledIds().filter(existing => existing !== id); + if (remaining.length > 0) safeWrite(handledKey, JSON.stringify(remaining)); + else safeRemove(handledKey); } function writeScrollY(y) { diff --git a/.pi/skills/impeccable/scripts/live-browser.js b/.pi/skills/impeccable/scripts/live-browser.js index 2b38ec62e..1f373a894 100644 --- a/.pi/skills/impeccable/scripts/live-browser.js +++ b/.pi/skills/impeccable/scripts/live-browser.js @@ -121,6 +121,10 @@ let hoveredElement = null; let selectedElement = null; let currentSessionId = null; + // Advances when the user begins configuring a fresh edit, before that edit + // has a server session id. Deferred recovery captures this revision so an + // older accept/discard can never reload over a replacement configuration. + let liveInteractionRevision = 0; let expectedVariants = 0; let arrivedVariants = 0; let visibleVariant = 0; @@ -188,6 +192,9 @@ // when the real accept result arrives or a new session starts. let awaitingAcceptResult = null; let variantObserver = null; + const discardedFrameworkWrapperWatchers = new Map(); + const handledRuntimeWrapperWatchers = new Map(); + const handledRuntimeWrapperReloadSessions = new Set(); let variantSelectionInFlight = false; let variantSelectionPromise = null; let recoveringEmptyCycling = false; @@ -208,6 +215,7 @@ const SCROLL_ANCHOR_LOCK_ID = 'impeccable-scroll-anchor-lock'; const VARIANT_STATE_STYLE_ID = 'impeccable-variant-state'; const DISCARD_STATE_STYLE_ID = 'impeccable-discard-state'; + const HANDLED_WRAPPER_RELOAD_KEY = PREFIX + '-handled-wrapper-reload'; // Dedicated key for scroll position - SEPARATE from LS_KEY so that // saveSession's state updates don't clobber a carefully-captured scrollY. @@ -270,6 +278,7 @@ desc, rectIsUsableAnchor, makeFrozenAnchor, + hasFrameworkHmrOwnership, id8, cssId, liveUiRoot, @@ -2034,6 +2043,16 @@ syncSteerQueueHint(); } + function beginNewLiveConfiguration() { + liveInteractionRevision += 1; + setLiveState('CONFIGURING'); + } + + function deferredRecoverySuperseded(sessionId, recoveryRevision) { + return liveInteractionRevision !== recoveryRevision + || !!(currentSessionId && currentSessionId !== sessionId); + } + /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { @@ -5036,7 +5055,7 @@ && el.parentElement && document.body.contains(el) && !own(el) - && !el.closest?.('[data-impeccable-variants]'); + && !el.closest?.('[data-impeccable-variants],[data-impeccable-carbonize]'); } function elementMatchesOriginalMarkup(liveEl, origContent) { @@ -6608,19 +6627,78 @@ document.getElementById(VARIANT_STATE_STYLE_ID)?.remove(); } + function discardStateStyleId(sessionId) { + return DISCARD_STATE_STYLE_ID + '-' + sessionId; + } + function showOriginalDuringDiscard(sessionId) { if (!sessionId) return; - let styleEl = document.getElementById(DISCARD_STATE_STYLE_ID); + let styleEl = document.getElementById(discardStateStyleId(sessionId)); if (!styleEl) { styleEl = document.createElement('style'); - styleEl.id = DISCARD_STATE_STYLE_ID; + styleEl.id = discardStateStyleId(sessionId); (document.head || document.documentElement).appendChild(styleEl); } + styleEl.dataset.impeccableDiscardSession = sessionId; const wrapper = '[data-impeccable-variants="' + sessionId + '"]'; styleEl.textContent = wrapper + ' > [data-impeccable-variant]:not([data-impeccable-variant="original"]) { display:none !important; }\n' + wrapper + ' > [data-impeccable-variant="original"] { display:block !important; }'; } + function removeDiscardStateStylesheet(sessionId) { + if (!sessionId) return; + document.getElementById(discardStateStyleId(sessionId))?.remove(); + } + + function releaseDiscardedStaticWrapper(wrapper, sessionId) { + removeDiscardStateStylesheet(sessionId); + if (!wrapper) return; + const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); + const content = orig?.firstElementChild; + if (content && wrapper.parentElement) { + wrapper.parentElement.replaceChild(content, wrapper); + return; + } + wrapper.remove(); + } + + function watchForDiscardedFrameworkWrapperRemoval(sessionId) { + if (!sessionId || !document.body) return; + if (discardedFrameworkWrapperWatchers.has(sessionId)) return; + const selector = '[data-impeccable-variants="' + sessionId + '"]'; + let observer = null; + let timer = null; + const stopWatching = function() { + observer?.disconnect(); + if (timer) clearTimeout(timer); + discardedFrameworkWrapperWatchers.delete(sessionId); + }; + const finishIfGone = function() { + if (document.querySelector(selector)) return false; + removeDiscardStateStylesheet(sessionId); + stopWatching(); + return true; + }; + if (finishIfGone()) return; + observer = new MutationObserver(finishIfGone); + observer.observe(document.body, { childList: true, subtree: true }); + const resolveStillMounted = function() { + if (finishIfGone()) return; + const replacementActive = !!currentSessionId + || (state !== 'IDLE' && state !== 'PICKING'); + if (replacementActive) { + timer = setTimeout(resolveStillMounted, 12000); + discardedFrameworkWrapperWatchers.get(sessionId).timer = timer; + return; + } + removeDiscardStateStylesheet(sessionId); + stopWatching(); + location.reload(); + }; + timer = setTimeout(resolveStillMounted, 12000); + discardedFrameworkWrapperWatchers.set(sessionId, { observer, timer }); + } + function resolveScrollLockAnchorTop() { const anchor = resolveBarAnchor(); if (!anchor?.isConnected) return null; @@ -7335,7 +7413,7 @@ hideInsertLine(); configureKind = 'insert'; selectedElement = placeholder; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); hideHighlight(); clearAnnotations(); showAnnotOverlay(placeholder); @@ -7353,7 +7431,7 @@ e.preventDefault(); e.stopPropagation(); selectedElement = hoveredElement; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -7530,7 +7608,7 @@ } else if (e.key === 'Enter') { e.preventDefault(); selectedElement = hoveredElement; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -8535,6 +8613,7 @@ void main() { } function scheduleAcceptCleanup(accepted) { + const recoveryRevision = liveInteractionRevision; queueMicrotask(function() { if (pendingAcceptedSession?.id !== accepted?.id) return; // Svelte previews live in an adapter-owned mount rather than in source @@ -8551,8 +8630,10 @@ void main() { // races. Static servers still need a fallback, but it must not keep Live // in SAVING or block the user's next pick. if (!accepted?.isSvelteComponent) { + watchForHandledRuntimeWrapper(accepted?.id, recoveryRevision); setTimeout(function() { - if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted); + if (deferredRecoverySuperseded(accepted?.id, recoveryRevision)) return; + if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted, recoveryRevision); }, 1200); } } @@ -8585,13 +8666,27 @@ void main() { && matches.every((el) => !el.closest('[data-impeccable-variants],[data-impeccable-variant],[data-impeccable-carbonize]')); } - function ensureAcceptedDomClean(pending) { + function ensureAcceptedDomClean(pending, recoveryRevision) { + // Background cleanup for an accepted session must never mutate or reload + // a newer comparison the user has already started. + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; if (acceptedDomAlreadyClean(pending)) return; const sessionId = pending?.id; const variantId = pending?.variant; const wrappers = findAcceptedRuntimeWrappers(sessionId); + if (hasFrameworkHmrOwnership(wrappers[0] || pending?.parentElement)) { + // Vite can coalesce rapid scaffold/carbonize writes and leave the last + // framework-owned preview tree mounted even though source is clean. Give + // HMR another grace window, then reload from clean source rather than + // violating reconciler ownership with a manual DOM mutation. + setTimeout(function() { + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; + if (!acceptedDomAlreadyClean(pending)) location.reload(); + }, 2000); + return; + } if (wrappers.length === 0) { - restoreAcceptedDomFromSnapshot(pending); + restoreAcceptedDomFromSnapshot(pending, recoveryRevision); return; } for (const wrapper of wrappers) { @@ -8608,7 +8703,7 @@ void main() { } wrapper.remove(); } - if (!acceptedDomAlreadyClean(pending)) restoreAcceptedDomFromSnapshot(pending); + if (!acceptedDomAlreadyClean(pending)) restoreAcceptedDomFromSnapshot(pending, recoveryRevision); } function findAcceptedRuntimeWrappers(sessionId) { @@ -8619,17 +8714,17 @@ void main() { ])]; } - function restoreAcceptedDomFromSnapshot(pending) { + function restoreAcceptedDomFromSnapshot(pending, recoveryRevision) { if (acceptedDomAlreadyClean(pending)) return; if (!pending?.acceptedHtml) { - reloadAfterMissingAcceptedDom(pending); + reloadAfterMissingAcceptedDom(pending, recoveryRevision); return; } const parent = pending.parentElement?.isConnected ? pending.parentElement : (pending.parentSelector ? document.querySelector(pending.parentSelector) : null); if (!parent) { - reloadAfterMissingAcceptedDom(pending); + reloadAfterMissingAcceptedDom(pending, recoveryRevision); return; } const template = document.createElement('template'); @@ -8638,10 +8733,11 @@ void main() { ? pending.nextSibling : null; parent.insertBefore(template.content, anchor); - if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending); + if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending, recoveryRevision); } - function reloadAfterMissingAcceptedDom(pending) { + function reloadAfterMissingAcceptedDom(pending, recoveryRevision) { + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; if (acceptedDomAlreadyClean(pending)) return; if (pending?.id && document.querySelector('[data-impeccable-variants="' + pending.id + '"]')) return; location.reload(); @@ -8991,14 +9087,15 @@ void main() { return sessionState.isHandled(id); } - function clearHandled() { - sessionState.clearHandled(); + function clearHandled(sessionId) { + sessionState.clearHandled(sessionId); } function cleanup(options) { const restoreOriginal = options?.restoreOriginal === true; const instantChrome = options?.instantChrome === true; const cleanupSessionId = currentSessionId; + const cleanupRevision = liveInteractionRevision; clearMountErrorCard(); lastReportedMountFailure = null; if (svelteComponentSession?.sessionId === cleanupSessionId) { @@ -9016,19 +9113,41 @@ void main() { else wrapper.style.display = 'none'; } setTimeout(function() { - document.getElementById(DISCARD_STATE_STYLE_ID)?.remove(); - if (!cleanupSessionId) return; - const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!lateWrapper) return; - const orig = lateWrapper.querySelector('[data-impeccable-variant="original"]'); - if (orig) { - const content = orig.firstElementChild; - if (content) { - lateWrapper.parentElement.replaceChild(content, lateWrapper); - return; - } + const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); + if (!cleanupSessionId) { + removeDiscardStateStylesheet(); + return; } - lateWrapper.remove(); + const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (!lateWrapper) { + removeDiscardStateStylesheet(cleanupSessionId); + return; + } + if (recoverySuperseded) { + if (hasFrameworkHmrOwnership(lateWrapper)) { + watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); + } else { + releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + } + return; + } + if (hasFrameworkHmrOwnership(lateWrapper)) { + // As on accept, never restructure framework-owned DOM. If HMR missed + // the final source rewrite, reload once after a grace window so the + // discarded source becomes authoritative without a reconciler race. + setTimeout(function() { + const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { + if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId); + else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); + return; + } + removeDiscardStateStylesheet(cleanupSessionId); + if (staleWrapper) location.reload(); + }, 2000); + return; + } + releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); }, 2000); } hideBar(instantChrome); @@ -9111,12 +9230,122 @@ void main() { // Resume an active variant session after HMR/page reload. // If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote // variants before HMR fired. Pick up where we left off. - function resumeSession() { + function handledWrapperReloadKey(sessionId) { + return HANDLED_WRAPPER_RELOAD_KEY + ':' + sessionId; + } + + function clearHandledWrapperReloadStamp(sessionId) { + try { + if (sessionId) { + sessionStorage.removeItem(handledWrapperReloadKey(sessionId)); + const legacy = sessionStorage.getItem(HANDLED_WRAPPER_RELOAD_KEY) || ''; + if (legacy === sessionId || legacy.startsWith(sessionId + ':')) { + sessionStorage.removeItem(HANDLED_WRAPPER_RELOAD_KEY); + } + return; + } + sessionStorage.removeItem(HANDLED_WRAPPER_RELOAD_KEY); + for (let i = sessionStorage.length - 1; i >= 0; i--) { + const key = sessionStorage.key(i); + if (key?.startsWith(HANDLED_WRAPPER_RELOAD_KEY + ':')) sessionStorage.removeItem(key); + } + } catch {} + } + + function scheduleHandledRuntimeWrapperReload(wrapper, recoveryRevision = liveInteractionRevision) { + const sessionId = wrapper?.dataset?.impeccableVariants + || wrapper?.dataset?.impeccableCarbonize; + if (!sessionId || !isSessionHandled(sessionId)) return false; + if (deferredRecoverySuperseded(sessionId, recoveryRevision)) return true; + + if (handledRuntimeWrapperReloadSessions.has(sessionId)) return true; + let reloadAttempts = 0; + try { + reloadAttempts = Number(sessionStorage.getItem(handledWrapperReloadKey(sessionId))) || 0; + if (reloadAttempts >= 2) return true; + sessionStorage.setItem(handledWrapperReloadKey(sessionId), String(reloadAttempts + 1)); + } catch {} + handledRuntimeWrapperReloadSessions.add(sessionId); + + // A framework refresh can replace the variants tree with an intermediate + // carbonize tree and reload the page, cancelling the original accept timer. + // Let the file-side cleanup settle, then reload once from authoritative + // source. The sessionStorage stamp prevents a stale dev-server response + // from turning this recovery into a reload loop. + setTimeout(function() { + if (deferredRecoverySuperseded(sessionId, recoveryRevision)) { + clearHandledWrapperReloadStamp(sessionId); + handledRuntimeWrapperReloadSessions.delete(sessionId); + return; + } + const staleWrapper = document.querySelector( + '[data-impeccable-variants="' + sessionId + '"],' + + '[data-impeccable-carbonize="' + sessionId + '"]', + ); + if (staleWrapper) location.reload(); + else { + clearHandledWrapperReloadStamp(sessionId); + handledRuntimeWrapperReloadSessions.delete(sessionId); + } + }, 3000); + return true; + } + + function watchForHandledRuntimeWrapper(sessionId, recoveryRevision = liveInteractionRevision) { + if (!sessionId || !document.body) return; + const existing = handledRuntimeWrapperWatchers.get(sessionId); + existing?.observer.disconnect(); + if (existing?.timer) clearTimeout(existing.timer); + + const findHandledWrapper = function() { + const wrapper = document.querySelector( + '[data-impeccable-variants="' + sessionId + '"],' + + '[data-impeccable-carbonize="' + sessionId + '"]', + ); + if (wrapper) scheduleHandledRuntimeWrapperReload(wrapper, recoveryRevision); + }; + + // Vite can briefly render the clean accepted tree, then apply a delayed + // carbonize refresh after the one-shot accept fallback has already passed. + // Keep a bounded scout alive through that refresh window so a late stale + // framework tree still reloads from the now-authoritative source. + const observer = new MutationObserver(findHandledWrapper); + observer.observe(document.body, { childList: true, subtree: true }); + const timer = setTimeout(function() { + if (handledRuntimeWrapperWatchers.get(sessionId)?.observer !== observer) return; + observer.disconnect(); + handledRuntimeWrapperWatchers.delete(sessionId); + }, 12000); + handledRuntimeWrapperWatchers.set(sessionId, { observer, timer }); + findHandledWrapper(); + } + + function restoreSessionSupersedingHandledWrapper(runtimeWrapper) { + const handledSessionId = runtimeWrapper?.dataset?.impeccableVariants + || runtimeWrapper?.dataset?.impeccableCarbonize; + if (!handledSessionId || !isSessionHandled(handledSessionId)) return false; + + // Accept releases the picker before carbonize finishes, so a replacement + // generation can already be durable while the prior handled wrapper is + // still mounted. Restore that newer session before the stale-wrapper + // recovery path gets a chance to reload or consume its retry budget. + const saved = loadSession(); + if (!saved?.id || saved.id === handledSessionId || isSessionHandled(saved.id)) return false; + if (currentSessionId === saved.id) return true; + return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); + } + + function resumeSession(recoveryRevision = liveInteractionRevision) { const wrapper = document.querySelector('[data-impeccable-variants]'); + const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); + if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; + if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; if (!wrapper) { if (restoreSessionWithoutWrapper('browser_resumed_without_wrapper')) return true; clearSession(); - clearHandled(); + // Keep the bounded handled-id history durable. A framework can hydrate a + // completed wrapper well after initialization, and a later reload must + // still recognize that wrapper as recovery work rather than resume it. return false; } @@ -9136,7 +9365,7 @@ void main() { wrapper.remove(); if (restoreSessionWithoutWrapper('browser_resumed_svelte_orphan_wrapper')) return true; clearSession(); - clearHandled(); + clearHandled(sessionId); return false; } @@ -12520,22 +12749,28 @@ void main() { connectSSE(); // Check for an active session to resume (variant wrapper already in DOM after HMR) - if (!resumeSession()) { + const resumed = resumeSession(); + if (!resumed) { console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.'); - // SvelteKit (and any framework that hydrates after HTML parse) may add - // the variant wrapper AFTER init runs. Watch for it and retry resume - // once it appears. Disconnect on first hit. + } else { + console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); + } + + // SvelteKit, React, and other frameworks may restore a durable session + // before hydration adds its variant wrapper. Keep a deferred-wrapper scout + // whenever init did not see a runtime wrapper, even if local/server state + // was already restored successfully. Disconnect on the first wrapper hit. + if (!resumed || !document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]')) { + const deferredResumeRevision = liveInteractionRevision; const scout = new MutationObserver(() => { - const wrapper = document.querySelector('[data-impeccable-variants]'); + const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); if (!wrapper) return; scout.disconnect(); - if (resumeSession()) { + if (resumeSession(deferredResumeRevision)) { console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); } }); scout.observe(document.body, { childList: true, subtree: true }); - } else { - console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); } if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING'); diff --git a/.qoder/skills/impeccable/scripts/live-browser-dom.js b/.qoder/skills/impeccable/scripts/live-browser-dom.js index ad6a794b3..e85c95c43 100644 --- a/.qoder/skills/impeccable/scripts/live-browser-dom.js +++ b/.qoder/skills/impeccable/scripts/live-browser-dom.js @@ -64,6 +64,26 @@ }; } + function hasFrameworkHmrOwnership(el) { + for (let node = el; node; node = node.parentElement) { + let keys = []; + try { keys = Object.getOwnPropertyNames(node); } catch {} + if (keys.some((key) => ( + key.startsWith('__reactFiber$') + || key.startsWith('__reactProps$') + || key.startsWith('__reactContainer$') + || key === '_reactRootContainer' + || key === '__vueParentComponent' + || key === '__vue_app__' + || key === '__vnode' + || key === '__svelte_meta' + ))) { + return true; + } + } + return false; + } + function id8() { if (crypto?.randomUUID) return crypto.randomUUID().replace(/-/g, '').slice(0, 8); return (Math.random().toString(16).slice(2) + Date.now().toString(16)).slice(0, 8); @@ -128,6 +148,7 @@ desc, rectIsUsableAnchor, makeFrozenAnchor, + hasFrameworkHmrOwnership, id8, cssId, liveUiRoot, diff --git a/.qoder/skills/impeccable/scripts/live-browser-session.js b/.qoder/skills/impeccable/scripts/live-browser-session.js index 0e362d6be..1514bf472 100644 --- a/.qoder/skills/impeccable/scripts/live-browser-session.js +++ b/.qoder/skills/impeccable/scripts/live-browser-session.js @@ -71,17 +71,38 @@ return checkpointRevision; } + function readHandledIds() { + const raw = safeRead(handledKey); + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed.filter(id => typeof id === 'string' && id); + } + if (typeof parsed === 'string' && parsed) return [parsed]; + } catch { /* legacy values were stored as a plain session id */ } + return [raw]; + } + function markHandled(id) { if (!id) return; - safeWrite(handledKey, id); + const ids = readHandledIds().filter(existing => existing !== id); + ids.push(id); + safeWrite(handledKey, JSON.stringify(ids.slice(-8))); } function isHandled(id) { - return !!id && safeRead(handledKey) === id; + return !!id && readHandledIds().includes(id); } - function clearHandled() { - safeRemove(handledKey); + function clearHandled(id) { + if (!id) { + safeRemove(handledKey); + return; + } + const remaining = readHandledIds().filter(existing => existing !== id); + if (remaining.length > 0) safeWrite(handledKey, JSON.stringify(remaining)); + else safeRemove(handledKey); } function writeScrollY(y) { diff --git a/.qoder/skills/impeccable/scripts/live-browser.js b/.qoder/skills/impeccable/scripts/live-browser.js index 2b38ec62e..1f373a894 100644 --- a/.qoder/skills/impeccable/scripts/live-browser.js +++ b/.qoder/skills/impeccable/scripts/live-browser.js @@ -121,6 +121,10 @@ let hoveredElement = null; let selectedElement = null; let currentSessionId = null; + // Advances when the user begins configuring a fresh edit, before that edit + // has a server session id. Deferred recovery captures this revision so an + // older accept/discard can never reload over a replacement configuration. + let liveInteractionRevision = 0; let expectedVariants = 0; let arrivedVariants = 0; let visibleVariant = 0; @@ -188,6 +192,9 @@ // when the real accept result arrives or a new session starts. let awaitingAcceptResult = null; let variantObserver = null; + const discardedFrameworkWrapperWatchers = new Map(); + const handledRuntimeWrapperWatchers = new Map(); + const handledRuntimeWrapperReloadSessions = new Set(); let variantSelectionInFlight = false; let variantSelectionPromise = null; let recoveringEmptyCycling = false; @@ -208,6 +215,7 @@ const SCROLL_ANCHOR_LOCK_ID = 'impeccable-scroll-anchor-lock'; const VARIANT_STATE_STYLE_ID = 'impeccable-variant-state'; const DISCARD_STATE_STYLE_ID = 'impeccable-discard-state'; + const HANDLED_WRAPPER_RELOAD_KEY = PREFIX + '-handled-wrapper-reload'; // Dedicated key for scroll position - SEPARATE from LS_KEY so that // saveSession's state updates don't clobber a carefully-captured scrollY. @@ -270,6 +278,7 @@ desc, rectIsUsableAnchor, makeFrozenAnchor, + hasFrameworkHmrOwnership, id8, cssId, liveUiRoot, @@ -2034,6 +2043,16 @@ syncSteerQueueHint(); } + function beginNewLiveConfiguration() { + liveInteractionRevision += 1; + setLiveState('CONFIGURING'); + } + + function deferredRecoverySuperseded(sessionId, recoveryRevision) { + return liveInteractionRevision !== recoveryRevision + || !!(currentSessionId && currentSessionId !== sessionId); + } + /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { @@ -5036,7 +5055,7 @@ && el.parentElement && document.body.contains(el) && !own(el) - && !el.closest?.('[data-impeccable-variants]'); + && !el.closest?.('[data-impeccable-variants],[data-impeccable-carbonize]'); } function elementMatchesOriginalMarkup(liveEl, origContent) { @@ -6608,19 +6627,78 @@ document.getElementById(VARIANT_STATE_STYLE_ID)?.remove(); } + function discardStateStyleId(sessionId) { + return DISCARD_STATE_STYLE_ID + '-' + sessionId; + } + function showOriginalDuringDiscard(sessionId) { if (!sessionId) return; - let styleEl = document.getElementById(DISCARD_STATE_STYLE_ID); + let styleEl = document.getElementById(discardStateStyleId(sessionId)); if (!styleEl) { styleEl = document.createElement('style'); - styleEl.id = DISCARD_STATE_STYLE_ID; + styleEl.id = discardStateStyleId(sessionId); (document.head || document.documentElement).appendChild(styleEl); } + styleEl.dataset.impeccableDiscardSession = sessionId; const wrapper = '[data-impeccable-variants="' + sessionId + '"]'; styleEl.textContent = wrapper + ' > [data-impeccable-variant]:not([data-impeccable-variant="original"]) { display:none !important; }\n' + wrapper + ' > [data-impeccable-variant="original"] { display:block !important; }'; } + function removeDiscardStateStylesheet(sessionId) { + if (!sessionId) return; + document.getElementById(discardStateStyleId(sessionId))?.remove(); + } + + function releaseDiscardedStaticWrapper(wrapper, sessionId) { + removeDiscardStateStylesheet(sessionId); + if (!wrapper) return; + const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); + const content = orig?.firstElementChild; + if (content && wrapper.parentElement) { + wrapper.parentElement.replaceChild(content, wrapper); + return; + } + wrapper.remove(); + } + + function watchForDiscardedFrameworkWrapperRemoval(sessionId) { + if (!sessionId || !document.body) return; + if (discardedFrameworkWrapperWatchers.has(sessionId)) return; + const selector = '[data-impeccable-variants="' + sessionId + '"]'; + let observer = null; + let timer = null; + const stopWatching = function() { + observer?.disconnect(); + if (timer) clearTimeout(timer); + discardedFrameworkWrapperWatchers.delete(sessionId); + }; + const finishIfGone = function() { + if (document.querySelector(selector)) return false; + removeDiscardStateStylesheet(sessionId); + stopWatching(); + return true; + }; + if (finishIfGone()) return; + observer = new MutationObserver(finishIfGone); + observer.observe(document.body, { childList: true, subtree: true }); + const resolveStillMounted = function() { + if (finishIfGone()) return; + const replacementActive = !!currentSessionId + || (state !== 'IDLE' && state !== 'PICKING'); + if (replacementActive) { + timer = setTimeout(resolveStillMounted, 12000); + discardedFrameworkWrapperWatchers.get(sessionId).timer = timer; + return; + } + removeDiscardStateStylesheet(sessionId); + stopWatching(); + location.reload(); + }; + timer = setTimeout(resolveStillMounted, 12000); + discardedFrameworkWrapperWatchers.set(sessionId, { observer, timer }); + } + function resolveScrollLockAnchorTop() { const anchor = resolveBarAnchor(); if (!anchor?.isConnected) return null; @@ -7335,7 +7413,7 @@ hideInsertLine(); configureKind = 'insert'; selectedElement = placeholder; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); hideHighlight(); clearAnnotations(); showAnnotOverlay(placeholder); @@ -7353,7 +7431,7 @@ e.preventDefault(); e.stopPropagation(); selectedElement = hoveredElement; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -7530,7 +7608,7 @@ } else if (e.key === 'Enter') { e.preventDefault(); selectedElement = hoveredElement; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -8535,6 +8613,7 @@ void main() { } function scheduleAcceptCleanup(accepted) { + const recoveryRevision = liveInteractionRevision; queueMicrotask(function() { if (pendingAcceptedSession?.id !== accepted?.id) return; // Svelte previews live in an adapter-owned mount rather than in source @@ -8551,8 +8630,10 @@ void main() { // races. Static servers still need a fallback, but it must not keep Live // in SAVING or block the user's next pick. if (!accepted?.isSvelteComponent) { + watchForHandledRuntimeWrapper(accepted?.id, recoveryRevision); setTimeout(function() { - if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted); + if (deferredRecoverySuperseded(accepted?.id, recoveryRevision)) return; + if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted, recoveryRevision); }, 1200); } } @@ -8585,13 +8666,27 @@ void main() { && matches.every((el) => !el.closest('[data-impeccable-variants],[data-impeccable-variant],[data-impeccable-carbonize]')); } - function ensureAcceptedDomClean(pending) { + function ensureAcceptedDomClean(pending, recoveryRevision) { + // Background cleanup for an accepted session must never mutate or reload + // a newer comparison the user has already started. + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; if (acceptedDomAlreadyClean(pending)) return; const sessionId = pending?.id; const variantId = pending?.variant; const wrappers = findAcceptedRuntimeWrappers(sessionId); + if (hasFrameworkHmrOwnership(wrappers[0] || pending?.parentElement)) { + // Vite can coalesce rapid scaffold/carbonize writes and leave the last + // framework-owned preview tree mounted even though source is clean. Give + // HMR another grace window, then reload from clean source rather than + // violating reconciler ownership with a manual DOM mutation. + setTimeout(function() { + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; + if (!acceptedDomAlreadyClean(pending)) location.reload(); + }, 2000); + return; + } if (wrappers.length === 0) { - restoreAcceptedDomFromSnapshot(pending); + restoreAcceptedDomFromSnapshot(pending, recoveryRevision); return; } for (const wrapper of wrappers) { @@ -8608,7 +8703,7 @@ void main() { } wrapper.remove(); } - if (!acceptedDomAlreadyClean(pending)) restoreAcceptedDomFromSnapshot(pending); + if (!acceptedDomAlreadyClean(pending)) restoreAcceptedDomFromSnapshot(pending, recoveryRevision); } function findAcceptedRuntimeWrappers(sessionId) { @@ -8619,17 +8714,17 @@ void main() { ])]; } - function restoreAcceptedDomFromSnapshot(pending) { + function restoreAcceptedDomFromSnapshot(pending, recoveryRevision) { if (acceptedDomAlreadyClean(pending)) return; if (!pending?.acceptedHtml) { - reloadAfterMissingAcceptedDom(pending); + reloadAfterMissingAcceptedDom(pending, recoveryRevision); return; } const parent = pending.parentElement?.isConnected ? pending.parentElement : (pending.parentSelector ? document.querySelector(pending.parentSelector) : null); if (!parent) { - reloadAfterMissingAcceptedDom(pending); + reloadAfterMissingAcceptedDom(pending, recoveryRevision); return; } const template = document.createElement('template'); @@ -8638,10 +8733,11 @@ void main() { ? pending.nextSibling : null; parent.insertBefore(template.content, anchor); - if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending); + if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending, recoveryRevision); } - function reloadAfterMissingAcceptedDom(pending) { + function reloadAfterMissingAcceptedDom(pending, recoveryRevision) { + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; if (acceptedDomAlreadyClean(pending)) return; if (pending?.id && document.querySelector('[data-impeccable-variants="' + pending.id + '"]')) return; location.reload(); @@ -8991,14 +9087,15 @@ void main() { return sessionState.isHandled(id); } - function clearHandled() { - sessionState.clearHandled(); + function clearHandled(sessionId) { + sessionState.clearHandled(sessionId); } function cleanup(options) { const restoreOriginal = options?.restoreOriginal === true; const instantChrome = options?.instantChrome === true; const cleanupSessionId = currentSessionId; + const cleanupRevision = liveInteractionRevision; clearMountErrorCard(); lastReportedMountFailure = null; if (svelteComponentSession?.sessionId === cleanupSessionId) { @@ -9016,19 +9113,41 @@ void main() { else wrapper.style.display = 'none'; } setTimeout(function() { - document.getElementById(DISCARD_STATE_STYLE_ID)?.remove(); - if (!cleanupSessionId) return; - const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!lateWrapper) return; - const orig = lateWrapper.querySelector('[data-impeccable-variant="original"]'); - if (orig) { - const content = orig.firstElementChild; - if (content) { - lateWrapper.parentElement.replaceChild(content, lateWrapper); - return; - } + const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); + if (!cleanupSessionId) { + removeDiscardStateStylesheet(); + return; } - lateWrapper.remove(); + const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (!lateWrapper) { + removeDiscardStateStylesheet(cleanupSessionId); + return; + } + if (recoverySuperseded) { + if (hasFrameworkHmrOwnership(lateWrapper)) { + watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); + } else { + releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + } + return; + } + if (hasFrameworkHmrOwnership(lateWrapper)) { + // As on accept, never restructure framework-owned DOM. If HMR missed + // the final source rewrite, reload once after a grace window so the + // discarded source becomes authoritative without a reconciler race. + setTimeout(function() { + const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { + if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId); + else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); + return; + } + removeDiscardStateStylesheet(cleanupSessionId); + if (staleWrapper) location.reload(); + }, 2000); + return; + } + releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); }, 2000); } hideBar(instantChrome); @@ -9111,12 +9230,122 @@ void main() { // Resume an active variant session after HMR/page reload. // If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote // variants before HMR fired. Pick up where we left off. - function resumeSession() { + function handledWrapperReloadKey(sessionId) { + return HANDLED_WRAPPER_RELOAD_KEY + ':' + sessionId; + } + + function clearHandledWrapperReloadStamp(sessionId) { + try { + if (sessionId) { + sessionStorage.removeItem(handledWrapperReloadKey(sessionId)); + const legacy = sessionStorage.getItem(HANDLED_WRAPPER_RELOAD_KEY) || ''; + if (legacy === sessionId || legacy.startsWith(sessionId + ':')) { + sessionStorage.removeItem(HANDLED_WRAPPER_RELOAD_KEY); + } + return; + } + sessionStorage.removeItem(HANDLED_WRAPPER_RELOAD_KEY); + for (let i = sessionStorage.length - 1; i >= 0; i--) { + const key = sessionStorage.key(i); + if (key?.startsWith(HANDLED_WRAPPER_RELOAD_KEY + ':')) sessionStorage.removeItem(key); + } + } catch {} + } + + function scheduleHandledRuntimeWrapperReload(wrapper, recoveryRevision = liveInteractionRevision) { + const sessionId = wrapper?.dataset?.impeccableVariants + || wrapper?.dataset?.impeccableCarbonize; + if (!sessionId || !isSessionHandled(sessionId)) return false; + if (deferredRecoverySuperseded(sessionId, recoveryRevision)) return true; + + if (handledRuntimeWrapperReloadSessions.has(sessionId)) return true; + let reloadAttempts = 0; + try { + reloadAttempts = Number(sessionStorage.getItem(handledWrapperReloadKey(sessionId))) || 0; + if (reloadAttempts >= 2) return true; + sessionStorage.setItem(handledWrapperReloadKey(sessionId), String(reloadAttempts + 1)); + } catch {} + handledRuntimeWrapperReloadSessions.add(sessionId); + + // A framework refresh can replace the variants tree with an intermediate + // carbonize tree and reload the page, cancelling the original accept timer. + // Let the file-side cleanup settle, then reload once from authoritative + // source. The sessionStorage stamp prevents a stale dev-server response + // from turning this recovery into a reload loop. + setTimeout(function() { + if (deferredRecoverySuperseded(sessionId, recoveryRevision)) { + clearHandledWrapperReloadStamp(sessionId); + handledRuntimeWrapperReloadSessions.delete(sessionId); + return; + } + const staleWrapper = document.querySelector( + '[data-impeccable-variants="' + sessionId + '"],' + + '[data-impeccable-carbonize="' + sessionId + '"]', + ); + if (staleWrapper) location.reload(); + else { + clearHandledWrapperReloadStamp(sessionId); + handledRuntimeWrapperReloadSessions.delete(sessionId); + } + }, 3000); + return true; + } + + function watchForHandledRuntimeWrapper(sessionId, recoveryRevision = liveInteractionRevision) { + if (!sessionId || !document.body) return; + const existing = handledRuntimeWrapperWatchers.get(sessionId); + existing?.observer.disconnect(); + if (existing?.timer) clearTimeout(existing.timer); + + const findHandledWrapper = function() { + const wrapper = document.querySelector( + '[data-impeccable-variants="' + sessionId + '"],' + + '[data-impeccable-carbonize="' + sessionId + '"]', + ); + if (wrapper) scheduleHandledRuntimeWrapperReload(wrapper, recoveryRevision); + }; + + // Vite can briefly render the clean accepted tree, then apply a delayed + // carbonize refresh after the one-shot accept fallback has already passed. + // Keep a bounded scout alive through that refresh window so a late stale + // framework tree still reloads from the now-authoritative source. + const observer = new MutationObserver(findHandledWrapper); + observer.observe(document.body, { childList: true, subtree: true }); + const timer = setTimeout(function() { + if (handledRuntimeWrapperWatchers.get(sessionId)?.observer !== observer) return; + observer.disconnect(); + handledRuntimeWrapperWatchers.delete(sessionId); + }, 12000); + handledRuntimeWrapperWatchers.set(sessionId, { observer, timer }); + findHandledWrapper(); + } + + function restoreSessionSupersedingHandledWrapper(runtimeWrapper) { + const handledSessionId = runtimeWrapper?.dataset?.impeccableVariants + || runtimeWrapper?.dataset?.impeccableCarbonize; + if (!handledSessionId || !isSessionHandled(handledSessionId)) return false; + + // Accept releases the picker before carbonize finishes, so a replacement + // generation can already be durable while the prior handled wrapper is + // still mounted. Restore that newer session before the stale-wrapper + // recovery path gets a chance to reload or consume its retry budget. + const saved = loadSession(); + if (!saved?.id || saved.id === handledSessionId || isSessionHandled(saved.id)) return false; + if (currentSessionId === saved.id) return true; + return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); + } + + function resumeSession(recoveryRevision = liveInteractionRevision) { const wrapper = document.querySelector('[data-impeccable-variants]'); + const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); + if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; + if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; if (!wrapper) { if (restoreSessionWithoutWrapper('browser_resumed_without_wrapper')) return true; clearSession(); - clearHandled(); + // Keep the bounded handled-id history durable. A framework can hydrate a + // completed wrapper well after initialization, and a later reload must + // still recognize that wrapper as recovery work rather than resume it. return false; } @@ -9136,7 +9365,7 @@ void main() { wrapper.remove(); if (restoreSessionWithoutWrapper('browser_resumed_svelte_orphan_wrapper')) return true; clearSession(); - clearHandled(); + clearHandled(sessionId); return false; } @@ -12520,22 +12749,28 @@ void main() { connectSSE(); // Check for an active session to resume (variant wrapper already in DOM after HMR) - if (!resumeSession()) { + const resumed = resumeSession(); + if (!resumed) { console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.'); - // SvelteKit (and any framework that hydrates after HTML parse) may add - // the variant wrapper AFTER init runs. Watch for it and retry resume - // once it appears. Disconnect on first hit. + } else { + console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); + } + + // SvelteKit, React, and other frameworks may restore a durable session + // before hydration adds its variant wrapper. Keep a deferred-wrapper scout + // whenever init did not see a runtime wrapper, even if local/server state + // was already restored successfully. Disconnect on the first wrapper hit. + if (!resumed || !document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]')) { + const deferredResumeRevision = liveInteractionRevision; const scout = new MutationObserver(() => { - const wrapper = document.querySelector('[data-impeccable-variants]'); + const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); if (!wrapper) return; scout.disconnect(); - if (resumeSession()) { + if (resumeSession(deferredResumeRevision)) { console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); } }); scout.observe(document.body, { childList: true, subtree: true }); - } else { - console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); } if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING'); diff --git a/.rovodev/skills/impeccable/scripts/live-browser-dom.js b/.rovodev/skills/impeccable/scripts/live-browser-dom.js index ad6a794b3..e85c95c43 100644 --- a/.rovodev/skills/impeccable/scripts/live-browser-dom.js +++ b/.rovodev/skills/impeccable/scripts/live-browser-dom.js @@ -64,6 +64,26 @@ }; } + function hasFrameworkHmrOwnership(el) { + for (let node = el; node; node = node.parentElement) { + let keys = []; + try { keys = Object.getOwnPropertyNames(node); } catch {} + if (keys.some((key) => ( + key.startsWith('__reactFiber$') + || key.startsWith('__reactProps$') + || key.startsWith('__reactContainer$') + || key === '_reactRootContainer' + || key === '__vueParentComponent' + || key === '__vue_app__' + || key === '__vnode' + || key === '__svelte_meta' + ))) { + return true; + } + } + return false; + } + function id8() { if (crypto?.randomUUID) return crypto.randomUUID().replace(/-/g, '').slice(0, 8); return (Math.random().toString(16).slice(2) + Date.now().toString(16)).slice(0, 8); @@ -128,6 +148,7 @@ desc, rectIsUsableAnchor, makeFrozenAnchor, + hasFrameworkHmrOwnership, id8, cssId, liveUiRoot, diff --git a/.rovodev/skills/impeccable/scripts/live-browser-session.js b/.rovodev/skills/impeccable/scripts/live-browser-session.js index 0e362d6be..1514bf472 100644 --- a/.rovodev/skills/impeccable/scripts/live-browser-session.js +++ b/.rovodev/skills/impeccable/scripts/live-browser-session.js @@ -71,17 +71,38 @@ return checkpointRevision; } + function readHandledIds() { + const raw = safeRead(handledKey); + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed.filter(id => typeof id === 'string' && id); + } + if (typeof parsed === 'string' && parsed) return [parsed]; + } catch { /* legacy values were stored as a plain session id */ } + return [raw]; + } + function markHandled(id) { if (!id) return; - safeWrite(handledKey, id); + const ids = readHandledIds().filter(existing => existing !== id); + ids.push(id); + safeWrite(handledKey, JSON.stringify(ids.slice(-8))); } function isHandled(id) { - return !!id && safeRead(handledKey) === id; + return !!id && readHandledIds().includes(id); } - function clearHandled() { - safeRemove(handledKey); + function clearHandled(id) { + if (!id) { + safeRemove(handledKey); + return; + } + const remaining = readHandledIds().filter(existing => existing !== id); + if (remaining.length > 0) safeWrite(handledKey, JSON.stringify(remaining)); + else safeRemove(handledKey); } function writeScrollY(y) { diff --git a/.rovodev/skills/impeccable/scripts/live-browser.js b/.rovodev/skills/impeccable/scripts/live-browser.js index 2b38ec62e..1f373a894 100644 --- a/.rovodev/skills/impeccable/scripts/live-browser.js +++ b/.rovodev/skills/impeccable/scripts/live-browser.js @@ -121,6 +121,10 @@ let hoveredElement = null; let selectedElement = null; let currentSessionId = null; + // Advances when the user begins configuring a fresh edit, before that edit + // has a server session id. Deferred recovery captures this revision so an + // older accept/discard can never reload over a replacement configuration. + let liveInteractionRevision = 0; let expectedVariants = 0; let arrivedVariants = 0; let visibleVariant = 0; @@ -188,6 +192,9 @@ // when the real accept result arrives or a new session starts. let awaitingAcceptResult = null; let variantObserver = null; + const discardedFrameworkWrapperWatchers = new Map(); + const handledRuntimeWrapperWatchers = new Map(); + const handledRuntimeWrapperReloadSessions = new Set(); let variantSelectionInFlight = false; let variantSelectionPromise = null; let recoveringEmptyCycling = false; @@ -208,6 +215,7 @@ const SCROLL_ANCHOR_LOCK_ID = 'impeccable-scroll-anchor-lock'; const VARIANT_STATE_STYLE_ID = 'impeccable-variant-state'; const DISCARD_STATE_STYLE_ID = 'impeccable-discard-state'; + const HANDLED_WRAPPER_RELOAD_KEY = PREFIX + '-handled-wrapper-reload'; // Dedicated key for scroll position - SEPARATE from LS_KEY so that // saveSession's state updates don't clobber a carefully-captured scrollY. @@ -270,6 +278,7 @@ desc, rectIsUsableAnchor, makeFrozenAnchor, + hasFrameworkHmrOwnership, id8, cssId, liveUiRoot, @@ -2034,6 +2043,16 @@ syncSteerQueueHint(); } + function beginNewLiveConfiguration() { + liveInteractionRevision += 1; + setLiveState('CONFIGURING'); + } + + function deferredRecoverySuperseded(sessionId, recoveryRevision) { + return liveInteractionRevision !== recoveryRevision + || !!(currentSessionId && currentSessionId !== sessionId); + } + /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { @@ -5036,7 +5055,7 @@ && el.parentElement && document.body.contains(el) && !own(el) - && !el.closest?.('[data-impeccable-variants]'); + && !el.closest?.('[data-impeccable-variants],[data-impeccable-carbonize]'); } function elementMatchesOriginalMarkup(liveEl, origContent) { @@ -6608,19 +6627,78 @@ document.getElementById(VARIANT_STATE_STYLE_ID)?.remove(); } + function discardStateStyleId(sessionId) { + return DISCARD_STATE_STYLE_ID + '-' + sessionId; + } + function showOriginalDuringDiscard(sessionId) { if (!sessionId) return; - let styleEl = document.getElementById(DISCARD_STATE_STYLE_ID); + let styleEl = document.getElementById(discardStateStyleId(sessionId)); if (!styleEl) { styleEl = document.createElement('style'); - styleEl.id = DISCARD_STATE_STYLE_ID; + styleEl.id = discardStateStyleId(sessionId); (document.head || document.documentElement).appendChild(styleEl); } + styleEl.dataset.impeccableDiscardSession = sessionId; const wrapper = '[data-impeccable-variants="' + sessionId + '"]'; styleEl.textContent = wrapper + ' > [data-impeccable-variant]:not([data-impeccable-variant="original"]) { display:none !important; }\n' + wrapper + ' > [data-impeccable-variant="original"] { display:block !important; }'; } + function removeDiscardStateStylesheet(sessionId) { + if (!sessionId) return; + document.getElementById(discardStateStyleId(sessionId))?.remove(); + } + + function releaseDiscardedStaticWrapper(wrapper, sessionId) { + removeDiscardStateStylesheet(sessionId); + if (!wrapper) return; + const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); + const content = orig?.firstElementChild; + if (content && wrapper.parentElement) { + wrapper.parentElement.replaceChild(content, wrapper); + return; + } + wrapper.remove(); + } + + function watchForDiscardedFrameworkWrapperRemoval(sessionId) { + if (!sessionId || !document.body) return; + if (discardedFrameworkWrapperWatchers.has(sessionId)) return; + const selector = '[data-impeccable-variants="' + sessionId + '"]'; + let observer = null; + let timer = null; + const stopWatching = function() { + observer?.disconnect(); + if (timer) clearTimeout(timer); + discardedFrameworkWrapperWatchers.delete(sessionId); + }; + const finishIfGone = function() { + if (document.querySelector(selector)) return false; + removeDiscardStateStylesheet(sessionId); + stopWatching(); + return true; + }; + if (finishIfGone()) return; + observer = new MutationObserver(finishIfGone); + observer.observe(document.body, { childList: true, subtree: true }); + const resolveStillMounted = function() { + if (finishIfGone()) return; + const replacementActive = !!currentSessionId + || (state !== 'IDLE' && state !== 'PICKING'); + if (replacementActive) { + timer = setTimeout(resolveStillMounted, 12000); + discardedFrameworkWrapperWatchers.get(sessionId).timer = timer; + return; + } + removeDiscardStateStylesheet(sessionId); + stopWatching(); + location.reload(); + }; + timer = setTimeout(resolveStillMounted, 12000); + discardedFrameworkWrapperWatchers.set(sessionId, { observer, timer }); + } + function resolveScrollLockAnchorTop() { const anchor = resolveBarAnchor(); if (!anchor?.isConnected) return null; @@ -7335,7 +7413,7 @@ hideInsertLine(); configureKind = 'insert'; selectedElement = placeholder; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); hideHighlight(); clearAnnotations(); showAnnotOverlay(placeholder); @@ -7353,7 +7431,7 @@ e.preventDefault(); e.stopPropagation(); selectedElement = hoveredElement; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -7530,7 +7608,7 @@ } else if (e.key === 'Enter') { e.preventDefault(); selectedElement = hoveredElement; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -8535,6 +8613,7 @@ void main() { } function scheduleAcceptCleanup(accepted) { + const recoveryRevision = liveInteractionRevision; queueMicrotask(function() { if (pendingAcceptedSession?.id !== accepted?.id) return; // Svelte previews live in an adapter-owned mount rather than in source @@ -8551,8 +8630,10 @@ void main() { // races. Static servers still need a fallback, but it must not keep Live // in SAVING or block the user's next pick. if (!accepted?.isSvelteComponent) { + watchForHandledRuntimeWrapper(accepted?.id, recoveryRevision); setTimeout(function() { - if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted); + if (deferredRecoverySuperseded(accepted?.id, recoveryRevision)) return; + if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted, recoveryRevision); }, 1200); } } @@ -8585,13 +8666,27 @@ void main() { && matches.every((el) => !el.closest('[data-impeccable-variants],[data-impeccable-variant],[data-impeccable-carbonize]')); } - function ensureAcceptedDomClean(pending) { + function ensureAcceptedDomClean(pending, recoveryRevision) { + // Background cleanup for an accepted session must never mutate or reload + // a newer comparison the user has already started. + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; if (acceptedDomAlreadyClean(pending)) return; const sessionId = pending?.id; const variantId = pending?.variant; const wrappers = findAcceptedRuntimeWrappers(sessionId); + if (hasFrameworkHmrOwnership(wrappers[0] || pending?.parentElement)) { + // Vite can coalesce rapid scaffold/carbonize writes and leave the last + // framework-owned preview tree mounted even though source is clean. Give + // HMR another grace window, then reload from clean source rather than + // violating reconciler ownership with a manual DOM mutation. + setTimeout(function() { + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; + if (!acceptedDomAlreadyClean(pending)) location.reload(); + }, 2000); + return; + } if (wrappers.length === 0) { - restoreAcceptedDomFromSnapshot(pending); + restoreAcceptedDomFromSnapshot(pending, recoveryRevision); return; } for (const wrapper of wrappers) { @@ -8608,7 +8703,7 @@ void main() { } wrapper.remove(); } - if (!acceptedDomAlreadyClean(pending)) restoreAcceptedDomFromSnapshot(pending); + if (!acceptedDomAlreadyClean(pending)) restoreAcceptedDomFromSnapshot(pending, recoveryRevision); } function findAcceptedRuntimeWrappers(sessionId) { @@ -8619,17 +8714,17 @@ void main() { ])]; } - function restoreAcceptedDomFromSnapshot(pending) { + function restoreAcceptedDomFromSnapshot(pending, recoveryRevision) { if (acceptedDomAlreadyClean(pending)) return; if (!pending?.acceptedHtml) { - reloadAfterMissingAcceptedDom(pending); + reloadAfterMissingAcceptedDom(pending, recoveryRevision); return; } const parent = pending.parentElement?.isConnected ? pending.parentElement : (pending.parentSelector ? document.querySelector(pending.parentSelector) : null); if (!parent) { - reloadAfterMissingAcceptedDom(pending); + reloadAfterMissingAcceptedDom(pending, recoveryRevision); return; } const template = document.createElement('template'); @@ -8638,10 +8733,11 @@ void main() { ? pending.nextSibling : null; parent.insertBefore(template.content, anchor); - if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending); + if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending, recoveryRevision); } - function reloadAfterMissingAcceptedDom(pending) { + function reloadAfterMissingAcceptedDom(pending, recoveryRevision) { + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; if (acceptedDomAlreadyClean(pending)) return; if (pending?.id && document.querySelector('[data-impeccable-variants="' + pending.id + '"]')) return; location.reload(); @@ -8991,14 +9087,15 @@ void main() { return sessionState.isHandled(id); } - function clearHandled() { - sessionState.clearHandled(); + function clearHandled(sessionId) { + sessionState.clearHandled(sessionId); } function cleanup(options) { const restoreOriginal = options?.restoreOriginal === true; const instantChrome = options?.instantChrome === true; const cleanupSessionId = currentSessionId; + const cleanupRevision = liveInteractionRevision; clearMountErrorCard(); lastReportedMountFailure = null; if (svelteComponentSession?.sessionId === cleanupSessionId) { @@ -9016,19 +9113,41 @@ void main() { else wrapper.style.display = 'none'; } setTimeout(function() { - document.getElementById(DISCARD_STATE_STYLE_ID)?.remove(); - if (!cleanupSessionId) return; - const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!lateWrapper) return; - const orig = lateWrapper.querySelector('[data-impeccable-variant="original"]'); - if (orig) { - const content = orig.firstElementChild; - if (content) { - lateWrapper.parentElement.replaceChild(content, lateWrapper); - return; - } + const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); + if (!cleanupSessionId) { + removeDiscardStateStylesheet(); + return; } - lateWrapper.remove(); + const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (!lateWrapper) { + removeDiscardStateStylesheet(cleanupSessionId); + return; + } + if (recoverySuperseded) { + if (hasFrameworkHmrOwnership(lateWrapper)) { + watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); + } else { + releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + } + return; + } + if (hasFrameworkHmrOwnership(lateWrapper)) { + // As on accept, never restructure framework-owned DOM. If HMR missed + // the final source rewrite, reload once after a grace window so the + // discarded source becomes authoritative without a reconciler race. + setTimeout(function() { + const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { + if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId); + else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); + return; + } + removeDiscardStateStylesheet(cleanupSessionId); + if (staleWrapper) location.reload(); + }, 2000); + return; + } + releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); }, 2000); } hideBar(instantChrome); @@ -9111,12 +9230,122 @@ void main() { // Resume an active variant session after HMR/page reload. // If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote // variants before HMR fired. Pick up where we left off. - function resumeSession() { + function handledWrapperReloadKey(sessionId) { + return HANDLED_WRAPPER_RELOAD_KEY + ':' + sessionId; + } + + function clearHandledWrapperReloadStamp(sessionId) { + try { + if (sessionId) { + sessionStorage.removeItem(handledWrapperReloadKey(sessionId)); + const legacy = sessionStorage.getItem(HANDLED_WRAPPER_RELOAD_KEY) || ''; + if (legacy === sessionId || legacy.startsWith(sessionId + ':')) { + sessionStorage.removeItem(HANDLED_WRAPPER_RELOAD_KEY); + } + return; + } + sessionStorage.removeItem(HANDLED_WRAPPER_RELOAD_KEY); + for (let i = sessionStorage.length - 1; i >= 0; i--) { + const key = sessionStorage.key(i); + if (key?.startsWith(HANDLED_WRAPPER_RELOAD_KEY + ':')) sessionStorage.removeItem(key); + } + } catch {} + } + + function scheduleHandledRuntimeWrapperReload(wrapper, recoveryRevision = liveInteractionRevision) { + const sessionId = wrapper?.dataset?.impeccableVariants + || wrapper?.dataset?.impeccableCarbonize; + if (!sessionId || !isSessionHandled(sessionId)) return false; + if (deferredRecoverySuperseded(sessionId, recoveryRevision)) return true; + + if (handledRuntimeWrapperReloadSessions.has(sessionId)) return true; + let reloadAttempts = 0; + try { + reloadAttempts = Number(sessionStorage.getItem(handledWrapperReloadKey(sessionId))) || 0; + if (reloadAttempts >= 2) return true; + sessionStorage.setItem(handledWrapperReloadKey(sessionId), String(reloadAttempts + 1)); + } catch {} + handledRuntimeWrapperReloadSessions.add(sessionId); + + // A framework refresh can replace the variants tree with an intermediate + // carbonize tree and reload the page, cancelling the original accept timer. + // Let the file-side cleanup settle, then reload once from authoritative + // source. The sessionStorage stamp prevents a stale dev-server response + // from turning this recovery into a reload loop. + setTimeout(function() { + if (deferredRecoverySuperseded(sessionId, recoveryRevision)) { + clearHandledWrapperReloadStamp(sessionId); + handledRuntimeWrapperReloadSessions.delete(sessionId); + return; + } + const staleWrapper = document.querySelector( + '[data-impeccable-variants="' + sessionId + '"],' + + '[data-impeccable-carbonize="' + sessionId + '"]', + ); + if (staleWrapper) location.reload(); + else { + clearHandledWrapperReloadStamp(sessionId); + handledRuntimeWrapperReloadSessions.delete(sessionId); + } + }, 3000); + return true; + } + + function watchForHandledRuntimeWrapper(sessionId, recoveryRevision = liveInteractionRevision) { + if (!sessionId || !document.body) return; + const existing = handledRuntimeWrapperWatchers.get(sessionId); + existing?.observer.disconnect(); + if (existing?.timer) clearTimeout(existing.timer); + + const findHandledWrapper = function() { + const wrapper = document.querySelector( + '[data-impeccable-variants="' + sessionId + '"],' + + '[data-impeccable-carbonize="' + sessionId + '"]', + ); + if (wrapper) scheduleHandledRuntimeWrapperReload(wrapper, recoveryRevision); + }; + + // Vite can briefly render the clean accepted tree, then apply a delayed + // carbonize refresh after the one-shot accept fallback has already passed. + // Keep a bounded scout alive through that refresh window so a late stale + // framework tree still reloads from the now-authoritative source. + const observer = new MutationObserver(findHandledWrapper); + observer.observe(document.body, { childList: true, subtree: true }); + const timer = setTimeout(function() { + if (handledRuntimeWrapperWatchers.get(sessionId)?.observer !== observer) return; + observer.disconnect(); + handledRuntimeWrapperWatchers.delete(sessionId); + }, 12000); + handledRuntimeWrapperWatchers.set(sessionId, { observer, timer }); + findHandledWrapper(); + } + + function restoreSessionSupersedingHandledWrapper(runtimeWrapper) { + const handledSessionId = runtimeWrapper?.dataset?.impeccableVariants + || runtimeWrapper?.dataset?.impeccableCarbonize; + if (!handledSessionId || !isSessionHandled(handledSessionId)) return false; + + // Accept releases the picker before carbonize finishes, so a replacement + // generation can already be durable while the prior handled wrapper is + // still mounted. Restore that newer session before the stale-wrapper + // recovery path gets a chance to reload or consume its retry budget. + const saved = loadSession(); + if (!saved?.id || saved.id === handledSessionId || isSessionHandled(saved.id)) return false; + if (currentSessionId === saved.id) return true; + return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); + } + + function resumeSession(recoveryRevision = liveInteractionRevision) { const wrapper = document.querySelector('[data-impeccable-variants]'); + const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); + if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; + if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; if (!wrapper) { if (restoreSessionWithoutWrapper('browser_resumed_without_wrapper')) return true; clearSession(); - clearHandled(); + // Keep the bounded handled-id history durable. A framework can hydrate a + // completed wrapper well after initialization, and a later reload must + // still recognize that wrapper as recovery work rather than resume it. return false; } @@ -9136,7 +9365,7 @@ void main() { wrapper.remove(); if (restoreSessionWithoutWrapper('browser_resumed_svelte_orphan_wrapper')) return true; clearSession(); - clearHandled(); + clearHandled(sessionId); return false; } @@ -12520,22 +12749,28 @@ void main() { connectSSE(); // Check for an active session to resume (variant wrapper already in DOM after HMR) - if (!resumeSession()) { + const resumed = resumeSession(); + if (!resumed) { console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.'); - // SvelteKit (and any framework that hydrates after HTML parse) may add - // the variant wrapper AFTER init runs. Watch for it and retry resume - // once it appears. Disconnect on first hit. + } else { + console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); + } + + // SvelteKit, React, and other frameworks may restore a durable session + // before hydration adds its variant wrapper. Keep a deferred-wrapper scout + // whenever init did not see a runtime wrapper, even if local/server state + // was already restored successfully. Disconnect on the first wrapper hit. + if (!resumed || !document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]')) { + const deferredResumeRevision = liveInteractionRevision; const scout = new MutationObserver(() => { - const wrapper = document.querySelector('[data-impeccable-variants]'); + const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); if (!wrapper) return; scout.disconnect(); - if (resumeSession()) { + if (resumeSession(deferredResumeRevision)) { console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); } }); scout.observe(document.body, { childList: true, subtree: true }); - } else { - console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); } if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING'); diff --git a/.trae-cn/skills/impeccable/scripts/live-browser-dom.js b/.trae-cn/skills/impeccable/scripts/live-browser-dom.js index ad6a794b3..e85c95c43 100644 --- a/.trae-cn/skills/impeccable/scripts/live-browser-dom.js +++ b/.trae-cn/skills/impeccable/scripts/live-browser-dom.js @@ -64,6 +64,26 @@ }; } + function hasFrameworkHmrOwnership(el) { + for (let node = el; node; node = node.parentElement) { + let keys = []; + try { keys = Object.getOwnPropertyNames(node); } catch {} + if (keys.some((key) => ( + key.startsWith('__reactFiber$') + || key.startsWith('__reactProps$') + || key.startsWith('__reactContainer$') + || key === '_reactRootContainer' + || key === '__vueParentComponent' + || key === '__vue_app__' + || key === '__vnode' + || key === '__svelte_meta' + ))) { + return true; + } + } + return false; + } + function id8() { if (crypto?.randomUUID) return crypto.randomUUID().replace(/-/g, '').slice(0, 8); return (Math.random().toString(16).slice(2) + Date.now().toString(16)).slice(0, 8); @@ -128,6 +148,7 @@ desc, rectIsUsableAnchor, makeFrozenAnchor, + hasFrameworkHmrOwnership, id8, cssId, liveUiRoot, diff --git a/.trae-cn/skills/impeccable/scripts/live-browser-session.js b/.trae-cn/skills/impeccable/scripts/live-browser-session.js index 0e362d6be..1514bf472 100644 --- a/.trae-cn/skills/impeccable/scripts/live-browser-session.js +++ b/.trae-cn/skills/impeccable/scripts/live-browser-session.js @@ -71,17 +71,38 @@ return checkpointRevision; } + function readHandledIds() { + const raw = safeRead(handledKey); + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed.filter(id => typeof id === 'string' && id); + } + if (typeof parsed === 'string' && parsed) return [parsed]; + } catch { /* legacy values were stored as a plain session id */ } + return [raw]; + } + function markHandled(id) { if (!id) return; - safeWrite(handledKey, id); + const ids = readHandledIds().filter(existing => existing !== id); + ids.push(id); + safeWrite(handledKey, JSON.stringify(ids.slice(-8))); } function isHandled(id) { - return !!id && safeRead(handledKey) === id; + return !!id && readHandledIds().includes(id); } - function clearHandled() { - safeRemove(handledKey); + function clearHandled(id) { + if (!id) { + safeRemove(handledKey); + return; + } + const remaining = readHandledIds().filter(existing => existing !== id); + if (remaining.length > 0) safeWrite(handledKey, JSON.stringify(remaining)); + else safeRemove(handledKey); } function writeScrollY(y) { diff --git a/.trae-cn/skills/impeccable/scripts/live-browser.js b/.trae-cn/skills/impeccable/scripts/live-browser.js index 2b38ec62e..1f373a894 100644 --- a/.trae-cn/skills/impeccable/scripts/live-browser.js +++ b/.trae-cn/skills/impeccable/scripts/live-browser.js @@ -121,6 +121,10 @@ let hoveredElement = null; let selectedElement = null; let currentSessionId = null; + // Advances when the user begins configuring a fresh edit, before that edit + // has a server session id. Deferred recovery captures this revision so an + // older accept/discard can never reload over a replacement configuration. + let liveInteractionRevision = 0; let expectedVariants = 0; let arrivedVariants = 0; let visibleVariant = 0; @@ -188,6 +192,9 @@ // when the real accept result arrives or a new session starts. let awaitingAcceptResult = null; let variantObserver = null; + const discardedFrameworkWrapperWatchers = new Map(); + const handledRuntimeWrapperWatchers = new Map(); + const handledRuntimeWrapperReloadSessions = new Set(); let variantSelectionInFlight = false; let variantSelectionPromise = null; let recoveringEmptyCycling = false; @@ -208,6 +215,7 @@ const SCROLL_ANCHOR_LOCK_ID = 'impeccable-scroll-anchor-lock'; const VARIANT_STATE_STYLE_ID = 'impeccable-variant-state'; const DISCARD_STATE_STYLE_ID = 'impeccable-discard-state'; + const HANDLED_WRAPPER_RELOAD_KEY = PREFIX + '-handled-wrapper-reload'; // Dedicated key for scroll position - SEPARATE from LS_KEY so that // saveSession's state updates don't clobber a carefully-captured scrollY. @@ -270,6 +278,7 @@ desc, rectIsUsableAnchor, makeFrozenAnchor, + hasFrameworkHmrOwnership, id8, cssId, liveUiRoot, @@ -2034,6 +2043,16 @@ syncSteerQueueHint(); } + function beginNewLiveConfiguration() { + liveInteractionRevision += 1; + setLiveState('CONFIGURING'); + } + + function deferredRecoverySuperseded(sessionId, recoveryRevision) { + return liveInteractionRevision !== recoveryRevision + || !!(currentSessionId && currentSessionId !== sessionId); + } + /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { @@ -5036,7 +5055,7 @@ && el.parentElement && document.body.contains(el) && !own(el) - && !el.closest?.('[data-impeccable-variants]'); + && !el.closest?.('[data-impeccable-variants],[data-impeccable-carbonize]'); } function elementMatchesOriginalMarkup(liveEl, origContent) { @@ -6608,19 +6627,78 @@ document.getElementById(VARIANT_STATE_STYLE_ID)?.remove(); } + function discardStateStyleId(sessionId) { + return DISCARD_STATE_STYLE_ID + '-' + sessionId; + } + function showOriginalDuringDiscard(sessionId) { if (!sessionId) return; - let styleEl = document.getElementById(DISCARD_STATE_STYLE_ID); + let styleEl = document.getElementById(discardStateStyleId(sessionId)); if (!styleEl) { styleEl = document.createElement('style'); - styleEl.id = DISCARD_STATE_STYLE_ID; + styleEl.id = discardStateStyleId(sessionId); (document.head || document.documentElement).appendChild(styleEl); } + styleEl.dataset.impeccableDiscardSession = sessionId; const wrapper = '[data-impeccable-variants="' + sessionId + '"]'; styleEl.textContent = wrapper + ' > [data-impeccable-variant]:not([data-impeccable-variant="original"]) { display:none !important; }\n' + wrapper + ' > [data-impeccable-variant="original"] { display:block !important; }'; } + function removeDiscardStateStylesheet(sessionId) { + if (!sessionId) return; + document.getElementById(discardStateStyleId(sessionId))?.remove(); + } + + function releaseDiscardedStaticWrapper(wrapper, sessionId) { + removeDiscardStateStylesheet(sessionId); + if (!wrapper) return; + const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); + const content = orig?.firstElementChild; + if (content && wrapper.parentElement) { + wrapper.parentElement.replaceChild(content, wrapper); + return; + } + wrapper.remove(); + } + + function watchForDiscardedFrameworkWrapperRemoval(sessionId) { + if (!sessionId || !document.body) return; + if (discardedFrameworkWrapperWatchers.has(sessionId)) return; + const selector = '[data-impeccable-variants="' + sessionId + '"]'; + let observer = null; + let timer = null; + const stopWatching = function() { + observer?.disconnect(); + if (timer) clearTimeout(timer); + discardedFrameworkWrapperWatchers.delete(sessionId); + }; + const finishIfGone = function() { + if (document.querySelector(selector)) return false; + removeDiscardStateStylesheet(sessionId); + stopWatching(); + return true; + }; + if (finishIfGone()) return; + observer = new MutationObserver(finishIfGone); + observer.observe(document.body, { childList: true, subtree: true }); + const resolveStillMounted = function() { + if (finishIfGone()) return; + const replacementActive = !!currentSessionId + || (state !== 'IDLE' && state !== 'PICKING'); + if (replacementActive) { + timer = setTimeout(resolveStillMounted, 12000); + discardedFrameworkWrapperWatchers.get(sessionId).timer = timer; + return; + } + removeDiscardStateStylesheet(sessionId); + stopWatching(); + location.reload(); + }; + timer = setTimeout(resolveStillMounted, 12000); + discardedFrameworkWrapperWatchers.set(sessionId, { observer, timer }); + } + function resolveScrollLockAnchorTop() { const anchor = resolveBarAnchor(); if (!anchor?.isConnected) return null; @@ -7335,7 +7413,7 @@ hideInsertLine(); configureKind = 'insert'; selectedElement = placeholder; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); hideHighlight(); clearAnnotations(); showAnnotOverlay(placeholder); @@ -7353,7 +7431,7 @@ e.preventDefault(); e.stopPropagation(); selectedElement = hoveredElement; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -7530,7 +7608,7 @@ } else if (e.key === 'Enter') { e.preventDefault(); selectedElement = hoveredElement; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -8535,6 +8613,7 @@ void main() { } function scheduleAcceptCleanup(accepted) { + const recoveryRevision = liveInteractionRevision; queueMicrotask(function() { if (pendingAcceptedSession?.id !== accepted?.id) return; // Svelte previews live in an adapter-owned mount rather than in source @@ -8551,8 +8630,10 @@ void main() { // races. Static servers still need a fallback, but it must not keep Live // in SAVING or block the user's next pick. if (!accepted?.isSvelteComponent) { + watchForHandledRuntimeWrapper(accepted?.id, recoveryRevision); setTimeout(function() { - if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted); + if (deferredRecoverySuperseded(accepted?.id, recoveryRevision)) return; + if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted, recoveryRevision); }, 1200); } } @@ -8585,13 +8666,27 @@ void main() { && matches.every((el) => !el.closest('[data-impeccable-variants],[data-impeccable-variant],[data-impeccable-carbonize]')); } - function ensureAcceptedDomClean(pending) { + function ensureAcceptedDomClean(pending, recoveryRevision) { + // Background cleanup for an accepted session must never mutate or reload + // a newer comparison the user has already started. + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; if (acceptedDomAlreadyClean(pending)) return; const sessionId = pending?.id; const variantId = pending?.variant; const wrappers = findAcceptedRuntimeWrappers(sessionId); + if (hasFrameworkHmrOwnership(wrappers[0] || pending?.parentElement)) { + // Vite can coalesce rapid scaffold/carbonize writes and leave the last + // framework-owned preview tree mounted even though source is clean. Give + // HMR another grace window, then reload from clean source rather than + // violating reconciler ownership with a manual DOM mutation. + setTimeout(function() { + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; + if (!acceptedDomAlreadyClean(pending)) location.reload(); + }, 2000); + return; + } if (wrappers.length === 0) { - restoreAcceptedDomFromSnapshot(pending); + restoreAcceptedDomFromSnapshot(pending, recoveryRevision); return; } for (const wrapper of wrappers) { @@ -8608,7 +8703,7 @@ void main() { } wrapper.remove(); } - if (!acceptedDomAlreadyClean(pending)) restoreAcceptedDomFromSnapshot(pending); + if (!acceptedDomAlreadyClean(pending)) restoreAcceptedDomFromSnapshot(pending, recoveryRevision); } function findAcceptedRuntimeWrappers(sessionId) { @@ -8619,17 +8714,17 @@ void main() { ])]; } - function restoreAcceptedDomFromSnapshot(pending) { + function restoreAcceptedDomFromSnapshot(pending, recoveryRevision) { if (acceptedDomAlreadyClean(pending)) return; if (!pending?.acceptedHtml) { - reloadAfterMissingAcceptedDom(pending); + reloadAfterMissingAcceptedDom(pending, recoveryRevision); return; } const parent = pending.parentElement?.isConnected ? pending.parentElement : (pending.parentSelector ? document.querySelector(pending.parentSelector) : null); if (!parent) { - reloadAfterMissingAcceptedDom(pending); + reloadAfterMissingAcceptedDom(pending, recoveryRevision); return; } const template = document.createElement('template'); @@ -8638,10 +8733,11 @@ void main() { ? pending.nextSibling : null; parent.insertBefore(template.content, anchor); - if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending); + if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending, recoveryRevision); } - function reloadAfterMissingAcceptedDom(pending) { + function reloadAfterMissingAcceptedDom(pending, recoveryRevision) { + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; if (acceptedDomAlreadyClean(pending)) return; if (pending?.id && document.querySelector('[data-impeccable-variants="' + pending.id + '"]')) return; location.reload(); @@ -8991,14 +9087,15 @@ void main() { return sessionState.isHandled(id); } - function clearHandled() { - sessionState.clearHandled(); + function clearHandled(sessionId) { + sessionState.clearHandled(sessionId); } function cleanup(options) { const restoreOriginal = options?.restoreOriginal === true; const instantChrome = options?.instantChrome === true; const cleanupSessionId = currentSessionId; + const cleanupRevision = liveInteractionRevision; clearMountErrorCard(); lastReportedMountFailure = null; if (svelteComponentSession?.sessionId === cleanupSessionId) { @@ -9016,19 +9113,41 @@ void main() { else wrapper.style.display = 'none'; } setTimeout(function() { - document.getElementById(DISCARD_STATE_STYLE_ID)?.remove(); - if (!cleanupSessionId) return; - const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!lateWrapper) return; - const orig = lateWrapper.querySelector('[data-impeccable-variant="original"]'); - if (orig) { - const content = orig.firstElementChild; - if (content) { - lateWrapper.parentElement.replaceChild(content, lateWrapper); - return; - } + const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); + if (!cleanupSessionId) { + removeDiscardStateStylesheet(); + return; } - lateWrapper.remove(); + const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (!lateWrapper) { + removeDiscardStateStylesheet(cleanupSessionId); + return; + } + if (recoverySuperseded) { + if (hasFrameworkHmrOwnership(lateWrapper)) { + watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); + } else { + releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + } + return; + } + if (hasFrameworkHmrOwnership(lateWrapper)) { + // As on accept, never restructure framework-owned DOM. If HMR missed + // the final source rewrite, reload once after a grace window so the + // discarded source becomes authoritative without a reconciler race. + setTimeout(function() { + const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { + if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId); + else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); + return; + } + removeDiscardStateStylesheet(cleanupSessionId); + if (staleWrapper) location.reload(); + }, 2000); + return; + } + releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); }, 2000); } hideBar(instantChrome); @@ -9111,12 +9230,122 @@ void main() { // Resume an active variant session after HMR/page reload. // If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote // variants before HMR fired. Pick up where we left off. - function resumeSession() { + function handledWrapperReloadKey(sessionId) { + return HANDLED_WRAPPER_RELOAD_KEY + ':' + sessionId; + } + + function clearHandledWrapperReloadStamp(sessionId) { + try { + if (sessionId) { + sessionStorage.removeItem(handledWrapperReloadKey(sessionId)); + const legacy = sessionStorage.getItem(HANDLED_WRAPPER_RELOAD_KEY) || ''; + if (legacy === sessionId || legacy.startsWith(sessionId + ':')) { + sessionStorage.removeItem(HANDLED_WRAPPER_RELOAD_KEY); + } + return; + } + sessionStorage.removeItem(HANDLED_WRAPPER_RELOAD_KEY); + for (let i = sessionStorage.length - 1; i >= 0; i--) { + const key = sessionStorage.key(i); + if (key?.startsWith(HANDLED_WRAPPER_RELOAD_KEY + ':')) sessionStorage.removeItem(key); + } + } catch {} + } + + function scheduleHandledRuntimeWrapperReload(wrapper, recoveryRevision = liveInteractionRevision) { + const sessionId = wrapper?.dataset?.impeccableVariants + || wrapper?.dataset?.impeccableCarbonize; + if (!sessionId || !isSessionHandled(sessionId)) return false; + if (deferredRecoverySuperseded(sessionId, recoveryRevision)) return true; + + if (handledRuntimeWrapperReloadSessions.has(sessionId)) return true; + let reloadAttempts = 0; + try { + reloadAttempts = Number(sessionStorage.getItem(handledWrapperReloadKey(sessionId))) || 0; + if (reloadAttempts >= 2) return true; + sessionStorage.setItem(handledWrapperReloadKey(sessionId), String(reloadAttempts + 1)); + } catch {} + handledRuntimeWrapperReloadSessions.add(sessionId); + + // A framework refresh can replace the variants tree with an intermediate + // carbonize tree and reload the page, cancelling the original accept timer. + // Let the file-side cleanup settle, then reload once from authoritative + // source. The sessionStorage stamp prevents a stale dev-server response + // from turning this recovery into a reload loop. + setTimeout(function() { + if (deferredRecoverySuperseded(sessionId, recoveryRevision)) { + clearHandledWrapperReloadStamp(sessionId); + handledRuntimeWrapperReloadSessions.delete(sessionId); + return; + } + const staleWrapper = document.querySelector( + '[data-impeccable-variants="' + sessionId + '"],' + + '[data-impeccable-carbonize="' + sessionId + '"]', + ); + if (staleWrapper) location.reload(); + else { + clearHandledWrapperReloadStamp(sessionId); + handledRuntimeWrapperReloadSessions.delete(sessionId); + } + }, 3000); + return true; + } + + function watchForHandledRuntimeWrapper(sessionId, recoveryRevision = liveInteractionRevision) { + if (!sessionId || !document.body) return; + const existing = handledRuntimeWrapperWatchers.get(sessionId); + existing?.observer.disconnect(); + if (existing?.timer) clearTimeout(existing.timer); + + const findHandledWrapper = function() { + const wrapper = document.querySelector( + '[data-impeccable-variants="' + sessionId + '"],' + + '[data-impeccable-carbonize="' + sessionId + '"]', + ); + if (wrapper) scheduleHandledRuntimeWrapperReload(wrapper, recoveryRevision); + }; + + // Vite can briefly render the clean accepted tree, then apply a delayed + // carbonize refresh after the one-shot accept fallback has already passed. + // Keep a bounded scout alive through that refresh window so a late stale + // framework tree still reloads from the now-authoritative source. + const observer = new MutationObserver(findHandledWrapper); + observer.observe(document.body, { childList: true, subtree: true }); + const timer = setTimeout(function() { + if (handledRuntimeWrapperWatchers.get(sessionId)?.observer !== observer) return; + observer.disconnect(); + handledRuntimeWrapperWatchers.delete(sessionId); + }, 12000); + handledRuntimeWrapperWatchers.set(sessionId, { observer, timer }); + findHandledWrapper(); + } + + function restoreSessionSupersedingHandledWrapper(runtimeWrapper) { + const handledSessionId = runtimeWrapper?.dataset?.impeccableVariants + || runtimeWrapper?.dataset?.impeccableCarbonize; + if (!handledSessionId || !isSessionHandled(handledSessionId)) return false; + + // Accept releases the picker before carbonize finishes, so a replacement + // generation can already be durable while the prior handled wrapper is + // still mounted. Restore that newer session before the stale-wrapper + // recovery path gets a chance to reload or consume its retry budget. + const saved = loadSession(); + if (!saved?.id || saved.id === handledSessionId || isSessionHandled(saved.id)) return false; + if (currentSessionId === saved.id) return true; + return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); + } + + function resumeSession(recoveryRevision = liveInteractionRevision) { const wrapper = document.querySelector('[data-impeccable-variants]'); + const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); + if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; + if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; if (!wrapper) { if (restoreSessionWithoutWrapper('browser_resumed_without_wrapper')) return true; clearSession(); - clearHandled(); + // Keep the bounded handled-id history durable. A framework can hydrate a + // completed wrapper well after initialization, and a later reload must + // still recognize that wrapper as recovery work rather than resume it. return false; } @@ -9136,7 +9365,7 @@ void main() { wrapper.remove(); if (restoreSessionWithoutWrapper('browser_resumed_svelte_orphan_wrapper')) return true; clearSession(); - clearHandled(); + clearHandled(sessionId); return false; } @@ -12520,22 +12749,28 @@ void main() { connectSSE(); // Check for an active session to resume (variant wrapper already in DOM after HMR) - if (!resumeSession()) { + const resumed = resumeSession(); + if (!resumed) { console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.'); - // SvelteKit (and any framework that hydrates after HTML parse) may add - // the variant wrapper AFTER init runs. Watch for it and retry resume - // once it appears. Disconnect on first hit. + } else { + console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); + } + + // SvelteKit, React, and other frameworks may restore a durable session + // before hydration adds its variant wrapper. Keep a deferred-wrapper scout + // whenever init did not see a runtime wrapper, even if local/server state + // was already restored successfully. Disconnect on the first wrapper hit. + if (!resumed || !document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]')) { + const deferredResumeRevision = liveInteractionRevision; const scout = new MutationObserver(() => { - const wrapper = document.querySelector('[data-impeccable-variants]'); + const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); if (!wrapper) return; scout.disconnect(); - if (resumeSession()) { + if (resumeSession(deferredResumeRevision)) { console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); } }); scout.observe(document.body, { childList: true, subtree: true }); - } else { - console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); } if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING'); diff --git a/.trae/skills/impeccable/scripts/live-browser-dom.js b/.trae/skills/impeccable/scripts/live-browser-dom.js index ad6a794b3..e85c95c43 100644 --- a/.trae/skills/impeccable/scripts/live-browser-dom.js +++ b/.trae/skills/impeccable/scripts/live-browser-dom.js @@ -64,6 +64,26 @@ }; } + function hasFrameworkHmrOwnership(el) { + for (let node = el; node; node = node.parentElement) { + let keys = []; + try { keys = Object.getOwnPropertyNames(node); } catch {} + if (keys.some((key) => ( + key.startsWith('__reactFiber$') + || key.startsWith('__reactProps$') + || key.startsWith('__reactContainer$') + || key === '_reactRootContainer' + || key === '__vueParentComponent' + || key === '__vue_app__' + || key === '__vnode' + || key === '__svelte_meta' + ))) { + return true; + } + } + return false; + } + function id8() { if (crypto?.randomUUID) return crypto.randomUUID().replace(/-/g, '').slice(0, 8); return (Math.random().toString(16).slice(2) + Date.now().toString(16)).slice(0, 8); @@ -128,6 +148,7 @@ desc, rectIsUsableAnchor, makeFrozenAnchor, + hasFrameworkHmrOwnership, id8, cssId, liveUiRoot, diff --git a/.trae/skills/impeccable/scripts/live-browser-session.js b/.trae/skills/impeccable/scripts/live-browser-session.js index 0e362d6be..1514bf472 100644 --- a/.trae/skills/impeccable/scripts/live-browser-session.js +++ b/.trae/skills/impeccable/scripts/live-browser-session.js @@ -71,17 +71,38 @@ return checkpointRevision; } + function readHandledIds() { + const raw = safeRead(handledKey); + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed.filter(id => typeof id === 'string' && id); + } + if (typeof parsed === 'string' && parsed) return [parsed]; + } catch { /* legacy values were stored as a plain session id */ } + return [raw]; + } + function markHandled(id) { if (!id) return; - safeWrite(handledKey, id); + const ids = readHandledIds().filter(existing => existing !== id); + ids.push(id); + safeWrite(handledKey, JSON.stringify(ids.slice(-8))); } function isHandled(id) { - return !!id && safeRead(handledKey) === id; + return !!id && readHandledIds().includes(id); } - function clearHandled() { - safeRemove(handledKey); + function clearHandled(id) { + if (!id) { + safeRemove(handledKey); + return; + } + const remaining = readHandledIds().filter(existing => existing !== id); + if (remaining.length > 0) safeWrite(handledKey, JSON.stringify(remaining)); + else safeRemove(handledKey); } function writeScrollY(y) { diff --git a/.trae/skills/impeccable/scripts/live-browser.js b/.trae/skills/impeccable/scripts/live-browser.js index 2b38ec62e..1f373a894 100644 --- a/.trae/skills/impeccable/scripts/live-browser.js +++ b/.trae/skills/impeccable/scripts/live-browser.js @@ -121,6 +121,10 @@ let hoveredElement = null; let selectedElement = null; let currentSessionId = null; + // Advances when the user begins configuring a fresh edit, before that edit + // has a server session id. Deferred recovery captures this revision so an + // older accept/discard can never reload over a replacement configuration. + let liveInteractionRevision = 0; let expectedVariants = 0; let arrivedVariants = 0; let visibleVariant = 0; @@ -188,6 +192,9 @@ // when the real accept result arrives or a new session starts. let awaitingAcceptResult = null; let variantObserver = null; + const discardedFrameworkWrapperWatchers = new Map(); + const handledRuntimeWrapperWatchers = new Map(); + const handledRuntimeWrapperReloadSessions = new Set(); let variantSelectionInFlight = false; let variantSelectionPromise = null; let recoveringEmptyCycling = false; @@ -208,6 +215,7 @@ const SCROLL_ANCHOR_LOCK_ID = 'impeccable-scroll-anchor-lock'; const VARIANT_STATE_STYLE_ID = 'impeccable-variant-state'; const DISCARD_STATE_STYLE_ID = 'impeccable-discard-state'; + const HANDLED_WRAPPER_RELOAD_KEY = PREFIX + '-handled-wrapper-reload'; // Dedicated key for scroll position - SEPARATE from LS_KEY so that // saveSession's state updates don't clobber a carefully-captured scrollY. @@ -270,6 +278,7 @@ desc, rectIsUsableAnchor, makeFrozenAnchor, + hasFrameworkHmrOwnership, id8, cssId, liveUiRoot, @@ -2034,6 +2043,16 @@ syncSteerQueueHint(); } + function beginNewLiveConfiguration() { + liveInteractionRevision += 1; + setLiveState('CONFIGURING'); + } + + function deferredRecoverySuperseded(sessionId, recoveryRevision) { + return liveInteractionRevision !== recoveryRevision + || !!(currentSessionId && currentSessionId !== sessionId); + } + /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { @@ -5036,7 +5055,7 @@ && el.parentElement && document.body.contains(el) && !own(el) - && !el.closest?.('[data-impeccable-variants]'); + && !el.closest?.('[data-impeccable-variants],[data-impeccable-carbonize]'); } function elementMatchesOriginalMarkup(liveEl, origContent) { @@ -6608,19 +6627,78 @@ document.getElementById(VARIANT_STATE_STYLE_ID)?.remove(); } + function discardStateStyleId(sessionId) { + return DISCARD_STATE_STYLE_ID + '-' + sessionId; + } + function showOriginalDuringDiscard(sessionId) { if (!sessionId) return; - let styleEl = document.getElementById(DISCARD_STATE_STYLE_ID); + let styleEl = document.getElementById(discardStateStyleId(sessionId)); if (!styleEl) { styleEl = document.createElement('style'); - styleEl.id = DISCARD_STATE_STYLE_ID; + styleEl.id = discardStateStyleId(sessionId); (document.head || document.documentElement).appendChild(styleEl); } + styleEl.dataset.impeccableDiscardSession = sessionId; const wrapper = '[data-impeccable-variants="' + sessionId + '"]'; styleEl.textContent = wrapper + ' > [data-impeccable-variant]:not([data-impeccable-variant="original"]) { display:none !important; }\n' + wrapper + ' > [data-impeccable-variant="original"] { display:block !important; }'; } + function removeDiscardStateStylesheet(sessionId) { + if (!sessionId) return; + document.getElementById(discardStateStyleId(sessionId))?.remove(); + } + + function releaseDiscardedStaticWrapper(wrapper, sessionId) { + removeDiscardStateStylesheet(sessionId); + if (!wrapper) return; + const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); + const content = orig?.firstElementChild; + if (content && wrapper.parentElement) { + wrapper.parentElement.replaceChild(content, wrapper); + return; + } + wrapper.remove(); + } + + function watchForDiscardedFrameworkWrapperRemoval(sessionId) { + if (!sessionId || !document.body) return; + if (discardedFrameworkWrapperWatchers.has(sessionId)) return; + const selector = '[data-impeccable-variants="' + sessionId + '"]'; + let observer = null; + let timer = null; + const stopWatching = function() { + observer?.disconnect(); + if (timer) clearTimeout(timer); + discardedFrameworkWrapperWatchers.delete(sessionId); + }; + const finishIfGone = function() { + if (document.querySelector(selector)) return false; + removeDiscardStateStylesheet(sessionId); + stopWatching(); + return true; + }; + if (finishIfGone()) return; + observer = new MutationObserver(finishIfGone); + observer.observe(document.body, { childList: true, subtree: true }); + const resolveStillMounted = function() { + if (finishIfGone()) return; + const replacementActive = !!currentSessionId + || (state !== 'IDLE' && state !== 'PICKING'); + if (replacementActive) { + timer = setTimeout(resolveStillMounted, 12000); + discardedFrameworkWrapperWatchers.get(sessionId).timer = timer; + return; + } + removeDiscardStateStylesheet(sessionId); + stopWatching(); + location.reload(); + }; + timer = setTimeout(resolveStillMounted, 12000); + discardedFrameworkWrapperWatchers.set(sessionId, { observer, timer }); + } + function resolveScrollLockAnchorTop() { const anchor = resolveBarAnchor(); if (!anchor?.isConnected) return null; @@ -7335,7 +7413,7 @@ hideInsertLine(); configureKind = 'insert'; selectedElement = placeholder; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); hideHighlight(); clearAnnotations(); showAnnotOverlay(placeholder); @@ -7353,7 +7431,7 @@ e.preventDefault(); e.stopPropagation(); selectedElement = hoveredElement; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -7530,7 +7608,7 @@ } else if (e.key === 'Enter') { e.preventDefault(); selectedElement = hoveredElement; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -8535,6 +8613,7 @@ void main() { } function scheduleAcceptCleanup(accepted) { + const recoveryRevision = liveInteractionRevision; queueMicrotask(function() { if (pendingAcceptedSession?.id !== accepted?.id) return; // Svelte previews live in an adapter-owned mount rather than in source @@ -8551,8 +8630,10 @@ void main() { // races. Static servers still need a fallback, but it must not keep Live // in SAVING or block the user's next pick. if (!accepted?.isSvelteComponent) { + watchForHandledRuntimeWrapper(accepted?.id, recoveryRevision); setTimeout(function() { - if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted); + if (deferredRecoverySuperseded(accepted?.id, recoveryRevision)) return; + if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted, recoveryRevision); }, 1200); } } @@ -8585,13 +8666,27 @@ void main() { && matches.every((el) => !el.closest('[data-impeccable-variants],[data-impeccable-variant],[data-impeccable-carbonize]')); } - function ensureAcceptedDomClean(pending) { + function ensureAcceptedDomClean(pending, recoveryRevision) { + // Background cleanup for an accepted session must never mutate or reload + // a newer comparison the user has already started. + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; if (acceptedDomAlreadyClean(pending)) return; const sessionId = pending?.id; const variantId = pending?.variant; const wrappers = findAcceptedRuntimeWrappers(sessionId); + if (hasFrameworkHmrOwnership(wrappers[0] || pending?.parentElement)) { + // Vite can coalesce rapid scaffold/carbonize writes and leave the last + // framework-owned preview tree mounted even though source is clean. Give + // HMR another grace window, then reload from clean source rather than + // violating reconciler ownership with a manual DOM mutation. + setTimeout(function() { + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; + if (!acceptedDomAlreadyClean(pending)) location.reload(); + }, 2000); + return; + } if (wrappers.length === 0) { - restoreAcceptedDomFromSnapshot(pending); + restoreAcceptedDomFromSnapshot(pending, recoveryRevision); return; } for (const wrapper of wrappers) { @@ -8608,7 +8703,7 @@ void main() { } wrapper.remove(); } - if (!acceptedDomAlreadyClean(pending)) restoreAcceptedDomFromSnapshot(pending); + if (!acceptedDomAlreadyClean(pending)) restoreAcceptedDomFromSnapshot(pending, recoveryRevision); } function findAcceptedRuntimeWrappers(sessionId) { @@ -8619,17 +8714,17 @@ void main() { ])]; } - function restoreAcceptedDomFromSnapshot(pending) { + function restoreAcceptedDomFromSnapshot(pending, recoveryRevision) { if (acceptedDomAlreadyClean(pending)) return; if (!pending?.acceptedHtml) { - reloadAfterMissingAcceptedDom(pending); + reloadAfterMissingAcceptedDom(pending, recoveryRevision); return; } const parent = pending.parentElement?.isConnected ? pending.parentElement : (pending.parentSelector ? document.querySelector(pending.parentSelector) : null); if (!parent) { - reloadAfterMissingAcceptedDom(pending); + reloadAfterMissingAcceptedDom(pending, recoveryRevision); return; } const template = document.createElement('template'); @@ -8638,10 +8733,11 @@ void main() { ? pending.nextSibling : null; parent.insertBefore(template.content, anchor); - if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending); + if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending, recoveryRevision); } - function reloadAfterMissingAcceptedDom(pending) { + function reloadAfterMissingAcceptedDom(pending, recoveryRevision) { + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; if (acceptedDomAlreadyClean(pending)) return; if (pending?.id && document.querySelector('[data-impeccable-variants="' + pending.id + '"]')) return; location.reload(); @@ -8991,14 +9087,15 @@ void main() { return sessionState.isHandled(id); } - function clearHandled() { - sessionState.clearHandled(); + function clearHandled(sessionId) { + sessionState.clearHandled(sessionId); } function cleanup(options) { const restoreOriginal = options?.restoreOriginal === true; const instantChrome = options?.instantChrome === true; const cleanupSessionId = currentSessionId; + const cleanupRevision = liveInteractionRevision; clearMountErrorCard(); lastReportedMountFailure = null; if (svelteComponentSession?.sessionId === cleanupSessionId) { @@ -9016,19 +9113,41 @@ void main() { else wrapper.style.display = 'none'; } setTimeout(function() { - document.getElementById(DISCARD_STATE_STYLE_ID)?.remove(); - if (!cleanupSessionId) return; - const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!lateWrapper) return; - const orig = lateWrapper.querySelector('[data-impeccable-variant="original"]'); - if (orig) { - const content = orig.firstElementChild; - if (content) { - lateWrapper.parentElement.replaceChild(content, lateWrapper); - return; - } + const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); + if (!cleanupSessionId) { + removeDiscardStateStylesheet(); + return; } - lateWrapper.remove(); + const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (!lateWrapper) { + removeDiscardStateStylesheet(cleanupSessionId); + return; + } + if (recoverySuperseded) { + if (hasFrameworkHmrOwnership(lateWrapper)) { + watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); + } else { + releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + } + return; + } + if (hasFrameworkHmrOwnership(lateWrapper)) { + // As on accept, never restructure framework-owned DOM. If HMR missed + // the final source rewrite, reload once after a grace window so the + // discarded source becomes authoritative without a reconciler race. + setTimeout(function() { + const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { + if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId); + else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); + return; + } + removeDiscardStateStylesheet(cleanupSessionId); + if (staleWrapper) location.reload(); + }, 2000); + return; + } + releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); }, 2000); } hideBar(instantChrome); @@ -9111,12 +9230,122 @@ void main() { // Resume an active variant session after HMR/page reload. // If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote // variants before HMR fired. Pick up where we left off. - function resumeSession() { + function handledWrapperReloadKey(sessionId) { + return HANDLED_WRAPPER_RELOAD_KEY + ':' + sessionId; + } + + function clearHandledWrapperReloadStamp(sessionId) { + try { + if (sessionId) { + sessionStorage.removeItem(handledWrapperReloadKey(sessionId)); + const legacy = sessionStorage.getItem(HANDLED_WRAPPER_RELOAD_KEY) || ''; + if (legacy === sessionId || legacy.startsWith(sessionId + ':')) { + sessionStorage.removeItem(HANDLED_WRAPPER_RELOAD_KEY); + } + return; + } + sessionStorage.removeItem(HANDLED_WRAPPER_RELOAD_KEY); + for (let i = sessionStorage.length - 1; i >= 0; i--) { + const key = sessionStorage.key(i); + if (key?.startsWith(HANDLED_WRAPPER_RELOAD_KEY + ':')) sessionStorage.removeItem(key); + } + } catch {} + } + + function scheduleHandledRuntimeWrapperReload(wrapper, recoveryRevision = liveInteractionRevision) { + const sessionId = wrapper?.dataset?.impeccableVariants + || wrapper?.dataset?.impeccableCarbonize; + if (!sessionId || !isSessionHandled(sessionId)) return false; + if (deferredRecoverySuperseded(sessionId, recoveryRevision)) return true; + + if (handledRuntimeWrapperReloadSessions.has(sessionId)) return true; + let reloadAttempts = 0; + try { + reloadAttempts = Number(sessionStorage.getItem(handledWrapperReloadKey(sessionId))) || 0; + if (reloadAttempts >= 2) return true; + sessionStorage.setItem(handledWrapperReloadKey(sessionId), String(reloadAttempts + 1)); + } catch {} + handledRuntimeWrapperReloadSessions.add(sessionId); + + // A framework refresh can replace the variants tree with an intermediate + // carbonize tree and reload the page, cancelling the original accept timer. + // Let the file-side cleanup settle, then reload once from authoritative + // source. The sessionStorage stamp prevents a stale dev-server response + // from turning this recovery into a reload loop. + setTimeout(function() { + if (deferredRecoverySuperseded(sessionId, recoveryRevision)) { + clearHandledWrapperReloadStamp(sessionId); + handledRuntimeWrapperReloadSessions.delete(sessionId); + return; + } + const staleWrapper = document.querySelector( + '[data-impeccable-variants="' + sessionId + '"],' + + '[data-impeccable-carbonize="' + sessionId + '"]', + ); + if (staleWrapper) location.reload(); + else { + clearHandledWrapperReloadStamp(sessionId); + handledRuntimeWrapperReloadSessions.delete(sessionId); + } + }, 3000); + return true; + } + + function watchForHandledRuntimeWrapper(sessionId, recoveryRevision = liveInteractionRevision) { + if (!sessionId || !document.body) return; + const existing = handledRuntimeWrapperWatchers.get(sessionId); + existing?.observer.disconnect(); + if (existing?.timer) clearTimeout(existing.timer); + + const findHandledWrapper = function() { + const wrapper = document.querySelector( + '[data-impeccable-variants="' + sessionId + '"],' + + '[data-impeccable-carbonize="' + sessionId + '"]', + ); + if (wrapper) scheduleHandledRuntimeWrapperReload(wrapper, recoveryRevision); + }; + + // Vite can briefly render the clean accepted tree, then apply a delayed + // carbonize refresh after the one-shot accept fallback has already passed. + // Keep a bounded scout alive through that refresh window so a late stale + // framework tree still reloads from the now-authoritative source. + const observer = new MutationObserver(findHandledWrapper); + observer.observe(document.body, { childList: true, subtree: true }); + const timer = setTimeout(function() { + if (handledRuntimeWrapperWatchers.get(sessionId)?.observer !== observer) return; + observer.disconnect(); + handledRuntimeWrapperWatchers.delete(sessionId); + }, 12000); + handledRuntimeWrapperWatchers.set(sessionId, { observer, timer }); + findHandledWrapper(); + } + + function restoreSessionSupersedingHandledWrapper(runtimeWrapper) { + const handledSessionId = runtimeWrapper?.dataset?.impeccableVariants + || runtimeWrapper?.dataset?.impeccableCarbonize; + if (!handledSessionId || !isSessionHandled(handledSessionId)) return false; + + // Accept releases the picker before carbonize finishes, so a replacement + // generation can already be durable while the prior handled wrapper is + // still mounted. Restore that newer session before the stale-wrapper + // recovery path gets a chance to reload or consume its retry budget. + const saved = loadSession(); + if (!saved?.id || saved.id === handledSessionId || isSessionHandled(saved.id)) return false; + if (currentSessionId === saved.id) return true; + return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); + } + + function resumeSession(recoveryRevision = liveInteractionRevision) { const wrapper = document.querySelector('[data-impeccable-variants]'); + const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); + if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; + if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; if (!wrapper) { if (restoreSessionWithoutWrapper('browser_resumed_without_wrapper')) return true; clearSession(); - clearHandled(); + // Keep the bounded handled-id history durable. A framework can hydrate a + // completed wrapper well after initialization, and a later reload must + // still recognize that wrapper as recovery work rather than resume it. return false; } @@ -9136,7 +9365,7 @@ void main() { wrapper.remove(); if (restoreSessionWithoutWrapper('browser_resumed_svelte_orphan_wrapper')) return true; clearSession(); - clearHandled(); + clearHandled(sessionId); return false; } @@ -12520,22 +12749,28 @@ void main() { connectSSE(); // Check for an active session to resume (variant wrapper already in DOM after HMR) - if (!resumeSession()) { + const resumed = resumeSession(); + if (!resumed) { console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.'); - // SvelteKit (and any framework that hydrates after HTML parse) may add - // the variant wrapper AFTER init runs. Watch for it and retry resume - // once it appears. Disconnect on first hit. + } else { + console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); + } + + // SvelteKit, React, and other frameworks may restore a durable session + // before hydration adds its variant wrapper. Keep a deferred-wrapper scout + // whenever init did not see a runtime wrapper, even if local/server state + // was already restored successfully. Disconnect on the first wrapper hit. + if (!resumed || !document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]')) { + const deferredResumeRevision = liveInteractionRevision; const scout = new MutationObserver(() => { - const wrapper = document.querySelector('[data-impeccable-variants]'); + const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); if (!wrapper) return; scout.disconnect(); - if (resumeSession()) { + if (resumeSession(deferredResumeRevision)) { console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); } }); scout.observe(document.body, { childList: true, subtree: true }); - } else { - console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); } if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING'); diff --git a/.vibe/skills/impeccable/scripts/live-browser-dom.js b/.vibe/skills/impeccable/scripts/live-browser-dom.js index ad6a794b3..e85c95c43 100644 --- a/.vibe/skills/impeccable/scripts/live-browser-dom.js +++ b/.vibe/skills/impeccable/scripts/live-browser-dom.js @@ -64,6 +64,26 @@ }; } + function hasFrameworkHmrOwnership(el) { + for (let node = el; node; node = node.parentElement) { + let keys = []; + try { keys = Object.getOwnPropertyNames(node); } catch {} + if (keys.some((key) => ( + key.startsWith('__reactFiber$') + || key.startsWith('__reactProps$') + || key.startsWith('__reactContainer$') + || key === '_reactRootContainer' + || key === '__vueParentComponent' + || key === '__vue_app__' + || key === '__vnode' + || key === '__svelte_meta' + ))) { + return true; + } + } + return false; + } + function id8() { if (crypto?.randomUUID) return crypto.randomUUID().replace(/-/g, '').slice(0, 8); return (Math.random().toString(16).slice(2) + Date.now().toString(16)).slice(0, 8); @@ -128,6 +148,7 @@ desc, rectIsUsableAnchor, makeFrozenAnchor, + hasFrameworkHmrOwnership, id8, cssId, liveUiRoot, diff --git a/.vibe/skills/impeccable/scripts/live-browser-session.js b/.vibe/skills/impeccable/scripts/live-browser-session.js index 0e362d6be..1514bf472 100644 --- a/.vibe/skills/impeccable/scripts/live-browser-session.js +++ b/.vibe/skills/impeccable/scripts/live-browser-session.js @@ -71,17 +71,38 @@ return checkpointRevision; } + function readHandledIds() { + const raw = safeRead(handledKey); + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed.filter(id => typeof id === 'string' && id); + } + if (typeof parsed === 'string' && parsed) return [parsed]; + } catch { /* legacy values were stored as a plain session id */ } + return [raw]; + } + function markHandled(id) { if (!id) return; - safeWrite(handledKey, id); + const ids = readHandledIds().filter(existing => existing !== id); + ids.push(id); + safeWrite(handledKey, JSON.stringify(ids.slice(-8))); } function isHandled(id) { - return !!id && safeRead(handledKey) === id; + return !!id && readHandledIds().includes(id); } - function clearHandled() { - safeRemove(handledKey); + function clearHandled(id) { + if (!id) { + safeRemove(handledKey); + return; + } + const remaining = readHandledIds().filter(existing => existing !== id); + if (remaining.length > 0) safeWrite(handledKey, JSON.stringify(remaining)); + else safeRemove(handledKey); } function writeScrollY(y) { diff --git a/.vibe/skills/impeccable/scripts/live-browser.js b/.vibe/skills/impeccable/scripts/live-browser.js index 2b38ec62e..1f373a894 100644 --- a/.vibe/skills/impeccable/scripts/live-browser.js +++ b/.vibe/skills/impeccable/scripts/live-browser.js @@ -121,6 +121,10 @@ let hoveredElement = null; let selectedElement = null; let currentSessionId = null; + // Advances when the user begins configuring a fresh edit, before that edit + // has a server session id. Deferred recovery captures this revision so an + // older accept/discard can never reload over a replacement configuration. + let liveInteractionRevision = 0; let expectedVariants = 0; let arrivedVariants = 0; let visibleVariant = 0; @@ -188,6 +192,9 @@ // when the real accept result arrives or a new session starts. let awaitingAcceptResult = null; let variantObserver = null; + const discardedFrameworkWrapperWatchers = new Map(); + const handledRuntimeWrapperWatchers = new Map(); + const handledRuntimeWrapperReloadSessions = new Set(); let variantSelectionInFlight = false; let variantSelectionPromise = null; let recoveringEmptyCycling = false; @@ -208,6 +215,7 @@ const SCROLL_ANCHOR_LOCK_ID = 'impeccable-scroll-anchor-lock'; const VARIANT_STATE_STYLE_ID = 'impeccable-variant-state'; const DISCARD_STATE_STYLE_ID = 'impeccable-discard-state'; + const HANDLED_WRAPPER_RELOAD_KEY = PREFIX + '-handled-wrapper-reload'; // Dedicated key for scroll position - SEPARATE from LS_KEY so that // saveSession's state updates don't clobber a carefully-captured scrollY. @@ -270,6 +278,7 @@ desc, rectIsUsableAnchor, makeFrozenAnchor, + hasFrameworkHmrOwnership, id8, cssId, liveUiRoot, @@ -2034,6 +2043,16 @@ syncSteerQueueHint(); } + function beginNewLiveConfiguration() { + liveInteractionRevision += 1; + setLiveState('CONFIGURING'); + } + + function deferredRecoverySuperseded(sessionId, recoveryRevision) { + return liveInteractionRevision !== recoveryRevision + || !!(currentSessionId && currentSessionId !== sessionId); + } + /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { @@ -5036,7 +5055,7 @@ && el.parentElement && document.body.contains(el) && !own(el) - && !el.closest?.('[data-impeccable-variants]'); + && !el.closest?.('[data-impeccable-variants],[data-impeccable-carbonize]'); } function elementMatchesOriginalMarkup(liveEl, origContent) { @@ -6608,19 +6627,78 @@ document.getElementById(VARIANT_STATE_STYLE_ID)?.remove(); } + function discardStateStyleId(sessionId) { + return DISCARD_STATE_STYLE_ID + '-' + sessionId; + } + function showOriginalDuringDiscard(sessionId) { if (!sessionId) return; - let styleEl = document.getElementById(DISCARD_STATE_STYLE_ID); + let styleEl = document.getElementById(discardStateStyleId(sessionId)); if (!styleEl) { styleEl = document.createElement('style'); - styleEl.id = DISCARD_STATE_STYLE_ID; + styleEl.id = discardStateStyleId(sessionId); (document.head || document.documentElement).appendChild(styleEl); } + styleEl.dataset.impeccableDiscardSession = sessionId; const wrapper = '[data-impeccable-variants="' + sessionId + '"]'; styleEl.textContent = wrapper + ' > [data-impeccable-variant]:not([data-impeccable-variant="original"]) { display:none !important; }\n' + wrapper + ' > [data-impeccable-variant="original"] { display:block !important; }'; } + function removeDiscardStateStylesheet(sessionId) { + if (!sessionId) return; + document.getElementById(discardStateStyleId(sessionId))?.remove(); + } + + function releaseDiscardedStaticWrapper(wrapper, sessionId) { + removeDiscardStateStylesheet(sessionId); + if (!wrapper) return; + const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); + const content = orig?.firstElementChild; + if (content && wrapper.parentElement) { + wrapper.parentElement.replaceChild(content, wrapper); + return; + } + wrapper.remove(); + } + + function watchForDiscardedFrameworkWrapperRemoval(sessionId) { + if (!sessionId || !document.body) return; + if (discardedFrameworkWrapperWatchers.has(sessionId)) return; + const selector = '[data-impeccable-variants="' + sessionId + '"]'; + let observer = null; + let timer = null; + const stopWatching = function() { + observer?.disconnect(); + if (timer) clearTimeout(timer); + discardedFrameworkWrapperWatchers.delete(sessionId); + }; + const finishIfGone = function() { + if (document.querySelector(selector)) return false; + removeDiscardStateStylesheet(sessionId); + stopWatching(); + return true; + }; + if (finishIfGone()) return; + observer = new MutationObserver(finishIfGone); + observer.observe(document.body, { childList: true, subtree: true }); + const resolveStillMounted = function() { + if (finishIfGone()) return; + const replacementActive = !!currentSessionId + || (state !== 'IDLE' && state !== 'PICKING'); + if (replacementActive) { + timer = setTimeout(resolveStillMounted, 12000); + discardedFrameworkWrapperWatchers.get(sessionId).timer = timer; + return; + } + removeDiscardStateStylesheet(sessionId); + stopWatching(); + location.reload(); + }; + timer = setTimeout(resolveStillMounted, 12000); + discardedFrameworkWrapperWatchers.set(sessionId, { observer, timer }); + } + function resolveScrollLockAnchorTop() { const anchor = resolveBarAnchor(); if (!anchor?.isConnected) return null; @@ -7335,7 +7413,7 @@ hideInsertLine(); configureKind = 'insert'; selectedElement = placeholder; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); hideHighlight(); clearAnnotations(); showAnnotOverlay(placeholder); @@ -7353,7 +7431,7 @@ e.preventDefault(); e.stopPropagation(); selectedElement = hoveredElement; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -7530,7 +7608,7 @@ } else if (e.key === 'Enter') { e.preventDefault(); selectedElement = hoveredElement; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -8535,6 +8613,7 @@ void main() { } function scheduleAcceptCleanup(accepted) { + const recoveryRevision = liveInteractionRevision; queueMicrotask(function() { if (pendingAcceptedSession?.id !== accepted?.id) return; // Svelte previews live in an adapter-owned mount rather than in source @@ -8551,8 +8630,10 @@ void main() { // races. Static servers still need a fallback, but it must not keep Live // in SAVING or block the user's next pick. if (!accepted?.isSvelteComponent) { + watchForHandledRuntimeWrapper(accepted?.id, recoveryRevision); setTimeout(function() { - if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted); + if (deferredRecoverySuperseded(accepted?.id, recoveryRevision)) return; + if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted, recoveryRevision); }, 1200); } } @@ -8585,13 +8666,27 @@ void main() { && matches.every((el) => !el.closest('[data-impeccable-variants],[data-impeccable-variant],[data-impeccable-carbonize]')); } - function ensureAcceptedDomClean(pending) { + function ensureAcceptedDomClean(pending, recoveryRevision) { + // Background cleanup for an accepted session must never mutate or reload + // a newer comparison the user has already started. + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; if (acceptedDomAlreadyClean(pending)) return; const sessionId = pending?.id; const variantId = pending?.variant; const wrappers = findAcceptedRuntimeWrappers(sessionId); + if (hasFrameworkHmrOwnership(wrappers[0] || pending?.parentElement)) { + // Vite can coalesce rapid scaffold/carbonize writes and leave the last + // framework-owned preview tree mounted even though source is clean. Give + // HMR another grace window, then reload from clean source rather than + // violating reconciler ownership with a manual DOM mutation. + setTimeout(function() { + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; + if (!acceptedDomAlreadyClean(pending)) location.reload(); + }, 2000); + return; + } if (wrappers.length === 0) { - restoreAcceptedDomFromSnapshot(pending); + restoreAcceptedDomFromSnapshot(pending, recoveryRevision); return; } for (const wrapper of wrappers) { @@ -8608,7 +8703,7 @@ void main() { } wrapper.remove(); } - if (!acceptedDomAlreadyClean(pending)) restoreAcceptedDomFromSnapshot(pending); + if (!acceptedDomAlreadyClean(pending)) restoreAcceptedDomFromSnapshot(pending, recoveryRevision); } function findAcceptedRuntimeWrappers(sessionId) { @@ -8619,17 +8714,17 @@ void main() { ])]; } - function restoreAcceptedDomFromSnapshot(pending) { + function restoreAcceptedDomFromSnapshot(pending, recoveryRevision) { if (acceptedDomAlreadyClean(pending)) return; if (!pending?.acceptedHtml) { - reloadAfterMissingAcceptedDom(pending); + reloadAfterMissingAcceptedDom(pending, recoveryRevision); return; } const parent = pending.parentElement?.isConnected ? pending.parentElement : (pending.parentSelector ? document.querySelector(pending.parentSelector) : null); if (!parent) { - reloadAfterMissingAcceptedDom(pending); + reloadAfterMissingAcceptedDom(pending, recoveryRevision); return; } const template = document.createElement('template'); @@ -8638,10 +8733,11 @@ void main() { ? pending.nextSibling : null; parent.insertBefore(template.content, anchor); - if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending); + if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending, recoveryRevision); } - function reloadAfterMissingAcceptedDom(pending) { + function reloadAfterMissingAcceptedDom(pending, recoveryRevision) { + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; if (acceptedDomAlreadyClean(pending)) return; if (pending?.id && document.querySelector('[data-impeccable-variants="' + pending.id + '"]')) return; location.reload(); @@ -8991,14 +9087,15 @@ void main() { return sessionState.isHandled(id); } - function clearHandled() { - sessionState.clearHandled(); + function clearHandled(sessionId) { + sessionState.clearHandled(sessionId); } function cleanup(options) { const restoreOriginal = options?.restoreOriginal === true; const instantChrome = options?.instantChrome === true; const cleanupSessionId = currentSessionId; + const cleanupRevision = liveInteractionRevision; clearMountErrorCard(); lastReportedMountFailure = null; if (svelteComponentSession?.sessionId === cleanupSessionId) { @@ -9016,19 +9113,41 @@ void main() { else wrapper.style.display = 'none'; } setTimeout(function() { - document.getElementById(DISCARD_STATE_STYLE_ID)?.remove(); - if (!cleanupSessionId) return; - const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!lateWrapper) return; - const orig = lateWrapper.querySelector('[data-impeccable-variant="original"]'); - if (orig) { - const content = orig.firstElementChild; - if (content) { - lateWrapper.parentElement.replaceChild(content, lateWrapper); - return; - } + const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); + if (!cleanupSessionId) { + removeDiscardStateStylesheet(); + return; } - lateWrapper.remove(); + const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (!lateWrapper) { + removeDiscardStateStylesheet(cleanupSessionId); + return; + } + if (recoverySuperseded) { + if (hasFrameworkHmrOwnership(lateWrapper)) { + watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); + } else { + releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + } + return; + } + if (hasFrameworkHmrOwnership(lateWrapper)) { + // As on accept, never restructure framework-owned DOM. If HMR missed + // the final source rewrite, reload once after a grace window so the + // discarded source becomes authoritative without a reconciler race. + setTimeout(function() { + const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { + if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId); + else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); + return; + } + removeDiscardStateStylesheet(cleanupSessionId); + if (staleWrapper) location.reload(); + }, 2000); + return; + } + releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); }, 2000); } hideBar(instantChrome); @@ -9111,12 +9230,122 @@ void main() { // Resume an active variant session after HMR/page reload. // If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote // variants before HMR fired. Pick up where we left off. - function resumeSession() { + function handledWrapperReloadKey(sessionId) { + return HANDLED_WRAPPER_RELOAD_KEY + ':' + sessionId; + } + + function clearHandledWrapperReloadStamp(sessionId) { + try { + if (sessionId) { + sessionStorage.removeItem(handledWrapperReloadKey(sessionId)); + const legacy = sessionStorage.getItem(HANDLED_WRAPPER_RELOAD_KEY) || ''; + if (legacy === sessionId || legacy.startsWith(sessionId + ':')) { + sessionStorage.removeItem(HANDLED_WRAPPER_RELOAD_KEY); + } + return; + } + sessionStorage.removeItem(HANDLED_WRAPPER_RELOAD_KEY); + for (let i = sessionStorage.length - 1; i >= 0; i--) { + const key = sessionStorage.key(i); + if (key?.startsWith(HANDLED_WRAPPER_RELOAD_KEY + ':')) sessionStorage.removeItem(key); + } + } catch {} + } + + function scheduleHandledRuntimeWrapperReload(wrapper, recoveryRevision = liveInteractionRevision) { + const sessionId = wrapper?.dataset?.impeccableVariants + || wrapper?.dataset?.impeccableCarbonize; + if (!sessionId || !isSessionHandled(sessionId)) return false; + if (deferredRecoverySuperseded(sessionId, recoveryRevision)) return true; + + if (handledRuntimeWrapperReloadSessions.has(sessionId)) return true; + let reloadAttempts = 0; + try { + reloadAttempts = Number(sessionStorage.getItem(handledWrapperReloadKey(sessionId))) || 0; + if (reloadAttempts >= 2) return true; + sessionStorage.setItem(handledWrapperReloadKey(sessionId), String(reloadAttempts + 1)); + } catch {} + handledRuntimeWrapperReloadSessions.add(sessionId); + + // A framework refresh can replace the variants tree with an intermediate + // carbonize tree and reload the page, cancelling the original accept timer. + // Let the file-side cleanup settle, then reload once from authoritative + // source. The sessionStorage stamp prevents a stale dev-server response + // from turning this recovery into a reload loop. + setTimeout(function() { + if (deferredRecoverySuperseded(sessionId, recoveryRevision)) { + clearHandledWrapperReloadStamp(sessionId); + handledRuntimeWrapperReloadSessions.delete(sessionId); + return; + } + const staleWrapper = document.querySelector( + '[data-impeccable-variants="' + sessionId + '"],' + + '[data-impeccable-carbonize="' + sessionId + '"]', + ); + if (staleWrapper) location.reload(); + else { + clearHandledWrapperReloadStamp(sessionId); + handledRuntimeWrapperReloadSessions.delete(sessionId); + } + }, 3000); + return true; + } + + function watchForHandledRuntimeWrapper(sessionId, recoveryRevision = liveInteractionRevision) { + if (!sessionId || !document.body) return; + const existing = handledRuntimeWrapperWatchers.get(sessionId); + existing?.observer.disconnect(); + if (existing?.timer) clearTimeout(existing.timer); + + const findHandledWrapper = function() { + const wrapper = document.querySelector( + '[data-impeccable-variants="' + sessionId + '"],' + + '[data-impeccable-carbonize="' + sessionId + '"]', + ); + if (wrapper) scheduleHandledRuntimeWrapperReload(wrapper, recoveryRevision); + }; + + // Vite can briefly render the clean accepted tree, then apply a delayed + // carbonize refresh after the one-shot accept fallback has already passed. + // Keep a bounded scout alive through that refresh window so a late stale + // framework tree still reloads from the now-authoritative source. + const observer = new MutationObserver(findHandledWrapper); + observer.observe(document.body, { childList: true, subtree: true }); + const timer = setTimeout(function() { + if (handledRuntimeWrapperWatchers.get(sessionId)?.observer !== observer) return; + observer.disconnect(); + handledRuntimeWrapperWatchers.delete(sessionId); + }, 12000); + handledRuntimeWrapperWatchers.set(sessionId, { observer, timer }); + findHandledWrapper(); + } + + function restoreSessionSupersedingHandledWrapper(runtimeWrapper) { + const handledSessionId = runtimeWrapper?.dataset?.impeccableVariants + || runtimeWrapper?.dataset?.impeccableCarbonize; + if (!handledSessionId || !isSessionHandled(handledSessionId)) return false; + + // Accept releases the picker before carbonize finishes, so a replacement + // generation can already be durable while the prior handled wrapper is + // still mounted. Restore that newer session before the stale-wrapper + // recovery path gets a chance to reload or consume its retry budget. + const saved = loadSession(); + if (!saved?.id || saved.id === handledSessionId || isSessionHandled(saved.id)) return false; + if (currentSessionId === saved.id) return true; + return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); + } + + function resumeSession(recoveryRevision = liveInteractionRevision) { const wrapper = document.querySelector('[data-impeccable-variants]'); + const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); + if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; + if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; if (!wrapper) { if (restoreSessionWithoutWrapper('browser_resumed_without_wrapper')) return true; clearSession(); - clearHandled(); + // Keep the bounded handled-id history durable. A framework can hydrate a + // completed wrapper well after initialization, and a later reload must + // still recognize that wrapper as recovery work rather than resume it. return false; } @@ -9136,7 +9365,7 @@ void main() { wrapper.remove(); if (restoreSessionWithoutWrapper('browser_resumed_svelte_orphan_wrapper')) return true; clearSession(); - clearHandled(); + clearHandled(sessionId); return false; } @@ -12520,22 +12749,28 @@ void main() { connectSSE(); // Check for an active session to resume (variant wrapper already in DOM after HMR) - if (!resumeSession()) { + const resumed = resumeSession(); + if (!resumed) { console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.'); - // SvelteKit (and any framework that hydrates after HTML parse) may add - // the variant wrapper AFTER init runs. Watch for it and retry resume - // once it appears. Disconnect on first hit. + } else { + console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); + } + + // SvelteKit, React, and other frameworks may restore a durable session + // before hydration adds its variant wrapper. Keep a deferred-wrapper scout + // whenever init did not see a runtime wrapper, even if local/server state + // was already restored successfully. Disconnect on the first wrapper hit. + if (!resumed || !document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]')) { + const deferredResumeRevision = liveInteractionRevision; const scout = new MutationObserver(() => { - const wrapper = document.querySelector('[data-impeccable-variants]'); + const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); if (!wrapper) return; scout.disconnect(); - if (resumeSession()) { + if (resumeSession(deferredResumeRevision)) { console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); } }); scout.observe(document.body, { childList: true, subtree: true }); - } else { - console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); } if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING'); diff --git a/plugin/skills/impeccable/scripts/live-browser-dom.js b/plugin/skills/impeccable/scripts/live-browser-dom.js index ad6a794b3..e85c95c43 100644 --- a/plugin/skills/impeccable/scripts/live-browser-dom.js +++ b/plugin/skills/impeccable/scripts/live-browser-dom.js @@ -64,6 +64,26 @@ }; } + function hasFrameworkHmrOwnership(el) { + for (let node = el; node; node = node.parentElement) { + let keys = []; + try { keys = Object.getOwnPropertyNames(node); } catch {} + if (keys.some((key) => ( + key.startsWith('__reactFiber$') + || key.startsWith('__reactProps$') + || key.startsWith('__reactContainer$') + || key === '_reactRootContainer' + || key === '__vueParentComponent' + || key === '__vue_app__' + || key === '__vnode' + || key === '__svelte_meta' + ))) { + return true; + } + } + return false; + } + function id8() { if (crypto?.randomUUID) return crypto.randomUUID().replace(/-/g, '').slice(0, 8); return (Math.random().toString(16).slice(2) + Date.now().toString(16)).slice(0, 8); @@ -128,6 +148,7 @@ desc, rectIsUsableAnchor, makeFrozenAnchor, + hasFrameworkHmrOwnership, id8, cssId, liveUiRoot, diff --git a/plugin/skills/impeccable/scripts/live-browser-session.js b/plugin/skills/impeccable/scripts/live-browser-session.js index 0e362d6be..1514bf472 100644 --- a/plugin/skills/impeccable/scripts/live-browser-session.js +++ b/plugin/skills/impeccable/scripts/live-browser-session.js @@ -71,17 +71,38 @@ return checkpointRevision; } + function readHandledIds() { + const raw = safeRead(handledKey); + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed.filter(id => typeof id === 'string' && id); + } + if (typeof parsed === 'string' && parsed) return [parsed]; + } catch { /* legacy values were stored as a plain session id */ } + return [raw]; + } + function markHandled(id) { if (!id) return; - safeWrite(handledKey, id); + const ids = readHandledIds().filter(existing => existing !== id); + ids.push(id); + safeWrite(handledKey, JSON.stringify(ids.slice(-8))); } function isHandled(id) { - return !!id && safeRead(handledKey) === id; + return !!id && readHandledIds().includes(id); } - function clearHandled() { - safeRemove(handledKey); + function clearHandled(id) { + if (!id) { + safeRemove(handledKey); + return; + } + const remaining = readHandledIds().filter(existing => existing !== id); + if (remaining.length > 0) safeWrite(handledKey, JSON.stringify(remaining)); + else safeRemove(handledKey); } function writeScrollY(y) { diff --git a/plugin/skills/impeccable/scripts/live-browser.js b/plugin/skills/impeccable/scripts/live-browser.js index 2b38ec62e..1f373a894 100644 --- a/plugin/skills/impeccable/scripts/live-browser.js +++ b/plugin/skills/impeccable/scripts/live-browser.js @@ -121,6 +121,10 @@ let hoveredElement = null; let selectedElement = null; let currentSessionId = null; + // Advances when the user begins configuring a fresh edit, before that edit + // has a server session id. Deferred recovery captures this revision so an + // older accept/discard can never reload over a replacement configuration. + let liveInteractionRevision = 0; let expectedVariants = 0; let arrivedVariants = 0; let visibleVariant = 0; @@ -188,6 +192,9 @@ // when the real accept result arrives or a new session starts. let awaitingAcceptResult = null; let variantObserver = null; + const discardedFrameworkWrapperWatchers = new Map(); + const handledRuntimeWrapperWatchers = new Map(); + const handledRuntimeWrapperReloadSessions = new Set(); let variantSelectionInFlight = false; let variantSelectionPromise = null; let recoveringEmptyCycling = false; @@ -208,6 +215,7 @@ const SCROLL_ANCHOR_LOCK_ID = 'impeccable-scroll-anchor-lock'; const VARIANT_STATE_STYLE_ID = 'impeccable-variant-state'; const DISCARD_STATE_STYLE_ID = 'impeccable-discard-state'; + const HANDLED_WRAPPER_RELOAD_KEY = PREFIX + '-handled-wrapper-reload'; // Dedicated key for scroll position - SEPARATE from LS_KEY so that // saveSession's state updates don't clobber a carefully-captured scrollY. @@ -270,6 +278,7 @@ desc, rectIsUsableAnchor, makeFrozenAnchor, + hasFrameworkHmrOwnership, id8, cssId, liveUiRoot, @@ -2034,6 +2043,16 @@ syncSteerQueueHint(); } + function beginNewLiveConfiguration() { + liveInteractionRevision += 1; + setLiveState('CONFIGURING'); + } + + function deferredRecoverySuperseded(sessionId, recoveryRevision) { + return liveInteractionRevision !== recoveryRevision + || !!(currentSessionId && currentSessionId !== sessionId); + } + /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { @@ -5036,7 +5055,7 @@ && el.parentElement && document.body.contains(el) && !own(el) - && !el.closest?.('[data-impeccable-variants]'); + && !el.closest?.('[data-impeccable-variants],[data-impeccable-carbonize]'); } function elementMatchesOriginalMarkup(liveEl, origContent) { @@ -6608,19 +6627,78 @@ document.getElementById(VARIANT_STATE_STYLE_ID)?.remove(); } + function discardStateStyleId(sessionId) { + return DISCARD_STATE_STYLE_ID + '-' + sessionId; + } + function showOriginalDuringDiscard(sessionId) { if (!sessionId) return; - let styleEl = document.getElementById(DISCARD_STATE_STYLE_ID); + let styleEl = document.getElementById(discardStateStyleId(sessionId)); if (!styleEl) { styleEl = document.createElement('style'); - styleEl.id = DISCARD_STATE_STYLE_ID; + styleEl.id = discardStateStyleId(sessionId); (document.head || document.documentElement).appendChild(styleEl); } + styleEl.dataset.impeccableDiscardSession = sessionId; const wrapper = '[data-impeccable-variants="' + sessionId + '"]'; styleEl.textContent = wrapper + ' > [data-impeccable-variant]:not([data-impeccable-variant="original"]) { display:none !important; }\n' + wrapper + ' > [data-impeccable-variant="original"] { display:block !important; }'; } + function removeDiscardStateStylesheet(sessionId) { + if (!sessionId) return; + document.getElementById(discardStateStyleId(sessionId))?.remove(); + } + + function releaseDiscardedStaticWrapper(wrapper, sessionId) { + removeDiscardStateStylesheet(sessionId); + if (!wrapper) return; + const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); + const content = orig?.firstElementChild; + if (content && wrapper.parentElement) { + wrapper.parentElement.replaceChild(content, wrapper); + return; + } + wrapper.remove(); + } + + function watchForDiscardedFrameworkWrapperRemoval(sessionId) { + if (!sessionId || !document.body) return; + if (discardedFrameworkWrapperWatchers.has(sessionId)) return; + const selector = '[data-impeccable-variants="' + sessionId + '"]'; + let observer = null; + let timer = null; + const stopWatching = function() { + observer?.disconnect(); + if (timer) clearTimeout(timer); + discardedFrameworkWrapperWatchers.delete(sessionId); + }; + const finishIfGone = function() { + if (document.querySelector(selector)) return false; + removeDiscardStateStylesheet(sessionId); + stopWatching(); + return true; + }; + if (finishIfGone()) return; + observer = new MutationObserver(finishIfGone); + observer.observe(document.body, { childList: true, subtree: true }); + const resolveStillMounted = function() { + if (finishIfGone()) return; + const replacementActive = !!currentSessionId + || (state !== 'IDLE' && state !== 'PICKING'); + if (replacementActive) { + timer = setTimeout(resolveStillMounted, 12000); + discardedFrameworkWrapperWatchers.get(sessionId).timer = timer; + return; + } + removeDiscardStateStylesheet(sessionId); + stopWatching(); + location.reload(); + }; + timer = setTimeout(resolveStillMounted, 12000); + discardedFrameworkWrapperWatchers.set(sessionId, { observer, timer }); + } + function resolveScrollLockAnchorTop() { const anchor = resolveBarAnchor(); if (!anchor?.isConnected) return null; @@ -7335,7 +7413,7 @@ hideInsertLine(); configureKind = 'insert'; selectedElement = placeholder; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); hideHighlight(); clearAnnotations(); showAnnotOverlay(placeholder); @@ -7353,7 +7431,7 @@ e.preventDefault(); e.stopPropagation(); selectedElement = hoveredElement; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -7530,7 +7608,7 @@ } else if (e.key === 'Enter') { e.preventDefault(); selectedElement = hoveredElement; - setLiveState('CONFIGURING'); + beginNewLiveConfiguration(); showHighlight(selectedElement); clearAnnotations(); showAnnotOverlay(selectedElement); @@ -8535,6 +8613,7 @@ void main() { } function scheduleAcceptCleanup(accepted) { + const recoveryRevision = liveInteractionRevision; queueMicrotask(function() { if (pendingAcceptedSession?.id !== accepted?.id) return; // Svelte previews live in an adapter-owned mount rather than in source @@ -8551,8 +8630,10 @@ void main() { // races. Static servers still need a fallback, but it must not keep Live // in SAVING or block the user's next pick. if (!accepted?.isSvelteComponent) { + watchForHandledRuntimeWrapper(accepted?.id, recoveryRevision); setTimeout(function() { - if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted); + if (deferredRecoverySuperseded(accepted?.id, recoveryRevision)) return; + if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted, recoveryRevision); }, 1200); } } @@ -8585,13 +8666,27 @@ void main() { && matches.every((el) => !el.closest('[data-impeccable-variants],[data-impeccable-variant],[data-impeccable-carbonize]')); } - function ensureAcceptedDomClean(pending) { + function ensureAcceptedDomClean(pending, recoveryRevision) { + // Background cleanup for an accepted session must never mutate or reload + // a newer comparison the user has already started. + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; if (acceptedDomAlreadyClean(pending)) return; const sessionId = pending?.id; const variantId = pending?.variant; const wrappers = findAcceptedRuntimeWrappers(sessionId); + if (hasFrameworkHmrOwnership(wrappers[0] || pending?.parentElement)) { + // Vite can coalesce rapid scaffold/carbonize writes and leave the last + // framework-owned preview tree mounted even though source is clean. Give + // HMR another grace window, then reload from clean source rather than + // violating reconciler ownership with a manual DOM mutation. + setTimeout(function() { + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; + if (!acceptedDomAlreadyClean(pending)) location.reload(); + }, 2000); + return; + } if (wrappers.length === 0) { - restoreAcceptedDomFromSnapshot(pending); + restoreAcceptedDomFromSnapshot(pending, recoveryRevision); return; } for (const wrapper of wrappers) { @@ -8608,7 +8703,7 @@ void main() { } wrapper.remove(); } - if (!acceptedDomAlreadyClean(pending)) restoreAcceptedDomFromSnapshot(pending); + if (!acceptedDomAlreadyClean(pending)) restoreAcceptedDomFromSnapshot(pending, recoveryRevision); } function findAcceptedRuntimeWrappers(sessionId) { @@ -8619,17 +8714,17 @@ void main() { ])]; } - function restoreAcceptedDomFromSnapshot(pending) { + function restoreAcceptedDomFromSnapshot(pending, recoveryRevision) { if (acceptedDomAlreadyClean(pending)) return; if (!pending?.acceptedHtml) { - reloadAfterMissingAcceptedDom(pending); + reloadAfterMissingAcceptedDom(pending, recoveryRevision); return; } const parent = pending.parentElement?.isConnected ? pending.parentElement : (pending.parentSelector ? document.querySelector(pending.parentSelector) : null); if (!parent) { - reloadAfterMissingAcceptedDom(pending); + reloadAfterMissingAcceptedDom(pending, recoveryRevision); return; } const template = document.createElement('template'); @@ -8638,10 +8733,11 @@ void main() { ? pending.nextSibling : null; parent.insertBefore(template.content, anchor); - if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending); + if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending, recoveryRevision); } - function reloadAfterMissingAcceptedDom(pending) { + function reloadAfterMissingAcceptedDom(pending, recoveryRevision) { + if (deferredRecoverySuperseded(pending?.id, recoveryRevision)) return; if (acceptedDomAlreadyClean(pending)) return; if (pending?.id && document.querySelector('[data-impeccable-variants="' + pending.id + '"]')) return; location.reload(); @@ -8991,14 +9087,15 @@ void main() { return sessionState.isHandled(id); } - function clearHandled() { - sessionState.clearHandled(); + function clearHandled(sessionId) { + sessionState.clearHandled(sessionId); } function cleanup(options) { const restoreOriginal = options?.restoreOriginal === true; const instantChrome = options?.instantChrome === true; const cleanupSessionId = currentSessionId; + const cleanupRevision = liveInteractionRevision; clearMountErrorCard(); lastReportedMountFailure = null; if (svelteComponentSession?.sessionId === cleanupSessionId) { @@ -9016,19 +9113,41 @@ void main() { else wrapper.style.display = 'none'; } setTimeout(function() { - document.getElementById(DISCARD_STATE_STYLE_ID)?.remove(); - if (!cleanupSessionId) return; - const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!lateWrapper) return; - const orig = lateWrapper.querySelector('[data-impeccable-variant="original"]'); - if (orig) { - const content = orig.firstElementChild; - if (content) { - lateWrapper.parentElement.replaceChild(content, lateWrapper); - return; - } + const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); + if (!cleanupSessionId) { + removeDiscardStateStylesheet(); + return; } - lateWrapper.remove(); + const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (!lateWrapper) { + removeDiscardStateStylesheet(cleanupSessionId); + return; + } + if (recoverySuperseded) { + if (hasFrameworkHmrOwnership(lateWrapper)) { + watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); + } else { + releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + } + return; + } + if (hasFrameworkHmrOwnership(lateWrapper)) { + // As on accept, never restructure framework-owned DOM. If HMR missed + // the final source rewrite, reload once after a grace window so the + // discarded source becomes authoritative without a reconciler race. + setTimeout(function() { + const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { + if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId); + else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); + return; + } + removeDiscardStateStylesheet(cleanupSessionId); + if (staleWrapper) location.reload(); + }, 2000); + return; + } + releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); }, 2000); } hideBar(instantChrome); @@ -9111,12 +9230,122 @@ void main() { // Resume an active variant session after HMR/page reload. // If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote // variants before HMR fired. Pick up where we left off. - function resumeSession() { + function handledWrapperReloadKey(sessionId) { + return HANDLED_WRAPPER_RELOAD_KEY + ':' + sessionId; + } + + function clearHandledWrapperReloadStamp(sessionId) { + try { + if (sessionId) { + sessionStorage.removeItem(handledWrapperReloadKey(sessionId)); + const legacy = sessionStorage.getItem(HANDLED_WRAPPER_RELOAD_KEY) || ''; + if (legacy === sessionId || legacy.startsWith(sessionId + ':')) { + sessionStorage.removeItem(HANDLED_WRAPPER_RELOAD_KEY); + } + return; + } + sessionStorage.removeItem(HANDLED_WRAPPER_RELOAD_KEY); + for (let i = sessionStorage.length - 1; i >= 0; i--) { + const key = sessionStorage.key(i); + if (key?.startsWith(HANDLED_WRAPPER_RELOAD_KEY + ':')) sessionStorage.removeItem(key); + } + } catch {} + } + + function scheduleHandledRuntimeWrapperReload(wrapper, recoveryRevision = liveInteractionRevision) { + const sessionId = wrapper?.dataset?.impeccableVariants + || wrapper?.dataset?.impeccableCarbonize; + if (!sessionId || !isSessionHandled(sessionId)) return false; + if (deferredRecoverySuperseded(sessionId, recoveryRevision)) return true; + + if (handledRuntimeWrapperReloadSessions.has(sessionId)) return true; + let reloadAttempts = 0; + try { + reloadAttempts = Number(sessionStorage.getItem(handledWrapperReloadKey(sessionId))) || 0; + if (reloadAttempts >= 2) return true; + sessionStorage.setItem(handledWrapperReloadKey(sessionId), String(reloadAttempts + 1)); + } catch {} + handledRuntimeWrapperReloadSessions.add(sessionId); + + // A framework refresh can replace the variants tree with an intermediate + // carbonize tree and reload the page, cancelling the original accept timer. + // Let the file-side cleanup settle, then reload once from authoritative + // source. The sessionStorage stamp prevents a stale dev-server response + // from turning this recovery into a reload loop. + setTimeout(function() { + if (deferredRecoverySuperseded(sessionId, recoveryRevision)) { + clearHandledWrapperReloadStamp(sessionId); + handledRuntimeWrapperReloadSessions.delete(sessionId); + return; + } + const staleWrapper = document.querySelector( + '[data-impeccable-variants="' + sessionId + '"],' + + '[data-impeccable-carbonize="' + sessionId + '"]', + ); + if (staleWrapper) location.reload(); + else { + clearHandledWrapperReloadStamp(sessionId); + handledRuntimeWrapperReloadSessions.delete(sessionId); + } + }, 3000); + return true; + } + + function watchForHandledRuntimeWrapper(sessionId, recoveryRevision = liveInteractionRevision) { + if (!sessionId || !document.body) return; + const existing = handledRuntimeWrapperWatchers.get(sessionId); + existing?.observer.disconnect(); + if (existing?.timer) clearTimeout(existing.timer); + + const findHandledWrapper = function() { + const wrapper = document.querySelector( + '[data-impeccable-variants="' + sessionId + '"],' + + '[data-impeccable-carbonize="' + sessionId + '"]', + ); + if (wrapper) scheduleHandledRuntimeWrapperReload(wrapper, recoveryRevision); + }; + + // Vite can briefly render the clean accepted tree, then apply a delayed + // carbonize refresh after the one-shot accept fallback has already passed. + // Keep a bounded scout alive through that refresh window so a late stale + // framework tree still reloads from the now-authoritative source. + const observer = new MutationObserver(findHandledWrapper); + observer.observe(document.body, { childList: true, subtree: true }); + const timer = setTimeout(function() { + if (handledRuntimeWrapperWatchers.get(sessionId)?.observer !== observer) return; + observer.disconnect(); + handledRuntimeWrapperWatchers.delete(sessionId); + }, 12000); + handledRuntimeWrapperWatchers.set(sessionId, { observer, timer }); + findHandledWrapper(); + } + + function restoreSessionSupersedingHandledWrapper(runtimeWrapper) { + const handledSessionId = runtimeWrapper?.dataset?.impeccableVariants + || runtimeWrapper?.dataset?.impeccableCarbonize; + if (!handledSessionId || !isSessionHandled(handledSessionId)) return false; + + // Accept releases the picker before carbonize finishes, so a replacement + // generation can already be durable while the prior handled wrapper is + // still mounted. Restore that newer session before the stale-wrapper + // recovery path gets a chance to reload or consume its retry budget. + const saved = loadSession(); + if (!saved?.id || saved.id === handledSessionId || isSessionHandled(saved.id)) return false; + if (currentSessionId === saved.id) return true; + return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); + } + + function resumeSession(recoveryRevision = liveInteractionRevision) { const wrapper = document.querySelector('[data-impeccable-variants]'); + const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); + if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; + if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; if (!wrapper) { if (restoreSessionWithoutWrapper('browser_resumed_without_wrapper')) return true; clearSession(); - clearHandled(); + // Keep the bounded handled-id history durable. A framework can hydrate a + // completed wrapper well after initialization, and a later reload must + // still recognize that wrapper as recovery work rather than resume it. return false; } @@ -9136,7 +9365,7 @@ void main() { wrapper.remove(); if (restoreSessionWithoutWrapper('browser_resumed_svelte_orphan_wrapper')) return true; clearSession(); - clearHandled(); + clearHandled(sessionId); return false; } @@ -12520,22 +12749,28 @@ void main() { connectSSE(); // Check for an active session to resume (variant wrapper already in DOM after HMR) - if (!resumeSession()) { + const resumed = resumeSession(); + if (!resumed) { console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.'); - // SvelteKit (and any framework that hydrates after HTML parse) may add - // the variant wrapper AFTER init runs. Watch for it and retry resume - // once it appears. Disconnect on first hit. + } else { + console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); + } + + // SvelteKit, React, and other frameworks may restore a durable session + // before hydration adds its variant wrapper. Keep a deferred-wrapper scout + // whenever init did not see a runtime wrapper, even if local/server state + // was already restored successfully. Disconnect on the first wrapper hit. + if (!resumed || !document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]')) { + const deferredResumeRevision = liveInteractionRevision; const scout = new MutationObserver(() => { - const wrapper = document.querySelector('[data-impeccable-variants]'); + const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); if (!wrapper) return; scout.disconnect(); - if (resumeSession()) { + if (resumeSession(deferredResumeRevision)) { console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); } }); scout.observe(document.body, { childList: true, subtree: true }); - } else { - console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); } if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING'); From 38e102f0b24cb60b4acab683dcdaf8b3598477f0 Mon Sep 17 00:00:00 2001 From: Abdul Wahab <32850166+abdulwahabone@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:01:46 +0500 Subject: [PATCH 12/31] Fix: never inject raw JSX in live-mode fallback (#454) (#694) * Fix: never inject raw JSX in live-mode fallback (#454) On React/JSX targets, missed HMR used to fetch source and DOMParser-inject it, painting {expressions} and comment markers as page text. Adopt a live wrapper that already has variants, otherwise leave HMR alone. AI assistance: Cursor Grok 4.6 implemented this change. Co-authored-by: Cursor * Fix: wait for unmounted JSX variants instead of tearing down (#454) A missing live wrapper on React is often a closed modal or other route, not a failed generation. Leave the observer armed so mount can still reach CYCLING. AI assistance: Cursor Grok 4.6 implemented this change. Co-authored-by: Cursor * Fix: recover empty JSX replace wraps after fallback retries (#454) Insert scaffolds still wait for HMR. A replace wrapper with no variants after retries is a failed generation and should leave GENERATING. AI assistance: Cursor Grok 4.6 implemented this change. Co-authored-by: Cursor * Fix: align live-reference setup assertions with current SKILL.src.md #689 shortened Setup step 2, but the live-reference tests still expected the old playbook sentence, which kept CI red on main and this branch. AI assistance: Cursor Grok 4.6 implemented this change. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- skill/scripts/live-browser.js | 253 +++++++++++++++-------------- tests/live-browser-source.test.mjs | 62 ++++--- 2 files changed, 171 insertions(+), 144 deletions(-) diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index 1f373a894..da026e255 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -6212,6 +6212,72 @@ showToast('The previous live session no longer matches the source file, so it was discarded. Pick an element to start fresh.', 6000); } + function isJsxSourceFile(filePath) { + return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); + } + + function completeSourceInjection(wrapper, sessionId, opts) { + recoveryWaitingForAnchor = false; + if (pendingVariantAnchorRetryObserver) { + pendingVariantAnchorRetryObserver.disconnect(); + pendingVariantAnchorRetryObserver = null; + } + + const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; + const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); + arrivedVariants = variants.length; + expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); + if (arrivedVariants <= 0) { + if (state === 'GENERATING') { + // Mid-generation the source legitimately holds a scaffold wrapper + // with no variants yet (the server-side preflight wraps before the + // agent writes). Tearing the session down here would destroy an + // in-flight generation; stay in GENERATING — the variant observer + // is armed and the server re-delivers a missed `done`. + if (!opts.generationCompleted) { + console.log('[impeccable] Source has scaffold but no variants yet; still generating.'); + return; + } + // Generation finished, yet the read shows only the scaffold: the + // source view is stale and no further event will fire. Re-read a + // few times before surfacing recovery — a single silent return + // here would strand the tab in GENERATING forever. + const attempt = opts.attempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + console.log('[impeccable] Generation is done but source shows no variants yet; retrying read (' + + (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').'); + setTimeout(() => { + if (state !== 'GENERATING' || currentSessionId !== sessionId) return; + if (arrivedVariants > 0) return; + injectVariantsFromSource(opts.filePath, sessionId, { ...opts, attempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + return; + } + } + recoverEmptyCycling('source-fallback-empty'); + return; + } + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants + ? previousVisibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + showVariantInDOM(sessionId, visibleVariant); + + selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; + + setLiveState('CYCLING'); + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + showOrUpdateCyclingBar(); + disableInlineEdit(); + refreshParamsPanel(); + positionBar(); + saveSession(); + completeParameterGenerationIfReady(); + console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); + } + /** * No-HMR fallback: fetch the raw source file from the live server, * parse it, extract the variant wrapper, and inject it into the live DOM. @@ -6229,14 +6295,53 @@ return; } rememberSessionFileMeta({ file: filePath }); + if (isJsxSourceFile(filePath)) { + const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { + completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); + return; + } + // #454: never fetch/parse JSX. Missing wrap waits for mount (closed + // modal / other route). Insert scaffolds stay for late HMR. A replace + // scaffold with no variants after retries is a failed generation. + if (opts.generationCompleted && sessionId === currentSessionId) { + const attempt = opts.attempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + setTimeout(() => { + if (state !== 'GENERATING' || currentSessionId !== sessionId) return; + injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + return; + } + if (!liveWrapper) { + showToast( + "Variants ready. If the picked element isn't visible, retrace the path that revealed it - they'll appear automatically.", + 15000, + ); + return; + } + if (liveWrapper.dataset.impeccableMode !== 'insert') { + recoverEmptyCycling('source-fallback-empty'); + } + return; + } + if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { + const attempt = opts._orphanAttempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + setTimeout(() => { + if (sessionId !== currentSessionId) return; + if (state !== 'GENERATING' && state !== 'CYCLING') return; + injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + } + } + return; + } const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); fetch(url) .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) .then(html => { const parser = new DOMParser(); - let srcWrapper = null; - - // Full-file parse works for HTML/JSX; Astro/Vue sources need marker extraction. const startMark = ''; const endMark = ''; const startIdx = html.indexOf(startMark); @@ -6244,8 +6349,8 @@ const block = startIdx !== -1 && endIdx !== -1 && endIdx > startIdx ? html.slice(startIdx + startMark.length, endIdx).trim() : html; - const doc = parser.parseFromString(normalizeSourceFallbackBlock(block, filePath), 'text/html'); - srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const doc = parser.parseFromString(block, 'text/html'); + const srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { console.warn('[impeccable] Variant wrapper not found in source file.'); // A resumed cycling session whose wrapper is gone from source is an @@ -6270,93 +6375,33 @@ return; } - const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; - const wrapper = srcWrapper.cloneNode(true); - - // Wrapper already in DOM (wrap HMR landed, variant insert did not). const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (existingWrapper) { + const wrapper = srcWrapper.cloneNode(true); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); - } else { - const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); - if (!origContent) return; - - const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); - if (!liveEl) { - console.warn('[impeccable] Could not find original element in live DOM.'); - enterRecoveryWaitingForAnchor({ - filePath, - sessionId, - srcWrapper, - checkpointReason: 'variant_anchor_missing', - trackScroll: false, - }); - return; - } - - liveEl.parentElement.replaceChild(wrapper, liveEl); - } - recoveryWaitingForAnchor = false; - if (pendingVariantAnchorRetryObserver) { - pendingVariantAnchorRetryObserver.disconnect(); - pendingVariantAnchorRetryObserver = null; - } - - // Update state: count variants, preserving the user's current variant - // when a late HMR/source reinjection lands after they have cycled. - const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); - arrivedVariants = variants.length; - expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); - if (arrivedVariants <= 0) { - if (state === 'GENERATING') { - // Mid-generation the source legitimately holds a scaffold wrapper - // with no variants yet (the server-side preflight wraps before the - // agent writes). Tearing the session down here would destroy an - // in-flight generation; stay in GENERATING — the variant observer - // is armed and the server re-delivers a missed `done`. - if (!opts.generationCompleted) { - console.log('[impeccable] Source has scaffold but no variants yet; still generating.'); - return; - } - // Generation finished, yet the read shows only the scaffold: the - // source view is stale and no further event will fire. Re-read a - // few times before surfacing recovery — a single silent return - // here would strand the tab in GENERATING forever. - const attempt = opts.attempt || 0; - if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { - console.log('[impeccable] Generation is done but source shows no variants yet; retrying read (' - + (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').'); - setTimeout(() => { - if (state !== 'GENERATING' || currentSessionId !== sessionId) return; - if (arrivedVariants > 0) return; - injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 }); - }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); - return; - } - } - recoverEmptyCycling('source-fallback-empty'); + completeSourceInjection(wrapper, sessionId, { ...opts, filePath }); return; } - const saved = loadSession(); - const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; - visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants - ? previousVisibleVariant - : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); - showVariantInDOM(sessionId, visibleVariant); - // Update selectedElement to the visible variant's content - selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; + const wrapper = srcWrapper.cloneNode(true); + const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); + if (!origContent) return; - setLiveState('CYCLING'); - recoveryWaitingForAnchor = false; - hideShaderOverlay(); - showOrUpdateCyclingBar(); - disableInlineEdit(); - refreshParamsPanel(); - positionBar(); - saveSession(); - completeParameterGenerationIfReady(); - console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); + const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); + if (!liveEl) { + console.warn('[impeccable] Could not find original element in live DOM.'); + enterRecoveryWaitingForAnchor({ + filePath, + sessionId, + srcWrapper, + checkpointReason: 'variant_anchor_missing', + trackScroll: false, + }); + return; + } + + liveEl.parentElement.replaceChild(wrapper, liveEl); + completeSourceInjection(wrapper, sessionId, { ...opts, filePath }); }) .catch(err => { console.error('[impeccable] Failed to fetch source:', err); @@ -6364,44 +6409,6 @@ }); } - function normalizeSourceFallbackBlock(block, filePath) { - if (!/\.[cm]?[jt]sx$/i.test(String(filePath || ''))) return block; - return String(block) - .replace( - /]*)>\s*\{\s*`([\s\S]*?)`\s*\}\s*<\/style>/g, - (_match, attrs, css) => '' + css + '', - ) - .replace(/\bclassName\s*=\s*\{\s*`([^`]*?)`\s*\}/g, (_match, value) => { - const literalClasses = value.replace(/\$\{[^}]*\}/g, ' ').replace(/\s+/g, ' ').trim(); - return literalClasses ? 'class="' + escapeHtml(literalClasses) + '"' : ''; - }) - .replace(/\bclassName\s*=/g, 'class=') - .replace(/\sstyle=\{\{([\s\S]*?)\}\}/g, (_match, body) => { - const css = jsxStyleObjectToCss(body); - return css ? ' style="' + escapeHtml(css) + '"' : ''; - }); - } - - function jsxStyleObjectToCss(body) { - const declarations = []; - const re = /(["'][^"']+["']|[A-Za-z_$][\w$-]*)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?\d+(?:\.\d+)?))/g; - let match; - while ((match = re.exec(String(body || '')))) { - const prop = jsxStylePropToCss(match[1]); - const value = match[2] ?? match[3] ?? match[4] ?? ''; - if (!prop || value === '') continue; - declarations.push(prop + ': ' + value); - } - return declarations.join('; '); - } - - function jsxStylePropToCss(prop) { - let out = String(prop || '').trim().replace(/^["']|["']$/g, ''); - if (!out) return ''; - if (out.startsWith('--')) return out; - return out.replace(/[A-Z]/g, (ch) => '-' + ch.toLowerCase()).replace(/^-ms-/, '-ms-'); - } - function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { const map = new Map(); if (!sourceOriginal || !liveOriginal) return map; diff --git a/tests/live-browser-source.test.mjs b/tests/live-browser-source.test.mjs index 235bd866d..e93dbff86 100644 --- a/tests/live-browser-source.test.mjs +++ b/tests/live-browser-source.test.mjs @@ -553,37 +553,57 @@ describe('live-browser source contracts', () => { ); }); - it('normalizes generated JSX source before source-fallback DOM parsing', () => { - assert.match( - SOURCE, - /parser\.parseFromString\(normalizeSourceFallbackBlock\(block, filePath\), 'text\/html'\)/, - 'source fallback should normalize JSX wrapper syntax before DOMParser sees it', + it('never DOMParser-injects JSX source (#454)', () => { + const isJsxStart = SOURCE.indexOf('function isJsxSourceFile('); + const isJsxEnd = SOURCE.indexOf('function completeSourceInjection', isJsxStart); + const isJsxSourceFile = new Function( + SOURCE.slice(isJsxStart, isJsxEnd) + '\nreturn isJsxSourceFile;', + )(); + + assert.equal(isJsxSourceFile('src/App.jsx'), true); + assert.equal(isJsxSourceFile('panel/src/Widget.tsx'), true); + assert.equal(isJsxSourceFile('index.html'), false); + assert.equal(isJsxSourceFile('Card.vue'), false); + + const injectStart = SOURCE.indexOf('function injectVariantsFromSource('); + const injectEnd = SOURCE.indexOf('function buildSvelteExpressionTextMap', injectStart); + const injectFn = SOURCE.slice(injectStart, injectEnd); + const jsxGateIdx = injectFn.indexOf('if (isJsxSourceFile(filePath))'); + const htmlFetchIdx = injectFn.indexOf("const url = 'http://localhost:'"); + assert.ok(jsxGateIdx !== -1 && htmlFetchIdx > jsxGateIdx, 'JSX must return before /source fetch'); + const jsxGate = injectFn.slice(jsxGateIdx, htmlFetchIdx); + assert.doesNotMatch( + jsxGate, + /replaceChild/, + 'the JSX gate must not replaceChild a React tree', + ); + assert.doesNotMatch( + jsxGate, + /discardOrphanedSession/, + 'a missing JSX wrap must wait for mount, not discard as an orphan', ); assert.match( - SOURCE, - /function normalizeSourceFallbackBlock\(block, filePath\)[\s\S]*?\]\*\)>\\s\*\\\{\\s\*`\(\[\\s\\S\]\*\?\)`\\s\*\\\}\\s\*<\\\/style>/, - 'source fallback should unwrap JSX style template literals', + jsxGate, + /if \(!liveWrapper\) \{[\s\S]*?showToast\([\s\S]*?return;[\s\S]*?if \(liveWrapper\.dataset\.impeccableMode !== 'insert'\) \{[\s\S]*?recoverEmptyCycling\('source-fallback-empty'\)/, + 'missing wrap waits; empty replace wrap recovers after retries; insert scaffolds stay', + ); + assert.doesNotMatch(SOURCE, /function normalizeSourceFallbackBlock/); + assert.doesNotMatch(SOURCE, /function jsxStyleObjectToCss/); + assert.match( + injectFn, + /parser\.parseFromString\(block, 'text\/html'\)/, + 'HTML fallback should parse the extracted marker block as HTML', ); assert.match( - SOURCE, - /replace\(\/\\bclassName\\s\*=\/g, 'class='\)/, - 'source fallback should translate className back to HTML class attributes', - ); - assert.match( - SOURCE, - /value\.replace\(\/\\\$\\\{\[\^}\]\*\\\}\/g, ' '\)/, - 'source fallback should reduce JSX template className values to literal class tokens', + injectFn, + /const startMark = ''/, + 'HTML fallback should still scan HTML comment markers', ); assert.doesNotMatch( SOURCE, /querySelectorAll\(tag \+ '\\.' \+ cls\.split/, 'source fallback should not construct unsafe selectors from JSX-ish class strings', ); - assert.match( - SOURCE, - /function jsxStyleObjectToCss\(body\)/, - 'source fallback should translate simple JSX style objects such as display:none', - ); }); it('does not source-inject per variant_progress checkpoint (HMR owns mid-generation reconciliation)', () => { From 6f6af815af7db71602d2fb4bf95f84e677d2414d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:02:32 +0000 Subject: [PATCH 13/31] Sync generated provider output --- .../skills/impeccable/scripts/live-browser.js | 253 +++++++++--------- .../skills/impeccable/scripts/live-browser.js | 253 +++++++++--------- .../skills/impeccable/scripts/live-browser.js | 253 +++++++++--------- .../skills/impeccable/scripts/live-browser.js | 253 +++++++++--------- .../skills/impeccable/scripts/live-browser.js | 253 +++++++++--------- .../skills/impeccable/scripts/live-browser.js | 253 +++++++++--------- .../skills/impeccable/scripts/live-browser.js | 253 +++++++++--------- .../skills/impeccable/scripts/live-browser.js | 253 +++++++++--------- .../skills/impeccable/scripts/live-browser.js | 253 +++++++++--------- .pi/skills/impeccable/scripts/live-browser.js | 253 +++++++++--------- .../skills/impeccable/scripts/live-browser.js | 253 +++++++++--------- .../skills/impeccable/scripts/live-browser.js | 253 +++++++++--------- .../skills/impeccable/scripts/live-browser.js | 253 +++++++++--------- .../skills/impeccable/scripts/live-browser.js | 253 +++++++++--------- .../skills/impeccable/scripts/live-browser.js | 253 +++++++++--------- .../skills/impeccable/scripts/live-browser.js | 253 +++++++++--------- 16 files changed, 2080 insertions(+), 1968 deletions(-) diff --git a/.agents/skills/impeccable/scripts/live-browser.js b/.agents/skills/impeccable/scripts/live-browser.js index 1f373a894..da026e255 100644 --- a/.agents/skills/impeccable/scripts/live-browser.js +++ b/.agents/skills/impeccable/scripts/live-browser.js @@ -6212,6 +6212,72 @@ showToast('The previous live session no longer matches the source file, so it was discarded. Pick an element to start fresh.', 6000); } + function isJsxSourceFile(filePath) { + return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); + } + + function completeSourceInjection(wrapper, sessionId, opts) { + recoveryWaitingForAnchor = false; + if (pendingVariantAnchorRetryObserver) { + pendingVariantAnchorRetryObserver.disconnect(); + pendingVariantAnchorRetryObserver = null; + } + + const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; + const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); + arrivedVariants = variants.length; + expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); + if (arrivedVariants <= 0) { + if (state === 'GENERATING') { + // Mid-generation the source legitimately holds a scaffold wrapper + // with no variants yet (the server-side preflight wraps before the + // agent writes). Tearing the session down here would destroy an + // in-flight generation; stay in GENERATING — the variant observer + // is armed and the server re-delivers a missed `done`. + if (!opts.generationCompleted) { + console.log('[impeccable] Source has scaffold but no variants yet; still generating.'); + return; + } + // Generation finished, yet the read shows only the scaffold: the + // source view is stale and no further event will fire. Re-read a + // few times before surfacing recovery — a single silent return + // here would strand the tab in GENERATING forever. + const attempt = opts.attempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + console.log('[impeccable] Generation is done but source shows no variants yet; retrying read (' + + (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').'); + setTimeout(() => { + if (state !== 'GENERATING' || currentSessionId !== sessionId) return; + if (arrivedVariants > 0) return; + injectVariantsFromSource(opts.filePath, sessionId, { ...opts, attempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + return; + } + } + recoverEmptyCycling('source-fallback-empty'); + return; + } + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants + ? previousVisibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + showVariantInDOM(sessionId, visibleVariant); + + selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; + + setLiveState('CYCLING'); + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + showOrUpdateCyclingBar(); + disableInlineEdit(); + refreshParamsPanel(); + positionBar(); + saveSession(); + completeParameterGenerationIfReady(); + console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); + } + /** * No-HMR fallback: fetch the raw source file from the live server, * parse it, extract the variant wrapper, and inject it into the live DOM. @@ -6229,14 +6295,53 @@ return; } rememberSessionFileMeta({ file: filePath }); + if (isJsxSourceFile(filePath)) { + const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { + completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); + return; + } + // #454: never fetch/parse JSX. Missing wrap waits for mount (closed + // modal / other route). Insert scaffolds stay for late HMR. A replace + // scaffold with no variants after retries is a failed generation. + if (opts.generationCompleted && sessionId === currentSessionId) { + const attempt = opts.attempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + setTimeout(() => { + if (state !== 'GENERATING' || currentSessionId !== sessionId) return; + injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + return; + } + if (!liveWrapper) { + showToast( + "Variants ready. If the picked element isn't visible, retrace the path that revealed it - they'll appear automatically.", + 15000, + ); + return; + } + if (liveWrapper.dataset.impeccableMode !== 'insert') { + recoverEmptyCycling('source-fallback-empty'); + } + return; + } + if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { + const attempt = opts._orphanAttempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + setTimeout(() => { + if (sessionId !== currentSessionId) return; + if (state !== 'GENERATING' && state !== 'CYCLING') return; + injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + } + } + return; + } const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); fetch(url) .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) .then(html => { const parser = new DOMParser(); - let srcWrapper = null; - - // Full-file parse works for HTML/JSX; Astro/Vue sources need marker extraction. const startMark = ''; const endMark = ''; const startIdx = html.indexOf(startMark); @@ -6244,8 +6349,8 @@ const block = startIdx !== -1 && endIdx !== -1 && endIdx > startIdx ? html.slice(startIdx + startMark.length, endIdx).trim() : html; - const doc = parser.parseFromString(normalizeSourceFallbackBlock(block, filePath), 'text/html'); - srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const doc = parser.parseFromString(block, 'text/html'); + const srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { console.warn('[impeccable] Variant wrapper not found in source file.'); // A resumed cycling session whose wrapper is gone from source is an @@ -6270,93 +6375,33 @@ return; } - const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; - const wrapper = srcWrapper.cloneNode(true); - - // Wrapper already in DOM (wrap HMR landed, variant insert did not). const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (existingWrapper) { + const wrapper = srcWrapper.cloneNode(true); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); - } else { - const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); - if (!origContent) return; - - const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); - if (!liveEl) { - console.warn('[impeccable] Could not find original element in live DOM.'); - enterRecoveryWaitingForAnchor({ - filePath, - sessionId, - srcWrapper, - checkpointReason: 'variant_anchor_missing', - trackScroll: false, - }); - return; - } - - liveEl.parentElement.replaceChild(wrapper, liveEl); - } - recoveryWaitingForAnchor = false; - if (pendingVariantAnchorRetryObserver) { - pendingVariantAnchorRetryObserver.disconnect(); - pendingVariantAnchorRetryObserver = null; - } - - // Update state: count variants, preserving the user's current variant - // when a late HMR/source reinjection lands after they have cycled. - const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); - arrivedVariants = variants.length; - expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); - if (arrivedVariants <= 0) { - if (state === 'GENERATING') { - // Mid-generation the source legitimately holds a scaffold wrapper - // with no variants yet (the server-side preflight wraps before the - // agent writes). Tearing the session down here would destroy an - // in-flight generation; stay in GENERATING — the variant observer - // is armed and the server re-delivers a missed `done`. - if (!opts.generationCompleted) { - console.log('[impeccable] Source has scaffold but no variants yet; still generating.'); - return; - } - // Generation finished, yet the read shows only the scaffold: the - // source view is stale and no further event will fire. Re-read a - // few times before surfacing recovery — a single silent return - // here would strand the tab in GENERATING forever. - const attempt = opts.attempt || 0; - if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { - console.log('[impeccable] Generation is done but source shows no variants yet; retrying read (' - + (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').'); - setTimeout(() => { - if (state !== 'GENERATING' || currentSessionId !== sessionId) return; - if (arrivedVariants > 0) return; - injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 }); - }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); - return; - } - } - recoverEmptyCycling('source-fallback-empty'); + completeSourceInjection(wrapper, sessionId, { ...opts, filePath }); return; } - const saved = loadSession(); - const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; - visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants - ? previousVisibleVariant - : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); - showVariantInDOM(sessionId, visibleVariant); - // Update selectedElement to the visible variant's content - selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; + const wrapper = srcWrapper.cloneNode(true); + const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); + if (!origContent) return; - setLiveState('CYCLING'); - recoveryWaitingForAnchor = false; - hideShaderOverlay(); - showOrUpdateCyclingBar(); - disableInlineEdit(); - refreshParamsPanel(); - positionBar(); - saveSession(); - completeParameterGenerationIfReady(); - console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); + const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); + if (!liveEl) { + console.warn('[impeccable] Could not find original element in live DOM.'); + enterRecoveryWaitingForAnchor({ + filePath, + sessionId, + srcWrapper, + checkpointReason: 'variant_anchor_missing', + trackScroll: false, + }); + return; + } + + liveEl.parentElement.replaceChild(wrapper, liveEl); + completeSourceInjection(wrapper, sessionId, { ...opts, filePath }); }) .catch(err => { console.error('[impeccable] Failed to fetch source:', err); @@ -6364,44 +6409,6 @@ }); } - function normalizeSourceFallbackBlock(block, filePath) { - if (!/\.[cm]?[jt]sx$/i.test(String(filePath || ''))) return block; - return String(block) - .replace( - /]*)>\s*\{\s*`([\s\S]*?)`\s*\}\s*<\/style>/g, - (_match, attrs, css) => '' + css + '', - ) - .replace(/\bclassName\s*=\s*\{\s*`([^`]*?)`\s*\}/g, (_match, value) => { - const literalClasses = value.replace(/\$\{[^}]*\}/g, ' ').replace(/\s+/g, ' ').trim(); - return literalClasses ? 'class="' + escapeHtml(literalClasses) + '"' : ''; - }) - .replace(/\bclassName\s*=/g, 'class=') - .replace(/\sstyle=\{\{([\s\S]*?)\}\}/g, (_match, body) => { - const css = jsxStyleObjectToCss(body); - return css ? ' style="' + escapeHtml(css) + '"' : ''; - }); - } - - function jsxStyleObjectToCss(body) { - const declarations = []; - const re = /(["'][^"']+["']|[A-Za-z_$][\w$-]*)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?\d+(?:\.\d+)?))/g; - let match; - while ((match = re.exec(String(body || '')))) { - const prop = jsxStylePropToCss(match[1]); - const value = match[2] ?? match[3] ?? match[4] ?? ''; - if (!prop || value === '') continue; - declarations.push(prop + ': ' + value); - } - return declarations.join('; '); - } - - function jsxStylePropToCss(prop) { - let out = String(prop || '').trim().replace(/^["']|["']$/g, ''); - if (!out) return ''; - if (out.startsWith('--')) return out; - return out.replace(/[A-Z]/g, (ch) => '-' + ch.toLowerCase()).replace(/^-ms-/, '-ms-'); - } - function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { const map = new Map(); if (!sourceOriginal || !liveOriginal) return map; diff --git a/.claude/skills/impeccable/scripts/live-browser.js b/.claude/skills/impeccable/scripts/live-browser.js index 1f373a894..da026e255 100644 --- a/.claude/skills/impeccable/scripts/live-browser.js +++ b/.claude/skills/impeccable/scripts/live-browser.js @@ -6212,6 +6212,72 @@ showToast('The previous live session no longer matches the source file, so it was discarded. Pick an element to start fresh.', 6000); } + function isJsxSourceFile(filePath) { + return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); + } + + function completeSourceInjection(wrapper, sessionId, opts) { + recoveryWaitingForAnchor = false; + if (pendingVariantAnchorRetryObserver) { + pendingVariantAnchorRetryObserver.disconnect(); + pendingVariantAnchorRetryObserver = null; + } + + const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; + const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); + arrivedVariants = variants.length; + expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); + if (arrivedVariants <= 0) { + if (state === 'GENERATING') { + // Mid-generation the source legitimately holds a scaffold wrapper + // with no variants yet (the server-side preflight wraps before the + // agent writes). Tearing the session down here would destroy an + // in-flight generation; stay in GENERATING — the variant observer + // is armed and the server re-delivers a missed `done`. + if (!opts.generationCompleted) { + console.log('[impeccable] Source has scaffold but no variants yet; still generating.'); + return; + } + // Generation finished, yet the read shows only the scaffold: the + // source view is stale and no further event will fire. Re-read a + // few times before surfacing recovery — a single silent return + // here would strand the tab in GENERATING forever. + const attempt = opts.attempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + console.log('[impeccable] Generation is done but source shows no variants yet; retrying read (' + + (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').'); + setTimeout(() => { + if (state !== 'GENERATING' || currentSessionId !== sessionId) return; + if (arrivedVariants > 0) return; + injectVariantsFromSource(opts.filePath, sessionId, { ...opts, attempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + return; + } + } + recoverEmptyCycling('source-fallback-empty'); + return; + } + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants + ? previousVisibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + showVariantInDOM(sessionId, visibleVariant); + + selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; + + setLiveState('CYCLING'); + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + showOrUpdateCyclingBar(); + disableInlineEdit(); + refreshParamsPanel(); + positionBar(); + saveSession(); + completeParameterGenerationIfReady(); + console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); + } + /** * No-HMR fallback: fetch the raw source file from the live server, * parse it, extract the variant wrapper, and inject it into the live DOM. @@ -6229,14 +6295,53 @@ return; } rememberSessionFileMeta({ file: filePath }); + if (isJsxSourceFile(filePath)) { + const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { + completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); + return; + } + // #454: never fetch/parse JSX. Missing wrap waits for mount (closed + // modal / other route). Insert scaffolds stay for late HMR. A replace + // scaffold with no variants after retries is a failed generation. + if (opts.generationCompleted && sessionId === currentSessionId) { + const attempt = opts.attempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + setTimeout(() => { + if (state !== 'GENERATING' || currentSessionId !== sessionId) return; + injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + return; + } + if (!liveWrapper) { + showToast( + "Variants ready. If the picked element isn't visible, retrace the path that revealed it - they'll appear automatically.", + 15000, + ); + return; + } + if (liveWrapper.dataset.impeccableMode !== 'insert') { + recoverEmptyCycling('source-fallback-empty'); + } + return; + } + if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { + const attempt = opts._orphanAttempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + setTimeout(() => { + if (sessionId !== currentSessionId) return; + if (state !== 'GENERATING' && state !== 'CYCLING') return; + injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + } + } + return; + } const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); fetch(url) .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) .then(html => { const parser = new DOMParser(); - let srcWrapper = null; - - // Full-file parse works for HTML/JSX; Astro/Vue sources need marker extraction. const startMark = ''; const endMark = ''; const startIdx = html.indexOf(startMark); @@ -6244,8 +6349,8 @@ const block = startIdx !== -1 && endIdx !== -1 && endIdx > startIdx ? html.slice(startIdx + startMark.length, endIdx).trim() : html; - const doc = parser.parseFromString(normalizeSourceFallbackBlock(block, filePath), 'text/html'); - srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const doc = parser.parseFromString(block, 'text/html'); + const srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { console.warn('[impeccable] Variant wrapper not found in source file.'); // A resumed cycling session whose wrapper is gone from source is an @@ -6270,93 +6375,33 @@ return; } - const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; - const wrapper = srcWrapper.cloneNode(true); - - // Wrapper already in DOM (wrap HMR landed, variant insert did not). const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (existingWrapper) { + const wrapper = srcWrapper.cloneNode(true); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); - } else { - const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); - if (!origContent) return; - - const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); - if (!liveEl) { - console.warn('[impeccable] Could not find original element in live DOM.'); - enterRecoveryWaitingForAnchor({ - filePath, - sessionId, - srcWrapper, - checkpointReason: 'variant_anchor_missing', - trackScroll: false, - }); - return; - } - - liveEl.parentElement.replaceChild(wrapper, liveEl); - } - recoveryWaitingForAnchor = false; - if (pendingVariantAnchorRetryObserver) { - pendingVariantAnchorRetryObserver.disconnect(); - pendingVariantAnchorRetryObserver = null; - } - - // Update state: count variants, preserving the user's current variant - // when a late HMR/source reinjection lands after they have cycled. - const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); - arrivedVariants = variants.length; - expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); - if (arrivedVariants <= 0) { - if (state === 'GENERATING') { - // Mid-generation the source legitimately holds a scaffold wrapper - // with no variants yet (the server-side preflight wraps before the - // agent writes). Tearing the session down here would destroy an - // in-flight generation; stay in GENERATING — the variant observer - // is armed and the server re-delivers a missed `done`. - if (!opts.generationCompleted) { - console.log('[impeccable] Source has scaffold but no variants yet; still generating.'); - return; - } - // Generation finished, yet the read shows only the scaffold: the - // source view is stale and no further event will fire. Re-read a - // few times before surfacing recovery — a single silent return - // here would strand the tab in GENERATING forever. - const attempt = opts.attempt || 0; - if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { - console.log('[impeccable] Generation is done but source shows no variants yet; retrying read (' - + (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').'); - setTimeout(() => { - if (state !== 'GENERATING' || currentSessionId !== sessionId) return; - if (arrivedVariants > 0) return; - injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 }); - }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); - return; - } - } - recoverEmptyCycling('source-fallback-empty'); + completeSourceInjection(wrapper, sessionId, { ...opts, filePath }); return; } - const saved = loadSession(); - const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; - visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants - ? previousVisibleVariant - : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); - showVariantInDOM(sessionId, visibleVariant); - // Update selectedElement to the visible variant's content - selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; + const wrapper = srcWrapper.cloneNode(true); + const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); + if (!origContent) return; - setLiveState('CYCLING'); - recoveryWaitingForAnchor = false; - hideShaderOverlay(); - showOrUpdateCyclingBar(); - disableInlineEdit(); - refreshParamsPanel(); - positionBar(); - saveSession(); - completeParameterGenerationIfReady(); - console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); + const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); + if (!liveEl) { + console.warn('[impeccable] Could not find original element in live DOM.'); + enterRecoveryWaitingForAnchor({ + filePath, + sessionId, + srcWrapper, + checkpointReason: 'variant_anchor_missing', + trackScroll: false, + }); + return; + } + + liveEl.parentElement.replaceChild(wrapper, liveEl); + completeSourceInjection(wrapper, sessionId, { ...opts, filePath }); }) .catch(err => { console.error('[impeccable] Failed to fetch source:', err); @@ -6364,44 +6409,6 @@ }); } - function normalizeSourceFallbackBlock(block, filePath) { - if (!/\.[cm]?[jt]sx$/i.test(String(filePath || ''))) return block; - return String(block) - .replace( - /]*)>\s*\{\s*`([\s\S]*?)`\s*\}\s*<\/style>/g, - (_match, attrs, css) => '' + css + '', - ) - .replace(/\bclassName\s*=\s*\{\s*`([^`]*?)`\s*\}/g, (_match, value) => { - const literalClasses = value.replace(/\$\{[^}]*\}/g, ' ').replace(/\s+/g, ' ').trim(); - return literalClasses ? 'class="' + escapeHtml(literalClasses) + '"' : ''; - }) - .replace(/\bclassName\s*=/g, 'class=') - .replace(/\sstyle=\{\{([\s\S]*?)\}\}/g, (_match, body) => { - const css = jsxStyleObjectToCss(body); - return css ? ' style="' + escapeHtml(css) + '"' : ''; - }); - } - - function jsxStyleObjectToCss(body) { - const declarations = []; - const re = /(["'][^"']+["']|[A-Za-z_$][\w$-]*)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?\d+(?:\.\d+)?))/g; - let match; - while ((match = re.exec(String(body || '')))) { - const prop = jsxStylePropToCss(match[1]); - const value = match[2] ?? match[3] ?? match[4] ?? ''; - if (!prop || value === '') continue; - declarations.push(prop + ': ' + value); - } - return declarations.join('; '); - } - - function jsxStylePropToCss(prop) { - let out = String(prop || '').trim().replace(/^["']|["']$/g, ''); - if (!out) return ''; - if (out.startsWith('--')) return out; - return out.replace(/[A-Z]/g, (ch) => '-' + ch.toLowerCase()).replace(/^-ms-/, '-ms-'); - } - function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { const map = new Map(); if (!sourceOriginal || !liveOriginal) return map; diff --git a/.cursor/skills/impeccable/scripts/live-browser.js b/.cursor/skills/impeccable/scripts/live-browser.js index 1f373a894..da026e255 100644 --- a/.cursor/skills/impeccable/scripts/live-browser.js +++ b/.cursor/skills/impeccable/scripts/live-browser.js @@ -6212,6 +6212,72 @@ showToast('The previous live session no longer matches the source file, so it was discarded. Pick an element to start fresh.', 6000); } + function isJsxSourceFile(filePath) { + return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); + } + + function completeSourceInjection(wrapper, sessionId, opts) { + recoveryWaitingForAnchor = false; + if (pendingVariantAnchorRetryObserver) { + pendingVariantAnchorRetryObserver.disconnect(); + pendingVariantAnchorRetryObserver = null; + } + + const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; + const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); + arrivedVariants = variants.length; + expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); + if (arrivedVariants <= 0) { + if (state === 'GENERATING') { + // Mid-generation the source legitimately holds a scaffold wrapper + // with no variants yet (the server-side preflight wraps before the + // agent writes). Tearing the session down here would destroy an + // in-flight generation; stay in GENERATING — the variant observer + // is armed and the server re-delivers a missed `done`. + if (!opts.generationCompleted) { + console.log('[impeccable] Source has scaffold but no variants yet; still generating.'); + return; + } + // Generation finished, yet the read shows only the scaffold: the + // source view is stale and no further event will fire. Re-read a + // few times before surfacing recovery — a single silent return + // here would strand the tab in GENERATING forever. + const attempt = opts.attempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + console.log('[impeccable] Generation is done but source shows no variants yet; retrying read (' + + (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').'); + setTimeout(() => { + if (state !== 'GENERATING' || currentSessionId !== sessionId) return; + if (arrivedVariants > 0) return; + injectVariantsFromSource(opts.filePath, sessionId, { ...opts, attempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + return; + } + } + recoverEmptyCycling('source-fallback-empty'); + return; + } + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants + ? previousVisibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + showVariantInDOM(sessionId, visibleVariant); + + selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; + + setLiveState('CYCLING'); + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + showOrUpdateCyclingBar(); + disableInlineEdit(); + refreshParamsPanel(); + positionBar(); + saveSession(); + completeParameterGenerationIfReady(); + console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); + } + /** * No-HMR fallback: fetch the raw source file from the live server, * parse it, extract the variant wrapper, and inject it into the live DOM. @@ -6229,14 +6295,53 @@ return; } rememberSessionFileMeta({ file: filePath }); + if (isJsxSourceFile(filePath)) { + const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { + completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); + return; + } + // #454: never fetch/parse JSX. Missing wrap waits for mount (closed + // modal / other route). Insert scaffolds stay for late HMR. A replace + // scaffold with no variants after retries is a failed generation. + if (opts.generationCompleted && sessionId === currentSessionId) { + const attempt = opts.attempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + setTimeout(() => { + if (state !== 'GENERATING' || currentSessionId !== sessionId) return; + injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + return; + } + if (!liveWrapper) { + showToast( + "Variants ready. If the picked element isn't visible, retrace the path that revealed it - they'll appear automatically.", + 15000, + ); + return; + } + if (liveWrapper.dataset.impeccableMode !== 'insert') { + recoverEmptyCycling('source-fallback-empty'); + } + return; + } + if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { + const attempt = opts._orphanAttempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + setTimeout(() => { + if (sessionId !== currentSessionId) return; + if (state !== 'GENERATING' && state !== 'CYCLING') return; + injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + } + } + return; + } const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); fetch(url) .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) .then(html => { const parser = new DOMParser(); - let srcWrapper = null; - - // Full-file parse works for HTML/JSX; Astro/Vue sources need marker extraction. const startMark = ''; const endMark = ''; const startIdx = html.indexOf(startMark); @@ -6244,8 +6349,8 @@ const block = startIdx !== -1 && endIdx !== -1 && endIdx > startIdx ? html.slice(startIdx + startMark.length, endIdx).trim() : html; - const doc = parser.parseFromString(normalizeSourceFallbackBlock(block, filePath), 'text/html'); - srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const doc = parser.parseFromString(block, 'text/html'); + const srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { console.warn('[impeccable] Variant wrapper not found in source file.'); // A resumed cycling session whose wrapper is gone from source is an @@ -6270,93 +6375,33 @@ return; } - const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; - const wrapper = srcWrapper.cloneNode(true); - - // Wrapper already in DOM (wrap HMR landed, variant insert did not). const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (existingWrapper) { + const wrapper = srcWrapper.cloneNode(true); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); - } else { - const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); - if (!origContent) return; - - const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); - if (!liveEl) { - console.warn('[impeccable] Could not find original element in live DOM.'); - enterRecoveryWaitingForAnchor({ - filePath, - sessionId, - srcWrapper, - checkpointReason: 'variant_anchor_missing', - trackScroll: false, - }); - return; - } - - liveEl.parentElement.replaceChild(wrapper, liveEl); - } - recoveryWaitingForAnchor = false; - if (pendingVariantAnchorRetryObserver) { - pendingVariantAnchorRetryObserver.disconnect(); - pendingVariantAnchorRetryObserver = null; - } - - // Update state: count variants, preserving the user's current variant - // when a late HMR/source reinjection lands after they have cycled. - const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); - arrivedVariants = variants.length; - expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); - if (arrivedVariants <= 0) { - if (state === 'GENERATING') { - // Mid-generation the source legitimately holds a scaffold wrapper - // with no variants yet (the server-side preflight wraps before the - // agent writes). Tearing the session down here would destroy an - // in-flight generation; stay in GENERATING — the variant observer - // is armed and the server re-delivers a missed `done`. - if (!opts.generationCompleted) { - console.log('[impeccable] Source has scaffold but no variants yet; still generating.'); - return; - } - // Generation finished, yet the read shows only the scaffold: the - // source view is stale and no further event will fire. Re-read a - // few times before surfacing recovery — a single silent return - // here would strand the tab in GENERATING forever. - const attempt = opts.attempt || 0; - if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { - console.log('[impeccable] Generation is done but source shows no variants yet; retrying read (' - + (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').'); - setTimeout(() => { - if (state !== 'GENERATING' || currentSessionId !== sessionId) return; - if (arrivedVariants > 0) return; - injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 }); - }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); - return; - } - } - recoverEmptyCycling('source-fallback-empty'); + completeSourceInjection(wrapper, sessionId, { ...opts, filePath }); return; } - const saved = loadSession(); - const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; - visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants - ? previousVisibleVariant - : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); - showVariantInDOM(sessionId, visibleVariant); - // Update selectedElement to the visible variant's content - selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; + const wrapper = srcWrapper.cloneNode(true); + const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); + if (!origContent) return; - setLiveState('CYCLING'); - recoveryWaitingForAnchor = false; - hideShaderOverlay(); - showOrUpdateCyclingBar(); - disableInlineEdit(); - refreshParamsPanel(); - positionBar(); - saveSession(); - completeParameterGenerationIfReady(); - console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); + const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); + if (!liveEl) { + console.warn('[impeccable] Could not find original element in live DOM.'); + enterRecoveryWaitingForAnchor({ + filePath, + sessionId, + srcWrapper, + checkpointReason: 'variant_anchor_missing', + trackScroll: false, + }); + return; + } + + liveEl.parentElement.replaceChild(wrapper, liveEl); + completeSourceInjection(wrapper, sessionId, { ...opts, filePath }); }) .catch(err => { console.error('[impeccable] Failed to fetch source:', err); @@ -6364,44 +6409,6 @@ }); } - function normalizeSourceFallbackBlock(block, filePath) { - if (!/\.[cm]?[jt]sx$/i.test(String(filePath || ''))) return block; - return String(block) - .replace( - /]*)>\s*\{\s*`([\s\S]*?)`\s*\}\s*<\/style>/g, - (_match, attrs, css) => '' + css + '', - ) - .replace(/\bclassName\s*=\s*\{\s*`([^`]*?)`\s*\}/g, (_match, value) => { - const literalClasses = value.replace(/\$\{[^}]*\}/g, ' ').replace(/\s+/g, ' ').trim(); - return literalClasses ? 'class="' + escapeHtml(literalClasses) + '"' : ''; - }) - .replace(/\bclassName\s*=/g, 'class=') - .replace(/\sstyle=\{\{([\s\S]*?)\}\}/g, (_match, body) => { - const css = jsxStyleObjectToCss(body); - return css ? ' style="' + escapeHtml(css) + '"' : ''; - }); - } - - function jsxStyleObjectToCss(body) { - const declarations = []; - const re = /(["'][^"']+["']|[A-Za-z_$][\w$-]*)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?\d+(?:\.\d+)?))/g; - let match; - while ((match = re.exec(String(body || '')))) { - const prop = jsxStylePropToCss(match[1]); - const value = match[2] ?? match[3] ?? match[4] ?? ''; - if (!prop || value === '') continue; - declarations.push(prop + ': ' + value); - } - return declarations.join('; '); - } - - function jsxStylePropToCss(prop) { - let out = String(prop || '').trim().replace(/^["']|["']$/g, ''); - if (!out) return ''; - if (out.startsWith('--')) return out; - return out.replace(/[A-Z]/g, (ch) => '-' + ch.toLowerCase()).replace(/^-ms-/, '-ms-'); - } - function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { const map = new Map(); if (!sourceOriginal || !liveOriginal) return map; diff --git a/.gemini/skills/impeccable/scripts/live-browser.js b/.gemini/skills/impeccable/scripts/live-browser.js index 1f373a894..da026e255 100644 --- a/.gemini/skills/impeccable/scripts/live-browser.js +++ b/.gemini/skills/impeccable/scripts/live-browser.js @@ -6212,6 +6212,72 @@ showToast('The previous live session no longer matches the source file, so it was discarded. Pick an element to start fresh.', 6000); } + function isJsxSourceFile(filePath) { + return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); + } + + function completeSourceInjection(wrapper, sessionId, opts) { + recoveryWaitingForAnchor = false; + if (pendingVariantAnchorRetryObserver) { + pendingVariantAnchorRetryObserver.disconnect(); + pendingVariantAnchorRetryObserver = null; + } + + const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; + const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); + arrivedVariants = variants.length; + expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); + if (arrivedVariants <= 0) { + if (state === 'GENERATING') { + // Mid-generation the source legitimately holds a scaffold wrapper + // with no variants yet (the server-side preflight wraps before the + // agent writes). Tearing the session down here would destroy an + // in-flight generation; stay in GENERATING — the variant observer + // is armed and the server re-delivers a missed `done`. + if (!opts.generationCompleted) { + console.log('[impeccable] Source has scaffold but no variants yet; still generating.'); + return; + } + // Generation finished, yet the read shows only the scaffold: the + // source view is stale and no further event will fire. Re-read a + // few times before surfacing recovery — a single silent return + // here would strand the tab in GENERATING forever. + const attempt = opts.attempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + console.log('[impeccable] Generation is done but source shows no variants yet; retrying read (' + + (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').'); + setTimeout(() => { + if (state !== 'GENERATING' || currentSessionId !== sessionId) return; + if (arrivedVariants > 0) return; + injectVariantsFromSource(opts.filePath, sessionId, { ...opts, attempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + return; + } + } + recoverEmptyCycling('source-fallback-empty'); + return; + } + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants + ? previousVisibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + showVariantInDOM(sessionId, visibleVariant); + + selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; + + setLiveState('CYCLING'); + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + showOrUpdateCyclingBar(); + disableInlineEdit(); + refreshParamsPanel(); + positionBar(); + saveSession(); + completeParameterGenerationIfReady(); + console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); + } + /** * No-HMR fallback: fetch the raw source file from the live server, * parse it, extract the variant wrapper, and inject it into the live DOM. @@ -6229,14 +6295,53 @@ return; } rememberSessionFileMeta({ file: filePath }); + if (isJsxSourceFile(filePath)) { + const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { + completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); + return; + } + // #454: never fetch/parse JSX. Missing wrap waits for mount (closed + // modal / other route). Insert scaffolds stay for late HMR. A replace + // scaffold with no variants after retries is a failed generation. + if (opts.generationCompleted && sessionId === currentSessionId) { + const attempt = opts.attempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + setTimeout(() => { + if (state !== 'GENERATING' || currentSessionId !== sessionId) return; + injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + return; + } + if (!liveWrapper) { + showToast( + "Variants ready. If the picked element isn't visible, retrace the path that revealed it - they'll appear automatically.", + 15000, + ); + return; + } + if (liveWrapper.dataset.impeccableMode !== 'insert') { + recoverEmptyCycling('source-fallback-empty'); + } + return; + } + if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { + const attempt = opts._orphanAttempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + setTimeout(() => { + if (sessionId !== currentSessionId) return; + if (state !== 'GENERATING' && state !== 'CYCLING') return; + injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + } + } + return; + } const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); fetch(url) .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) .then(html => { const parser = new DOMParser(); - let srcWrapper = null; - - // Full-file parse works for HTML/JSX; Astro/Vue sources need marker extraction. const startMark = ''; const endMark = ''; const startIdx = html.indexOf(startMark); @@ -6244,8 +6349,8 @@ const block = startIdx !== -1 && endIdx !== -1 && endIdx > startIdx ? html.slice(startIdx + startMark.length, endIdx).trim() : html; - const doc = parser.parseFromString(normalizeSourceFallbackBlock(block, filePath), 'text/html'); - srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const doc = parser.parseFromString(block, 'text/html'); + const srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { console.warn('[impeccable] Variant wrapper not found in source file.'); // A resumed cycling session whose wrapper is gone from source is an @@ -6270,93 +6375,33 @@ return; } - const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; - const wrapper = srcWrapper.cloneNode(true); - - // Wrapper already in DOM (wrap HMR landed, variant insert did not). const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (existingWrapper) { + const wrapper = srcWrapper.cloneNode(true); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); - } else { - const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); - if (!origContent) return; - - const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); - if (!liveEl) { - console.warn('[impeccable] Could not find original element in live DOM.'); - enterRecoveryWaitingForAnchor({ - filePath, - sessionId, - srcWrapper, - checkpointReason: 'variant_anchor_missing', - trackScroll: false, - }); - return; - } - - liveEl.parentElement.replaceChild(wrapper, liveEl); - } - recoveryWaitingForAnchor = false; - if (pendingVariantAnchorRetryObserver) { - pendingVariantAnchorRetryObserver.disconnect(); - pendingVariantAnchorRetryObserver = null; - } - - // Update state: count variants, preserving the user's current variant - // when a late HMR/source reinjection lands after they have cycled. - const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); - arrivedVariants = variants.length; - expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); - if (arrivedVariants <= 0) { - if (state === 'GENERATING') { - // Mid-generation the source legitimately holds a scaffold wrapper - // with no variants yet (the server-side preflight wraps before the - // agent writes). Tearing the session down here would destroy an - // in-flight generation; stay in GENERATING — the variant observer - // is armed and the server re-delivers a missed `done`. - if (!opts.generationCompleted) { - console.log('[impeccable] Source has scaffold but no variants yet; still generating.'); - return; - } - // Generation finished, yet the read shows only the scaffold: the - // source view is stale and no further event will fire. Re-read a - // few times before surfacing recovery — a single silent return - // here would strand the tab in GENERATING forever. - const attempt = opts.attempt || 0; - if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { - console.log('[impeccable] Generation is done but source shows no variants yet; retrying read (' - + (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').'); - setTimeout(() => { - if (state !== 'GENERATING' || currentSessionId !== sessionId) return; - if (arrivedVariants > 0) return; - injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 }); - }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); - return; - } - } - recoverEmptyCycling('source-fallback-empty'); + completeSourceInjection(wrapper, sessionId, { ...opts, filePath }); return; } - const saved = loadSession(); - const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; - visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants - ? previousVisibleVariant - : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); - showVariantInDOM(sessionId, visibleVariant); - // Update selectedElement to the visible variant's content - selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; + const wrapper = srcWrapper.cloneNode(true); + const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); + if (!origContent) return; - setLiveState('CYCLING'); - recoveryWaitingForAnchor = false; - hideShaderOverlay(); - showOrUpdateCyclingBar(); - disableInlineEdit(); - refreshParamsPanel(); - positionBar(); - saveSession(); - completeParameterGenerationIfReady(); - console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); + const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); + if (!liveEl) { + console.warn('[impeccable] Could not find original element in live DOM.'); + enterRecoveryWaitingForAnchor({ + filePath, + sessionId, + srcWrapper, + checkpointReason: 'variant_anchor_missing', + trackScroll: false, + }); + return; + } + + liveEl.parentElement.replaceChild(wrapper, liveEl); + completeSourceInjection(wrapper, sessionId, { ...opts, filePath }); }) .catch(err => { console.error('[impeccable] Failed to fetch source:', err); @@ -6364,44 +6409,6 @@ }); } - function normalizeSourceFallbackBlock(block, filePath) { - if (!/\.[cm]?[jt]sx$/i.test(String(filePath || ''))) return block; - return String(block) - .replace( - /]*)>\s*\{\s*`([\s\S]*?)`\s*\}\s*<\/style>/g, - (_match, attrs, css) => '' + css + '', - ) - .replace(/\bclassName\s*=\s*\{\s*`([^`]*?)`\s*\}/g, (_match, value) => { - const literalClasses = value.replace(/\$\{[^}]*\}/g, ' ').replace(/\s+/g, ' ').trim(); - return literalClasses ? 'class="' + escapeHtml(literalClasses) + '"' : ''; - }) - .replace(/\bclassName\s*=/g, 'class=') - .replace(/\sstyle=\{\{([\s\S]*?)\}\}/g, (_match, body) => { - const css = jsxStyleObjectToCss(body); - return css ? ' style="' + escapeHtml(css) + '"' : ''; - }); - } - - function jsxStyleObjectToCss(body) { - const declarations = []; - const re = /(["'][^"']+["']|[A-Za-z_$][\w$-]*)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?\d+(?:\.\d+)?))/g; - let match; - while ((match = re.exec(String(body || '')))) { - const prop = jsxStylePropToCss(match[1]); - const value = match[2] ?? match[3] ?? match[4] ?? ''; - if (!prop || value === '') continue; - declarations.push(prop + ': ' + value); - } - return declarations.join('; '); - } - - function jsxStylePropToCss(prop) { - let out = String(prop || '').trim().replace(/^["']|["']$/g, ''); - if (!out) return ''; - if (out.startsWith('--')) return out; - return out.replace(/[A-Z]/g, (ch) => '-' + ch.toLowerCase()).replace(/^-ms-/, '-ms-'); - } - function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { const map = new Map(); if (!sourceOriginal || !liveOriginal) return map; diff --git a/.github/skills/impeccable/scripts/live-browser.js b/.github/skills/impeccable/scripts/live-browser.js index 1f373a894..da026e255 100644 --- a/.github/skills/impeccable/scripts/live-browser.js +++ b/.github/skills/impeccable/scripts/live-browser.js @@ -6212,6 +6212,72 @@ showToast('The previous live session no longer matches the source file, so it was discarded. Pick an element to start fresh.', 6000); } + function isJsxSourceFile(filePath) { + return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); + } + + function completeSourceInjection(wrapper, sessionId, opts) { + recoveryWaitingForAnchor = false; + if (pendingVariantAnchorRetryObserver) { + pendingVariantAnchorRetryObserver.disconnect(); + pendingVariantAnchorRetryObserver = null; + } + + const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; + const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); + arrivedVariants = variants.length; + expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); + if (arrivedVariants <= 0) { + if (state === 'GENERATING') { + // Mid-generation the source legitimately holds a scaffold wrapper + // with no variants yet (the server-side preflight wraps before the + // agent writes). Tearing the session down here would destroy an + // in-flight generation; stay in GENERATING — the variant observer + // is armed and the server re-delivers a missed `done`. + if (!opts.generationCompleted) { + console.log('[impeccable] Source has scaffold but no variants yet; still generating.'); + return; + } + // Generation finished, yet the read shows only the scaffold: the + // source view is stale and no further event will fire. Re-read a + // few times before surfacing recovery — a single silent return + // here would strand the tab in GENERATING forever. + const attempt = opts.attempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + console.log('[impeccable] Generation is done but source shows no variants yet; retrying read (' + + (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').'); + setTimeout(() => { + if (state !== 'GENERATING' || currentSessionId !== sessionId) return; + if (arrivedVariants > 0) return; + injectVariantsFromSource(opts.filePath, sessionId, { ...opts, attempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + return; + } + } + recoverEmptyCycling('source-fallback-empty'); + return; + } + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants + ? previousVisibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + showVariantInDOM(sessionId, visibleVariant); + + selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; + + setLiveState('CYCLING'); + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + showOrUpdateCyclingBar(); + disableInlineEdit(); + refreshParamsPanel(); + positionBar(); + saveSession(); + completeParameterGenerationIfReady(); + console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); + } + /** * No-HMR fallback: fetch the raw source file from the live server, * parse it, extract the variant wrapper, and inject it into the live DOM. @@ -6229,14 +6295,53 @@ return; } rememberSessionFileMeta({ file: filePath }); + if (isJsxSourceFile(filePath)) { + const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { + completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); + return; + } + // #454: never fetch/parse JSX. Missing wrap waits for mount (closed + // modal / other route). Insert scaffolds stay for late HMR. A replace + // scaffold with no variants after retries is a failed generation. + if (opts.generationCompleted && sessionId === currentSessionId) { + const attempt = opts.attempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + setTimeout(() => { + if (state !== 'GENERATING' || currentSessionId !== sessionId) return; + injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + return; + } + if (!liveWrapper) { + showToast( + "Variants ready. If the picked element isn't visible, retrace the path that revealed it - they'll appear automatically.", + 15000, + ); + return; + } + if (liveWrapper.dataset.impeccableMode !== 'insert') { + recoverEmptyCycling('source-fallback-empty'); + } + return; + } + if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { + const attempt = opts._orphanAttempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + setTimeout(() => { + if (sessionId !== currentSessionId) return; + if (state !== 'GENERATING' && state !== 'CYCLING') return; + injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + } + } + return; + } const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); fetch(url) .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) .then(html => { const parser = new DOMParser(); - let srcWrapper = null; - - // Full-file parse works for HTML/JSX; Astro/Vue sources need marker extraction. const startMark = ''; const endMark = ''; const startIdx = html.indexOf(startMark); @@ -6244,8 +6349,8 @@ const block = startIdx !== -1 && endIdx !== -1 && endIdx > startIdx ? html.slice(startIdx + startMark.length, endIdx).trim() : html; - const doc = parser.parseFromString(normalizeSourceFallbackBlock(block, filePath), 'text/html'); - srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const doc = parser.parseFromString(block, 'text/html'); + const srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { console.warn('[impeccable] Variant wrapper not found in source file.'); // A resumed cycling session whose wrapper is gone from source is an @@ -6270,93 +6375,33 @@ return; } - const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; - const wrapper = srcWrapper.cloneNode(true); - - // Wrapper already in DOM (wrap HMR landed, variant insert did not). const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (existingWrapper) { + const wrapper = srcWrapper.cloneNode(true); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); - } else { - const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); - if (!origContent) return; - - const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); - if (!liveEl) { - console.warn('[impeccable] Could not find original element in live DOM.'); - enterRecoveryWaitingForAnchor({ - filePath, - sessionId, - srcWrapper, - checkpointReason: 'variant_anchor_missing', - trackScroll: false, - }); - return; - } - - liveEl.parentElement.replaceChild(wrapper, liveEl); - } - recoveryWaitingForAnchor = false; - if (pendingVariantAnchorRetryObserver) { - pendingVariantAnchorRetryObserver.disconnect(); - pendingVariantAnchorRetryObserver = null; - } - - // Update state: count variants, preserving the user's current variant - // when a late HMR/source reinjection lands after they have cycled. - const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); - arrivedVariants = variants.length; - expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); - if (arrivedVariants <= 0) { - if (state === 'GENERATING') { - // Mid-generation the source legitimately holds a scaffold wrapper - // with no variants yet (the server-side preflight wraps before the - // agent writes). Tearing the session down here would destroy an - // in-flight generation; stay in GENERATING — the variant observer - // is armed and the server re-delivers a missed `done`. - if (!opts.generationCompleted) { - console.log('[impeccable] Source has scaffold but no variants yet; still generating.'); - return; - } - // Generation finished, yet the read shows only the scaffold: the - // source view is stale and no further event will fire. Re-read a - // few times before surfacing recovery — a single silent return - // here would strand the tab in GENERATING forever. - const attempt = opts.attempt || 0; - if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { - console.log('[impeccable] Generation is done but source shows no variants yet; retrying read (' - + (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').'); - setTimeout(() => { - if (state !== 'GENERATING' || currentSessionId !== sessionId) return; - if (arrivedVariants > 0) return; - injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 }); - }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); - return; - } - } - recoverEmptyCycling('source-fallback-empty'); + completeSourceInjection(wrapper, sessionId, { ...opts, filePath }); return; } - const saved = loadSession(); - const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; - visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants - ? previousVisibleVariant - : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); - showVariantInDOM(sessionId, visibleVariant); - // Update selectedElement to the visible variant's content - selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; + const wrapper = srcWrapper.cloneNode(true); + const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); + if (!origContent) return; - setLiveState('CYCLING'); - recoveryWaitingForAnchor = false; - hideShaderOverlay(); - showOrUpdateCyclingBar(); - disableInlineEdit(); - refreshParamsPanel(); - positionBar(); - saveSession(); - completeParameterGenerationIfReady(); - console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); + const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); + if (!liveEl) { + console.warn('[impeccable] Could not find original element in live DOM.'); + enterRecoveryWaitingForAnchor({ + filePath, + sessionId, + srcWrapper, + checkpointReason: 'variant_anchor_missing', + trackScroll: false, + }); + return; + } + + liveEl.parentElement.replaceChild(wrapper, liveEl); + completeSourceInjection(wrapper, sessionId, { ...opts, filePath }); }) .catch(err => { console.error('[impeccable] Failed to fetch source:', err); @@ -6364,44 +6409,6 @@ }); } - function normalizeSourceFallbackBlock(block, filePath) { - if (!/\.[cm]?[jt]sx$/i.test(String(filePath || ''))) return block; - return String(block) - .replace( - /]*)>\s*\{\s*`([\s\S]*?)`\s*\}\s*<\/style>/g, - (_match, attrs, css) => '' + css + '', - ) - .replace(/\bclassName\s*=\s*\{\s*`([^`]*?)`\s*\}/g, (_match, value) => { - const literalClasses = value.replace(/\$\{[^}]*\}/g, ' ').replace(/\s+/g, ' ').trim(); - return literalClasses ? 'class="' + escapeHtml(literalClasses) + '"' : ''; - }) - .replace(/\bclassName\s*=/g, 'class=') - .replace(/\sstyle=\{\{([\s\S]*?)\}\}/g, (_match, body) => { - const css = jsxStyleObjectToCss(body); - return css ? ' style="' + escapeHtml(css) + '"' : ''; - }); - } - - function jsxStyleObjectToCss(body) { - const declarations = []; - const re = /(["'][^"']+["']|[A-Za-z_$][\w$-]*)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?\d+(?:\.\d+)?))/g; - let match; - while ((match = re.exec(String(body || '')))) { - const prop = jsxStylePropToCss(match[1]); - const value = match[2] ?? match[3] ?? match[4] ?? ''; - if (!prop || value === '') continue; - declarations.push(prop + ': ' + value); - } - return declarations.join('; '); - } - - function jsxStylePropToCss(prop) { - let out = String(prop || '').trim().replace(/^["']|["']$/g, ''); - if (!out) return ''; - if (out.startsWith('--')) return out; - return out.replace(/[A-Z]/g, (ch) => '-' + ch.toLowerCase()).replace(/^-ms-/, '-ms-'); - } - function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { const map = new Map(); if (!sourceOriginal || !liveOriginal) return map; diff --git a/.grok/skills/impeccable/scripts/live-browser.js b/.grok/skills/impeccable/scripts/live-browser.js index 1f373a894..da026e255 100644 --- a/.grok/skills/impeccable/scripts/live-browser.js +++ b/.grok/skills/impeccable/scripts/live-browser.js @@ -6212,6 +6212,72 @@ showToast('The previous live session no longer matches the source file, so it was discarded. Pick an element to start fresh.', 6000); } + function isJsxSourceFile(filePath) { + return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); + } + + function completeSourceInjection(wrapper, sessionId, opts) { + recoveryWaitingForAnchor = false; + if (pendingVariantAnchorRetryObserver) { + pendingVariantAnchorRetryObserver.disconnect(); + pendingVariantAnchorRetryObserver = null; + } + + const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; + const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); + arrivedVariants = variants.length; + expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); + if (arrivedVariants <= 0) { + if (state === 'GENERATING') { + // Mid-generation the source legitimately holds a scaffold wrapper + // with no variants yet (the server-side preflight wraps before the + // agent writes). Tearing the session down here would destroy an + // in-flight generation; stay in GENERATING — the variant observer + // is armed and the server re-delivers a missed `done`. + if (!opts.generationCompleted) { + console.log('[impeccable] Source has scaffold but no variants yet; still generating.'); + return; + } + // Generation finished, yet the read shows only the scaffold: the + // source view is stale and no further event will fire. Re-read a + // few times before surfacing recovery — a single silent return + // here would strand the tab in GENERATING forever. + const attempt = opts.attempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + console.log('[impeccable] Generation is done but source shows no variants yet; retrying read (' + + (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').'); + setTimeout(() => { + if (state !== 'GENERATING' || currentSessionId !== sessionId) return; + if (arrivedVariants > 0) return; + injectVariantsFromSource(opts.filePath, sessionId, { ...opts, attempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + return; + } + } + recoverEmptyCycling('source-fallback-empty'); + return; + } + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants + ? previousVisibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + showVariantInDOM(sessionId, visibleVariant); + + selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; + + setLiveState('CYCLING'); + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + showOrUpdateCyclingBar(); + disableInlineEdit(); + refreshParamsPanel(); + positionBar(); + saveSession(); + completeParameterGenerationIfReady(); + console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); + } + /** * No-HMR fallback: fetch the raw source file from the live server, * parse it, extract the variant wrapper, and inject it into the live DOM. @@ -6229,14 +6295,53 @@ return; } rememberSessionFileMeta({ file: filePath }); + if (isJsxSourceFile(filePath)) { + const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { + completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); + return; + } + // #454: never fetch/parse JSX. Missing wrap waits for mount (closed + // modal / other route). Insert scaffolds stay for late HMR. A replace + // scaffold with no variants after retries is a failed generation. + if (opts.generationCompleted && sessionId === currentSessionId) { + const attempt = opts.attempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + setTimeout(() => { + if (state !== 'GENERATING' || currentSessionId !== sessionId) return; + injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + return; + } + if (!liveWrapper) { + showToast( + "Variants ready. If the picked element isn't visible, retrace the path that revealed it - they'll appear automatically.", + 15000, + ); + return; + } + if (liveWrapper.dataset.impeccableMode !== 'insert') { + recoverEmptyCycling('source-fallback-empty'); + } + return; + } + if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { + const attempt = opts._orphanAttempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + setTimeout(() => { + if (sessionId !== currentSessionId) return; + if (state !== 'GENERATING' && state !== 'CYCLING') return; + injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + } + } + return; + } const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); fetch(url) .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) .then(html => { const parser = new DOMParser(); - let srcWrapper = null; - - // Full-file parse works for HTML/JSX; Astro/Vue sources need marker extraction. const startMark = ''; const endMark = ''; const startIdx = html.indexOf(startMark); @@ -6244,8 +6349,8 @@ const block = startIdx !== -1 && endIdx !== -1 && endIdx > startIdx ? html.slice(startIdx + startMark.length, endIdx).trim() : html; - const doc = parser.parseFromString(normalizeSourceFallbackBlock(block, filePath), 'text/html'); - srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const doc = parser.parseFromString(block, 'text/html'); + const srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { console.warn('[impeccable] Variant wrapper not found in source file.'); // A resumed cycling session whose wrapper is gone from source is an @@ -6270,93 +6375,33 @@ return; } - const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; - const wrapper = srcWrapper.cloneNode(true); - - // Wrapper already in DOM (wrap HMR landed, variant insert did not). const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (existingWrapper) { + const wrapper = srcWrapper.cloneNode(true); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); - } else { - const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); - if (!origContent) return; - - const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); - if (!liveEl) { - console.warn('[impeccable] Could not find original element in live DOM.'); - enterRecoveryWaitingForAnchor({ - filePath, - sessionId, - srcWrapper, - checkpointReason: 'variant_anchor_missing', - trackScroll: false, - }); - return; - } - - liveEl.parentElement.replaceChild(wrapper, liveEl); - } - recoveryWaitingForAnchor = false; - if (pendingVariantAnchorRetryObserver) { - pendingVariantAnchorRetryObserver.disconnect(); - pendingVariantAnchorRetryObserver = null; - } - - // Update state: count variants, preserving the user's current variant - // when a late HMR/source reinjection lands after they have cycled. - const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); - arrivedVariants = variants.length; - expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); - if (arrivedVariants <= 0) { - if (state === 'GENERATING') { - // Mid-generation the source legitimately holds a scaffold wrapper - // with no variants yet (the server-side preflight wraps before the - // agent writes). Tearing the session down here would destroy an - // in-flight generation; stay in GENERATING — the variant observer - // is armed and the server re-delivers a missed `done`. - if (!opts.generationCompleted) { - console.log('[impeccable] Source has scaffold but no variants yet; still generating.'); - return; - } - // Generation finished, yet the read shows only the scaffold: the - // source view is stale and no further event will fire. Re-read a - // few times before surfacing recovery — a single silent return - // here would strand the tab in GENERATING forever. - const attempt = opts.attempt || 0; - if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { - console.log('[impeccable] Generation is done but source shows no variants yet; retrying read (' - + (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').'); - setTimeout(() => { - if (state !== 'GENERATING' || currentSessionId !== sessionId) return; - if (arrivedVariants > 0) return; - injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 }); - }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); - return; - } - } - recoverEmptyCycling('source-fallback-empty'); + completeSourceInjection(wrapper, sessionId, { ...opts, filePath }); return; } - const saved = loadSession(); - const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; - visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants - ? previousVisibleVariant - : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); - showVariantInDOM(sessionId, visibleVariant); - // Update selectedElement to the visible variant's content - selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; + const wrapper = srcWrapper.cloneNode(true); + const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); + if (!origContent) return; - setLiveState('CYCLING'); - recoveryWaitingForAnchor = false; - hideShaderOverlay(); - showOrUpdateCyclingBar(); - disableInlineEdit(); - refreshParamsPanel(); - positionBar(); - saveSession(); - completeParameterGenerationIfReady(); - console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); + const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); + if (!liveEl) { + console.warn('[impeccable] Could not find original element in live DOM.'); + enterRecoveryWaitingForAnchor({ + filePath, + sessionId, + srcWrapper, + checkpointReason: 'variant_anchor_missing', + trackScroll: false, + }); + return; + } + + liveEl.parentElement.replaceChild(wrapper, liveEl); + completeSourceInjection(wrapper, sessionId, { ...opts, filePath }); }) .catch(err => { console.error('[impeccable] Failed to fetch source:', err); @@ -6364,44 +6409,6 @@ }); } - function normalizeSourceFallbackBlock(block, filePath) { - if (!/\.[cm]?[jt]sx$/i.test(String(filePath || ''))) return block; - return String(block) - .replace( - /]*)>\s*\{\s*`([\s\S]*?)`\s*\}\s*<\/style>/g, - (_match, attrs, css) => '' + css + '', - ) - .replace(/\bclassName\s*=\s*\{\s*`([^`]*?)`\s*\}/g, (_match, value) => { - const literalClasses = value.replace(/\$\{[^}]*\}/g, ' ').replace(/\s+/g, ' ').trim(); - return literalClasses ? 'class="' + escapeHtml(literalClasses) + '"' : ''; - }) - .replace(/\bclassName\s*=/g, 'class=') - .replace(/\sstyle=\{\{([\s\S]*?)\}\}/g, (_match, body) => { - const css = jsxStyleObjectToCss(body); - return css ? ' style="' + escapeHtml(css) + '"' : ''; - }); - } - - function jsxStyleObjectToCss(body) { - const declarations = []; - const re = /(["'][^"']+["']|[A-Za-z_$][\w$-]*)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?\d+(?:\.\d+)?))/g; - let match; - while ((match = re.exec(String(body || '')))) { - const prop = jsxStylePropToCss(match[1]); - const value = match[2] ?? match[3] ?? match[4] ?? ''; - if (!prop || value === '') continue; - declarations.push(prop + ': ' + value); - } - return declarations.join('; '); - } - - function jsxStylePropToCss(prop) { - let out = String(prop || '').trim().replace(/^["']|["']$/g, ''); - if (!out) return ''; - if (out.startsWith('--')) return out; - return out.replace(/[A-Z]/g, (ch) => '-' + ch.toLowerCase()).replace(/^-ms-/, '-ms-'); - } - function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { const map = new Map(); if (!sourceOriginal || !liveOriginal) return map; diff --git a/.hermes/skills/impeccable/scripts/live-browser.js b/.hermes/skills/impeccable/scripts/live-browser.js index 1f373a894..da026e255 100644 --- a/.hermes/skills/impeccable/scripts/live-browser.js +++ b/.hermes/skills/impeccable/scripts/live-browser.js @@ -6212,6 +6212,72 @@ showToast('The previous live session no longer matches the source file, so it was discarded. Pick an element to start fresh.', 6000); } + function isJsxSourceFile(filePath) { + return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); + } + + function completeSourceInjection(wrapper, sessionId, opts) { + recoveryWaitingForAnchor = false; + if (pendingVariantAnchorRetryObserver) { + pendingVariantAnchorRetryObserver.disconnect(); + pendingVariantAnchorRetryObserver = null; + } + + const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; + const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); + arrivedVariants = variants.length; + expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); + if (arrivedVariants <= 0) { + if (state === 'GENERATING') { + // Mid-generation the source legitimately holds a scaffold wrapper + // with no variants yet (the server-side preflight wraps before the + // agent writes). Tearing the session down here would destroy an + // in-flight generation; stay in GENERATING — the variant observer + // is armed and the server re-delivers a missed `done`. + if (!opts.generationCompleted) { + console.log('[impeccable] Source has scaffold but no variants yet; still generating.'); + return; + } + // Generation finished, yet the read shows only the scaffold: the + // source view is stale and no further event will fire. Re-read a + // few times before surfacing recovery — a single silent return + // here would strand the tab in GENERATING forever. + const attempt = opts.attempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + console.log('[impeccable] Generation is done but source shows no variants yet; retrying read (' + + (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').'); + setTimeout(() => { + if (state !== 'GENERATING' || currentSessionId !== sessionId) return; + if (arrivedVariants > 0) return; + injectVariantsFromSource(opts.filePath, sessionId, { ...opts, attempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + return; + } + } + recoverEmptyCycling('source-fallback-empty'); + return; + } + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants + ? previousVisibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + showVariantInDOM(sessionId, visibleVariant); + + selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; + + setLiveState('CYCLING'); + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + showOrUpdateCyclingBar(); + disableInlineEdit(); + refreshParamsPanel(); + positionBar(); + saveSession(); + completeParameterGenerationIfReady(); + console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); + } + /** * No-HMR fallback: fetch the raw source file from the live server, * parse it, extract the variant wrapper, and inject it into the live DOM. @@ -6229,14 +6295,53 @@ return; } rememberSessionFileMeta({ file: filePath }); + if (isJsxSourceFile(filePath)) { + const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { + completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); + return; + } + // #454: never fetch/parse JSX. Missing wrap waits for mount (closed + // modal / other route). Insert scaffolds stay for late HMR. A replace + // scaffold with no variants after retries is a failed generation. + if (opts.generationCompleted && sessionId === currentSessionId) { + const attempt = opts.attempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + setTimeout(() => { + if (state !== 'GENERATING' || currentSessionId !== sessionId) return; + injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + return; + } + if (!liveWrapper) { + showToast( + "Variants ready. If the picked element isn't visible, retrace the path that revealed it - they'll appear automatically.", + 15000, + ); + return; + } + if (liveWrapper.dataset.impeccableMode !== 'insert') { + recoverEmptyCycling('source-fallback-empty'); + } + return; + } + if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { + const attempt = opts._orphanAttempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + setTimeout(() => { + if (sessionId !== currentSessionId) return; + if (state !== 'GENERATING' && state !== 'CYCLING') return; + injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + } + } + return; + } const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); fetch(url) .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) .then(html => { const parser = new DOMParser(); - let srcWrapper = null; - - // Full-file parse works for HTML/JSX; Astro/Vue sources need marker extraction. const startMark = ''; const endMark = ''; const startIdx = html.indexOf(startMark); @@ -6244,8 +6349,8 @@ const block = startIdx !== -1 && endIdx !== -1 && endIdx > startIdx ? html.slice(startIdx + startMark.length, endIdx).trim() : html; - const doc = parser.parseFromString(normalizeSourceFallbackBlock(block, filePath), 'text/html'); - srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const doc = parser.parseFromString(block, 'text/html'); + const srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { console.warn('[impeccable] Variant wrapper not found in source file.'); // A resumed cycling session whose wrapper is gone from source is an @@ -6270,93 +6375,33 @@ return; } - const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; - const wrapper = srcWrapper.cloneNode(true); - - // Wrapper already in DOM (wrap HMR landed, variant insert did not). const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (existingWrapper) { + const wrapper = srcWrapper.cloneNode(true); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); - } else { - const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); - if (!origContent) return; - - const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); - if (!liveEl) { - console.warn('[impeccable] Could not find original element in live DOM.'); - enterRecoveryWaitingForAnchor({ - filePath, - sessionId, - srcWrapper, - checkpointReason: 'variant_anchor_missing', - trackScroll: false, - }); - return; - } - - liveEl.parentElement.replaceChild(wrapper, liveEl); - } - recoveryWaitingForAnchor = false; - if (pendingVariantAnchorRetryObserver) { - pendingVariantAnchorRetryObserver.disconnect(); - pendingVariantAnchorRetryObserver = null; - } - - // Update state: count variants, preserving the user's current variant - // when a late HMR/source reinjection lands after they have cycled. - const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); - arrivedVariants = variants.length; - expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); - if (arrivedVariants <= 0) { - if (state === 'GENERATING') { - // Mid-generation the source legitimately holds a scaffold wrapper - // with no variants yet (the server-side preflight wraps before the - // agent writes). Tearing the session down here would destroy an - // in-flight generation; stay in GENERATING — the variant observer - // is armed and the server re-delivers a missed `done`. - if (!opts.generationCompleted) { - console.log('[impeccable] Source has scaffold but no variants yet; still generating.'); - return; - } - // Generation finished, yet the read shows only the scaffold: the - // source view is stale and no further event will fire. Re-read a - // few times before surfacing recovery — a single silent return - // here would strand the tab in GENERATING forever. - const attempt = opts.attempt || 0; - if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { - console.log('[impeccable] Generation is done but source shows no variants yet; retrying read (' - + (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').'); - setTimeout(() => { - if (state !== 'GENERATING' || currentSessionId !== sessionId) return; - if (arrivedVariants > 0) return; - injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 }); - }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); - return; - } - } - recoverEmptyCycling('source-fallback-empty'); + completeSourceInjection(wrapper, sessionId, { ...opts, filePath }); return; } - const saved = loadSession(); - const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; - visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants - ? previousVisibleVariant - : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); - showVariantInDOM(sessionId, visibleVariant); - // Update selectedElement to the visible variant's content - selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; + const wrapper = srcWrapper.cloneNode(true); + const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); + if (!origContent) return; - setLiveState('CYCLING'); - recoveryWaitingForAnchor = false; - hideShaderOverlay(); - showOrUpdateCyclingBar(); - disableInlineEdit(); - refreshParamsPanel(); - positionBar(); - saveSession(); - completeParameterGenerationIfReady(); - console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); + const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); + if (!liveEl) { + console.warn('[impeccable] Could not find original element in live DOM.'); + enterRecoveryWaitingForAnchor({ + filePath, + sessionId, + srcWrapper, + checkpointReason: 'variant_anchor_missing', + trackScroll: false, + }); + return; + } + + liveEl.parentElement.replaceChild(wrapper, liveEl); + completeSourceInjection(wrapper, sessionId, { ...opts, filePath }); }) .catch(err => { console.error('[impeccable] Failed to fetch source:', err); @@ -6364,44 +6409,6 @@ }); } - function normalizeSourceFallbackBlock(block, filePath) { - if (!/\.[cm]?[jt]sx$/i.test(String(filePath || ''))) return block; - return String(block) - .replace( - /]*)>\s*\{\s*`([\s\S]*?)`\s*\}\s*<\/style>/g, - (_match, attrs, css) => '' + css + '', - ) - .replace(/\bclassName\s*=\s*\{\s*`([^`]*?)`\s*\}/g, (_match, value) => { - const literalClasses = value.replace(/\$\{[^}]*\}/g, ' ').replace(/\s+/g, ' ').trim(); - return literalClasses ? 'class="' + escapeHtml(literalClasses) + '"' : ''; - }) - .replace(/\bclassName\s*=/g, 'class=') - .replace(/\sstyle=\{\{([\s\S]*?)\}\}/g, (_match, body) => { - const css = jsxStyleObjectToCss(body); - return css ? ' style="' + escapeHtml(css) + '"' : ''; - }); - } - - function jsxStyleObjectToCss(body) { - const declarations = []; - const re = /(["'][^"']+["']|[A-Za-z_$][\w$-]*)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?\d+(?:\.\d+)?))/g; - let match; - while ((match = re.exec(String(body || '')))) { - const prop = jsxStylePropToCss(match[1]); - const value = match[2] ?? match[3] ?? match[4] ?? ''; - if (!prop || value === '') continue; - declarations.push(prop + ': ' + value); - } - return declarations.join('; '); - } - - function jsxStylePropToCss(prop) { - let out = String(prop || '').trim().replace(/^["']|["']$/g, ''); - if (!out) return ''; - if (out.startsWith('--')) return out; - return out.replace(/[A-Z]/g, (ch) => '-' + ch.toLowerCase()).replace(/^-ms-/, '-ms-'); - } - function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { const map = new Map(); if (!sourceOriginal || !liveOriginal) return map; diff --git a/.kiro/skills/impeccable/scripts/live-browser.js b/.kiro/skills/impeccable/scripts/live-browser.js index 1f373a894..da026e255 100644 --- a/.kiro/skills/impeccable/scripts/live-browser.js +++ b/.kiro/skills/impeccable/scripts/live-browser.js @@ -6212,6 +6212,72 @@ showToast('The previous live session no longer matches the source file, so it was discarded. Pick an element to start fresh.', 6000); } + function isJsxSourceFile(filePath) { + return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); + } + + function completeSourceInjection(wrapper, sessionId, opts) { + recoveryWaitingForAnchor = false; + if (pendingVariantAnchorRetryObserver) { + pendingVariantAnchorRetryObserver.disconnect(); + pendingVariantAnchorRetryObserver = null; + } + + const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; + const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); + arrivedVariants = variants.length; + expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); + if (arrivedVariants <= 0) { + if (state === 'GENERATING') { + // Mid-generation the source legitimately holds a scaffold wrapper + // with no variants yet (the server-side preflight wraps before the + // agent writes). Tearing the session down here would destroy an + // in-flight generation; stay in GENERATING — the variant observer + // is armed and the server re-delivers a missed `done`. + if (!opts.generationCompleted) { + console.log('[impeccable] Source has scaffold but no variants yet; still generating.'); + return; + } + // Generation finished, yet the read shows only the scaffold: the + // source view is stale and no further event will fire. Re-read a + // few times before surfacing recovery — a single silent return + // here would strand the tab in GENERATING forever. + const attempt = opts.attempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + console.log('[impeccable] Generation is done but source shows no variants yet; retrying read (' + + (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').'); + setTimeout(() => { + if (state !== 'GENERATING' || currentSessionId !== sessionId) return; + if (arrivedVariants > 0) return; + injectVariantsFromSource(opts.filePath, sessionId, { ...opts, attempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + return; + } + } + recoverEmptyCycling('source-fallback-empty'); + return; + } + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants + ? previousVisibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + showVariantInDOM(sessionId, visibleVariant); + + selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; + + setLiveState('CYCLING'); + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + showOrUpdateCyclingBar(); + disableInlineEdit(); + refreshParamsPanel(); + positionBar(); + saveSession(); + completeParameterGenerationIfReady(); + console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); + } + /** * No-HMR fallback: fetch the raw source file from the live server, * parse it, extract the variant wrapper, and inject it into the live DOM. @@ -6229,14 +6295,53 @@ return; } rememberSessionFileMeta({ file: filePath }); + if (isJsxSourceFile(filePath)) { + const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { + completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); + return; + } + // #454: never fetch/parse JSX. Missing wrap waits for mount (closed + // modal / other route). Insert scaffolds stay for late HMR. A replace + // scaffold with no variants after retries is a failed generation. + if (opts.generationCompleted && sessionId === currentSessionId) { + const attempt = opts.attempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + setTimeout(() => { + if (state !== 'GENERATING' || currentSessionId !== sessionId) return; + injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + return; + } + if (!liveWrapper) { + showToast( + "Variants ready. If the picked element isn't visible, retrace the path that revealed it - they'll appear automatically.", + 15000, + ); + return; + } + if (liveWrapper.dataset.impeccableMode !== 'insert') { + recoverEmptyCycling('source-fallback-empty'); + } + return; + } + if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { + const attempt = opts._orphanAttempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + setTimeout(() => { + if (sessionId !== currentSessionId) return; + if (state !== 'GENERATING' && state !== 'CYCLING') return; + injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + } + } + return; + } const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); fetch(url) .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) .then(html => { const parser = new DOMParser(); - let srcWrapper = null; - - // Full-file parse works for HTML/JSX; Astro/Vue sources need marker extraction. const startMark = ''; const endMark = ''; const startIdx = html.indexOf(startMark); @@ -6244,8 +6349,8 @@ const block = startIdx !== -1 && endIdx !== -1 && endIdx > startIdx ? html.slice(startIdx + startMark.length, endIdx).trim() : html; - const doc = parser.parseFromString(normalizeSourceFallbackBlock(block, filePath), 'text/html'); - srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const doc = parser.parseFromString(block, 'text/html'); + const srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { console.warn('[impeccable] Variant wrapper not found in source file.'); // A resumed cycling session whose wrapper is gone from source is an @@ -6270,93 +6375,33 @@ return; } - const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; - const wrapper = srcWrapper.cloneNode(true); - - // Wrapper already in DOM (wrap HMR landed, variant insert did not). const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (existingWrapper) { + const wrapper = srcWrapper.cloneNode(true); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); - } else { - const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); - if (!origContent) return; - - const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); - if (!liveEl) { - console.warn('[impeccable] Could not find original element in live DOM.'); - enterRecoveryWaitingForAnchor({ - filePath, - sessionId, - srcWrapper, - checkpointReason: 'variant_anchor_missing', - trackScroll: false, - }); - return; - } - - liveEl.parentElement.replaceChild(wrapper, liveEl); - } - recoveryWaitingForAnchor = false; - if (pendingVariantAnchorRetryObserver) { - pendingVariantAnchorRetryObserver.disconnect(); - pendingVariantAnchorRetryObserver = null; - } - - // Update state: count variants, preserving the user's current variant - // when a late HMR/source reinjection lands after they have cycled. - const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); - arrivedVariants = variants.length; - expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); - if (arrivedVariants <= 0) { - if (state === 'GENERATING') { - // Mid-generation the source legitimately holds a scaffold wrapper - // with no variants yet (the server-side preflight wraps before the - // agent writes). Tearing the session down here would destroy an - // in-flight generation; stay in GENERATING — the variant observer - // is armed and the server re-delivers a missed `done`. - if (!opts.generationCompleted) { - console.log('[impeccable] Source has scaffold but no variants yet; still generating.'); - return; - } - // Generation finished, yet the read shows only the scaffold: the - // source view is stale and no further event will fire. Re-read a - // few times before surfacing recovery — a single silent return - // here would strand the tab in GENERATING forever. - const attempt = opts.attempt || 0; - if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { - console.log('[impeccable] Generation is done but source shows no variants yet; retrying read (' - + (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').'); - setTimeout(() => { - if (state !== 'GENERATING' || currentSessionId !== sessionId) return; - if (arrivedVariants > 0) return; - injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 }); - }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); - return; - } - } - recoverEmptyCycling('source-fallback-empty'); + completeSourceInjection(wrapper, sessionId, { ...opts, filePath }); return; } - const saved = loadSession(); - const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; - visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants - ? previousVisibleVariant - : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); - showVariantInDOM(sessionId, visibleVariant); - // Update selectedElement to the visible variant's content - selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; + const wrapper = srcWrapper.cloneNode(true); + const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); + if (!origContent) return; - setLiveState('CYCLING'); - recoveryWaitingForAnchor = false; - hideShaderOverlay(); - showOrUpdateCyclingBar(); - disableInlineEdit(); - refreshParamsPanel(); - positionBar(); - saveSession(); - completeParameterGenerationIfReady(); - console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); + const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); + if (!liveEl) { + console.warn('[impeccable] Could not find original element in live DOM.'); + enterRecoveryWaitingForAnchor({ + filePath, + sessionId, + srcWrapper, + checkpointReason: 'variant_anchor_missing', + trackScroll: false, + }); + return; + } + + liveEl.parentElement.replaceChild(wrapper, liveEl); + completeSourceInjection(wrapper, sessionId, { ...opts, filePath }); }) .catch(err => { console.error('[impeccable] Failed to fetch source:', err); @@ -6364,44 +6409,6 @@ }); } - function normalizeSourceFallbackBlock(block, filePath) { - if (!/\.[cm]?[jt]sx$/i.test(String(filePath || ''))) return block; - return String(block) - .replace( - /]*)>\s*\{\s*`([\s\S]*?)`\s*\}\s*<\/style>/g, - (_match, attrs, css) => '' + css + '', - ) - .replace(/\bclassName\s*=\s*\{\s*`([^`]*?)`\s*\}/g, (_match, value) => { - const literalClasses = value.replace(/\$\{[^}]*\}/g, ' ').replace(/\s+/g, ' ').trim(); - return literalClasses ? 'class="' + escapeHtml(literalClasses) + '"' : ''; - }) - .replace(/\bclassName\s*=/g, 'class=') - .replace(/\sstyle=\{\{([\s\S]*?)\}\}/g, (_match, body) => { - const css = jsxStyleObjectToCss(body); - return css ? ' style="' + escapeHtml(css) + '"' : ''; - }); - } - - function jsxStyleObjectToCss(body) { - const declarations = []; - const re = /(["'][^"']+["']|[A-Za-z_$][\w$-]*)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?\d+(?:\.\d+)?))/g; - let match; - while ((match = re.exec(String(body || '')))) { - const prop = jsxStylePropToCss(match[1]); - const value = match[2] ?? match[3] ?? match[4] ?? ''; - if (!prop || value === '') continue; - declarations.push(prop + ': ' + value); - } - return declarations.join('; '); - } - - function jsxStylePropToCss(prop) { - let out = String(prop || '').trim().replace(/^["']|["']$/g, ''); - if (!out) return ''; - if (out.startsWith('--')) return out; - return out.replace(/[A-Z]/g, (ch) => '-' + ch.toLowerCase()).replace(/^-ms-/, '-ms-'); - } - function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { const map = new Map(); if (!sourceOriginal || !liveOriginal) return map; diff --git a/.opencode/skills/impeccable/scripts/live-browser.js b/.opencode/skills/impeccable/scripts/live-browser.js index 1f373a894..da026e255 100644 --- a/.opencode/skills/impeccable/scripts/live-browser.js +++ b/.opencode/skills/impeccable/scripts/live-browser.js @@ -6212,6 +6212,72 @@ showToast('The previous live session no longer matches the source file, so it was discarded. Pick an element to start fresh.', 6000); } + function isJsxSourceFile(filePath) { + return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); + } + + function completeSourceInjection(wrapper, sessionId, opts) { + recoveryWaitingForAnchor = false; + if (pendingVariantAnchorRetryObserver) { + pendingVariantAnchorRetryObserver.disconnect(); + pendingVariantAnchorRetryObserver = null; + } + + const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; + const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); + arrivedVariants = variants.length; + expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); + if (arrivedVariants <= 0) { + if (state === 'GENERATING') { + // Mid-generation the source legitimately holds a scaffold wrapper + // with no variants yet (the server-side preflight wraps before the + // agent writes). Tearing the session down here would destroy an + // in-flight generation; stay in GENERATING — the variant observer + // is armed and the server re-delivers a missed `done`. + if (!opts.generationCompleted) { + console.log('[impeccable] Source has scaffold but no variants yet; still generating.'); + return; + } + // Generation finished, yet the read shows only the scaffold: the + // source view is stale and no further event will fire. Re-read a + // few times before surfacing recovery — a single silent return + // here would strand the tab in GENERATING forever. + const attempt = opts.attempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + console.log('[impeccable] Generation is done but source shows no variants yet; retrying read (' + + (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').'); + setTimeout(() => { + if (state !== 'GENERATING' || currentSessionId !== sessionId) return; + if (arrivedVariants > 0) return; + injectVariantsFromSource(opts.filePath, sessionId, { ...opts, attempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + return; + } + } + recoverEmptyCycling('source-fallback-empty'); + return; + } + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants + ? previousVisibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + showVariantInDOM(sessionId, visibleVariant); + + selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; + + setLiveState('CYCLING'); + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + showOrUpdateCyclingBar(); + disableInlineEdit(); + refreshParamsPanel(); + positionBar(); + saveSession(); + completeParameterGenerationIfReady(); + console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); + } + /** * No-HMR fallback: fetch the raw source file from the live server, * parse it, extract the variant wrapper, and inject it into the live DOM. @@ -6229,14 +6295,53 @@ return; } rememberSessionFileMeta({ file: filePath }); + if (isJsxSourceFile(filePath)) { + const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { + completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); + return; + } + // #454: never fetch/parse JSX. Missing wrap waits for mount (closed + // modal / other route). Insert scaffolds stay for late HMR. A replace + // scaffold with no variants after retries is a failed generation. + if (opts.generationCompleted && sessionId === currentSessionId) { + const attempt = opts.attempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + setTimeout(() => { + if (state !== 'GENERATING' || currentSessionId !== sessionId) return; + injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + return; + } + if (!liveWrapper) { + showToast( + "Variants ready. If the picked element isn't visible, retrace the path that revealed it - they'll appear automatically.", + 15000, + ); + return; + } + if (liveWrapper.dataset.impeccableMode !== 'insert') { + recoverEmptyCycling('source-fallback-empty'); + } + return; + } + if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { + const attempt = opts._orphanAttempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + setTimeout(() => { + if (sessionId !== currentSessionId) return; + if (state !== 'GENERATING' && state !== 'CYCLING') return; + injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + } + } + return; + } const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); fetch(url) .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) .then(html => { const parser = new DOMParser(); - let srcWrapper = null; - - // Full-file parse works for HTML/JSX; Astro/Vue sources need marker extraction. const startMark = ''; const endMark = ''; const startIdx = html.indexOf(startMark); @@ -6244,8 +6349,8 @@ const block = startIdx !== -1 && endIdx !== -1 && endIdx > startIdx ? html.slice(startIdx + startMark.length, endIdx).trim() : html; - const doc = parser.parseFromString(normalizeSourceFallbackBlock(block, filePath), 'text/html'); - srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const doc = parser.parseFromString(block, 'text/html'); + const srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { console.warn('[impeccable] Variant wrapper not found in source file.'); // A resumed cycling session whose wrapper is gone from source is an @@ -6270,93 +6375,33 @@ return; } - const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; - const wrapper = srcWrapper.cloneNode(true); - - // Wrapper already in DOM (wrap HMR landed, variant insert did not). const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (existingWrapper) { + const wrapper = srcWrapper.cloneNode(true); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); - } else { - const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); - if (!origContent) return; - - const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); - if (!liveEl) { - console.warn('[impeccable] Could not find original element in live DOM.'); - enterRecoveryWaitingForAnchor({ - filePath, - sessionId, - srcWrapper, - checkpointReason: 'variant_anchor_missing', - trackScroll: false, - }); - return; - } - - liveEl.parentElement.replaceChild(wrapper, liveEl); - } - recoveryWaitingForAnchor = false; - if (pendingVariantAnchorRetryObserver) { - pendingVariantAnchorRetryObserver.disconnect(); - pendingVariantAnchorRetryObserver = null; - } - - // Update state: count variants, preserving the user's current variant - // when a late HMR/source reinjection lands after they have cycled. - const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); - arrivedVariants = variants.length; - expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); - if (arrivedVariants <= 0) { - if (state === 'GENERATING') { - // Mid-generation the source legitimately holds a scaffold wrapper - // with no variants yet (the server-side preflight wraps before the - // agent writes). Tearing the session down here would destroy an - // in-flight generation; stay in GENERATING — the variant observer - // is armed and the server re-delivers a missed `done`. - if (!opts.generationCompleted) { - console.log('[impeccable] Source has scaffold but no variants yet; still generating.'); - return; - } - // Generation finished, yet the read shows only the scaffold: the - // source view is stale and no further event will fire. Re-read a - // few times before surfacing recovery — a single silent return - // here would strand the tab in GENERATING forever. - const attempt = opts.attempt || 0; - if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { - console.log('[impeccable] Generation is done but source shows no variants yet; retrying read (' - + (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').'); - setTimeout(() => { - if (state !== 'GENERATING' || currentSessionId !== sessionId) return; - if (arrivedVariants > 0) return; - injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 }); - }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); - return; - } - } - recoverEmptyCycling('source-fallback-empty'); + completeSourceInjection(wrapper, sessionId, { ...opts, filePath }); return; } - const saved = loadSession(); - const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; - visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants - ? previousVisibleVariant - : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); - showVariantInDOM(sessionId, visibleVariant); - // Update selectedElement to the visible variant's content - selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; + const wrapper = srcWrapper.cloneNode(true); + const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); + if (!origContent) return; - setLiveState('CYCLING'); - recoveryWaitingForAnchor = false; - hideShaderOverlay(); - showOrUpdateCyclingBar(); - disableInlineEdit(); - refreshParamsPanel(); - positionBar(); - saveSession(); - completeParameterGenerationIfReady(); - console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); + const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); + if (!liveEl) { + console.warn('[impeccable] Could not find original element in live DOM.'); + enterRecoveryWaitingForAnchor({ + filePath, + sessionId, + srcWrapper, + checkpointReason: 'variant_anchor_missing', + trackScroll: false, + }); + return; + } + + liveEl.parentElement.replaceChild(wrapper, liveEl); + completeSourceInjection(wrapper, sessionId, { ...opts, filePath }); }) .catch(err => { console.error('[impeccable] Failed to fetch source:', err); @@ -6364,44 +6409,6 @@ }); } - function normalizeSourceFallbackBlock(block, filePath) { - if (!/\.[cm]?[jt]sx$/i.test(String(filePath || ''))) return block; - return String(block) - .replace( - /]*)>\s*\{\s*`([\s\S]*?)`\s*\}\s*<\/style>/g, - (_match, attrs, css) => '' + css + '', - ) - .replace(/\bclassName\s*=\s*\{\s*`([^`]*?)`\s*\}/g, (_match, value) => { - const literalClasses = value.replace(/\$\{[^}]*\}/g, ' ').replace(/\s+/g, ' ').trim(); - return literalClasses ? 'class="' + escapeHtml(literalClasses) + '"' : ''; - }) - .replace(/\bclassName\s*=/g, 'class=') - .replace(/\sstyle=\{\{([\s\S]*?)\}\}/g, (_match, body) => { - const css = jsxStyleObjectToCss(body); - return css ? ' style="' + escapeHtml(css) + '"' : ''; - }); - } - - function jsxStyleObjectToCss(body) { - const declarations = []; - const re = /(["'][^"']+["']|[A-Za-z_$][\w$-]*)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?\d+(?:\.\d+)?))/g; - let match; - while ((match = re.exec(String(body || '')))) { - const prop = jsxStylePropToCss(match[1]); - const value = match[2] ?? match[3] ?? match[4] ?? ''; - if (!prop || value === '') continue; - declarations.push(prop + ': ' + value); - } - return declarations.join('; '); - } - - function jsxStylePropToCss(prop) { - let out = String(prop || '').trim().replace(/^["']|["']$/g, ''); - if (!out) return ''; - if (out.startsWith('--')) return out; - return out.replace(/[A-Z]/g, (ch) => '-' + ch.toLowerCase()).replace(/^-ms-/, '-ms-'); - } - function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { const map = new Map(); if (!sourceOriginal || !liveOriginal) return map; diff --git a/.pi/skills/impeccable/scripts/live-browser.js b/.pi/skills/impeccable/scripts/live-browser.js index 1f373a894..da026e255 100644 --- a/.pi/skills/impeccable/scripts/live-browser.js +++ b/.pi/skills/impeccable/scripts/live-browser.js @@ -6212,6 +6212,72 @@ showToast('The previous live session no longer matches the source file, so it was discarded. Pick an element to start fresh.', 6000); } + function isJsxSourceFile(filePath) { + return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); + } + + function completeSourceInjection(wrapper, sessionId, opts) { + recoveryWaitingForAnchor = false; + if (pendingVariantAnchorRetryObserver) { + pendingVariantAnchorRetryObserver.disconnect(); + pendingVariantAnchorRetryObserver = null; + } + + const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; + const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); + arrivedVariants = variants.length; + expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); + if (arrivedVariants <= 0) { + if (state === 'GENERATING') { + // Mid-generation the source legitimately holds a scaffold wrapper + // with no variants yet (the server-side preflight wraps before the + // agent writes). Tearing the session down here would destroy an + // in-flight generation; stay in GENERATING — the variant observer + // is armed and the server re-delivers a missed `done`. + if (!opts.generationCompleted) { + console.log('[impeccable] Source has scaffold but no variants yet; still generating.'); + return; + } + // Generation finished, yet the read shows only the scaffold: the + // source view is stale and no further event will fire. Re-read a + // few times before surfacing recovery — a single silent return + // here would strand the tab in GENERATING forever. + const attempt = opts.attempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + console.log('[impeccable] Generation is done but source shows no variants yet; retrying read (' + + (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').'); + setTimeout(() => { + if (state !== 'GENERATING' || currentSessionId !== sessionId) return; + if (arrivedVariants > 0) return; + injectVariantsFromSource(opts.filePath, sessionId, { ...opts, attempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + return; + } + } + recoverEmptyCycling('source-fallback-empty'); + return; + } + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants + ? previousVisibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + showVariantInDOM(sessionId, visibleVariant); + + selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; + + setLiveState('CYCLING'); + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + showOrUpdateCyclingBar(); + disableInlineEdit(); + refreshParamsPanel(); + positionBar(); + saveSession(); + completeParameterGenerationIfReady(); + console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); + } + /** * No-HMR fallback: fetch the raw source file from the live server, * parse it, extract the variant wrapper, and inject it into the live DOM. @@ -6229,14 +6295,53 @@ return; } rememberSessionFileMeta({ file: filePath }); + if (isJsxSourceFile(filePath)) { + const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { + completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); + return; + } + // #454: never fetch/parse JSX. Missing wrap waits for mount (closed + // modal / other route). Insert scaffolds stay for late HMR. A replace + // scaffold with no variants after retries is a failed generation. + if (opts.generationCompleted && sessionId === currentSessionId) { + const attempt = opts.attempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + setTimeout(() => { + if (state !== 'GENERATING' || currentSessionId !== sessionId) return; + injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + return; + } + if (!liveWrapper) { + showToast( + "Variants ready. If the picked element isn't visible, retrace the path that revealed it - they'll appear automatically.", + 15000, + ); + return; + } + if (liveWrapper.dataset.impeccableMode !== 'insert') { + recoverEmptyCycling('source-fallback-empty'); + } + return; + } + if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { + const attempt = opts._orphanAttempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + setTimeout(() => { + if (sessionId !== currentSessionId) return; + if (state !== 'GENERATING' && state !== 'CYCLING') return; + injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + } + } + return; + } const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); fetch(url) .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) .then(html => { const parser = new DOMParser(); - let srcWrapper = null; - - // Full-file parse works for HTML/JSX; Astro/Vue sources need marker extraction. const startMark = ''; const endMark = ''; const startIdx = html.indexOf(startMark); @@ -6244,8 +6349,8 @@ const block = startIdx !== -1 && endIdx !== -1 && endIdx > startIdx ? html.slice(startIdx + startMark.length, endIdx).trim() : html; - const doc = parser.parseFromString(normalizeSourceFallbackBlock(block, filePath), 'text/html'); - srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const doc = parser.parseFromString(block, 'text/html'); + const srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { console.warn('[impeccable] Variant wrapper not found in source file.'); // A resumed cycling session whose wrapper is gone from source is an @@ -6270,93 +6375,33 @@ return; } - const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; - const wrapper = srcWrapper.cloneNode(true); - - // Wrapper already in DOM (wrap HMR landed, variant insert did not). const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (existingWrapper) { + const wrapper = srcWrapper.cloneNode(true); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); - } else { - const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); - if (!origContent) return; - - const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); - if (!liveEl) { - console.warn('[impeccable] Could not find original element in live DOM.'); - enterRecoveryWaitingForAnchor({ - filePath, - sessionId, - srcWrapper, - checkpointReason: 'variant_anchor_missing', - trackScroll: false, - }); - return; - } - - liveEl.parentElement.replaceChild(wrapper, liveEl); - } - recoveryWaitingForAnchor = false; - if (pendingVariantAnchorRetryObserver) { - pendingVariantAnchorRetryObserver.disconnect(); - pendingVariantAnchorRetryObserver = null; - } - - // Update state: count variants, preserving the user's current variant - // when a late HMR/source reinjection lands after they have cycled. - const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); - arrivedVariants = variants.length; - expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); - if (arrivedVariants <= 0) { - if (state === 'GENERATING') { - // Mid-generation the source legitimately holds a scaffold wrapper - // with no variants yet (the server-side preflight wraps before the - // agent writes). Tearing the session down here would destroy an - // in-flight generation; stay in GENERATING — the variant observer - // is armed and the server re-delivers a missed `done`. - if (!opts.generationCompleted) { - console.log('[impeccable] Source has scaffold but no variants yet; still generating.'); - return; - } - // Generation finished, yet the read shows only the scaffold: the - // source view is stale and no further event will fire. Re-read a - // few times before surfacing recovery — a single silent return - // here would strand the tab in GENERATING forever. - const attempt = opts.attempt || 0; - if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { - console.log('[impeccable] Generation is done but source shows no variants yet; retrying read (' - + (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').'); - setTimeout(() => { - if (state !== 'GENERATING' || currentSessionId !== sessionId) return; - if (arrivedVariants > 0) return; - injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 }); - }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); - return; - } - } - recoverEmptyCycling('source-fallback-empty'); + completeSourceInjection(wrapper, sessionId, { ...opts, filePath }); return; } - const saved = loadSession(); - const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; - visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants - ? previousVisibleVariant - : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); - showVariantInDOM(sessionId, visibleVariant); - // Update selectedElement to the visible variant's content - selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; + const wrapper = srcWrapper.cloneNode(true); + const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); + if (!origContent) return; - setLiveState('CYCLING'); - recoveryWaitingForAnchor = false; - hideShaderOverlay(); - showOrUpdateCyclingBar(); - disableInlineEdit(); - refreshParamsPanel(); - positionBar(); - saveSession(); - completeParameterGenerationIfReady(); - console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); + const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); + if (!liveEl) { + console.warn('[impeccable] Could not find original element in live DOM.'); + enterRecoveryWaitingForAnchor({ + filePath, + sessionId, + srcWrapper, + checkpointReason: 'variant_anchor_missing', + trackScroll: false, + }); + return; + } + + liveEl.parentElement.replaceChild(wrapper, liveEl); + completeSourceInjection(wrapper, sessionId, { ...opts, filePath }); }) .catch(err => { console.error('[impeccable] Failed to fetch source:', err); @@ -6364,44 +6409,6 @@ }); } - function normalizeSourceFallbackBlock(block, filePath) { - if (!/\.[cm]?[jt]sx$/i.test(String(filePath || ''))) return block; - return String(block) - .replace( - /]*)>\s*\{\s*`([\s\S]*?)`\s*\}\s*<\/style>/g, - (_match, attrs, css) => '' + css + '', - ) - .replace(/\bclassName\s*=\s*\{\s*`([^`]*?)`\s*\}/g, (_match, value) => { - const literalClasses = value.replace(/\$\{[^}]*\}/g, ' ').replace(/\s+/g, ' ').trim(); - return literalClasses ? 'class="' + escapeHtml(literalClasses) + '"' : ''; - }) - .replace(/\bclassName\s*=/g, 'class=') - .replace(/\sstyle=\{\{([\s\S]*?)\}\}/g, (_match, body) => { - const css = jsxStyleObjectToCss(body); - return css ? ' style="' + escapeHtml(css) + '"' : ''; - }); - } - - function jsxStyleObjectToCss(body) { - const declarations = []; - const re = /(["'][^"']+["']|[A-Za-z_$][\w$-]*)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?\d+(?:\.\d+)?))/g; - let match; - while ((match = re.exec(String(body || '')))) { - const prop = jsxStylePropToCss(match[1]); - const value = match[2] ?? match[3] ?? match[4] ?? ''; - if (!prop || value === '') continue; - declarations.push(prop + ': ' + value); - } - return declarations.join('; '); - } - - function jsxStylePropToCss(prop) { - let out = String(prop || '').trim().replace(/^["']|["']$/g, ''); - if (!out) return ''; - if (out.startsWith('--')) return out; - return out.replace(/[A-Z]/g, (ch) => '-' + ch.toLowerCase()).replace(/^-ms-/, '-ms-'); - } - function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { const map = new Map(); if (!sourceOriginal || !liveOriginal) return map; diff --git a/.qoder/skills/impeccable/scripts/live-browser.js b/.qoder/skills/impeccable/scripts/live-browser.js index 1f373a894..da026e255 100644 --- a/.qoder/skills/impeccable/scripts/live-browser.js +++ b/.qoder/skills/impeccable/scripts/live-browser.js @@ -6212,6 +6212,72 @@ showToast('The previous live session no longer matches the source file, so it was discarded. Pick an element to start fresh.', 6000); } + function isJsxSourceFile(filePath) { + return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); + } + + function completeSourceInjection(wrapper, sessionId, opts) { + recoveryWaitingForAnchor = false; + if (pendingVariantAnchorRetryObserver) { + pendingVariantAnchorRetryObserver.disconnect(); + pendingVariantAnchorRetryObserver = null; + } + + const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; + const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); + arrivedVariants = variants.length; + expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); + if (arrivedVariants <= 0) { + if (state === 'GENERATING') { + // Mid-generation the source legitimately holds a scaffold wrapper + // with no variants yet (the server-side preflight wraps before the + // agent writes). Tearing the session down here would destroy an + // in-flight generation; stay in GENERATING — the variant observer + // is armed and the server re-delivers a missed `done`. + if (!opts.generationCompleted) { + console.log('[impeccable] Source has scaffold but no variants yet; still generating.'); + return; + } + // Generation finished, yet the read shows only the scaffold: the + // source view is stale and no further event will fire. Re-read a + // few times before surfacing recovery — a single silent return + // here would strand the tab in GENERATING forever. + const attempt = opts.attempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + console.log('[impeccable] Generation is done but source shows no variants yet; retrying read (' + + (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').'); + setTimeout(() => { + if (state !== 'GENERATING' || currentSessionId !== sessionId) return; + if (arrivedVariants > 0) return; + injectVariantsFromSource(opts.filePath, sessionId, { ...opts, attempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + return; + } + } + recoverEmptyCycling('source-fallback-empty'); + return; + } + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants + ? previousVisibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + showVariantInDOM(sessionId, visibleVariant); + + selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; + + setLiveState('CYCLING'); + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + showOrUpdateCyclingBar(); + disableInlineEdit(); + refreshParamsPanel(); + positionBar(); + saveSession(); + completeParameterGenerationIfReady(); + console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); + } + /** * No-HMR fallback: fetch the raw source file from the live server, * parse it, extract the variant wrapper, and inject it into the live DOM. @@ -6229,14 +6295,53 @@ return; } rememberSessionFileMeta({ file: filePath }); + if (isJsxSourceFile(filePath)) { + const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { + completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); + return; + } + // #454: never fetch/parse JSX. Missing wrap waits for mount (closed + // modal / other route). Insert scaffolds stay for late HMR. A replace + // scaffold with no variants after retries is a failed generation. + if (opts.generationCompleted && sessionId === currentSessionId) { + const attempt = opts.attempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + setTimeout(() => { + if (state !== 'GENERATING' || currentSessionId !== sessionId) return; + injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + return; + } + if (!liveWrapper) { + showToast( + "Variants ready. If the picked element isn't visible, retrace the path that revealed it - they'll appear automatically.", + 15000, + ); + return; + } + if (liveWrapper.dataset.impeccableMode !== 'insert') { + recoverEmptyCycling('source-fallback-empty'); + } + return; + } + if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { + const attempt = opts._orphanAttempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + setTimeout(() => { + if (sessionId !== currentSessionId) return; + if (state !== 'GENERATING' && state !== 'CYCLING') return; + injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + } + } + return; + } const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); fetch(url) .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) .then(html => { const parser = new DOMParser(); - let srcWrapper = null; - - // Full-file parse works for HTML/JSX; Astro/Vue sources need marker extraction. const startMark = ''; const endMark = ''; const startIdx = html.indexOf(startMark); @@ -6244,8 +6349,8 @@ const block = startIdx !== -1 && endIdx !== -1 && endIdx > startIdx ? html.slice(startIdx + startMark.length, endIdx).trim() : html; - const doc = parser.parseFromString(normalizeSourceFallbackBlock(block, filePath), 'text/html'); - srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const doc = parser.parseFromString(block, 'text/html'); + const srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { console.warn('[impeccable] Variant wrapper not found in source file.'); // A resumed cycling session whose wrapper is gone from source is an @@ -6270,93 +6375,33 @@ return; } - const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; - const wrapper = srcWrapper.cloneNode(true); - - // Wrapper already in DOM (wrap HMR landed, variant insert did not). const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (existingWrapper) { + const wrapper = srcWrapper.cloneNode(true); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); - } else { - const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); - if (!origContent) return; - - const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); - if (!liveEl) { - console.warn('[impeccable] Could not find original element in live DOM.'); - enterRecoveryWaitingForAnchor({ - filePath, - sessionId, - srcWrapper, - checkpointReason: 'variant_anchor_missing', - trackScroll: false, - }); - return; - } - - liveEl.parentElement.replaceChild(wrapper, liveEl); - } - recoveryWaitingForAnchor = false; - if (pendingVariantAnchorRetryObserver) { - pendingVariantAnchorRetryObserver.disconnect(); - pendingVariantAnchorRetryObserver = null; - } - - // Update state: count variants, preserving the user's current variant - // when a late HMR/source reinjection lands after they have cycled. - const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); - arrivedVariants = variants.length; - expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); - if (arrivedVariants <= 0) { - if (state === 'GENERATING') { - // Mid-generation the source legitimately holds a scaffold wrapper - // with no variants yet (the server-side preflight wraps before the - // agent writes). Tearing the session down here would destroy an - // in-flight generation; stay in GENERATING — the variant observer - // is armed and the server re-delivers a missed `done`. - if (!opts.generationCompleted) { - console.log('[impeccable] Source has scaffold but no variants yet; still generating.'); - return; - } - // Generation finished, yet the read shows only the scaffold: the - // source view is stale and no further event will fire. Re-read a - // few times before surfacing recovery — a single silent return - // here would strand the tab in GENERATING forever. - const attempt = opts.attempt || 0; - if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { - console.log('[impeccable] Generation is done but source shows no variants yet; retrying read (' - + (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').'); - setTimeout(() => { - if (state !== 'GENERATING' || currentSessionId !== sessionId) return; - if (arrivedVariants > 0) return; - injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 }); - }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); - return; - } - } - recoverEmptyCycling('source-fallback-empty'); + completeSourceInjection(wrapper, sessionId, { ...opts, filePath }); return; } - const saved = loadSession(); - const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; - visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants - ? previousVisibleVariant - : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); - showVariantInDOM(sessionId, visibleVariant); - // Update selectedElement to the visible variant's content - selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; + const wrapper = srcWrapper.cloneNode(true); + const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); + if (!origContent) return; - setLiveState('CYCLING'); - recoveryWaitingForAnchor = false; - hideShaderOverlay(); - showOrUpdateCyclingBar(); - disableInlineEdit(); - refreshParamsPanel(); - positionBar(); - saveSession(); - completeParameterGenerationIfReady(); - console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); + const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); + if (!liveEl) { + console.warn('[impeccable] Could not find original element in live DOM.'); + enterRecoveryWaitingForAnchor({ + filePath, + sessionId, + srcWrapper, + checkpointReason: 'variant_anchor_missing', + trackScroll: false, + }); + return; + } + + liveEl.parentElement.replaceChild(wrapper, liveEl); + completeSourceInjection(wrapper, sessionId, { ...opts, filePath }); }) .catch(err => { console.error('[impeccable] Failed to fetch source:', err); @@ -6364,44 +6409,6 @@ }); } - function normalizeSourceFallbackBlock(block, filePath) { - if (!/\.[cm]?[jt]sx$/i.test(String(filePath || ''))) return block; - return String(block) - .replace( - /]*)>\s*\{\s*`([\s\S]*?)`\s*\}\s*<\/style>/g, - (_match, attrs, css) => '' + css + '', - ) - .replace(/\bclassName\s*=\s*\{\s*`([^`]*?)`\s*\}/g, (_match, value) => { - const literalClasses = value.replace(/\$\{[^}]*\}/g, ' ').replace(/\s+/g, ' ').trim(); - return literalClasses ? 'class="' + escapeHtml(literalClasses) + '"' : ''; - }) - .replace(/\bclassName\s*=/g, 'class=') - .replace(/\sstyle=\{\{([\s\S]*?)\}\}/g, (_match, body) => { - const css = jsxStyleObjectToCss(body); - return css ? ' style="' + escapeHtml(css) + '"' : ''; - }); - } - - function jsxStyleObjectToCss(body) { - const declarations = []; - const re = /(["'][^"']+["']|[A-Za-z_$][\w$-]*)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?\d+(?:\.\d+)?))/g; - let match; - while ((match = re.exec(String(body || '')))) { - const prop = jsxStylePropToCss(match[1]); - const value = match[2] ?? match[3] ?? match[4] ?? ''; - if (!prop || value === '') continue; - declarations.push(prop + ': ' + value); - } - return declarations.join('; '); - } - - function jsxStylePropToCss(prop) { - let out = String(prop || '').trim().replace(/^["']|["']$/g, ''); - if (!out) return ''; - if (out.startsWith('--')) return out; - return out.replace(/[A-Z]/g, (ch) => '-' + ch.toLowerCase()).replace(/^-ms-/, '-ms-'); - } - function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { const map = new Map(); if (!sourceOriginal || !liveOriginal) return map; diff --git a/.rovodev/skills/impeccable/scripts/live-browser.js b/.rovodev/skills/impeccable/scripts/live-browser.js index 1f373a894..da026e255 100644 --- a/.rovodev/skills/impeccable/scripts/live-browser.js +++ b/.rovodev/skills/impeccable/scripts/live-browser.js @@ -6212,6 +6212,72 @@ showToast('The previous live session no longer matches the source file, so it was discarded. Pick an element to start fresh.', 6000); } + function isJsxSourceFile(filePath) { + return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); + } + + function completeSourceInjection(wrapper, sessionId, opts) { + recoveryWaitingForAnchor = false; + if (pendingVariantAnchorRetryObserver) { + pendingVariantAnchorRetryObserver.disconnect(); + pendingVariantAnchorRetryObserver = null; + } + + const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; + const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); + arrivedVariants = variants.length; + expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); + if (arrivedVariants <= 0) { + if (state === 'GENERATING') { + // Mid-generation the source legitimately holds a scaffold wrapper + // with no variants yet (the server-side preflight wraps before the + // agent writes). Tearing the session down here would destroy an + // in-flight generation; stay in GENERATING — the variant observer + // is armed and the server re-delivers a missed `done`. + if (!opts.generationCompleted) { + console.log('[impeccable] Source has scaffold but no variants yet; still generating.'); + return; + } + // Generation finished, yet the read shows only the scaffold: the + // source view is stale and no further event will fire. Re-read a + // few times before surfacing recovery — a single silent return + // here would strand the tab in GENERATING forever. + const attempt = opts.attempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + console.log('[impeccable] Generation is done but source shows no variants yet; retrying read (' + + (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').'); + setTimeout(() => { + if (state !== 'GENERATING' || currentSessionId !== sessionId) return; + if (arrivedVariants > 0) return; + injectVariantsFromSource(opts.filePath, sessionId, { ...opts, attempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + return; + } + } + recoverEmptyCycling('source-fallback-empty'); + return; + } + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants + ? previousVisibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + showVariantInDOM(sessionId, visibleVariant); + + selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; + + setLiveState('CYCLING'); + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + showOrUpdateCyclingBar(); + disableInlineEdit(); + refreshParamsPanel(); + positionBar(); + saveSession(); + completeParameterGenerationIfReady(); + console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); + } + /** * No-HMR fallback: fetch the raw source file from the live server, * parse it, extract the variant wrapper, and inject it into the live DOM. @@ -6229,14 +6295,53 @@ return; } rememberSessionFileMeta({ file: filePath }); + if (isJsxSourceFile(filePath)) { + const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { + completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); + return; + } + // #454: never fetch/parse JSX. Missing wrap waits for mount (closed + // modal / other route). Insert scaffolds stay for late HMR. A replace + // scaffold with no variants after retries is a failed generation. + if (opts.generationCompleted && sessionId === currentSessionId) { + const attempt = opts.attempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + setTimeout(() => { + if (state !== 'GENERATING' || currentSessionId !== sessionId) return; + injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + return; + } + if (!liveWrapper) { + showToast( + "Variants ready. If the picked element isn't visible, retrace the path that revealed it - they'll appear automatically.", + 15000, + ); + return; + } + if (liveWrapper.dataset.impeccableMode !== 'insert') { + recoverEmptyCycling('source-fallback-empty'); + } + return; + } + if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { + const attempt = opts._orphanAttempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + setTimeout(() => { + if (sessionId !== currentSessionId) return; + if (state !== 'GENERATING' && state !== 'CYCLING') return; + injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + } + } + return; + } const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); fetch(url) .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) .then(html => { const parser = new DOMParser(); - let srcWrapper = null; - - // Full-file parse works for HTML/JSX; Astro/Vue sources need marker extraction. const startMark = ''; const endMark = ''; const startIdx = html.indexOf(startMark); @@ -6244,8 +6349,8 @@ const block = startIdx !== -1 && endIdx !== -1 && endIdx > startIdx ? html.slice(startIdx + startMark.length, endIdx).trim() : html; - const doc = parser.parseFromString(normalizeSourceFallbackBlock(block, filePath), 'text/html'); - srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const doc = parser.parseFromString(block, 'text/html'); + const srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { console.warn('[impeccable] Variant wrapper not found in source file.'); // A resumed cycling session whose wrapper is gone from source is an @@ -6270,93 +6375,33 @@ return; } - const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; - const wrapper = srcWrapper.cloneNode(true); - - // Wrapper already in DOM (wrap HMR landed, variant insert did not). const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (existingWrapper) { + const wrapper = srcWrapper.cloneNode(true); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); - } else { - const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); - if (!origContent) return; - - const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); - if (!liveEl) { - console.warn('[impeccable] Could not find original element in live DOM.'); - enterRecoveryWaitingForAnchor({ - filePath, - sessionId, - srcWrapper, - checkpointReason: 'variant_anchor_missing', - trackScroll: false, - }); - return; - } - - liveEl.parentElement.replaceChild(wrapper, liveEl); - } - recoveryWaitingForAnchor = false; - if (pendingVariantAnchorRetryObserver) { - pendingVariantAnchorRetryObserver.disconnect(); - pendingVariantAnchorRetryObserver = null; - } - - // Update state: count variants, preserving the user's current variant - // when a late HMR/source reinjection lands after they have cycled. - const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); - arrivedVariants = variants.length; - expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); - if (arrivedVariants <= 0) { - if (state === 'GENERATING') { - // Mid-generation the source legitimately holds a scaffold wrapper - // with no variants yet (the server-side preflight wraps before the - // agent writes). Tearing the session down here would destroy an - // in-flight generation; stay in GENERATING — the variant observer - // is armed and the server re-delivers a missed `done`. - if (!opts.generationCompleted) { - console.log('[impeccable] Source has scaffold but no variants yet; still generating.'); - return; - } - // Generation finished, yet the read shows only the scaffold: the - // source view is stale and no further event will fire. Re-read a - // few times before surfacing recovery — a single silent return - // here would strand the tab in GENERATING forever. - const attempt = opts.attempt || 0; - if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { - console.log('[impeccable] Generation is done but source shows no variants yet; retrying read (' - + (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').'); - setTimeout(() => { - if (state !== 'GENERATING' || currentSessionId !== sessionId) return; - if (arrivedVariants > 0) return; - injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 }); - }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); - return; - } - } - recoverEmptyCycling('source-fallback-empty'); + completeSourceInjection(wrapper, sessionId, { ...opts, filePath }); return; } - const saved = loadSession(); - const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; - visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants - ? previousVisibleVariant - : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); - showVariantInDOM(sessionId, visibleVariant); - // Update selectedElement to the visible variant's content - selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; + const wrapper = srcWrapper.cloneNode(true); + const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); + if (!origContent) return; - setLiveState('CYCLING'); - recoveryWaitingForAnchor = false; - hideShaderOverlay(); - showOrUpdateCyclingBar(); - disableInlineEdit(); - refreshParamsPanel(); - positionBar(); - saveSession(); - completeParameterGenerationIfReady(); - console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); + const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); + if (!liveEl) { + console.warn('[impeccable] Could not find original element in live DOM.'); + enterRecoveryWaitingForAnchor({ + filePath, + sessionId, + srcWrapper, + checkpointReason: 'variant_anchor_missing', + trackScroll: false, + }); + return; + } + + liveEl.parentElement.replaceChild(wrapper, liveEl); + completeSourceInjection(wrapper, sessionId, { ...opts, filePath }); }) .catch(err => { console.error('[impeccable] Failed to fetch source:', err); @@ -6364,44 +6409,6 @@ }); } - function normalizeSourceFallbackBlock(block, filePath) { - if (!/\.[cm]?[jt]sx$/i.test(String(filePath || ''))) return block; - return String(block) - .replace( - /]*)>\s*\{\s*`([\s\S]*?)`\s*\}\s*<\/style>/g, - (_match, attrs, css) => '' + css + '', - ) - .replace(/\bclassName\s*=\s*\{\s*`([^`]*?)`\s*\}/g, (_match, value) => { - const literalClasses = value.replace(/\$\{[^}]*\}/g, ' ').replace(/\s+/g, ' ').trim(); - return literalClasses ? 'class="' + escapeHtml(literalClasses) + '"' : ''; - }) - .replace(/\bclassName\s*=/g, 'class=') - .replace(/\sstyle=\{\{([\s\S]*?)\}\}/g, (_match, body) => { - const css = jsxStyleObjectToCss(body); - return css ? ' style="' + escapeHtml(css) + '"' : ''; - }); - } - - function jsxStyleObjectToCss(body) { - const declarations = []; - const re = /(["'][^"']+["']|[A-Za-z_$][\w$-]*)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?\d+(?:\.\d+)?))/g; - let match; - while ((match = re.exec(String(body || '')))) { - const prop = jsxStylePropToCss(match[1]); - const value = match[2] ?? match[3] ?? match[4] ?? ''; - if (!prop || value === '') continue; - declarations.push(prop + ': ' + value); - } - return declarations.join('; '); - } - - function jsxStylePropToCss(prop) { - let out = String(prop || '').trim().replace(/^["']|["']$/g, ''); - if (!out) return ''; - if (out.startsWith('--')) return out; - return out.replace(/[A-Z]/g, (ch) => '-' + ch.toLowerCase()).replace(/^-ms-/, '-ms-'); - } - function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { const map = new Map(); if (!sourceOriginal || !liveOriginal) return map; diff --git a/.trae-cn/skills/impeccable/scripts/live-browser.js b/.trae-cn/skills/impeccable/scripts/live-browser.js index 1f373a894..da026e255 100644 --- a/.trae-cn/skills/impeccable/scripts/live-browser.js +++ b/.trae-cn/skills/impeccable/scripts/live-browser.js @@ -6212,6 +6212,72 @@ showToast('The previous live session no longer matches the source file, so it was discarded. Pick an element to start fresh.', 6000); } + function isJsxSourceFile(filePath) { + return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); + } + + function completeSourceInjection(wrapper, sessionId, opts) { + recoveryWaitingForAnchor = false; + if (pendingVariantAnchorRetryObserver) { + pendingVariantAnchorRetryObserver.disconnect(); + pendingVariantAnchorRetryObserver = null; + } + + const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; + const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); + arrivedVariants = variants.length; + expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); + if (arrivedVariants <= 0) { + if (state === 'GENERATING') { + // Mid-generation the source legitimately holds a scaffold wrapper + // with no variants yet (the server-side preflight wraps before the + // agent writes). Tearing the session down here would destroy an + // in-flight generation; stay in GENERATING — the variant observer + // is armed and the server re-delivers a missed `done`. + if (!opts.generationCompleted) { + console.log('[impeccable] Source has scaffold but no variants yet; still generating.'); + return; + } + // Generation finished, yet the read shows only the scaffold: the + // source view is stale and no further event will fire. Re-read a + // few times before surfacing recovery — a single silent return + // here would strand the tab in GENERATING forever. + const attempt = opts.attempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + console.log('[impeccable] Generation is done but source shows no variants yet; retrying read (' + + (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').'); + setTimeout(() => { + if (state !== 'GENERATING' || currentSessionId !== sessionId) return; + if (arrivedVariants > 0) return; + injectVariantsFromSource(opts.filePath, sessionId, { ...opts, attempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + return; + } + } + recoverEmptyCycling('source-fallback-empty'); + return; + } + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants + ? previousVisibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + showVariantInDOM(sessionId, visibleVariant); + + selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; + + setLiveState('CYCLING'); + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + showOrUpdateCyclingBar(); + disableInlineEdit(); + refreshParamsPanel(); + positionBar(); + saveSession(); + completeParameterGenerationIfReady(); + console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); + } + /** * No-HMR fallback: fetch the raw source file from the live server, * parse it, extract the variant wrapper, and inject it into the live DOM. @@ -6229,14 +6295,53 @@ return; } rememberSessionFileMeta({ file: filePath }); + if (isJsxSourceFile(filePath)) { + const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { + completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); + return; + } + // #454: never fetch/parse JSX. Missing wrap waits for mount (closed + // modal / other route). Insert scaffolds stay for late HMR. A replace + // scaffold with no variants after retries is a failed generation. + if (opts.generationCompleted && sessionId === currentSessionId) { + const attempt = opts.attempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + setTimeout(() => { + if (state !== 'GENERATING' || currentSessionId !== sessionId) return; + injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + return; + } + if (!liveWrapper) { + showToast( + "Variants ready. If the picked element isn't visible, retrace the path that revealed it - they'll appear automatically.", + 15000, + ); + return; + } + if (liveWrapper.dataset.impeccableMode !== 'insert') { + recoverEmptyCycling('source-fallback-empty'); + } + return; + } + if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { + const attempt = opts._orphanAttempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + setTimeout(() => { + if (sessionId !== currentSessionId) return; + if (state !== 'GENERATING' && state !== 'CYCLING') return; + injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + } + } + return; + } const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); fetch(url) .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) .then(html => { const parser = new DOMParser(); - let srcWrapper = null; - - // Full-file parse works for HTML/JSX; Astro/Vue sources need marker extraction. const startMark = ''; const endMark = ''; const startIdx = html.indexOf(startMark); @@ -6244,8 +6349,8 @@ const block = startIdx !== -1 && endIdx !== -1 && endIdx > startIdx ? html.slice(startIdx + startMark.length, endIdx).trim() : html; - const doc = parser.parseFromString(normalizeSourceFallbackBlock(block, filePath), 'text/html'); - srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const doc = parser.parseFromString(block, 'text/html'); + const srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { console.warn('[impeccable] Variant wrapper not found in source file.'); // A resumed cycling session whose wrapper is gone from source is an @@ -6270,93 +6375,33 @@ return; } - const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; - const wrapper = srcWrapper.cloneNode(true); - - // Wrapper already in DOM (wrap HMR landed, variant insert did not). const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (existingWrapper) { + const wrapper = srcWrapper.cloneNode(true); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); - } else { - const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); - if (!origContent) return; - - const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); - if (!liveEl) { - console.warn('[impeccable] Could not find original element in live DOM.'); - enterRecoveryWaitingForAnchor({ - filePath, - sessionId, - srcWrapper, - checkpointReason: 'variant_anchor_missing', - trackScroll: false, - }); - return; - } - - liveEl.parentElement.replaceChild(wrapper, liveEl); - } - recoveryWaitingForAnchor = false; - if (pendingVariantAnchorRetryObserver) { - pendingVariantAnchorRetryObserver.disconnect(); - pendingVariantAnchorRetryObserver = null; - } - - // Update state: count variants, preserving the user's current variant - // when a late HMR/source reinjection lands after they have cycled. - const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); - arrivedVariants = variants.length; - expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); - if (arrivedVariants <= 0) { - if (state === 'GENERATING') { - // Mid-generation the source legitimately holds a scaffold wrapper - // with no variants yet (the server-side preflight wraps before the - // agent writes). Tearing the session down here would destroy an - // in-flight generation; stay in GENERATING — the variant observer - // is armed and the server re-delivers a missed `done`. - if (!opts.generationCompleted) { - console.log('[impeccable] Source has scaffold but no variants yet; still generating.'); - return; - } - // Generation finished, yet the read shows only the scaffold: the - // source view is stale and no further event will fire. Re-read a - // few times before surfacing recovery — a single silent return - // here would strand the tab in GENERATING forever. - const attempt = opts.attempt || 0; - if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { - console.log('[impeccable] Generation is done but source shows no variants yet; retrying read (' - + (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').'); - setTimeout(() => { - if (state !== 'GENERATING' || currentSessionId !== sessionId) return; - if (arrivedVariants > 0) return; - injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 }); - }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); - return; - } - } - recoverEmptyCycling('source-fallback-empty'); + completeSourceInjection(wrapper, sessionId, { ...opts, filePath }); return; } - const saved = loadSession(); - const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; - visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants - ? previousVisibleVariant - : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); - showVariantInDOM(sessionId, visibleVariant); - // Update selectedElement to the visible variant's content - selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; + const wrapper = srcWrapper.cloneNode(true); + const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); + if (!origContent) return; - setLiveState('CYCLING'); - recoveryWaitingForAnchor = false; - hideShaderOverlay(); - showOrUpdateCyclingBar(); - disableInlineEdit(); - refreshParamsPanel(); - positionBar(); - saveSession(); - completeParameterGenerationIfReady(); - console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); + const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); + if (!liveEl) { + console.warn('[impeccable] Could not find original element in live DOM.'); + enterRecoveryWaitingForAnchor({ + filePath, + sessionId, + srcWrapper, + checkpointReason: 'variant_anchor_missing', + trackScroll: false, + }); + return; + } + + liveEl.parentElement.replaceChild(wrapper, liveEl); + completeSourceInjection(wrapper, sessionId, { ...opts, filePath }); }) .catch(err => { console.error('[impeccable] Failed to fetch source:', err); @@ -6364,44 +6409,6 @@ }); } - function normalizeSourceFallbackBlock(block, filePath) { - if (!/\.[cm]?[jt]sx$/i.test(String(filePath || ''))) return block; - return String(block) - .replace( - /]*)>\s*\{\s*`([\s\S]*?)`\s*\}\s*<\/style>/g, - (_match, attrs, css) => '' + css + '', - ) - .replace(/\bclassName\s*=\s*\{\s*`([^`]*?)`\s*\}/g, (_match, value) => { - const literalClasses = value.replace(/\$\{[^}]*\}/g, ' ').replace(/\s+/g, ' ').trim(); - return literalClasses ? 'class="' + escapeHtml(literalClasses) + '"' : ''; - }) - .replace(/\bclassName\s*=/g, 'class=') - .replace(/\sstyle=\{\{([\s\S]*?)\}\}/g, (_match, body) => { - const css = jsxStyleObjectToCss(body); - return css ? ' style="' + escapeHtml(css) + '"' : ''; - }); - } - - function jsxStyleObjectToCss(body) { - const declarations = []; - const re = /(["'][^"']+["']|[A-Za-z_$][\w$-]*)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?\d+(?:\.\d+)?))/g; - let match; - while ((match = re.exec(String(body || '')))) { - const prop = jsxStylePropToCss(match[1]); - const value = match[2] ?? match[3] ?? match[4] ?? ''; - if (!prop || value === '') continue; - declarations.push(prop + ': ' + value); - } - return declarations.join('; '); - } - - function jsxStylePropToCss(prop) { - let out = String(prop || '').trim().replace(/^["']|["']$/g, ''); - if (!out) return ''; - if (out.startsWith('--')) return out; - return out.replace(/[A-Z]/g, (ch) => '-' + ch.toLowerCase()).replace(/^-ms-/, '-ms-'); - } - function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { const map = new Map(); if (!sourceOriginal || !liveOriginal) return map; diff --git a/.trae/skills/impeccable/scripts/live-browser.js b/.trae/skills/impeccable/scripts/live-browser.js index 1f373a894..da026e255 100644 --- a/.trae/skills/impeccable/scripts/live-browser.js +++ b/.trae/skills/impeccable/scripts/live-browser.js @@ -6212,6 +6212,72 @@ showToast('The previous live session no longer matches the source file, so it was discarded. Pick an element to start fresh.', 6000); } + function isJsxSourceFile(filePath) { + return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); + } + + function completeSourceInjection(wrapper, sessionId, opts) { + recoveryWaitingForAnchor = false; + if (pendingVariantAnchorRetryObserver) { + pendingVariantAnchorRetryObserver.disconnect(); + pendingVariantAnchorRetryObserver = null; + } + + const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; + const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); + arrivedVariants = variants.length; + expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); + if (arrivedVariants <= 0) { + if (state === 'GENERATING') { + // Mid-generation the source legitimately holds a scaffold wrapper + // with no variants yet (the server-side preflight wraps before the + // agent writes). Tearing the session down here would destroy an + // in-flight generation; stay in GENERATING — the variant observer + // is armed and the server re-delivers a missed `done`. + if (!opts.generationCompleted) { + console.log('[impeccable] Source has scaffold but no variants yet; still generating.'); + return; + } + // Generation finished, yet the read shows only the scaffold: the + // source view is stale and no further event will fire. Re-read a + // few times before surfacing recovery — a single silent return + // here would strand the tab in GENERATING forever. + const attempt = opts.attempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + console.log('[impeccable] Generation is done but source shows no variants yet; retrying read (' + + (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').'); + setTimeout(() => { + if (state !== 'GENERATING' || currentSessionId !== sessionId) return; + if (arrivedVariants > 0) return; + injectVariantsFromSource(opts.filePath, sessionId, { ...opts, attempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + return; + } + } + recoverEmptyCycling('source-fallback-empty'); + return; + } + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants + ? previousVisibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + showVariantInDOM(sessionId, visibleVariant); + + selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; + + setLiveState('CYCLING'); + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + showOrUpdateCyclingBar(); + disableInlineEdit(); + refreshParamsPanel(); + positionBar(); + saveSession(); + completeParameterGenerationIfReady(); + console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); + } + /** * No-HMR fallback: fetch the raw source file from the live server, * parse it, extract the variant wrapper, and inject it into the live DOM. @@ -6229,14 +6295,53 @@ return; } rememberSessionFileMeta({ file: filePath }); + if (isJsxSourceFile(filePath)) { + const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { + completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); + return; + } + // #454: never fetch/parse JSX. Missing wrap waits for mount (closed + // modal / other route). Insert scaffolds stay for late HMR. A replace + // scaffold with no variants after retries is a failed generation. + if (opts.generationCompleted && sessionId === currentSessionId) { + const attempt = opts.attempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + setTimeout(() => { + if (state !== 'GENERATING' || currentSessionId !== sessionId) return; + injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + return; + } + if (!liveWrapper) { + showToast( + "Variants ready. If the picked element isn't visible, retrace the path that revealed it - they'll appear automatically.", + 15000, + ); + return; + } + if (liveWrapper.dataset.impeccableMode !== 'insert') { + recoverEmptyCycling('source-fallback-empty'); + } + return; + } + if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { + const attempt = opts._orphanAttempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + setTimeout(() => { + if (sessionId !== currentSessionId) return; + if (state !== 'GENERATING' && state !== 'CYCLING') return; + injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + } + } + return; + } const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); fetch(url) .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) .then(html => { const parser = new DOMParser(); - let srcWrapper = null; - - // Full-file parse works for HTML/JSX; Astro/Vue sources need marker extraction. const startMark = ''; const endMark = ''; const startIdx = html.indexOf(startMark); @@ -6244,8 +6349,8 @@ const block = startIdx !== -1 && endIdx !== -1 && endIdx > startIdx ? html.slice(startIdx + startMark.length, endIdx).trim() : html; - const doc = parser.parseFromString(normalizeSourceFallbackBlock(block, filePath), 'text/html'); - srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const doc = parser.parseFromString(block, 'text/html'); + const srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { console.warn('[impeccable] Variant wrapper not found in source file.'); // A resumed cycling session whose wrapper is gone from source is an @@ -6270,93 +6375,33 @@ return; } - const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; - const wrapper = srcWrapper.cloneNode(true); - - // Wrapper already in DOM (wrap HMR landed, variant insert did not). const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (existingWrapper) { + const wrapper = srcWrapper.cloneNode(true); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); - } else { - const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); - if (!origContent) return; - - const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); - if (!liveEl) { - console.warn('[impeccable] Could not find original element in live DOM.'); - enterRecoveryWaitingForAnchor({ - filePath, - sessionId, - srcWrapper, - checkpointReason: 'variant_anchor_missing', - trackScroll: false, - }); - return; - } - - liveEl.parentElement.replaceChild(wrapper, liveEl); - } - recoveryWaitingForAnchor = false; - if (pendingVariantAnchorRetryObserver) { - pendingVariantAnchorRetryObserver.disconnect(); - pendingVariantAnchorRetryObserver = null; - } - - // Update state: count variants, preserving the user's current variant - // when a late HMR/source reinjection lands after they have cycled. - const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); - arrivedVariants = variants.length; - expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); - if (arrivedVariants <= 0) { - if (state === 'GENERATING') { - // Mid-generation the source legitimately holds a scaffold wrapper - // with no variants yet (the server-side preflight wraps before the - // agent writes). Tearing the session down here would destroy an - // in-flight generation; stay in GENERATING — the variant observer - // is armed and the server re-delivers a missed `done`. - if (!opts.generationCompleted) { - console.log('[impeccable] Source has scaffold but no variants yet; still generating.'); - return; - } - // Generation finished, yet the read shows only the scaffold: the - // source view is stale and no further event will fire. Re-read a - // few times before surfacing recovery — a single silent return - // here would strand the tab in GENERATING forever. - const attempt = opts.attempt || 0; - if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { - console.log('[impeccable] Generation is done but source shows no variants yet; retrying read (' - + (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').'); - setTimeout(() => { - if (state !== 'GENERATING' || currentSessionId !== sessionId) return; - if (arrivedVariants > 0) return; - injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 }); - }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); - return; - } - } - recoverEmptyCycling('source-fallback-empty'); + completeSourceInjection(wrapper, sessionId, { ...opts, filePath }); return; } - const saved = loadSession(); - const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; - visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants - ? previousVisibleVariant - : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); - showVariantInDOM(sessionId, visibleVariant); - // Update selectedElement to the visible variant's content - selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; + const wrapper = srcWrapper.cloneNode(true); + const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); + if (!origContent) return; - setLiveState('CYCLING'); - recoveryWaitingForAnchor = false; - hideShaderOverlay(); - showOrUpdateCyclingBar(); - disableInlineEdit(); - refreshParamsPanel(); - positionBar(); - saveSession(); - completeParameterGenerationIfReady(); - console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); + const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); + if (!liveEl) { + console.warn('[impeccable] Could not find original element in live DOM.'); + enterRecoveryWaitingForAnchor({ + filePath, + sessionId, + srcWrapper, + checkpointReason: 'variant_anchor_missing', + trackScroll: false, + }); + return; + } + + liveEl.parentElement.replaceChild(wrapper, liveEl); + completeSourceInjection(wrapper, sessionId, { ...opts, filePath }); }) .catch(err => { console.error('[impeccable] Failed to fetch source:', err); @@ -6364,44 +6409,6 @@ }); } - function normalizeSourceFallbackBlock(block, filePath) { - if (!/\.[cm]?[jt]sx$/i.test(String(filePath || ''))) return block; - return String(block) - .replace( - /]*)>\s*\{\s*`([\s\S]*?)`\s*\}\s*<\/style>/g, - (_match, attrs, css) => '' + css + '', - ) - .replace(/\bclassName\s*=\s*\{\s*`([^`]*?)`\s*\}/g, (_match, value) => { - const literalClasses = value.replace(/\$\{[^}]*\}/g, ' ').replace(/\s+/g, ' ').trim(); - return literalClasses ? 'class="' + escapeHtml(literalClasses) + '"' : ''; - }) - .replace(/\bclassName\s*=/g, 'class=') - .replace(/\sstyle=\{\{([\s\S]*?)\}\}/g, (_match, body) => { - const css = jsxStyleObjectToCss(body); - return css ? ' style="' + escapeHtml(css) + '"' : ''; - }); - } - - function jsxStyleObjectToCss(body) { - const declarations = []; - const re = /(["'][^"']+["']|[A-Za-z_$][\w$-]*)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?\d+(?:\.\d+)?))/g; - let match; - while ((match = re.exec(String(body || '')))) { - const prop = jsxStylePropToCss(match[1]); - const value = match[2] ?? match[3] ?? match[4] ?? ''; - if (!prop || value === '') continue; - declarations.push(prop + ': ' + value); - } - return declarations.join('; '); - } - - function jsxStylePropToCss(prop) { - let out = String(prop || '').trim().replace(/^["']|["']$/g, ''); - if (!out) return ''; - if (out.startsWith('--')) return out; - return out.replace(/[A-Z]/g, (ch) => '-' + ch.toLowerCase()).replace(/^-ms-/, '-ms-'); - } - function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { const map = new Map(); if (!sourceOriginal || !liveOriginal) return map; diff --git a/.vibe/skills/impeccable/scripts/live-browser.js b/.vibe/skills/impeccable/scripts/live-browser.js index 1f373a894..da026e255 100644 --- a/.vibe/skills/impeccable/scripts/live-browser.js +++ b/.vibe/skills/impeccable/scripts/live-browser.js @@ -6212,6 +6212,72 @@ showToast('The previous live session no longer matches the source file, so it was discarded. Pick an element to start fresh.', 6000); } + function isJsxSourceFile(filePath) { + return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); + } + + function completeSourceInjection(wrapper, sessionId, opts) { + recoveryWaitingForAnchor = false; + if (pendingVariantAnchorRetryObserver) { + pendingVariantAnchorRetryObserver.disconnect(); + pendingVariantAnchorRetryObserver = null; + } + + const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; + const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); + arrivedVariants = variants.length; + expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); + if (arrivedVariants <= 0) { + if (state === 'GENERATING') { + // Mid-generation the source legitimately holds a scaffold wrapper + // with no variants yet (the server-side preflight wraps before the + // agent writes). Tearing the session down here would destroy an + // in-flight generation; stay in GENERATING — the variant observer + // is armed and the server re-delivers a missed `done`. + if (!opts.generationCompleted) { + console.log('[impeccable] Source has scaffold but no variants yet; still generating.'); + return; + } + // Generation finished, yet the read shows only the scaffold: the + // source view is stale and no further event will fire. Re-read a + // few times before surfacing recovery — a single silent return + // here would strand the tab in GENERATING forever. + const attempt = opts.attempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + console.log('[impeccable] Generation is done but source shows no variants yet; retrying read (' + + (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').'); + setTimeout(() => { + if (state !== 'GENERATING' || currentSessionId !== sessionId) return; + if (arrivedVariants > 0) return; + injectVariantsFromSource(opts.filePath, sessionId, { ...opts, attempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + return; + } + } + recoverEmptyCycling('source-fallback-empty'); + return; + } + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants + ? previousVisibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + showVariantInDOM(sessionId, visibleVariant); + + selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; + + setLiveState('CYCLING'); + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + showOrUpdateCyclingBar(); + disableInlineEdit(); + refreshParamsPanel(); + positionBar(); + saveSession(); + completeParameterGenerationIfReady(); + console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); + } + /** * No-HMR fallback: fetch the raw source file from the live server, * parse it, extract the variant wrapper, and inject it into the live DOM. @@ -6229,14 +6295,53 @@ return; } rememberSessionFileMeta({ file: filePath }); + if (isJsxSourceFile(filePath)) { + const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { + completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); + return; + } + // #454: never fetch/parse JSX. Missing wrap waits for mount (closed + // modal / other route). Insert scaffolds stay for late HMR. A replace + // scaffold with no variants after retries is a failed generation. + if (opts.generationCompleted && sessionId === currentSessionId) { + const attempt = opts.attempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + setTimeout(() => { + if (state !== 'GENERATING' || currentSessionId !== sessionId) return; + injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + return; + } + if (!liveWrapper) { + showToast( + "Variants ready. If the picked element isn't visible, retrace the path that revealed it - they'll appear automatically.", + 15000, + ); + return; + } + if (liveWrapper.dataset.impeccableMode !== 'insert') { + recoverEmptyCycling('source-fallback-empty'); + } + return; + } + if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { + const attempt = opts._orphanAttempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + setTimeout(() => { + if (sessionId !== currentSessionId) return; + if (state !== 'GENERATING' && state !== 'CYCLING') return; + injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + } + } + return; + } const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); fetch(url) .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) .then(html => { const parser = new DOMParser(); - let srcWrapper = null; - - // Full-file parse works for HTML/JSX; Astro/Vue sources need marker extraction. const startMark = ''; const endMark = ''; const startIdx = html.indexOf(startMark); @@ -6244,8 +6349,8 @@ const block = startIdx !== -1 && endIdx !== -1 && endIdx > startIdx ? html.slice(startIdx + startMark.length, endIdx).trim() : html; - const doc = parser.parseFromString(normalizeSourceFallbackBlock(block, filePath), 'text/html'); - srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const doc = parser.parseFromString(block, 'text/html'); + const srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { console.warn('[impeccable] Variant wrapper not found in source file.'); // A resumed cycling session whose wrapper is gone from source is an @@ -6270,93 +6375,33 @@ return; } - const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; - const wrapper = srcWrapper.cloneNode(true); - - // Wrapper already in DOM (wrap HMR landed, variant insert did not). const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (existingWrapper) { + const wrapper = srcWrapper.cloneNode(true); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); - } else { - const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); - if (!origContent) return; - - const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); - if (!liveEl) { - console.warn('[impeccable] Could not find original element in live DOM.'); - enterRecoveryWaitingForAnchor({ - filePath, - sessionId, - srcWrapper, - checkpointReason: 'variant_anchor_missing', - trackScroll: false, - }); - return; - } - - liveEl.parentElement.replaceChild(wrapper, liveEl); - } - recoveryWaitingForAnchor = false; - if (pendingVariantAnchorRetryObserver) { - pendingVariantAnchorRetryObserver.disconnect(); - pendingVariantAnchorRetryObserver = null; - } - - // Update state: count variants, preserving the user's current variant - // when a late HMR/source reinjection lands after they have cycled. - const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); - arrivedVariants = variants.length; - expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); - if (arrivedVariants <= 0) { - if (state === 'GENERATING') { - // Mid-generation the source legitimately holds a scaffold wrapper - // with no variants yet (the server-side preflight wraps before the - // agent writes). Tearing the session down here would destroy an - // in-flight generation; stay in GENERATING — the variant observer - // is armed and the server re-delivers a missed `done`. - if (!opts.generationCompleted) { - console.log('[impeccable] Source has scaffold but no variants yet; still generating.'); - return; - } - // Generation finished, yet the read shows only the scaffold: the - // source view is stale and no further event will fire. Re-read a - // few times before surfacing recovery — a single silent return - // here would strand the tab in GENERATING forever. - const attempt = opts.attempt || 0; - if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { - console.log('[impeccable] Generation is done but source shows no variants yet; retrying read (' - + (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').'); - setTimeout(() => { - if (state !== 'GENERATING' || currentSessionId !== sessionId) return; - if (arrivedVariants > 0) return; - injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 }); - }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); - return; - } - } - recoverEmptyCycling('source-fallback-empty'); + completeSourceInjection(wrapper, sessionId, { ...opts, filePath }); return; } - const saved = loadSession(); - const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; - visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants - ? previousVisibleVariant - : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); - showVariantInDOM(sessionId, visibleVariant); - // Update selectedElement to the visible variant's content - selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; + const wrapper = srcWrapper.cloneNode(true); + const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); + if (!origContent) return; - setLiveState('CYCLING'); - recoveryWaitingForAnchor = false; - hideShaderOverlay(); - showOrUpdateCyclingBar(); - disableInlineEdit(); - refreshParamsPanel(); - positionBar(); - saveSession(); - completeParameterGenerationIfReady(); - console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); + const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); + if (!liveEl) { + console.warn('[impeccable] Could not find original element in live DOM.'); + enterRecoveryWaitingForAnchor({ + filePath, + sessionId, + srcWrapper, + checkpointReason: 'variant_anchor_missing', + trackScroll: false, + }); + return; + } + + liveEl.parentElement.replaceChild(wrapper, liveEl); + completeSourceInjection(wrapper, sessionId, { ...opts, filePath }); }) .catch(err => { console.error('[impeccable] Failed to fetch source:', err); @@ -6364,44 +6409,6 @@ }); } - function normalizeSourceFallbackBlock(block, filePath) { - if (!/\.[cm]?[jt]sx$/i.test(String(filePath || ''))) return block; - return String(block) - .replace( - /]*)>\s*\{\s*`([\s\S]*?)`\s*\}\s*<\/style>/g, - (_match, attrs, css) => '' + css + '', - ) - .replace(/\bclassName\s*=\s*\{\s*`([^`]*?)`\s*\}/g, (_match, value) => { - const literalClasses = value.replace(/\$\{[^}]*\}/g, ' ').replace(/\s+/g, ' ').trim(); - return literalClasses ? 'class="' + escapeHtml(literalClasses) + '"' : ''; - }) - .replace(/\bclassName\s*=/g, 'class=') - .replace(/\sstyle=\{\{([\s\S]*?)\}\}/g, (_match, body) => { - const css = jsxStyleObjectToCss(body); - return css ? ' style="' + escapeHtml(css) + '"' : ''; - }); - } - - function jsxStyleObjectToCss(body) { - const declarations = []; - const re = /(["'][^"']+["']|[A-Za-z_$][\w$-]*)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?\d+(?:\.\d+)?))/g; - let match; - while ((match = re.exec(String(body || '')))) { - const prop = jsxStylePropToCss(match[1]); - const value = match[2] ?? match[3] ?? match[4] ?? ''; - if (!prop || value === '') continue; - declarations.push(prop + ': ' + value); - } - return declarations.join('; '); - } - - function jsxStylePropToCss(prop) { - let out = String(prop || '').trim().replace(/^["']|["']$/g, ''); - if (!out) return ''; - if (out.startsWith('--')) return out; - return out.replace(/[A-Z]/g, (ch) => '-' + ch.toLowerCase()).replace(/^-ms-/, '-ms-'); - } - function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { const map = new Map(); if (!sourceOriginal || !liveOriginal) return map; diff --git a/plugin/skills/impeccable/scripts/live-browser.js b/plugin/skills/impeccable/scripts/live-browser.js index 1f373a894..da026e255 100644 --- a/plugin/skills/impeccable/scripts/live-browser.js +++ b/plugin/skills/impeccable/scripts/live-browser.js @@ -6212,6 +6212,72 @@ showToast('The previous live session no longer matches the source file, so it was discarded. Pick an element to start fresh.', 6000); } + function isJsxSourceFile(filePath) { + return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); + } + + function completeSourceInjection(wrapper, sessionId, opts) { + recoveryWaitingForAnchor = false; + if (pendingVariantAnchorRetryObserver) { + pendingVariantAnchorRetryObserver.disconnect(); + pendingVariantAnchorRetryObserver = null; + } + + const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; + const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); + arrivedVariants = variants.length; + expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); + if (arrivedVariants <= 0) { + if (state === 'GENERATING') { + // Mid-generation the source legitimately holds a scaffold wrapper + // with no variants yet (the server-side preflight wraps before the + // agent writes). Tearing the session down here would destroy an + // in-flight generation; stay in GENERATING — the variant observer + // is armed and the server re-delivers a missed `done`. + if (!opts.generationCompleted) { + console.log('[impeccable] Source has scaffold but no variants yet; still generating.'); + return; + } + // Generation finished, yet the read shows only the scaffold: the + // source view is stale and no further event will fire. Re-read a + // few times before surfacing recovery — a single silent return + // here would strand the tab in GENERATING forever. + const attempt = opts.attempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + console.log('[impeccable] Generation is done but source shows no variants yet; retrying read (' + + (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').'); + setTimeout(() => { + if (state !== 'GENERATING' || currentSessionId !== sessionId) return; + if (arrivedVariants > 0) return; + injectVariantsFromSource(opts.filePath, sessionId, { ...opts, attempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + return; + } + } + recoverEmptyCycling('source-fallback-empty'); + return; + } + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants + ? previousVisibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + showVariantInDOM(sessionId, visibleVariant); + + selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; + + setLiveState('CYCLING'); + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + showOrUpdateCyclingBar(); + disableInlineEdit(); + refreshParamsPanel(); + positionBar(); + saveSession(); + completeParameterGenerationIfReady(); + console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); + } + /** * No-HMR fallback: fetch the raw source file from the live server, * parse it, extract the variant wrapper, and inject it into the live DOM. @@ -6229,14 +6295,53 @@ return; } rememberSessionFileMeta({ file: filePath }); + if (isJsxSourceFile(filePath)) { + const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { + completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); + return; + } + // #454: never fetch/parse JSX. Missing wrap waits for mount (closed + // modal / other route). Insert scaffolds stay for late HMR. A replace + // scaffold with no variants after retries is a failed generation. + if (opts.generationCompleted && sessionId === currentSessionId) { + const attempt = opts.attempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + setTimeout(() => { + if (state !== 'GENERATING' || currentSessionId !== sessionId) return; + injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + return; + } + if (!liveWrapper) { + showToast( + "Variants ready. If the picked element isn't visible, retrace the path that revealed it - they'll appear automatically.", + 15000, + ); + return; + } + if (liveWrapper.dataset.impeccableMode !== 'insert') { + recoverEmptyCycling('source-fallback-empty'); + } + return; + } + if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { + const attempt = opts._orphanAttempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + setTimeout(() => { + if (sessionId !== currentSessionId) return; + if (state !== 'GENERATING' && state !== 'CYCLING') return; + injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + } + } + return; + } const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); fetch(url) .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) .then(html => { const parser = new DOMParser(); - let srcWrapper = null; - - // Full-file parse works for HTML/JSX; Astro/Vue sources need marker extraction. const startMark = ''; const endMark = ''; const startIdx = html.indexOf(startMark); @@ -6244,8 +6349,8 @@ const block = startIdx !== -1 && endIdx !== -1 && endIdx > startIdx ? html.slice(startIdx + startMark.length, endIdx).trim() : html; - const doc = parser.parseFromString(normalizeSourceFallbackBlock(block, filePath), 'text/html'); - srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const doc = parser.parseFromString(block, 'text/html'); + const srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { console.warn('[impeccable] Variant wrapper not found in source file.'); // A resumed cycling session whose wrapper is gone from source is an @@ -6270,93 +6375,33 @@ return; } - const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; - const wrapper = srcWrapper.cloneNode(true); - - // Wrapper already in DOM (wrap HMR landed, variant insert did not). const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (existingWrapper) { + const wrapper = srcWrapper.cloneNode(true); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); - } else { - const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); - if (!origContent) return; - - const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); - if (!liveEl) { - console.warn('[impeccable] Could not find original element in live DOM.'); - enterRecoveryWaitingForAnchor({ - filePath, - sessionId, - srcWrapper, - checkpointReason: 'variant_anchor_missing', - trackScroll: false, - }); - return; - } - - liveEl.parentElement.replaceChild(wrapper, liveEl); - } - recoveryWaitingForAnchor = false; - if (pendingVariantAnchorRetryObserver) { - pendingVariantAnchorRetryObserver.disconnect(); - pendingVariantAnchorRetryObserver = null; - } - - // Update state: count variants, preserving the user's current variant - // when a late HMR/source reinjection lands after they have cycled. - const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); - arrivedVariants = variants.length; - expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); - if (arrivedVariants <= 0) { - if (state === 'GENERATING') { - // Mid-generation the source legitimately holds a scaffold wrapper - // with no variants yet (the server-side preflight wraps before the - // agent writes). Tearing the session down here would destroy an - // in-flight generation; stay in GENERATING — the variant observer - // is armed and the server re-delivers a missed `done`. - if (!opts.generationCompleted) { - console.log('[impeccable] Source has scaffold but no variants yet; still generating.'); - return; - } - // Generation finished, yet the read shows only the scaffold: the - // source view is stale and no further event will fire. Re-read a - // few times before surfacing recovery — a single silent return - // here would strand the tab in GENERATING forever. - const attempt = opts.attempt || 0; - if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { - console.log('[impeccable] Generation is done but source shows no variants yet; retrying read (' - + (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').'); - setTimeout(() => { - if (state !== 'GENERATING' || currentSessionId !== sessionId) return; - if (arrivedVariants > 0) return; - injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 }); - }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); - return; - } - } - recoverEmptyCycling('source-fallback-empty'); + completeSourceInjection(wrapper, sessionId, { ...opts, filePath }); return; } - const saved = loadSession(); - const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; - visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants - ? previousVisibleVariant - : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); - showVariantInDOM(sessionId, visibleVariant); - // Update selectedElement to the visible variant's content - selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; + const wrapper = srcWrapper.cloneNode(true); + const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); + if (!origContent) return; - setLiveState('CYCLING'); - recoveryWaitingForAnchor = false; - hideShaderOverlay(); - showOrUpdateCyclingBar(); - disableInlineEdit(); - refreshParamsPanel(); - positionBar(); - saveSession(); - completeParameterGenerationIfReady(); - console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); + const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); + if (!liveEl) { + console.warn('[impeccable] Could not find original element in live DOM.'); + enterRecoveryWaitingForAnchor({ + filePath, + sessionId, + srcWrapper, + checkpointReason: 'variant_anchor_missing', + trackScroll: false, + }); + return; + } + + liveEl.parentElement.replaceChild(wrapper, liveEl); + completeSourceInjection(wrapper, sessionId, { ...opts, filePath }); }) .catch(err => { console.error('[impeccable] Failed to fetch source:', err); @@ -6364,44 +6409,6 @@ }); } - function normalizeSourceFallbackBlock(block, filePath) { - if (!/\.[cm]?[jt]sx$/i.test(String(filePath || ''))) return block; - return String(block) - .replace( - /]*)>\s*\{\s*`([\s\S]*?)`\s*\}\s*<\/style>/g, - (_match, attrs, css) => '' + css + '', - ) - .replace(/\bclassName\s*=\s*\{\s*`([^`]*?)`\s*\}/g, (_match, value) => { - const literalClasses = value.replace(/\$\{[^}]*\}/g, ' ').replace(/\s+/g, ' ').trim(); - return literalClasses ? 'class="' + escapeHtml(literalClasses) + '"' : ''; - }) - .replace(/\bclassName\s*=/g, 'class=') - .replace(/\sstyle=\{\{([\s\S]*?)\}\}/g, (_match, body) => { - const css = jsxStyleObjectToCss(body); - return css ? ' style="' + escapeHtml(css) + '"' : ''; - }); - } - - function jsxStyleObjectToCss(body) { - const declarations = []; - const re = /(["'][^"']+["']|[A-Za-z_$][\w$-]*)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?\d+(?:\.\d+)?))/g; - let match; - while ((match = re.exec(String(body || '')))) { - const prop = jsxStylePropToCss(match[1]); - const value = match[2] ?? match[3] ?? match[4] ?? ''; - if (!prop || value === '') continue; - declarations.push(prop + ': ' + value); - } - return declarations.join('; '); - } - - function jsxStylePropToCss(prop) { - let out = String(prop || '').trim().replace(/^["']|["']$/g, ''); - if (!out) return ''; - if (out.startsWith('--')) return out; - return out.replace(/[A-Z]/g, (ch) => '-' + ch.toLowerCase()).replace(/^-ms-/, '-ms-'); - } - function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { const map = new Map(); if (!sourceOriginal || !liveOriginal) return map; From 84728e9ce43a3dba2a453b20130bbf836190d77c Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Tue, 1 Sep 2026 18:46:34 -0400 Subject: [PATCH 14/31] Fix flat type hierarchy false positives (#702) * Fix flat type hierarchy false positives Use rendered semantic roles and dominant size frequency, align the adjacent-step guidance, and abstain in source-only scans.\n\nAI assistance: prepared with Codex under maintainer direction. * Fix static hidden typography filtering Honor the hidden attribute in the static wrapper and use raw browser findings in regression coverage. AI assistance: prepared with Codex under maintainer direction. * Align typography sampling with painted content Count visibly painted aria-hidden text and exclude content-visibility hidden subtrees in both static and browser scans. AI assistance: prepared with Codex under maintainer direction. --- cli/engine/detect-antipatterns-browser.js | 115 ++++++++++++---- cli/engine/engines/regex/detect-text.mjs | 34 +---- .../engines/static-html/css-cascade.mjs | 1 + .../engines/static-html/detect-html.mjs | 14 +- cli/engine/registry/antipatterns.mjs | 2 +- cli/engine/rules/checks.mjs | 115 ++++++++++++---- tests/detect-antipatterns-browser.test.mjs | 48 +++++++ tests/detect-antipatterns-fixtures.test.mjs | 44 ++++++ tests/detect-antipatterns.test.js | 54 +++++++- .../antipatterns/flat-type-hierarchy.html | 129 ++++++++++++++++++ 10 files changed, 455 insertions(+), 101 deletions(-) create mode 100644 tests/fixtures/antipatterns/flat-type-hierarchy.html diff --git a/cli/engine/detect-antipatterns-browser.js b/cli/engine/detect-antipatterns-browser.js index 3f5abd0a8..e6899f569 100644 --- a/cli/engine/detect-antipatterns-browser.js +++ b/cli/engine/detect-antipatterns-browser.js @@ -159,7 +159,7 @@ const ANTIPATTERNS = [ scopes: ['type'], name: 'Flat type hierarchy', description: - 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', + 'Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.', skillSection: 'Typography', skillGuideline: 'flat type hierarchy', }, @@ -5184,6 +5184,88 @@ function checkElementGlow(tag, style, effectiveBg) { // ─── Section 6: Page-Level Checks ─────────────────────────────────────────── +const TYPE_HIERARCHY_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption'; +const TYPE_HIERARCHY_MIN_ROLES = 3; +const TYPE_HIERARCHY_MIN_STEP_RATIO = 1.25; + +function typeHierarchyRole(el) { + const tag = String(el?.tagName || el?.nodeName || '').toLowerCase(); + return /^h[1-6]$/.test(tag) ? tag : 'body'; +} + +function hasTextContent(el) { + return String(el?.textContent || '').trim().length > 0; +} + +function isRenderedTypeElement(el, getStyle) { + for (let current = el; current; current = current.parentElement) { + const hiddenAttr = typeof current.getAttribute === 'function' && current.getAttribute('hidden') !== null; + if (current.hidden || hiddenAttr) return false; + const style = getStyle(current); + if (!style) continue; + const display = String(style.display || '').toLowerCase(); + const visibility = String(style.visibility || '').toLowerCase(); + const contentVisibility = String(style.contentVisibility || '').toLowerCase(); + if (display === 'none' || visibility === 'hidden' || visibility === 'collapse' || contentVisibility === 'hidden') return false; + const opacity = parseFloat(style.opacity); + if (Number.isFinite(opacity) && opacity <= 0.01) return false; + } + return true; +} + +function dominantTypeRoleSize(samples) { + const counts = new Map(); + for (const sample of samples) { + counts.set(sample.size, (counts.get(sample.size) || 0) + 1); + } + const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]); + if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) return null; + return ranked[0]?.[0] ?? null; +} + +function checkFlatTypeHierarchySamples(samples) { + const byRole = new Map(); + for (const sample of samples || []) { + const role = String(sample?.role || ''); + const size = Math.round(Number(sample?.size) * 10) / 10; + if (!role || !Number.isFinite(size) || size < 8 || size >= 200) continue; + if (!byRole.has(role)) byRole.set(role, []); + byRole.get(role).push({ role, size }); + } + + const roles = [...byRole.entries()].map(([role, roleSamples]) => ({ + role, + size: dominantTypeRoleSize(roleSamples), + })).filter(item => item.size !== null); + + if (roles.length < TYPE_HIERARCHY_MIN_ROLES) return []; + + const sorted = roles.slice().sort((a, b) => a.size - b.size || a.role.localeCompare(b.role)); + let largestStep = 1; + for (let i = 1; i < sorted.length; i++) { + largestStep = Math.max(largestStep, sorted[i].size / sorted[i - 1].size); + } + if (largestStep >= TYPE_HIERARCHY_MIN_STEP_RATIO) return []; + + const roleSizes = sorted.map(item => `${item.role} ${item.size}px`).join(', '); + return [{ + id: 'flat-type-hierarchy', + snippet: `Role sizes: ${roleSizes} (largest adjacent step ${largestStep.toFixed(2)}:1; target ${TYPE_HIERARCHY_MIN_STEP_RATIO}:1)`, + }]; +} + +function checkFlatTypeHierarchyFromDoc(root, getStyle, options = {}) { + const samples = []; + for (const el of root.querySelectorAll(TYPE_HIERARCHY_SELECTOR)) { + if (options.skipElement?.(el)) continue; + if (!hasTextContent(el) || !isRenderedTypeElement(el, getStyle)) continue; + const fontSize = parseFloat(getStyle(el)?.fontSize); + if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize >= 200) continue; + samples.push({ role: typeHierarchyRole(el), size: fontSize }); + } + return checkFlatTypeHierarchySamples(samples); +} + // Browser page-level checks — use document/getComputedStyle globals function checkTypography() { @@ -5222,17 +5304,10 @@ function checkTypography() { } } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { - const fs = parseFloat(getComputedStyle(el).fontSize); - if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } + for (const finding of checkFlatTypeHierarchyFromDoc(document, getComputedStyle, { + skipElement: el => el.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip, [id^="impeccable-live-"]'), + })) { + findings.push({ type: finding.id, detail: finding.snippet }); } return findings; @@ -5479,21 +5554,7 @@ function checkPageTypography(doc, win) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - // Flat type hierarchy - const sizes = new Set(); - const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div'); - for (const el of textEls) { - const fontSize = parseFloat(win.getComputedStyle(el).fontSize); - // Filter out sub-8px values (jsdom doesn't resolve relative units properly) - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(doc, el => win.getComputedStyle(el))); return findings; } diff --git a/cli/engine/engines/regex/detect-text.mjs b/cli/engine/engines/regex/detect-text.mjs index 26ca4f390..df1334fdd 100644 --- a/cli/engine/engines/regex/detect-text.mjs +++ b/cli/engine/engines/regex/detect-text.mjs @@ -607,32 +607,6 @@ const REGEX_MATCHERS = [ ]; const REGEX_ANALYZERS = [ - // Flat type hierarchy - (content, filePath) => { - const sizes = new Set(); - const REM = 16; - let m; - const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi; - while ((m = sizeRe.exec(content)) !== null) { - const px = m[2] === 'px' ? +m[1] : +m[1] * REM; - if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10); - } - const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi; - while ((m = clampRe.exec(content)) !== null) { - sizes.add(Math.round((m[2] === 'px' ? +m[1] : +m[1] * REM) * 10) / 10); - sizes.add(Math.round((m[4] === 'px' ? +m[3] : +m[3] * REM) * 10) / 10); - } - const TW = { 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, 'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, 'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128 }; - for (const [cls, px] of Object.entries(TW)) { if (new RegExp(`\\b${cls}\\b`).test(content)) sizes.add(px); } - if (sizes.size < 3) return []; - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio >= 2.0) return []; - const lines = content.split('\n'); - let line = 1; - for (let i = 0; i < lines.length; i++) { if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) { line = i + 1; break; } } - return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)]; - }, // Monotonous spacing (regex) (content, filePath) => { const vals = []; @@ -1154,11 +1128,12 @@ const TEXT_CONTENT_ANALYZER_IDS = [ function runTextContentAnalyzers(content, filePath, options = {}) { const profile = options?.profile; if (!shouldRunPageAnalyzers(content, filePath)) return []; - // The 3 text-content analyzers are at indices 2-4 in REGEX_ANALYZERS - // (single-font's removal on 2026-07-29 shifted every index down one). + // The 3 text-content analyzers are at indices 1-3 in REGEX_ANALYZERS. + // flat-type-hierarchy left this source-only path in issue #619 because it + // needs rendered role and usage evidence. const findings = []; for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { - const analyzer = REGEX_ANALYZERS[2 + i]; + const analyzer = REGEX_ANALYZERS[1 + i]; const ruleId = TEXT_CONTENT_ANALYZER_IDS[i]; findings.push(...profileFindings(profile, { engine: 'regex', @@ -1284,7 +1259,6 @@ function detectText(content, filePath, options = {}) { // Page-level analyzers only run on full pages if (shouldRunPageAnalyzers(content, filePath)) { const analyzerIds = [ - 'flat-type-hierarchy', 'monotonous-spacing', 'em-dash-overuse', 'marketing-buzzword', diff --git a/cli/engine/engines/static-html/css-cascade.mjs b/cli/engine/engines/static-html/css-cascade.mjs index cc36ac9b5..7f6080b61 100644 --- a/cli/engine/engines/static-html/css-cascade.mjs +++ b/cli/engine/engines/static-html/css-cascade.mjs @@ -282,6 +282,7 @@ const STATIC_DEFAULT_STYLE = { marginLeft: '0px', position: 'static', visibility: 'visible', + contentVisibility: 'visible', opacity: '1', top: 'auto', right: 'auto', diff --git a/cli/engine/engines/static-html/detect-html.mjs b/cli/engine/engines/static-html/detect-html.mjs index 51c34224b..84e6ab1f5 100644 --- a/cli/engine/engines/static-html/detect-html.mjs +++ b/cli/engine/engines/static-html/detect-html.mjs @@ -25,6 +25,7 @@ import { checkElementOversizedH1, checkElementQuality, checkElementRadialSpotlight, + checkFlatTypeHierarchyFromDoc, checkCreamPalette, checkHtmlPatterns, checkKickerAboveHeadingFromDoc, @@ -59,18 +60,7 @@ function checkStaticPageTypography(document, window) { for (const font of overusedFound) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div')) { - const fontSize = parseFloat(window.getComputedStyle(el).fontSize); - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(document, el => window.getComputedStyle(el))); return findings; } diff --git a/cli/engine/registry/antipatterns.mjs b/cli/engine/registry/antipatterns.mjs index 88ff84a07..945307783 100644 --- a/cli/engine/registry/antipatterns.mjs +++ b/cli/engine/registry/antipatterns.mjs @@ -34,7 +34,7 @@ const ANTIPATTERNS = [ scopes: ['type'], name: 'Flat type hierarchy', description: - 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', + 'Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.', skillSection: 'Typography', skillGuideline: 'flat type hierarchy', }, diff --git a/cli/engine/rules/checks.mjs b/cli/engine/rules/checks.mjs index 8bfb2b1df..579e1c12e 100644 --- a/cli/engine/rules/checks.mjs +++ b/cli/engine/rules/checks.mjs @@ -3911,6 +3911,88 @@ function checkElementGlow(tag, style, effectiveBg) { // ─── Section 6: Page-Level Checks ─────────────────────────────────────────── +const TYPE_HIERARCHY_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption'; +const TYPE_HIERARCHY_MIN_ROLES = 3; +const TYPE_HIERARCHY_MIN_STEP_RATIO = 1.25; + +function typeHierarchyRole(el) { + const tag = String(el?.tagName || el?.nodeName || '').toLowerCase(); + return /^h[1-6]$/.test(tag) ? tag : 'body'; +} + +function hasTextContent(el) { + return String(el?.textContent || '').trim().length > 0; +} + +function isRenderedTypeElement(el, getStyle) { + for (let current = el; current; current = current.parentElement) { + const hiddenAttr = typeof current.getAttribute === 'function' && current.getAttribute('hidden') !== null; + if (current.hidden || hiddenAttr) return false; + const style = getStyle(current); + if (!style) continue; + const display = String(style.display || '').toLowerCase(); + const visibility = String(style.visibility || '').toLowerCase(); + const contentVisibility = String(style.contentVisibility || '').toLowerCase(); + if (display === 'none' || visibility === 'hidden' || visibility === 'collapse' || contentVisibility === 'hidden') return false; + const opacity = parseFloat(style.opacity); + if (Number.isFinite(opacity) && opacity <= 0.01) return false; + } + return true; +} + +function dominantTypeRoleSize(samples) { + const counts = new Map(); + for (const sample of samples) { + counts.set(sample.size, (counts.get(sample.size) || 0) + 1); + } + const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]); + if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) return null; + return ranked[0]?.[0] ?? null; +} + +function checkFlatTypeHierarchySamples(samples) { + const byRole = new Map(); + for (const sample of samples || []) { + const role = String(sample?.role || ''); + const size = Math.round(Number(sample?.size) * 10) / 10; + if (!role || !Number.isFinite(size) || size < 8 || size >= 200) continue; + if (!byRole.has(role)) byRole.set(role, []); + byRole.get(role).push({ role, size }); + } + + const roles = [...byRole.entries()].map(([role, roleSamples]) => ({ + role, + size: dominantTypeRoleSize(roleSamples), + })).filter(item => item.size !== null); + + if (roles.length < TYPE_HIERARCHY_MIN_ROLES) return []; + + const sorted = roles.slice().sort((a, b) => a.size - b.size || a.role.localeCompare(b.role)); + let largestStep = 1; + for (let i = 1; i < sorted.length; i++) { + largestStep = Math.max(largestStep, sorted[i].size / sorted[i - 1].size); + } + if (largestStep >= TYPE_HIERARCHY_MIN_STEP_RATIO) return []; + + const roleSizes = sorted.map(item => `${item.role} ${item.size}px`).join(', '); + return [{ + id: 'flat-type-hierarchy', + snippet: `Role sizes: ${roleSizes} (largest adjacent step ${largestStep.toFixed(2)}:1; target ${TYPE_HIERARCHY_MIN_STEP_RATIO}:1)`, + }]; +} + +function checkFlatTypeHierarchyFromDoc(root, getStyle, options = {}) { + const samples = []; + for (const el of root.querySelectorAll(TYPE_HIERARCHY_SELECTOR)) { + if (options.skipElement?.(el)) continue; + if (!hasTextContent(el) || !isRenderedTypeElement(el, getStyle)) continue; + const fontSize = parseFloat(getStyle(el)?.fontSize); + if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize >= 200) continue; + samples.push({ role: typeHierarchyRole(el), size: fontSize }); + } + return checkFlatTypeHierarchySamples(samples); +} + // Browser page-level checks — use document/getComputedStyle globals function checkTypography() { @@ -3949,17 +4031,10 @@ function checkTypography() { } } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { - const fs = parseFloat(getComputedStyle(el).fontSize); - if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } + for (const finding of checkFlatTypeHierarchyFromDoc(document, getComputedStyle, { + skipElement: el => el.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip, [id^="impeccable-live-"]'), + })) { + findings.push({ type: finding.id, detail: finding.snippet }); } return findings; @@ -4206,21 +4281,7 @@ function checkPageTypography(doc, win) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - // Flat type hierarchy - const sizes = new Set(); - const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div'); - for (const el of textEls) { - const fontSize = parseFloat(win.getComputedStyle(el).fontSize); - // Filter out sub-8px values (jsdom doesn't resolve relative units properly) - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(doc, el => win.getComputedStyle(el))); return findings; } @@ -5649,6 +5710,8 @@ export { checkKickerAboveHeadingFromDoc, checkElementMotion, checkElementGlow, + checkFlatTypeHierarchySamples, + checkFlatTypeHierarchyFromDoc, checkTypography, isCardLikeDOM, checkLayout, diff --git a/tests/detect-antipatterns-browser.test.mjs b/tests/detect-antipatterns-browser.test.mjs index 0a264bf28..b1f15979b 100644 --- a/tests/detect-antipatterns-browser.test.mjs +++ b/tests/detect-antipatterns-browser.test.mjs @@ -35,6 +35,20 @@ const MIME = { '.jpg': 'image/jpeg', }; +function isolatedBrowserFixtureCases(name) { + const source = fs.readFileSync(path.join(ROOT, 'tests', 'fixtures', 'antipatterns', name), 'utf8'); + const style = source.match(/`); + await page.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; }); + const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8'); + await page.evaluate(browserScript); + + for (const item of cases) { + const count = await page.evaluate((html) => { + document.body.innerHTML = html; + return window.impeccableDetect({ serialize: false }) + .flatMap(group => group.findings || []) + .filter(finding => (finding.type || finding.id) === 'flat-type-hierarchy') + .length; + }, item.html); + assert.equal( + count, + item.expect === 'flag' ? 1 : 0, + `unexpected browser result for "${item.caseName}"`, + ); + } + await page.close(); + } finally { + await browser.close().catch(() => {}); + } + }); + it('overused-font: hook inline-ignore comments do not suppress browser findings', async () => { const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/hook-inline-ignore.html`); assert.ok( diff --git a/tests/detect-antipatterns-fixtures.test.mjs b/tests/detect-antipatterns-fixtures.test.mjs index 814f259b4..9e3db1618 100644 --- a/tests/detect-antipatterns-fixtures.test.mjs +++ b/tests/detect-antipatterns-fixtures.test.mjs @@ -7,6 +7,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; +import os from 'node:os'; import path from 'path'; import { fileURLToPath } from 'url'; import { @@ -20,6 +21,49 @@ import { checkEmDashOveruse } from '../cli/engine/rules/checks.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const FIXTURES = path.join(__dirname, 'fixtures', 'antipatterns'); +function isolatedFixtureCases(name) { + const source = fs.readFileSync(path.join(FIXTURES, name), 'utf8'); + const style = source.match(/${match[2]}`, + }); + } + return cases; +} + +describe('flat-type-hierarchy — role and usage fixture (issue #619)', () => { + it('flags compressed document roles and passes dense UI/chrome shapes', async () => { + const cases = isolatedFixtureCases('flat-type-hierarchy.html'); + assert.equal(cases.filter(item => item.expect === 'flag').length, 5); + assert.equal(cases.filter(item => item.expect === 'pass').length, 6); + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-flat-type-')); + try { + for (const [index, item] of cases.entries()) { + const file = path.join(tempDir, `case-${index}.html`); + fs.writeFileSync(file, item.html); + const findings = await detectHtml(file); + const flat = findings.filter(finding => finding.antipattern === 'flat-type-hierarchy'); + if (item.expect === 'flag') { + assert.equal(flat.length, 1, `expected "${item.caseName}" to flag: ${JSON.stringify(findings)}`); + } else { + assert.equal(flat.length, 0, `expected "${item.caseName}" to pass: ${JSON.stringify(flat)}`); + } + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); + describe('detectText - Astro structural CSS fixtures', () => { const SHOULD_FLAG = [ 'Kinpaku Edge', diff --git a/tests/detect-antipatterns.test.js b/tests/detect-antipatterns.test.js index 4f0ae4cb7..9ad3a7a47 100644 --- a/tests/detect-antipatterns.test.js +++ b/tests/detect-antipatterns.test.js @@ -16,6 +16,7 @@ import * as domutils from 'domutils'; import { StaticDocument } from '../cli/engine/engines/static-html/css-cascade.mjs'; import { filterByScopes } from '../cli/engine/registry/antipatterns.mjs'; import { + checkFlatTypeHierarchySamples, checkColors, checkElementTextOverflowDOM, checkHeroEyebrow, @@ -703,19 +704,62 @@ describe('detectHtml — overused fonts system stack', () => { }); describe('detectText — flat type hierarchy', () => { - test('flags sizes too close together', () => { + test('source-only declarations abstain because rendered role frequency is unknowable', () => { const page = ''; const f = detectText(page, 'test.html'); - expect(f.some(r => r.antipattern === 'flat-type-hierarchy')).toBe(true); + expect(f.filter(r => r.antipattern === 'flat-type-hierarchy')).toHaveLength(0); }); - test('passes good hierarchy', () => { + test('also abstains when source declarations suggest a wide hierarchy', () => { const page = ''; const f = detectText(page, 'test.html'); expect(f.filter(r => r.antipattern === 'flat-type-hierarchy')).toHaveLength(0); }); }); +describe('flat-type-hierarchy — role analysis', () => { + test('uses the dominant size within each semantic role', () => { + const findings = checkFlatTypeHierarchySamples([ + { role: 'h1', size: 18 }, + { role: 'h2', size: 16 }, + { role: 'h2', size: 16 }, + { role: 'h2', size: 40 }, + ...Array.from({ length: 20 }, () => ({ role: 'body', size: 14 })), + { role: 'body', size: 10 }, + ]); + expect(findings).toHaveLength(1); + expect(findings[0].snippet).toContain('body 14px, h2 16px, h1 18px'); + expect(findings[0].snippet).not.toContain('40px'); + }); + + test('passes when one adjacent role step reaches the documented threshold', () => { + const findings = checkFlatTypeHierarchySamples([ + { role: 'h1', size: 25 }, + { role: 'h2', size: 20 }, + { role: 'body', size: 16 }, + ]); + expect(findings).toHaveLength(0); + }); + + test('abstains when fewer than three semantic roles render', () => { + const findings = checkFlatTypeHierarchySamples([ + { role: 'h1', size: 18 }, + ...Array.from({ length: 100 }, () => ({ role: 'body', size: 14 })), + ]); + expect(findings).toHaveLength(0); + }); + + test('abstains from a role whose competing sizes have no dominant value', () => { + const findings = checkFlatTypeHierarchySamples([ + { role: 'h1', size: 18 }, + { role: 'h1', size: 48 }, + { role: 'h2', size: 16 }, + { role: 'body', size: 14 }, + ]); + expect(findings).toHaveLength(0); + }); +}); + // Static HTML/CSS fixture tests moved to detect-antipatterns-fixtures.test.mjs (run via node --test) // --------------------------------------------------------------------------- @@ -749,13 +793,13 @@ describe('partials skip page-level checks', () => { expect(f.some(r => r.antipattern === 'side-tab')).toBe(true); }); - test('regex: full page with flat hierarchy IS flagged', () => { + test('regex: full page with declarations-only hierarchy abstains', () => { const page = '\n' + '

h1

\n

h2

\n' + '

p

\ns\n' + 'sm\n'; const f = detectText(page, 'index.html'); - expect(f.some(r => r.antipattern === 'flat-type-hierarchy')).toBe(true); + expect(f.filter(r => r.antipattern === 'flat-type-hierarchy')).toHaveLength(0); }); }); diff --git a/tests/fixtures/antipatterns/flat-type-hierarchy.html b/tests/fixtures/antipatterns/flat-type-hierarchy.html new file mode 100644 index 000000000..fa3441841 --- /dev/null +++ b/tests/fixtures/antipatterns/flat-type-hierarchy.html @@ -0,0 +1,129 @@ + + + + + + Flat Type Hierarchy — Role and Usage Cases + + + +
+
+
+

Flag Compressed Product

+

Primary section

+

Supporting section

+

Body copy is almost indistinguishable from every heading level.

+
+ +
+

Flag Soft Editorial

+

A section with barely less emphasis

+

The body is compressed into the same narrow band.

+
+ +
+

Flag Crowded Documentation

+

Second-level documentation heading

+

Third-level documentation heading

+

Reading text has no strong size step above it.

+
+ +
+

Flag Repeated Role

+

Dominant section size

+

Another section at the dominant size

+

A one-off section variation

+

The representative role sizes remain uniformly compressed.

+
+ + +
+ +
+
+

Pass Dense Session List

+
Session alpha
+
Session beta
+
Session gamma
+
Session delta
+
Session epsilon
+
Session zeta
+
Session eta
+
Session theta
+ One-off status + + One-off summary +
+ +
+

Pass Empty Inherited Containers

+

Hidden inherited title

+ +
+
+
+
+ +
+

Pass One-Off Chrome

+

The body size carries the page.

+

The body size repeats consistently.

+

The body size remains dominant.

+ + +
Rare utility value
+
+ + + +
+

Pass Strong Document Hierarchy

+

A clearly subordinate section

+

A readable subsection

+

Body copy sits on a clearly separated scale.

+
+ +
+

Pass Content Visibility Hidden

+

This section is not painted

+

Non-painted text should not affect the hierarchy.

+
+
+
+ + From 74cf3ec605c23abcfa0e8851a6de8336ae880ef2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:47:06 +0000 Subject: [PATCH 15/31] Sync generated provider output --- .../detector/detect-antipatterns-browser.js | 115 ++++++++++++++---- .../detector/engines/regex/detect-text.mjs | 34 +----- .../engines/static-html/css-cascade.mjs | 1 + .../engines/static-html/detect-html.mjs | 14 +-- .../detector/registry/antipatterns.mjs | 2 +- .../scripts/detector/rules/checks.mjs | 115 ++++++++++++++---- .../detector/detect-antipatterns-browser.js | 115 ++++++++++++++---- .../detector/engines/regex/detect-text.mjs | 34 +----- .../engines/static-html/css-cascade.mjs | 1 + .../engines/static-html/detect-html.mjs | 14 +-- .../detector/registry/antipatterns.mjs | 2 +- .../scripts/detector/rules/checks.mjs | 115 ++++++++++++++---- .../detector/detect-antipatterns-browser.js | 115 ++++++++++++++---- .../detector/engines/regex/detect-text.mjs | 34 +----- .../engines/static-html/css-cascade.mjs | 1 + .../engines/static-html/detect-html.mjs | 14 +-- .../detector/registry/antipatterns.mjs | 2 +- .../scripts/detector/rules/checks.mjs | 115 ++++++++++++++---- .../detector/detect-antipatterns-browser.js | 115 ++++++++++++++---- .../detector/engines/regex/detect-text.mjs | 34 +----- .../engines/static-html/css-cascade.mjs | 1 + .../engines/static-html/detect-html.mjs | 14 +-- .../detector/registry/antipatterns.mjs | 2 +- .../scripts/detector/rules/checks.mjs | 115 ++++++++++++++---- .../detector/detect-antipatterns-browser.js | 115 ++++++++++++++---- .../detector/engines/regex/detect-text.mjs | 34 +----- .../engines/static-html/css-cascade.mjs | 1 + .../engines/static-html/detect-html.mjs | 14 +-- .../detector/registry/antipatterns.mjs | 2 +- .../scripts/detector/rules/checks.mjs | 115 ++++++++++++++---- .../detector/detect-antipatterns-browser.js | 115 ++++++++++++++---- .../detector/engines/regex/detect-text.mjs | 34 +----- .../engines/static-html/css-cascade.mjs | 1 + .../engines/static-html/detect-html.mjs | 14 +-- .../detector/registry/antipatterns.mjs | 2 +- .../scripts/detector/rules/checks.mjs | 115 ++++++++++++++---- .../detector/detect-antipatterns-browser.js | 115 ++++++++++++++---- .../detector/engines/regex/detect-text.mjs | 34 +----- .../engines/static-html/css-cascade.mjs | 1 + .../engines/static-html/detect-html.mjs | 14 +-- .../detector/registry/antipatterns.mjs | 2 +- .../scripts/detector/rules/checks.mjs | 115 ++++++++++++++---- .../detector/detect-antipatterns-browser.js | 115 ++++++++++++++---- .../detector/engines/regex/detect-text.mjs | 34 +----- .../engines/static-html/css-cascade.mjs | 1 + .../engines/static-html/detect-html.mjs | 14 +-- .../detector/registry/antipatterns.mjs | 2 +- .../scripts/detector/rules/checks.mjs | 115 ++++++++++++++---- .../detector/detect-antipatterns-browser.js | 115 ++++++++++++++---- .../detector/engines/regex/detect-text.mjs | 34 +----- .../engines/static-html/css-cascade.mjs | 1 + .../engines/static-html/detect-html.mjs | 14 +-- .../detector/registry/antipatterns.mjs | 2 +- .../scripts/detector/rules/checks.mjs | 115 ++++++++++++++---- .../detector/detect-antipatterns-browser.js | 115 ++++++++++++++---- .../detector/engines/regex/detect-text.mjs | 34 +----- .../engines/static-html/css-cascade.mjs | 1 + .../engines/static-html/detect-html.mjs | 14 +-- .../detector/registry/antipatterns.mjs | 2 +- .../scripts/detector/rules/checks.mjs | 115 ++++++++++++++---- .../detector/detect-antipatterns-browser.js | 115 ++++++++++++++---- .../detector/engines/regex/detect-text.mjs | 34 +----- .../engines/static-html/css-cascade.mjs | 1 + .../engines/static-html/detect-html.mjs | 14 +-- .../detector/registry/antipatterns.mjs | 2 +- .../scripts/detector/rules/checks.mjs | 115 ++++++++++++++---- .../detector/detect-antipatterns-browser.js | 115 ++++++++++++++---- .../detector/engines/regex/detect-text.mjs | 34 +----- .../engines/static-html/css-cascade.mjs | 1 + .../engines/static-html/detect-html.mjs | 14 +-- .../detector/registry/antipatterns.mjs | 2 +- .../scripts/detector/rules/checks.mjs | 115 ++++++++++++++---- .../detector/detect-antipatterns-browser.js | 115 ++++++++++++++---- .../detector/engines/regex/detect-text.mjs | 34 +----- .../engines/static-html/css-cascade.mjs | 1 + .../engines/static-html/detect-html.mjs | 14 +-- .../detector/registry/antipatterns.mjs | 2 +- .../scripts/detector/rules/checks.mjs | 115 ++++++++++++++---- .../detector/detect-antipatterns-browser.js | 115 ++++++++++++++---- .../detector/engines/regex/detect-text.mjs | 34 +----- .../engines/static-html/css-cascade.mjs | 1 + .../engines/static-html/detect-html.mjs | 14 +-- .../detector/registry/antipatterns.mjs | 2 +- .../scripts/detector/rules/checks.mjs | 115 ++++++++++++++---- .../detector/detect-antipatterns-browser.js | 115 ++++++++++++++---- .../detector/engines/regex/detect-text.mjs | 34 +----- .../engines/static-html/css-cascade.mjs | 1 + .../engines/static-html/detect-html.mjs | 14 +-- .../detector/registry/antipatterns.mjs | 2 +- .../scripts/detector/rules/checks.mjs | 115 ++++++++++++++---- .../detector/detect-antipatterns-browser.js | 115 ++++++++++++++---- .../detector/engines/regex/detect-text.mjs | 34 +----- .../engines/static-html/css-cascade.mjs | 1 + .../engines/static-html/detect-html.mjs | 14 +-- .../detector/registry/antipatterns.mjs | 2 +- .../scripts/detector/rules/checks.mjs | 115 ++++++++++++++---- 96 files changed, 2960 insertions(+), 1536 deletions(-) diff --git a/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 3f5abd0a8..e6899f569 100644 --- a/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -159,7 +159,7 @@ const ANTIPATTERNS = [ scopes: ['type'], name: 'Flat type hierarchy', description: - 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', + 'Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.', skillSection: 'Typography', skillGuideline: 'flat type hierarchy', }, @@ -5184,6 +5184,88 @@ function checkElementGlow(tag, style, effectiveBg) { // ─── Section 6: Page-Level Checks ─────────────────────────────────────────── +const TYPE_HIERARCHY_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption'; +const TYPE_HIERARCHY_MIN_ROLES = 3; +const TYPE_HIERARCHY_MIN_STEP_RATIO = 1.25; + +function typeHierarchyRole(el) { + const tag = String(el?.tagName || el?.nodeName || '').toLowerCase(); + return /^h[1-6]$/.test(tag) ? tag : 'body'; +} + +function hasTextContent(el) { + return String(el?.textContent || '').trim().length > 0; +} + +function isRenderedTypeElement(el, getStyle) { + for (let current = el; current; current = current.parentElement) { + const hiddenAttr = typeof current.getAttribute === 'function' && current.getAttribute('hidden') !== null; + if (current.hidden || hiddenAttr) return false; + const style = getStyle(current); + if (!style) continue; + const display = String(style.display || '').toLowerCase(); + const visibility = String(style.visibility || '').toLowerCase(); + const contentVisibility = String(style.contentVisibility || '').toLowerCase(); + if (display === 'none' || visibility === 'hidden' || visibility === 'collapse' || contentVisibility === 'hidden') return false; + const opacity = parseFloat(style.opacity); + if (Number.isFinite(opacity) && opacity <= 0.01) return false; + } + return true; +} + +function dominantTypeRoleSize(samples) { + const counts = new Map(); + for (const sample of samples) { + counts.set(sample.size, (counts.get(sample.size) || 0) + 1); + } + const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]); + if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) return null; + return ranked[0]?.[0] ?? null; +} + +function checkFlatTypeHierarchySamples(samples) { + const byRole = new Map(); + for (const sample of samples || []) { + const role = String(sample?.role || ''); + const size = Math.round(Number(sample?.size) * 10) / 10; + if (!role || !Number.isFinite(size) || size < 8 || size >= 200) continue; + if (!byRole.has(role)) byRole.set(role, []); + byRole.get(role).push({ role, size }); + } + + const roles = [...byRole.entries()].map(([role, roleSamples]) => ({ + role, + size: dominantTypeRoleSize(roleSamples), + })).filter(item => item.size !== null); + + if (roles.length < TYPE_HIERARCHY_MIN_ROLES) return []; + + const sorted = roles.slice().sort((a, b) => a.size - b.size || a.role.localeCompare(b.role)); + let largestStep = 1; + for (let i = 1; i < sorted.length; i++) { + largestStep = Math.max(largestStep, sorted[i].size / sorted[i - 1].size); + } + if (largestStep >= TYPE_HIERARCHY_MIN_STEP_RATIO) return []; + + const roleSizes = sorted.map(item => `${item.role} ${item.size}px`).join(', '); + return [{ + id: 'flat-type-hierarchy', + snippet: `Role sizes: ${roleSizes} (largest adjacent step ${largestStep.toFixed(2)}:1; target ${TYPE_HIERARCHY_MIN_STEP_RATIO}:1)`, + }]; +} + +function checkFlatTypeHierarchyFromDoc(root, getStyle, options = {}) { + const samples = []; + for (const el of root.querySelectorAll(TYPE_HIERARCHY_SELECTOR)) { + if (options.skipElement?.(el)) continue; + if (!hasTextContent(el) || !isRenderedTypeElement(el, getStyle)) continue; + const fontSize = parseFloat(getStyle(el)?.fontSize); + if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize >= 200) continue; + samples.push({ role: typeHierarchyRole(el), size: fontSize }); + } + return checkFlatTypeHierarchySamples(samples); +} + // Browser page-level checks — use document/getComputedStyle globals function checkTypography() { @@ -5222,17 +5304,10 @@ function checkTypography() { } } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { - const fs = parseFloat(getComputedStyle(el).fontSize); - if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } + for (const finding of checkFlatTypeHierarchyFromDoc(document, getComputedStyle, { + skipElement: el => el.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip, [id^="impeccable-live-"]'), + })) { + findings.push({ type: finding.id, detail: finding.snippet }); } return findings; @@ -5479,21 +5554,7 @@ function checkPageTypography(doc, win) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - // Flat type hierarchy - const sizes = new Set(); - const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div'); - for (const el of textEls) { - const fontSize = parseFloat(win.getComputedStyle(el).fontSize); - // Filter out sub-8px values (jsdom doesn't resolve relative units properly) - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(doc, el => win.getComputedStyle(el))); return findings; } diff --git a/.agents/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.agents/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 26ca4f390..df1334fdd 100644 --- a/.agents/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.agents/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -607,32 +607,6 @@ const REGEX_MATCHERS = [ ]; const REGEX_ANALYZERS = [ - // Flat type hierarchy - (content, filePath) => { - const sizes = new Set(); - const REM = 16; - let m; - const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi; - while ((m = sizeRe.exec(content)) !== null) { - const px = m[2] === 'px' ? +m[1] : +m[1] * REM; - if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10); - } - const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi; - while ((m = clampRe.exec(content)) !== null) { - sizes.add(Math.round((m[2] === 'px' ? +m[1] : +m[1] * REM) * 10) / 10); - sizes.add(Math.round((m[4] === 'px' ? +m[3] : +m[3] * REM) * 10) / 10); - } - const TW = { 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, 'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, 'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128 }; - for (const [cls, px] of Object.entries(TW)) { if (new RegExp(`\\b${cls}\\b`).test(content)) sizes.add(px); } - if (sizes.size < 3) return []; - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio >= 2.0) return []; - const lines = content.split('\n'); - let line = 1; - for (let i = 0; i < lines.length; i++) { if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) { line = i + 1; break; } } - return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)]; - }, // Monotonous spacing (regex) (content, filePath) => { const vals = []; @@ -1154,11 +1128,12 @@ const TEXT_CONTENT_ANALYZER_IDS = [ function runTextContentAnalyzers(content, filePath, options = {}) { const profile = options?.profile; if (!shouldRunPageAnalyzers(content, filePath)) return []; - // The 3 text-content analyzers are at indices 2-4 in REGEX_ANALYZERS - // (single-font's removal on 2026-07-29 shifted every index down one). + // The 3 text-content analyzers are at indices 1-3 in REGEX_ANALYZERS. + // flat-type-hierarchy left this source-only path in issue #619 because it + // needs rendered role and usage evidence. const findings = []; for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { - const analyzer = REGEX_ANALYZERS[2 + i]; + const analyzer = REGEX_ANALYZERS[1 + i]; const ruleId = TEXT_CONTENT_ANALYZER_IDS[i]; findings.push(...profileFindings(profile, { engine: 'regex', @@ -1284,7 +1259,6 @@ function detectText(content, filePath, options = {}) { // Page-level analyzers only run on full pages if (shouldRunPageAnalyzers(content, filePath)) { const analyzerIds = [ - 'flat-type-hierarchy', 'monotonous-spacing', 'em-dash-overuse', 'marketing-buzzword', diff --git a/.agents/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.agents/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index cc36ac9b5..7f6080b61 100644 --- a/.agents/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.agents/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -282,6 +282,7 @@ const STATIC_DEFAULT_STYLE = { marginLeft: '0px', position: 'static', visibility: 'visible', + contentVisibility: 'visible', opacity: '1', top: 'auto', right: 'auto', diff --git a/.agents/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.agents/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 51c34224b..84e6ab1f5 100644 --- a/.agents/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.agents/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -25,6 +25,7 @@ import { checkElementOversizedH1, checkElementQuality, checkElementRadialSpotlight, + checkFlatTypeHierarchyFromDoc, checkCreamPalette, checkHtmlPatterns, checkKickerAboveHeadingFromDoc, @@ -59,18 +60,7 @@ function checkStaticPageTypography(document, window) { for (const font of overusedFound) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div')) { - const fontSize = parseFloat(window.getComputedStyle(el).fontSize); - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(document, el => window.getComputedStyle(el))); return findings; } diff --git a/.agents/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.agents/skills/impeccable/scripts/detector/registry/antipatterns.mjs index 88ff84a07..945307783 100644 --- a/.agents/skills/impeccable/scripts/detector/registry/antipatterns.mjs +++ b/.agents/skills/impeccable/scripts/detector/registry/antipatterns.mjs @@ -34,7 +34,7 @@ const ANTIPATTERNS = [ scopes: ['type'], name: 'Flat type hierarchy', description: - 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', + 'Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.', skillSection: 'Typography', skillGuideline: 'flat type hierarchy', }, diff --git a/.agents/skills/impeccable/scripts/detector/rules/checks.mjs b/.agents/skills/impeccable/scripts/detector/rules/checks.mjs index 8bfb2b1df..579e1c12e 100644 --- a/.agents/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.agents/skills/impeccable/scripts/detector/rules/checks.mjs @@ -3911,6 +3911,88 @@ function checkElementGlow(tag, style, effectiveBg) { // ─── Section 6: Page-Level Checks ─────────────────────────────────────────── +const TYPE_HIERARCHY_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption'; +const TYPE_HIERARCHY_MIN_ROLES = 3; +const TYPE_HIERARCHY_MIN_STEP_RATIO = 1.25; + +function typeHierarchyRole(el) { + const tag = String(el?.tagName || el?.nodeName || '').toLowerCase(); + return /^h[1-6]$/.test(tag) ? tag : 'body'; +} + +function hasTextContent(el) { + return String(el?.textContent || '').trim().length > 0; +} + +function isRenderedTypeElement(el, getStyle) { + for (let current = el; current; current = current.parentElement) { + const hiddenAttr = typeof current.getAttribute === 'function' && current.getAttribute('hidden') !== null; + if (current.hidden || hiddenAttr) return false; + const style = getStyle(current); + if (!style) continue; + const display = String(style.display || '').toLowerCase(); + const visibility = String(style.visibility || '').toLowerCase(); + const contentVisibility = String(style.contentVisibility || '').toLowerCase(); + if (display === 'none' || visibility === 'hidden' || visibility === 'collapse' || contentVisibility === 'hidden') return false; + const opacity = parseFloat(style.opacity); + if (Number.isFinite(opacity) && opacity <= 0.01) return false; + } + return true; +} + +function dominantTypeRoleSize(samples) { + const counts = new Map(); + for (const sample of samples) { + counts.set(sample.size, (counts.get(sample.size) || 0) + 1); + } + const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]); + if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) return null; + return ranked[0]?.[0] ?? null; +} + +function checkFlatTypeHierarchySamples(samples) { + const byRole = new Map(); + for (const sample of samples || []) { + const role = String(sample?.role || ''); + const size = Math.round(Number(sample?.size) * 10) / 10; + if (!role || !Number.isFinite(size) || size < 8 || size >= 200) continue; + if (!byRole.has(role)) byRole.set(role, []); + byRole.get(role).push({ role, size }); + } + + const roles = [...byRole.entries()].map(([role, roleSamples]) => ({ + role, + size: dominantTypeRoleSize(roleSamples), + })).filter(item => item.size !== null); + + if (roles.length < TYPE_HIERARCHY_MIN_ROLES) return []; + + const sorted = roles.slice().sort((a, b) => a.size - b.size || a.role.localeCompare(b.role)); + let largestStep = 1; + for (let i = 1; i < sorted.length; i++) { + largestStep = Math.max(largestStep, sorted[i].size / sorted[i - 1].size); + } + if (largestStep >= TYPE_HIERARCHY_MIN_STEP_RATIO) return []; + + const roleSizes = sorted.map(item => `${item.role} ${item.size}px`).join(', '); + return [{ + id: 'flat-type-hierarchy', + snippet: `Role sizes: ${roleSizes} (largest adjacent step ${largestStep.toFixed(2)}:1; target ${TYPE_HIERARCHY_MIN_STEP_RATIO}:1)`, + }]; +} + +function checkFlatTypeHierarchyFromDoc(root, getStyle, options = {}) { + const samples = []; + for (const el of root.querySelectorAll(TYPE_HIERARCHY_SELECTOR)) { + if (options.skipElement?.(el)) continue; + if (!hasTextContent(el) || !isRenderedTypeElement(el, getStyle)) continue; + const fontSize = parseFloat(getStyle(el)?.fontSize); + if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize >= 200) continue; + samples.push({ role: typeHierarchyRole(el), size: fontSize }); + } + return checkFlatTypeHierarchySamples(samples); +} + // Browser page-level checks — use document/getComputedStyle globals function checkTypography() { @@ -3949,17 +4031,10 @@ function checkTypography() { } } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { - const fs = parseFloat(getComputedStyle(el).fontSize); - if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } + for (const finding of checkFlatTypeHierarchyFromDoc(document, getComputedStyle, { + skipElement: el => el.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip, [id^="impeccable-live-"]'), + })) { + findings.push({ type: finding.id, detail: finding.snippet }); } return findings; @@ -4206,21 +4281,7 @@ function checkPageTypography(doc, win) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - // Flat type hierarchy - const sizes = new Set(); - const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div'); - for (const el of textEls) { - const fontSize = parseFloat(win.getComputedStyle(el).fontSize); - // Filter out sub-8px values (jsdom doesn't resolve relative units properly) - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(doc, el => win.getComputedStyle(el))); return findings; } @@ -5649,6 +5710,8 @@ export { checkKickerAboveHeadingFromDoc, checkElementMotion, checkElementGlow, + checkFlatTypeHierarchySamples, + checkFlatTypeHierarchyFromDoc, checkTypography, isCardLikeDOM, checkLayout, diff --git a/.claude/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.claude/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 3f5abd0a8..e6899f569 100644 --- a/.claude/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.claude/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -159,7 +159,7 @@ const ANTIPATTERNS = [ scopes: ['type'], name: 'Flat type hierarchy', description: - 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', + 'Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.', skillSection: 'Typography', skillGuideline: 'flat type hierarchy', }, @@ -5184,6 +5184,88 @@ function checkElementGlow(tag, style, effectiveBg) { // ─── Section 6: Page-Level Checks ─────────────────────────────────────────── +const TYPE_HIERARCHY_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption'; +const TYPE_HIERARCHY_MIN_ROLES = 3; +const TYPE_HIERARCHY_MIN_STEP_RATIO = 1.25; + +function typeHierarchyRole(el) { + const tag = String(el?.tagName || el?.nodeName || '').toLowerCase(); + return /^h[1-6]$/.test(tag) ? tag : 'body'; +} + +function hasTextContent(el) { + return String(el?.textContent || '').trim().length > 0; +} + +function isRenderedTypeElement(el, getStyle) { + for (let current = el; current; current = current.parentElement) { + const hiddenAttr = typeof current.getAttribute === 'function' && current.getAttribute('hidden') !== null; + if (current.hidden || hiddenAttr) return false; + const style = getStyle(current); + if (!style) continue; + const display = String(style.display || '').toLowerCase(); + const visibility = String(style.visibility || '').toLowerCase(); + const contentVisibility = String(style.contentVisibility || '').toLowerCase(); + if (display === 'none' || visibility === 'hidden' || visibility === 'collapse' || contentVisibility === 'hidden') return false; + const opacity = parseFloat(style.opacity); + if (Number.isFinite(opacity) && opacity <= 0.01) return false; + } + return true; +} + +function dominantTypeRoleSize(samples) { + const counts = new Map(); + for (const sample of samples) { + counts.set(sample.size, (counts.get(sample.size) || 0) + 1); + } + const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]); + if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) return null; + return ranked[0]?.[0] ?? null; +} + +function checkFlatTypeHierarchySamples(samples) { + const byRole = new Map(); + for (const sample of samples || []) { + const role = String(sample?.role || ''); + const size = Math.round(Number(sample?.size) * 10) / 10; + if (!role || !Number.isFinite(size) || size < 8 || size >= 200) continue; + if (!byRole.has(role)) byRole.set(role, []); + byRole.get(role).push({ role, size }); + } + + const roles = [...byRole.entries()].map(([role, roleSamples]) => ({ + role, + size: dominantTypeRoleSize(roleSamples), + })).filter(item => item.size !== null); + + if (roles.length < TYPE_HIERARCHY_MIN_ROLES) return []; + + const sorted = roles.slice().sort((a, b) => a.size - b.size || a.role.localeCompare(b.role)); + let largestStep = 1; + for (let i = 1; i < sorted.length; i++) { + largestStep = Math.max(largestStep, sorted[i].size / sorted[i - 1].size); + } + if (largestStep >= TYPE_HIERARCHY_MIN_STEP_RATIO) return []; + + const roleSizes = sorted.map(item => `${item.role} ${item.size}px`).join(', '); + return [{ + id: 'flat-type-hierarchy', + snippet: `Role sizes: ${roleSizes} (largest adjacent step ${largestStep.toFixed(2)}:1; target ${TYPE_HIERARCHY_MIN_STEP_RATIO}:1)`, + }]; +} + +function checkFlatTypeHierarchyFromDoc(root, getStyle, options = {}) { + const samples = []; + for (const el of root.querySelectorAll(TYPE_HIERARCHY_SELECTOR)) { + if (options.skipElement?.(el)) continue; + if (!hasTextContent(el) || !isRenderedTypeElement(el, getStyle)) continue; + const fontSize = parseFloat(getStyle(el)?.fontSize); + if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize >= 200) continue; + samples.push({ role: typeHierarchyRole(el), size: fontSize }); + } + return checkFlatTypeHierarchySamples(samples); +} + // Browser page-level checks — use document/getComputedStyle globals function checkTypography() { @@ -5222,17 +5304,10 @@ function checkTypography() { } } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { - const fs = parseFloat(getComputedStyle(el).fontSize); - if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } + for (const finding of checkFlatTypeHierarchyFromDoc(document, getComputedStyle, { + skipElement: el => el.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip, [id^="impeccable-live-"]'), + })) { + findings.push({ type: finding.id, detail: finding.snippet }); } return findings; @@ -5479,21 +5554,7 @@ function checkPageTypography(doc, win) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - // Flat type hierarchy - const sizes = new Set(); - const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div'); - for (const el of textEls) { - const fontSize = parseFloat(win.getComputedStyle(el).fontSize); - // Filter out sub-8px values (jsdom doesn't resolve relative units properly) - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(doc, el => win.getComputedStyle(el))); return findings; } diff --git a/.claude/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.claude/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 26ca4f390..df1334fdd 100644 --- a/.claude/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.claude/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -607,32 +607,6 @@ const REGEX_MATCHERS = [ ]; const REGEX_ANALYZERS = [ - // Flat type hierarchy - (content, filePath) => { - const sizes = new Set(); - const REM = 16; - let m; - const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi; - while ((m = sizeRe.exec(content)) !== null) { - const px = m[2] === 'px' ? +m[1] : +m[1] * REM; - if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10); - } - const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi; - while ((m = clampRe.exec(content)) !== null) { - sizes.add(Math.round((m[2] === 'px' ? +m[1] : +m[1] * REM) * 10) / 10); - sizes.add(Math.round((m[4] === 'px' ? +m[3] : +m[3] * REM) * 10) / 10); - } - const TW = { 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, 'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, 'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128 }; - for (const [cls, px] of Object.entries(TW)) { if (new RegExp(`\\b${cls}\\b`).test(content)) sizes.add(px); } - if (sizes.size < 3) return []; - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio >= 2.0) return []; - const lines = content.split('\n'); - let line = 1; - for (let i = 0; i < lines.length; i++) { if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) { line = i + 1; break; } } - return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)]; - }, // Monotonous spacing (regex) (content, filePath) => { const vals = []; @@ -1154,11 +1128,12 @@ const TEXT_CONTENT_ANALYZER_IDS = [ function runTextContentAnalyzers(content, filePath, options = {}) { const profile = options?.profile; if (!shouldRunPageAnalyzers(content, filePath)) return []; - // The 3 text-content analyzers are at indices 2-4 in REGEX_ANALYZERS - // (single-font's removal on 2026-07-29 shifted every index down one). + // The 3 text-content analyzers are at indices 1-3 in REGEX_ANALYZERS. + // flat-type-hierarchy left this source-only path in issue #619 because it + // needs rendered role and usage evidence. const findings = []; for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { - const analyzer = REGEX_ANALYZERS[2 + i]; + const analyzer = REGEX_ANALYZERS[1 + i]; const ruleId = TEXT_CONTENT_ANALYZER_IDS[i]; findings.push(...profileFindings(profile, { engine: 'regex', @@ -1284,7 +1259,6 @@ function detectText(content, filePath, options = {}) { // Page-level analyzers only run on full pages if (shouldRunPageAnalyzers(content, filePath)) { const analyzerIds = [ - 'flat-type-hierarchy', 'monotonous-spacing', 'em-dash-overuse', 'marketing-buzzword', diff --git a/.claude/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.claude/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index cc36ac9b5..7f6080b61 100644 --- a/.claude/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.claude/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -282,6 +282,7 @@ const STATIC_DEFAULT_STYLE = { marginLeft: '0px', position: 'static', visibility: 'visible', + contentVisibility: 'visible', opacity: '1', top: 'auto', right: 'auto', diff --git a/.claude/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.claude/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 51c34224b..84e6ab1f5 100644 --- a/.claude/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.claude/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -25,6 +25,7 @@ import { checkElementOversizedH1, checkElementQuality, checkElementRadialSpotlight, + checkFlatTypeHierarchyFromDoc, checkCreamPalette, checkHtmlPatterns, checkKickerAboveHeadingFromDoc, @@ -59,18 +60,7 @@ function checkStaticPageTypography(document, window) { for (const font of overusedFound) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div')) { - const fontSize = parseFloat(window.getComputedStyle(el).fontSize); - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(document, el => window.getComputedStyle(el))); return findings; } diff --git a/.claude/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.claude/skills/impeccable/scripts/detector/registry/antipatterns.mjs index 88ff84a07..945307783 100644 --- a/.claude/skills/impeccable/scripts/detector/registry/antipatterns.mjs +++ b/.claude/skills/impeccable/scripts/detector/registry/antipatterns.mjs @@ -34,7 +34,7 @@ const ANTIPATTERNS = [ scopes: ['type'], name: 'Flat type hierarchy', description: - 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', + 'Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.', skillSection: 'Typography', skillGuideline: 'flat type hierarchy', }, diff --git a/.claude/skills/impeccable/scripts/detector/rules/checks.mjs b/.claude/skills/impeccable/scripts/detector/rules/checks.mjs index 8bfb2b1df..579e1c12e 100644 --- a/.claude/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.claude/skills/impeccable/scripts/detector/rules/checks.mjs @@ -3911,6 +3911,88 @@ function checkElementGlow(tag, style, effectiveBg) { // ─── Section 6: Page-Level Checks ─────────────────────────────────────────── +const TYPE_HIERARCHY_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption'; +const TYPE_HIERARCHY_MIN_ROLES = 3; +const TYPE_HIERARCHY_MIN_STEP_RATIO = 1.25; + +function typeHierarchyRole(el) { + const tag = String(el?.tagName || el?.nodeName || '').toLowerCase(); + return /^h[1-6]$/.test(tag) ? tag : 'body'; +} + +function hasTextContent(el) { + return String(el?.textContent || '').trim().length > 0; +} + +function isRenderedTypeElement(el, getStyle) { + for (let current = el; current; current = current.parentElement) { + const hiddenAttr = typeof current.getAttribute === 'function' && current.getAttribute('hidden') !== null; + if (current.hidden || hiddenAttr) return false; + const style = getStyle(current); + if (!style) continue; + const display = String(style.display || '').toLowerCase(); + const visibility = String(style.visibility || '').toLowerCase(); + const contentVisibility = String(style.contentVisibility || '').toLowerCase(); + if (display === 'none' || visibility === 'hidden' || visibility === 'collapse' || contentVisibility === 'hidden') return false; + const opacity = parseFloat(style.opacity); + if (Number.isFinite(opacity) && opacity <= 0.01) return false; + } + return true; +} + +function dominantTypeRoleSize(samples) { + const counts = new Map(); + for (const sample of samples) { + counts.set(sample.size, (counts.get(sample.size) || 0) + 1); + } + const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]); + if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) return null; + return ranked[0]?.[0] ?? null; +} + +function checkFlatTypeHierarchySamples(samples) { + const byRole = new Map(); + for (const sample of samples || []) { + const role = String(sample?.role || ''); + const size = Math.round(Number(sample?.size) * 10) / 10; + if (!role || !Number.isFinite(size) || size < 8 || size >= 200) continue; + if (!byRole.has(role)) byRole.set(role, []); + byRole.get(role).push({ role, size }); + } + + const roles = [...byRole.entries()].map(([role, roleSamples]) => ({ + role, + size: dominantTypeRoleSize(roleSamples), + })).filter(item => item.size !== null); + + if (roles.length < TYPE_HIERARCHY_MIN_ROLES) return []; + + const sorted = roles.slice().sort((a, b) => a.size - b.size || a.role.localeCompare(b.role)); + let largestStep = 1; + for (let i = 1; i < sorted.length; i++) { + largestStep = Math.max(largestStep, sorted[i].size / sorted[i - 1].size); + } + if (largestStep >= TYPE_HIERARCHY_MIN_STEP_RATIO) return []; + + const roleSizes = sorted.map(item => `${item.role} ${item.size}px`).join(', '); + return [{ + id: 'flat-type-hierarchy', + snippet: `Role sizes: ${roleSizes} (largest adjacent step ${largestStep.toFixed(2)}:1; target ${TYPE_HIERARCHY_MIN_STEP_RATIO}:1)`, + }]; +} + +function checkFlatTypeHierarchyFromDoc(root, getStyle, options = {}) { + const samples = []; + for (const el of root.querySelectorAll(TYPE_HIERARCHY_SELECTOR)) { + if (options.skipElement?.(el)) continue; + if (!hasTextContent(el) || !isRenderedTypeElement(el, getStyle)) continue; + const fontSize = parseFloat(getStyle(el)?.fontSize); + if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize >= 200) continue; + samples.push({ role: typeHierarchyRole(el), size: fontSize }); + } + return checkFlatTypeHierarchySamples(samples); +} + // Browser page-level checks — use document/getComputedStyle globals function checkTypography() { @@ -3949,17 +4031,10 @@ function checkTypography() { } } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { - const fs = parseFloat(getComputedStyle(el).fontSize); - if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } + for (const finding of checkFlatTypeHierarchyFromDoc(document, getComputedStyle, { + skipElement: el => el.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip, [id^="impeccable-live-"]'), + })) { + findings.push({ type: finding.id, detail: finding.snippet }); } return findings; @@ -4206,21 +4281,7 @@ function checkPageTypography(doc, win) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - // Flat type hierarchy - const sizes = new Set(); - const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div'); - for (const el of textEls) { - const fontSize = parseFloat(win.getComputedStyle(el).fontSize); - // Filter out sub-8px values (jsdom doesn't resolve relative units properly) - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(doc, el => win.getComputedStyle(el))); return findings; } @@ -5649,6 +5710,8 @@ export { checkKickerAboveHeadingFromDoc, checkElementMotion, checkElementGlow, + checkFlatTypeHierarchySamples, + checkFlatTypeHierarchyFromDoc, checkTypography, isCardLikeDOM, checkLayout, diff --git a/.cursor/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.cursor/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 3f5abd0a8..e6899f569 100644 --- a/.cursor/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.cursor/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -159,7 +159,7 @@ const ANTIPATTERNS = [ scopes: ['type'], name: 'Flat type hierarchy', description: - 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', + 'Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.', skillSection: 'Typography', skillGuideline: 'flat type hierarchy', }, @@ -5184,6 +5184,88 @@ function checkElementGlow(tag, style, effectiveBg) { // ─── Section 6: Page-Level Checks ─────────────────────────────────────────── +const TYPE_HIERARCHY_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption'; +const TYPE_HIERARCHY_MIN_ROLES = 3; +const TYPE_HIERARCHY_MIN_STEP_RATIO = 1.25; + +function typeHierarchyRole(el) { + const tag = String(el?.tagName || el?.nodeName || '').toLowerCase(); + return /^h[1-6]$/.test(tag) ? tag : 'body'; +} + +function hasTextContent(el) { + return String(el?.textContent || '').trim().length > 0; +} + +function isRenderedTypeElement(el, getStyle) { + for (let current = el; current; current = current.parentElement) { + const hiddenAttr = typeof current.getAttribute === 'function' && current.getAttribute('hidden') !== null; + if (current.hidden || hiddenAttr) return false; + const style = getStyle(current); + if (!style) continue; + const display = String(style.display || '').toLowerCase(); + const visibility = String(style.visibility || '').toLowerCase(); + const contentVisibility = String(style.contentVisibility || '').toLowerCase(); + if (display === 'none' || visibility === 'hidden' || visibility === 'collapse' || contentVisibility === 'hidden') return false; + const opacity = parseFloat(style.opacity); + if (Number.isFinite(opacity) && opacity <= 0.01) return false; + } + return true; +} + +function dominantTypeRoleSize(samples) { + const counts = new Map(); + for (const sample of samples) { + counts.set(sample.size, (counts.get(sample.size) || 0) + 1); + } + const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]); + if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) return null; + return ranked[0]?.[0] ?? null; +} + +function checkFlatTypeHierarchySamples(samples) { + const byRole = new Map(); + for (const sample of samples || []) { + const role = String(sample?.role || ''); + const size = Math.round(Number(sample?.size) * 10) / 10; + if (!role || !Number.isFinite(size) || size < 8 || size >= 200) continue; + if (!byRole.has(role)) byRole.set(role, []); + byRole.get(role).push({ role, size }); + } + + const roles = [...byRole.entries()].map(([role, roleSamples]) => ({ + role, + size: dominantTypeRoleSize(roleSamples), + })).filter(item => item.size !== null); + + if (roles.length < TYPE_HIERARCHY_MIN_ROLES) return []; + + const sorted = roles.slice().sort((a, b) => a.size - b.size || a.role.localeCompare(b.role)); + let largestStep = 1; + for (let i = 1; i < sorted.length; i++) { + largestStep = Math.max(largestStep, sorted[i].size / sorted[i - 1].size); + } + if (largestStep >= TYPE_HIERARCHY_MIN_STEP_RATIO) return []; + + const roleSizes = sorted.map(item => `${item.role} ${item.size}px`).join(', '); + return [{ + id: 'flat-type-hierarchy', + snippet: `Role sizes: ${roleSizes} (largest adjacent step ${largestStep.toFixed(2)}:1; target ${TYPE_HIERARCHY_MIN_STEP_RATIO}:1)`, + }]; +} + +function checkFlatTypeHierarchyFromDoc(root, getStyle, options = {}) { + const samples = []; + for (const el of root.querySelectorAll(TYPE_HIERARCHY_SELECTOR)) { + if (options.skipElement?.(el)) continue; + if (!hasTextContent(el) || !isRenderedTypeElement(el, getStyle)) continue; + const fontSize = parseFloat(getStyle(el)?.fontSize); + if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize >= 200) continue; + samples.push({ role: typeHierarchyRole(el), size: fontSize }); + } + return checkFlatTypeHierarchySamples(samples); +} + // Browser page-level checks — use document/getComputedStyle globals function checkTypography() { @@ -5222,17 +5304,10 @@ function checkTypography() { } } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { - const fs = parseFloat(getComputedStyle(el).fontSize); - if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } + for (const finding of checkFlatTypeHierarchyFromDoc(document, getComputedStyle, { + skipElement: el => el.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip, [id^="impeccable-live-"]'), + })) { + findings.push({ type: finding.id, detail: finding.snippet }); } return findings; @@ -5479,21 +5554,7 @@ function checkPageTypography(doc, win) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - // Flat type hierarchy - const sizes = new Set(); - const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div'); - for (const el of textEls) { - const fontSize = parseFloat(win.getComputedStyle(el).fontSize); - // Filter out sub-8px values (jsdom doesn't resolve relative units properly) - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(doc, el => win.getComputedStyle(el))); return findings; } diff --git a/.cursor/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.cursor/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 26ca4f390..df1334fdd 100644 --- a/.cursor/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.cursor/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -607,32 +607,6 @@ const REGEX_MATCHERS = [ ]; const REGEX_ANALYZERS = [ - // Flat type hierarchy - (content, filePath) => { - const sizes = new Set(); - const REM = 16; - let m; - const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi; - while ((m = sizeRe.exec(content)) !== null) { - const px = m[2] === 'px' ? +m[1] : +m[1] * REM; - if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10); - } - const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi; - while ((m = clampRe.exec(content)) !== null) { - sizes.add(Math.round((m[2] === 'px' ? +m[1] : +m[1] * REM) * 10) / 10); - sizes.add(Math.round((m[4] === 'px' ? +m[3] : +m[3] * REM) * 10) / 10); - } - const TW = { 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, 'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, 'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128 }; - for (const [cls, px] of Object.entries(TW)) { if (new RegExp(`\\b${cls}\\b`).test(content)) sizes.add(px); } - if (sizes.size < 3) return []; - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio >= 2.0) return []; - const lines = content.split('\n'); - let line = 1; - for (let i = 0; i < lines.length; i++) { if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) { line = i + 1; break; } } - return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)]; - }, // Monotonous spacing (regex) (content, filePath) => { const vals = []; @@ -1154,11 +1128,12 @@ const TEXT_CONTENT_ANALYZER_IDS = [ function runTextContentAnalyzers(content, filePath, options = {}) { const profile = options?.profile; if (!shouldRunPageAnalyzers(content, filePath)) return []; - // The 3 text-content analyzers are at indices 2-4 in REGEX_ANALYZERS - // (single-font's removal on 2026-07-29 shifted every index down one). + // The 3 text-content analyzers are at indices 1-3 in REGEX_ANALYZERS. + // flat-type-hierarchy left this source-only path in issue #619 because it + // needs rendered role and usage evidence. const findings = []; for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { - const analyzer = REGEX_ANALYZERS[2 + i]; + const analyzer = REGEX_ANALYZERS[1 + i]; const ruleId = TEXT_CONTENT_ANALYZER_IDS[i]; findings.push(...profileFindings(profile, { engine: 'regex', @@ -1284,7 +1259,6 @@ function detectText(content, filePath, options = {}) { // Page-level analyzers only run on full pages if (shouldRunPageAnalyzers(content, filePath)) { const analyzerIds = [ - 'flat-type-hierarchy', 'monotonous-spacing', 'em-dash-overuse', 'marketing-buzzword', diff --git a/.cursor/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.cursor/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index cc36ac9b5..7f6080b61 100644 --- a/.cursor/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.cursor/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -282,6 +282,7 @@ const STATIC_DEFAULT_STYLE = { marginLeft: '0px', position: 'static', visibility: 'visible', + contentVisibility: 'visible', opacity: '1', top: 'auto', right: 'auto', diff --git a/.cursor/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.cursor/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 51c34224b..84e6ab1f5 100644 --- a/.cursor/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.cursor/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -25,6 +25,7 @@ import { checkElementOversizedH1, checkElementQuality, checkElementRadialSpotlight, + checkFlatTypeHierarchyFromDoc, checkCreamPalette, checkHtmlPatterns, checkKickerAboveHeadingFromDoc, @@ -59,18 +60,7 @@ function checkStaticPageTypography(document, window) { for (const font of overusedFound) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div')) { - const fontSize = parseFloat(window.getComputedStyle(el).fontSize); - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(document, el => window.getComputedStyle(el))); return findings; } diff --git a/.cursor/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.cursor/skills/impeccable/scripts/detector/registry/antipatterns.mjs index 88ff84a07..945307783 100644 --- a/.cursor/skills/impeccable/scripts/detector/registry/antipatterns.mjs +++ b/.cursor/skills/impeccable/scripts/detector/registry/antipatterns.mjs @@ -34,7 +34,7 @@ const ANTIPATTERNS = [ scopes: ['type'], name: 'Flat type hierarchy', description: - 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', + 'Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.', skillSection: 'Typography', skillGuideline: 'flat type hierarchy', }, diff --git a/.cursor/skills/impeccable/scripts/detector/rules/checks.mjs b/.cursor/skills/impeccable/scripts/detector/rules/checks.mjs index 8bfb2b1df..579e1c12e 100644 --- a/.cursor/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.cursor/skills/impeccable/scripts/detector/rules/checks.mjs @@ -3911,6 +3911,88 @@ function checkElementGlow(tag, style, effectiveBg) { // ─── Section 6: Page-Level Checks ─────────────────────────────────────────── +const TYPE_HIERARCHY_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption'; +const TYPE_HIERARCHY_MIN_ROLES = 3; +const TYPE_HIERARCHY_MIN_STEP_RATIO = 1.25; + +function typeHierarchyRole(el) { + const tag = String(el?.tagName || el?.nodeName || '').toLowerCase(); + return /^h[1-6]$/.test(tag) ? tag : 'body'; +} + +function hasTextContent(el) { + return String(el?.textContent || '').trim().length > 0; +} + +function isRenderedTypeElement(el, getStyle) { + for (let current = el; current; current = current.parentElement) { + const hiddenAttr = typeof current.getAttribute === 'function' && current.getAttribute('hidden') !== null; + if (current.hidden || hiddenAttr) return false; + const style = getStyle(current); + if (!style) continue; + const display = String(style.display || '').toLowerCase(); + const visibility = String(style.visibility || '').toLowerCase(); + const contentVisibility = String(style.contentVisibility || '').toLowerCase(); + if (display === 'none' || visibility === 'hidden' || visibility === 'collapse' || contentVisibility === 'hidden') return false; + const opacity = parseFloat(style.opacity); + if (Number.isFinite(opacity) && opacity <= 0.01) return false; + } + return true; +} + +function dominantTypeRoleSize(samples) { + const counts = new Map(); + for (const sample of samples) { + counts.set(sample.size, (counts.get(sample.size) || 0) + 1); + } + const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]); + if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) return null; + return ranked[0]?.[0] ?? null; +} + +function checkFlatTypeHierarchySamples(samples) { + const byRole = new Map(); + for (const sample of samples || []) { + const role = String(sample?.role || ''); + const size = Math.round(Number(sample?.size) * 10) / 10; + if (!role || !Number.isFinite(size) || size < 8 || size >= 200) continue; + if (!byRole.has(role)) byRole.set(role, []); + byRole.get(role).push({ role, size }); + } + + const roles = [...byRole.entries()].map(([role, roleSamples]) => ({ + role, + size: dominantTypeRoleSize(roleSamples), + })).filter(item => item.size !== null); + + if (roles.length < TYPE_HIERARCHY_MIN_ROLES) return []; + + const sorted = roles.slice().sort((a, b) => a.size - b.size || a.role.localeCompare(b.role)); + let largestStep = 1; + for (let i = 1; i < sorted.length; i++) { + largestStep = Math.max(largestStep, sorted[i].size / sorted[i - 1].size); + } + if (largestStep >= TYPE_HIERARCHY_MIN_STEP_RATIO) return []; + + const roleSizes = sorted.map(item => `${item.role} ${item.size}px`).join(', '); + return [{ + id: 'flat-type-hierarchy', + snippet: `Role sizes: ${roleSizes} (largest adjacent step ${largestStep.toFixed(2)}:1; target ${TYPE_HIERARCHY_MIN_STEP_RATIO}:1)`, + }]; +} + +function checkFlatTypeHierarchyFromDoc(root, getStyle, options = {}) { + const samples = []; + for (const el of root.querySelectorAll(TYPE_HIERARCHY_SELECTOR)) { + if (options.skipElement?.(el)) continue; + if (!hasTextContent(el) || !isRenderedTypeElement(el, getStyle)) continue; + const fontSize = parseFloat(getStyle(el)?.fontSize); + if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize >= 200) continue; + samples.push({ role: typeHierarchyRole(el), size: fontSize }); + } + return checkFlatTypeHierarchySamples(samples); +} + // Browser page-level checks — use document/getComputedStyle globals function checkTypography() { @@ -3949,17 +4031,10 @@ function checkTypography() { } } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { - const fs = parseFloat(getComputedStyle(el).fontSize); - if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } + for (const finding of checkFlatTypeHierarchyFromDoc(document, getComputedStyle, { + skipElement: el => el.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip, [id^="impeccable-live-"]'), + })) { + findings.push({ type: finding.id, detail: finding.snippet }); } return findings; @@ -4206,21 +4281,7 @@ function checkPageTypography(doc, win) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - // Flat type hierarchy - const sizes = new Set(); - const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div'); - for (const el of textEls) { - const fontSize = parseFloat(win.getComputedStyle(el).fontSize); - // Filter out sub-8px values (jsdom doesn't resolve relative units properly) - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(doc, el => win.getComputedStyle(el))); return findings; } @@ -5649,6 +5710,8 @@ export { checkKickerAboveHeadingFromDoc, checkElementMotion, checkElementGlow, + checkFlatTypeHierarchySamples, + checkFlatTypeHierarchyFromDoc, checkTypography, isCardLikeDOM, checkLayout, diff --git a/.gemini/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.gemini/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 3f5abd0a8..e6899f569 100644 --- a/.gemini/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.gemini/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -159,7 +159,7 @@ const ANTIPATTERNS = [ scopes: ['type'], name: 'Flat type hierarchy', description: - 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', + 'Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.', skillSection: 'Typography', skillGuideline: 'flat type hierarchy', }, @@ -5184,6 +5184,88 @@ function checkElementGlow(tag, style, effectiveBg) { // ─── Section 6: Page-Level Checks ─────────────────────────────────────────── +const TYPE_HIERARCHY_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption'; +const TYPE_HIERARCHY_MIN_ROLES = 3; +const TYPE_HIERARCHY_MIN_STEP_RATIO = 1.25; + +function typeHierarchyRole(el) { + const tag = String(el?.tagName || el?.nodeName || '').toLowerCase(); + return /^h[1-6]$/.test(tag) ? tag : 'body'; +} + +function hasTextContent(el) { + return String(el?.textContent || '').trim().length > 0; +} + +function isRenderedTypeElement(el, getStyle) { + for (let current = el; current; current = current.parentElement) { + const hiddenAttr = typeof current.getAttribute === 'function' && current.getAttribute('hidden') !== null; + if (current.hidden || hiddenAttr) return false; + const style = getStyle(current); + if (!style) continue; + const display = String(style.display || '').toLowerCase(); + const visibility = String(style.visibility || '').toLowerCase(); + const contentVisibility = String(style.contentVisibility || '').toLowerCase(); + if (display === 'none' || visibility === 'hidden' || visibility === 'collapse' || contentVisibility === 'hidden') return false; + const opacity = parseFloat(style.opacity); + if (Number.isFinite(opacity) && opacity <= 0.01) return false; + } + return true; +} + +function dominantTypeRoleSize(samples) { + const counts = new Map(); + for (const sample of samples) { + counts.set(sample.size, (counts.get(sample.size) || 0) + 1); + } + const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]); + if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) return null; + return ranked[0]?.[0] ?? null; +} + +function checkFlatTypeHierarchySamples(samples) { + const byRole = new Map(); + for (const sample of samples || []) { + const role = String(sample?.role || ''); + const size = Math.round(Number(sample?.size) * 10) / 10; + if (!role || !Number.isFinite(size) || size < 8 || size >= 200) continue; + if (!byRole.has(role)) byRole.set(role, []); + byRole.get(role).push({ role, size }); + } + + const roles = [...byRole.entries()].map(([role, roleSamples]) => ({ + role, + size: dominantTypeRoleSize(roleSamples), + })).filter(item => item.size !== null); + + if (roles.length < TYPE_HIERARCHY_MIN_ROLES) return []; + + const sorted = roles.slice().sort((a, b) => a.size - b.size || a.role.localeCompare(b.role)); + let largestStep = 1; + for (let i = 1; i < sorted.length; i++) { + largestStep = Math.max(largestStep, sorted[i].size / sorted[i - 1].size); + } + if (largestStep >= TYPE_HIERARCHY_MIN_STEP_RATIO) return []; + + const roleSizes = sorted.map(item => `${item.role} ${item.size}px`).join(', '); + return [{ + id: 'flat-type-hierarchy', + snippet: `Role sizes: ${roleSizes} (largest adjacent step ${largestStep.toFixed(2)}:1; target ${TYPE_HIERARCHY_MIN_STEP_RATIO}:1)`, + }]; +} + +function checkFlatTypeHierarchyFromDoc(root, getStyle, options = {}) { + const samples = []; + for (const el of root.querySelectorAll(TYPE_HIERARCHY_SELECTOR)) { + if (options.skipElement?.(el)) continue; + if (!hasTextContent(el) || !isRenderedTypeElement(el, getStyle)) continue; + const fontSize = parseFloat(getStyle(el)?.fontSize); + if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize >= 200) continue; + samples.push({ role: typeHierarchyRole(el), size: fontSize }); + } + return checkFlatTypeHierarchySamples(samples); +} + // Browser page-level checks — use document/getComputedStyle globals function checkTypography() { @@ -5222,17 +5304,10 @@ function checkTypography() { } } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { - const fs = parseFloat(getComputedStyle(el).fontSize); - if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } + for (const finding of checkFlatTypeHierarchyFromDoc(document, getComputedStyle, { + skipElement: el => el.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip, [id^="impeccable-live-"]'), + })) { + findings.push({ type: finding.id, detail: finding.snippet }); } return findings; @@ -5479,21 +5554,7 @@ function checkPageTypography(doc, win) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - // Flat type hierarchy - const sizes = new Set(); - const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div'); - for (const el of textEls) { - const fontSize = parseFloat(win.getComputedStyle(el).fontSize); - // Filter out sub-8px values (jsdom doesn't resolve relative units properly) - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(doc, el => win.getComputedStyle(el))); return findings; } diff --git a/.gemini/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.gemini/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 26ca4f390..df1334fdd 100644 --- a/.gemini/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.gemini/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -607,32 +607,6 @@ const REGEX_MATCHERS = [ ]; const REGEX_ANALYZERS = [ - // Flat type hierarchy - (content, filePath) => { - const sizes = new Set(); - const REM = 16; - let m; - const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi; - while ((m = sizeRe.exec(content)) !== null) { - const px = m[2] === 'px' ? +m[1] : +m[1] * REM; - if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10); - } - const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi; - while ((m = clampRe.exec(content)) !== null) { - sizes.add(Math.round((m[2] === 'px' ? +m[1] : +m[1] * REM) * 10) / 10); - sizes.add(Math.round((m[4] === 'px' ? +m[3] : +m[3] * REM) * 10) / 10); - } - const TW = { 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, 'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, 'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128 }; - for (const [cls, px] of Object.entries(TW)) { if (new RegExp(`\\b${cls}\\b`).test(content)) sizes.add(px); } - if (sizes.size < 3) return []; - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio >= 2.0) return []; - const lines = content.split('\n'); - let line = 1; - for (let i = 0; i < lines.length; i++) { if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) { line = i + 1; break; } } - return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)]; - }, // Monotonous spacing (regex) (content, filePath) => { const vals = []; @@ -1154,11 +1128,12 @@ const TEXT_CONTENT_ANALYZER_IDS = [ function runTextContentAnalyzers(content, filePath, options = {}) { const profile = options?.profile; if (!shouldRunPageAnalyzers(content, filePath)) return []; - // The 3 text-content analyzers are at indices 2-4 in REGEX_ANALYZERS - // (single-font's removal on 2026-07-29 shifted every index down one). + // The 3 text-content analyzers are at indices 1-3 in REGEX_ANALYZERS. + // flat-type-hierarchy left this source-only path in issue #619 because it + // needs rendered role and usage evidence. const findings = []; for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { - const analyzer = REGEX_ANALYZERS[2 + i]; + const analyzer = REGEX_ANALYZERS[1 + i]; const ruleId = TEXT_CONTENT_ANALYZER_IDS[i]; findings.push(...profileFindings(profile, { engine: 'regex', @@ -1284,7 +1259,6 @@ function detectText(content, filePath, options = {}) { // Page-level analyzers only run on full pages if (shouldRunPageAnalyzers(content, filePath)) { const analyzerIds = [ - 'flat-type-hierarchy', 'monotonous-spacing', 'em-dash-overuse', 'marketing-buzzword', diff --git a/.gemini/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.gemini/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index cc36ac9b5..7f6080b61 100644 --- a/.gemini/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.gemini/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -282,6 +282,7 @@ const STATIC_DEFAULT_STYLE = { marginLeft: '0px', position: 'static', visibility: 'visible', + contentVisibility: 'visible', opacity: '1', top: 'auto', right: 'auto', diff --git a/.gemini/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.gemini/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 51c34224b..84e6ab1f5 100644 --- a/.gemini/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.gemini/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -25,6 +25,7 @@ import { checkElementOversizedH1, checkElementQuality, checkElementRadialSpotlight, + checkFlatTypeHierarchyFromDoc, checkCreamPalette, checkHtmlPatterns, checkKickerAboveHeadingFromDoc, @@ -59,18 +60,7 @@ function checkStaticPageTypography(document, window) { for (const font of overusedFound) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div')) { - const fontSize = parseFloat(window.getComputedStyle(el).fontSize); - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(document, el => window.getComputedStyle(el))); return findings; } diff --git a/.gemini/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.gemini/skills/impeccable/scripts/detector/registry/antipatterns.mjs index 88ff84a07..945307783 100644 --- a/.gemini/skills/impeccable/scripts/detector/registry/antipatterns.mjs +++ b/.gemini/skills/impeccable/scripts/detector/registry/antipatterns.mjs @@ -34,7 +34,7 @@ const ANTIPATTERNS = [ scopes: ['type'], name: 'Flat type hierarchy', description: - 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', + 'Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.', skillSection: 'Typography', skillGuideline: 'flat type hierarchy', }, diff --git a/.gemini/skills/impeccable/scripts/detector/rules/checks.mjs b/.gemini/skills/impeccable/scripts/detector/rules/checks.mjs index 8bfb2b1df..579e1c12e 100644 --- a/.gemini/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.gemini/skills/impeccable/scripts/detector/rules/checks.mjs @@ -3911,6 +3911,88 @@ function checkElementGlow(tag, style, effectiveBg) { // ─── Section 6: Page-Level Checks ─────────────────────────────────────────── +const TYPE_HIERARCHY_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption'; +const TYPE_HIERARCHY_MIN_ROLES = 3; +const TYPE_HIERARCHY_MIN_STEP_RATIO = 1.25; + +function typeHierarchyRole(el) { + const tag = String(el?.tagName || el?.nodeName || '').toLowerCase(); + return /^h[1-6]$/.test(tag) ? tag : 'body'; +} + +function hasTextContent(el) { + return String(el?.textContent || '').trim().length > 0; +} + +function isRenderedTypeElement(el, getStyle) { + for (let current = el; current; current = current.parentElement) { + const hiddenAttr = typeof current.getAttribute === 'function' && current.getAttribute('hidden') !== null; + if (current.hidden || hiddenAttr) return false; + const style = getStyle(current); + if (!style) continue; + const display = String(style.display || '').toLowerCase(); + const visibility = String(style.visibility || '').toLowerCase(); + const contentVisibility = String(style.contentVisibility || '').toLowerCase(); + if (display === 'none' || visibility === 'hidden' || visibility === 'collapse' || contentVisibility === 'hidden') return false; + const opacity = parseFloat(style.opacity); + if (Number.isFinite(opacity) && opacity <= 0.01) return false; + } + return true; +} + +function dominantTypeRoleSize(samples) { + const counts = new Map(); + for (const sample of samples) { + counts.set(sample.size, (counts.get(sample.size) || 0) + 1); + } + const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]); + if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) return null; + return ranked[0]?.[0] ?? null; +} + +function checkFlatTypeHierarchySamples(samples) { + const byRole = new Map(); + for (const sample of samples || []) { + const role = String(sample?.role || ''); + const size = Math.round(Number(sample?.size) * 10) / 10; + if (!role || !Number.isFinite(size) || size < 8 || size >= 200) continue; + if (!byRole.has(role)) byRole.set(role, []); + byRole.get(role).push({ role, size }); + } + + const roles = [...byRole.entries()].map(([role, roleSamples]) => ({ + role, + size: dominantTypeRoleSize(roleSamples), + })).filter(item => item.size !== null); + + if (roles.length < TYPE_HIERARCHY_MIN_ROLES) return []; + + const sorted = roles.slice().sort((a, b) => a.size - b.size || a.role.localeCompare(b.role)); + let largestStep = 1; + for (let i = 1; i < sorted.length; i++) { + largestStep = Math.max(largestStep, sorted[i].size / sorted[i - 1].size); + } + if (largestStep >= TYPE_HIERARCHY_MIN_STEP_RATIO) return []; + + const roleSizes = sorted.map(item => `${item.role} ${item.size}px`).join(', '); + return [{ + id: 'flat-type-hierarchy', + snippet: `Role sizes: ${roleSizes} (largest adjacent step ${largestStep.toFixed(2)}:1; target ${TYPE_HIERARCHY_MIN_STEP_RATIO}:1)`, + }]; +} + +function checkFlatTypeHierarchyFromDoc(root, getStyle, options = {}) { + const samples = []; + for (const el of root.querySelectorAll(TYPE_HIERARCHY_SELECTOR)) { + if (options.skipElement?.(el)) continue; + if (!hasTextContent(el) || !isRenderedTypeElement(el, getStyle)) continue; + const fontSize = parseFloat(getStyle(el)?.fontSize); + if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize >= 200) continue; + samples.push({ role: typeHierarchyRole(el), size: fontSize }); + } + return checkFlatTypeHierarchySamples(samples); +} + // Browser page-level checks — use document/getComputedStyle globals function checkTypography() { @@ -3949,17 +4031,10 @@ function checkTypography() { } } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { - const fs = parseFloat(getComputedStyle(el).fontSize); - if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } + for (const finding of checkFlatTypeHierarchyFromDoc(document, getComputedStyle, { + skipElement: el => el.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip, [id^="impeccable-live-"]'), + })) { + findings.push({ type: finding.id, detail: finding.snippet }); } return findings; @@ -4206,21 +4281,7 @@ function checkPageTypography(doc, win) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - // Flat type hierarchy - const sizes = new Set(); - const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div'); - for (const el of textEls) { - const fontSize = parseFloat(win.getComputedStyle(el).fontSize); - // Filter out sub-8px values (jsdom doesn't resolve relative units properly) - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(doc, el => win.getComputedStyle(el))); return findings; } @@ -5649,6 +5710,8 @@ export { checkKickerAboveHeadingFromDoc, checkElementMotion, checkElementGlow, + checkFlatTypeHierarchySamples, + checkFlatTypeHierarchyFromDoc, checkTypography, isCardLikeDOM, checkLayout, diff --git a/.github/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.github/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 3f5abd0a8..e6899f569 100644 --- a/.github/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.github/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -159,7 +159,7 @@ const ANTIPATTERNS = [ scopes: ['type'], name: 'Flat type hierarchy', description: - 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', + 'Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.', skillSection: 'Typography', skillGuideline: 'flat type hierarchy', }, @@ -5184,6 +5184,88 @@ function checkElementGlow(tag, style, effectiveBg) { // ─── Section 6: Page-Level Checks ─────────────────────────────────────────── +const TYPE_HIERARCHY_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption'; +const TYPE_HIERARCHY_MIN_ROLES = 3; +const TYPE_HIERARCHY_MIN_STEP_RATIO = 1.25; + +function typeHierarchyRole(el) { + const tag = String(el?.tagName || el?.nodeName || '').toLowerCase(); + return /^h[1-6]$/.test(tag) ? tag : 'body'; +} + +function hasTextContent(el) { + return String(el?.textContent || '').trim().length > 0; +} + +function isRenderedTypeElement(el, getStyle) { + for (let current = el; current; current = current.parentElement) { + const hiddenAttr = typeof current.getAttribute === 'function' && current.getAttribute('hidden') !== null; + if (current.hidden || hiddenAttr) return false; + const style = getStyle(current); + if (!style) continue; + const display = String(style.display || '').toLowerCase(); + const visibility = String(style.visibility || '').toLowerCase(); + const contentVisibility = String(style.contentVisibility || '').toLowerCase(); + if (display === 'none' || visibility === 'hidden' || visibility === 'collapse' || contentVisibility === 'hidden') return false; + const opacity = parseFloat(style.opacity); + if (Number.isFinite(opacity) && opacity <= 0.01) return false; + } + return true; +} + +function dominantTypeRoleSize(samples) { + const counts = new Map(); + for (const sample of samples) { + counts.set(sample.size, (counts.get(sample.size) || 0) + 1); + } + const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]); + if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) return null; + return ranked[0]?.[0] ?? null; +} + +function checkFlatTypeHierarchySamples(samples) { + const byRole = new Map(); + for (const sample of samples || []) { + const role = String(sample?.role || ''); + const size = Math.round(Number(sample?.size) * 10) / 10; + if (!role || !Number.isFinite(size) || size < 8 || size >= 200) continue; + if (!byRole.has(role)) byRole.set(role, []); + byRole.get(role).push({ role, size }); + } + + const roles = [...byRole.entries()].map(([role, roleSamples]) => ({ + role, + size: dominantTypeRoleSize(roleSamples), + })).filter(item => item.size !== null); + + if (roles.length < TYPE_HIERARCHY_MIN_ROLES) return []; + + const sorted = roles.slice().sort((a, b) => a.size - b.size || a.role.localeCompare(b.role)); + let largestStep = 1; + for (let i = 1; i < sorted.length; i++) { + largestStep = Math.max(largestStep, sorted[i].size / sorted[i - 1].size); + } + if (largestStep >= TYPE_HIERARCHY_MIN_STEP_RATIO) return []; + + const roleSizes = sorted.map(item => `${item.role} ${item.size}px`).join(', '); + return [{ + id: 'flat-type-hierarchy', + snippet: `Role sizes: ${roleSizes} (largest adjacent step ${largestStep.toFixed(2)}:1; target ${TYPE_HIERARCHY_MIN_STEP_RATIO}:1)`, + }]; +} + +function checkFlatTypeHierarchyFromDoc(root, getStyle, options = {}) { + const samples = []; + for (const el of root.querySelectorAll(TYPE_HIERARCHY_SELECTOR)) { + if (options.skipElement?.(el)) continue; + if (!hasTextContent(el) || !isRenderedTypeElement(el, getStyle)) continue; + const fontSize = parseFloat(getStyle(el)?.fontSize); + if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize >= 200) continue; + samples.push({ role: typeHierarchyRole(el), size: fontSize }); + } + return checkFlatTypeHierarchySamples(samples); +} + // Browser page-level checks — use document/getComputedStyle globals function checkTypography() { @@ -5222,17 +5304,10 @@ function checkTypography() { } } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { - const fs = parseFloat(getComputedStyle(el).fontSize); - if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } + for (const finding of checkFlatTypeHierarchyFromDoc(document, getComputedStyle, { + skipElement: el => el.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip, [id^="impeccable-live-"]'), + })) { + findings.push({ type: finding.id, detail: finding.snippet }); } return findings; @@ -5479,21 +5554,7 @@ function checkPageTypography(doc, win) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - // Flat type hierarchy - const sizes = new Set(); - const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div'); - for (const el of textEls) { - const fontSize = parseFloat(win.getComputedStyle(el).fontSize); - // Filter out sub-8px values (jsdom doesn't resolve relative units properly) - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(doc, el => win.getComputedStyle(el))); return findings; } diff --git a/.github/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.github/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 26ca4f390..df1334fdd 100644 --- a/.github/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.github/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -607,32 +607,6 @@ const REGEX_MATCHERS = [ ]; const REGEX_ANALYZERS = [ - // Flat type hierarchy - (content, filePath) => { - const sizes = new Set(); - const REM = 16; - let m; - const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi; - while ((m = sizeRe.exec(content)) !== null) { - const px = m[2] === 'px' ? +m[1] : +m[1] * REM; - if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10); - } - const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi; - while ((m = clampRe.exec(content)) !== null) { - sizes.add(Math.round((m[2] === 'px' ? +m[1] : +m[1] * REM) * 10) / 10); - sizes.add(Math.round((m[4] === 'px' ? +m[3] : +m[3] * REM) * 10) / 10); - } - const TW = { 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, 'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, 'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128 }; - for (const [cls, px] of Object.entries(TW)) { if (new RegExp(`\\b${cls}\\b`).test(content)) sizes.add(px); } - if (sizes.size < 3) return []; - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio >= 2.0) return []; - const lines = content.split('\n'); - let line = 1; - for (let i = 0; i < lines.length; i++) { if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) { line = i + 1; break; } } - return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)]; - }, // Monotonous spacing (regex) (content, filePath) => { const vals = []; @@ -1154,11 +1128,12 @@ const TEXT_CONTENT_ANALYZER_IDS = [ function runTextContentAnalyzers(content, filePath, options = {}) { const profile = options?.profile; if (!shouldRunPageAnalyzers(content, filePath)) return []; - // The 3 text-content analyzers are at indices 2-4 in REGEX_ANALYZERS - // (single-font's removal on 2026-07-29 shifted every index down one). + // The 3 text-content analyzers are at indices 1-3 in REGEX_ANALYZERS. + // flat-type-hierarchy left this source-only path in issue #619 because it + // needs rendered role and usage evidence. const findings = []; for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { - const analyzer = REGEX_ANALYZERS[2 + i]; + const analyzer = REGEX_ANALYZERS[1 + i]; const ruleId = TEXT_CONTENT_ANALYZER_IDS[i]; findings.push(...profileFindings(profile, { engine: 'regex', @@ -1284,7 +1259,6 @@ function detectText(content, filePath, options = {}) { // Page-level analyzers only run on full pages if (shouldRunPageAnalyzers(content, filePath)) { const analyzerIds = [ - 'flat-type-hierarchy', 'monotonous-spacing', 'em-dash-overuse', 'marketing-buzzword', diff --git a/.github/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.github/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index cc36ac9b5..7f6080b61 100644 --- a/.github/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.github/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -282,6 +282,7 @@ const STATIC_DEFAULT_STYLE = { marginLeft: '0px', position: 'static', visibility: 'visible', + contentVisibility: 'visible', opacity: '1', top: 'auto', right: 'auto', diff --git a/.github/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.github/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 51c34224b..84e6ab1f5 100644 --- a/.github/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.github/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -25,6 +25,7 @@ import { checkElementOversizedH1, checkElementQuality, checkElementRadialSpotlight, + checkFlatTypeHierarchyFromDoc, checkCreamPalette, checkHtmlPatterns, checkKickerAboveHeadingFromDoc, @@ -59,18 +60,7 @@ function checkStaticPageTypography(document, window) { for (const font of overusedFound) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div')) { - const fontSize = parseFloat(window.getComputedStyle(el).fontSize); - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(document, el => window.getComputedStyle(el))); return findings; } diff --git a/.github/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.github/skills/impeccable/scripts/detector/registry/antipatterns.mjs index 88ff84a07..945307783 100644 --- a/.github/skills/impeccable/scripts/detector/registry/antipatterns.mjs +++ b/.github/skills/impeccable/scripts/detector/registry/antipatterns.mjs @@ -34,7 +34,7 @@ const ANTIPATTERNS = [ scopes: ['type'], name: 'Flat type hierarchy', description: - 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', + 'Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.', skillSection: 'Typography', skillGuideline: 'flat type hierarchy', }, diff --git a/.github/skills/impeccable/scripts/detector/rules/checks.mjs b/.github/skills/impeccable/scripts/detector/rules/checks.mjs index 8bfb2b1df..579e1c12e 100644 --- a/.github/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.github/skills/impeccable/scripts/detector/rules/checks.mjs @@ -3911,6 +3911,88 @@ function checkElementGlow(tag, style, effectiveBg) { // ─── Section 6: Page-Level Checks ─────────────────────────────────────────── +const TYPE_HIERARCHY_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption'; +const TYPE_HIERARCHY_MIN_ROLES = 3; +const TYPE_HIERARCHY_MIN_STEP_RATIO = 1.25; + +function typeHierarchyRole(el) { + const tag = String(el?.tagName || el?.nodeName || '').toLowerCase(); + return /^h[1-6]$/.test(tag) ? tag : 'body'; +} + +function hasTextContent(el) { + return String(el?.textContent || '').trim().length > 0; +} + +function isRenderedTypeElement(el, getStyle) { + for (let current = el; current; current = current.parentElement) { + const hiddenAttr = typeof current.getAttribute === 'function' && current.getAttribute('hidden') !== null; + if (current.hidden || hiddenAttr) return false; + const style = getStyle(current); + if (!style) continue; + const display = String(style.display || '').toLowerCase(); + const visibility = String(style.visibility || '').toLowerCase(); + const contentVisibility = String(style.contentVisibility || '').toLowerCase(); + if (display === 'none' || visibility === 'hidden' || visibility === 'collapse' || contentVisibility === 'hidden') return false; + const opacity = parseFloat(style.opacity); + if (Number.isFinite(opacity) && opacity <= 0.01) return false; + } + return true; +} + +function dominantTypeRoleSize(samples) { + const counts = new Map(); + for (const sample of samples) { + counts.set(sample.size, (counts.get(sample.size) || 0) + 1); + } + const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]); + if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) return null; + return ranked[0]?.[0] ?? null; +} + +function checkFlatTypeHierarchySamples(samples) { + const byRole = new Map(); + for (const sample of samples || []) { + const role = String(sample?.role || ''); + const size = Math.round(Number(sample?.size) * 10) / 10; + if (!role || !Number.isFinite(size) || size < 8 || size >= 200) continue; + if (!byRole.has(role)) byRole.set(role, []); + byRole.get(role).push({ role, size }); + } + + const roles = [...byRole.entries()].map(([role, roleSamples]) => ({ + role, + size: dominantTypeRoleSize(roleSamples), + })).filter(item => item.size !== null); + + if (roles.length < TYPE_HIERARCHY_MIN_ROLES) return []; + + const sorted = roles.slice().sort((a, b) => a.size - b.size || a.role.localeCompare(b.role)); + let largestStep = 1; + for (let i = 1; i < sorted.length; i++) { + largestStep = Math.max(largestStep, sorted[i].size / sorted[i - 1].size); + } + if (largestStep >= TYPE_HIERARCHY_MIN_STEP_RATIO) return []; + + const roleSizes = sorted.map(item => `${item.role} ${item.size}px`).join(', '); + return [{ + id: 'flat-type-hierarchy', + snippet: `Role sizes: ${roleSizes} (largest adjacent step ${largestStep.toFixed(2)}:1; target ${TYPE_HIERARCHY_MIN_STEP_RATIO}:1)`, + }]; +} + +function checkFlatTypeHierarchyFromDoc(root, getStyle, options = {}) { + const samples = []; + for (const el of root.querySelectorAll(TYPE_HIERARCHY_SELECTOR)) { + if (options.skipElement?.(el)) continue; + if (!hasTextContent(el) || !isRenderedTypeElement(el, getStyle)) continue; + const fontSize = parseFloat(getStyle(el)?.fontSize); + if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize >= 200) continue; + samples.push({ role: typeHierarchyRole(el), size: fontSize }); + } + return checkFlatTypeHierarchySamples(samples); +} + // Browser page-level checks — use document/getComputedStyle globals function checkTypography() { @@ -3949,17 +4031,10 @@ function checkTypography() { } } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { - const fs = parseFloat(getComputedStyle(el).fontSize); - if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } + for (const finding of checkFlatTypeHierarchyFromDoc(document, getComputedStyle, { + skipElement: el => el.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip, [id^="impeccable-live-"]'), + })) { + findings.push({ type: finding.id, detail: finding.snippet }); } return findings; @@ -4206,21 +4281,7 @@ function checkPageTypography(doc, win) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - // Flat type hierarchy - const sizes = new Set(); - const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div'); - for (const el of textEls) { - const fontSize = parseFloat(win.getComputedStyle(el).fontSize); - // Filter out sub-8px values (jsdom doesn't resolve relative units properly) - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(doc, el => win.getComputedStyle(el))); return findings; } @@ -5649,6 +5710,8 @@ export { checkKickerAboveHeadingFromDoc, checkElementMotion, checkElementGlow, + checkFlatTypeHierarchySamples, + checkFlatTypeHierarchyFromDoc, checkTypography, isCardLikeDOM, checkLayout, diff --git a/.grok/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.grok/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 3f5abd0a8..e6899f569 100644 --- a/.grok/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.grok/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -159,7 +159,7 @@ const ANTIPATTERNS = [ scopes: ['type'], name: 'Flat type hierarchy', description: - 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', + 'Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.', skillSection: 'Typography', skillGuideline: 'flat type hierarchy', }, @@ -5184,6 +5184,88 @@ function checkElementGlow(tag, style, effectiveBg) { // ─── Section 6: Page-Level Checks ─────────────────────────────────────────── +const TYPE_HIERARCHY_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption'; +const TYPE_HIERARCHY_MIN_ROLES = 3; +const TYPE_HIERARCHY_MIN_STEP_RATIO = 1.25; + +function typeHierarchyRole(el) { + const tag = String(el?.tagName || el?.nodeName || '').toLowerCase(); + return /^h[1-6]$/.test(tag) ? tag : 'body'; +} + +function hasTextContent(el) { + return String(el?.textContent || '').trim().length > 0; +} + +function isRenderedTypeElement(el, getStyle) { + for (let current = el; current; current = current.parentElement) { + const hiddenAttr = typeof current.getAttribute === 'function' && current.getAttribute('hidden') !== null; + if (current.hidden || hiddenAttr) return false; + const style = getStyle(current); + if (!style) continue; + const display = String(style.display || '').toLowerCase(); + const visibility = String(style.visibility || '').toLowerCase(); + const contentVisibility = String(style.contentVisibility || '').toLowerCase(); + if (display === 'none' || visibility === 'hidden' || visibility === 'collapse' || contentVisibility === 'hidden') return false; + const opacity = parseFloat(style.opacity); + if (Number.isFinite(opacity) && opacity <= 0.01) return false; + } + return true; +} + +function dominantTypeRoleSize(samples) { + const counts = new Map(); + for (const sample of samples) { + counts.set(sample.size, (counts.get(sample.size) || 0) + 1); + } + const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]); + if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) return null; + return ranked[0]?.[0] ?? null; +} + +function checkFlatTypeHierarchySamples(samples) { + const byRole = new Map(); + for (const sample of samples || []) { + const role = String(sample?.role || ''); + const size = Math.round(Number(sample?.size) * 10) / 10; + if (!role || !Number.isFinite(size) || size < 8 || size >= 200) continue; + if (!byRole.has(role)) byRole.set(role, []); + byRole.get(role).push({ role, size }); + } + + const roles = [...byRole.entries()].map(([role, roleSamples]) => ({ + role, + size: dominantTypeRoleSize(roleSamples), + })).filter(item => item.size !== null); + + if (roles.length < TYPE_HIERARCHY_MIN_ROLES) return []; + + const sorted = roles.slice().sort((a, b) => a.size - b.size || a.role.localeCompare(b.role)); + let largestStep = 1; + for (let i = 1; i < sorted.length; i++) { + largestStep = Math.max(largestStep, sorted[i].size / sorted[i - 1].size); + } + if (largestStep >= TYPE_HIERARCHY_MIN_STEP_RATIO) return []; + + const roleSizes = sorted.map(item => `${item.role} ${item.size}px`).join(', '); + return [{ + id: 'flat-type-hierarchy', + snippet: `Role sizes: ${roleSizes} (largest adjacent step ${largestStep.toFixed(2)}:1; target ${TYPE_HIERARCHY_MIN_STEP_RATIO}:1)`, + }]; +} + +function checkFlatTypeHierarchyFromDoc(root, getStyle, options = {}) { + const samples = []; + for (const el of root.querySelectorAll(TYPE_HIERARCHY_SELECTOR)) { + if (options.skipElement?.(el)) continue; + if (!hasTextContent(el) || !isRenderedTypeElement(el, getStyle)) continue; + const fontSize = parseFloat(getStyle(el)?.fontSize); + if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize >= 200) continue; + samples.push({ role: typeHierarchyRole(el), size: fontSize }); + } + return checkFlatTypeHierarchySamples(samples); +} + // Browser page-level checks — use document/getComputedStyle globals function checkTypography() { @@ -5222,17 +5304,10 @@ function checkTypography() { } } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { - const fs = parseFloat(getComputedStyle(el).fontSize); - if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } + for (const finding of checkFlatTypeHierarchyFromDoc(document, getComputedStyle, { + skipElement: el => el.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip, [id^="impeccable-live-"]'), + })) { + findings.push({ type: finding.id, detail: finding.snippet }); } return findings; @@ -5479,21 +5554,7 @@ function checkPageTypography(doc, win) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - // Flat type hierarchy - const sizes = new Set(); - const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div'); - for (const el of textEls) { - const fontSize = parseFloat(win.getComputedStyle(el).fontSize); - // Filter out sub-8px values (jsdom doesn't resolve relative units properly) - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(doc, el => win.getComputedStyle(el))); return findings; } diff --git a/.grok/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.grok/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 26ca4f390..df1334fdd 100644 --- a/.grok/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.grok/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -607,32 +607,6 @@ const REGEX_MATCHERS = [ ]; const REGEX_ANALYZERS = [ - // Flat type hierarchy - (content, filePath) => { - const sizes = new Set(); - const REM = 16; - let m; - const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi; - while ((m = sizeRe.exec(content)) !== null) { - const px = m[2] === 'px' ? +m[1] : +m[1] * REM; - if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10); - } - const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi; - while ((m = clampRe.exec(content)) !== null) { - sizes.add(Math.round((m[2] === 'px' ? +m[1] : +m[1] * REM) * 10) / 10); - sizes.add(Math.round((m[4] === 'px' ? +m[3] : +m[3] * REM) * 10) / 10); - } - const TW = { 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, 'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, 'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128 }; - for (const [cls, px] of Object.entries(TW)) { if (new RegExp(`\\b${cls}\\b`).test(content)) sizes.add(px); } - if (sizes.size < 3) return []; - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio >= 2.0) return []; - const lines = content.split('\n'); - let line = 1; - for (let i = 0; i < lines.length; i++) { if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) { line = i + 1; break; } } - return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)]; - }, // Monotonous spacing (regex) (content, filePath) => { const vals = []; @@ -1154,11 +1128,12 @@ const TEXT_CONTENT_ANALYZER_IDS = [ function runTextContentAnalyzers(content, filePath, options = {}) { const profile = options?.profile; if (!shouldRunPageAnalyzers(content, filePath)) return []; - // The 3 text-content analyzers are at indices 2-4 in REGEX_ANALYZERS - // (single-font's removal on 2026-07-29 shifted every index down one). + // The 3 text-content analyzers are at indices 1-3 in REGEX_ANALYZERS. + // flat-type-hierarchy left this source-only path in issue #619 because it + // needs rendered role and usage evidence. const findings = []; for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { - const analyzer = REGEX_ANALYZERS[2 + i]; + const analyzer = REGEX_ANALYZERS[1 + i]; const ruleId = TEXT_CONTENT_ANALYZER_IDS[i]; findings.push(...profileFindings(profile, { engine: 'regex', @@ -1284,7 +1259,6 @@ function detectText(content, filePath, options = {}) { // Page-level analyzers only run on full pages if (shouldRunPageAnalyzers(content, filePath)) { const analyzerIds = [ - 'flat-type-hierarchy', 'monotonous-spacing', 'em-dash-overuse', 'marketing-buzzword', diff --git a/.grok/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.grok/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index cc36ac9b5..7f6080b61 100644 --- a/.grok/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.grok/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -282,6 +282,7 @@ const STATIC_DEFAULT_STYLE = { marginLeft: '0px', position: 'static', visibility: 'visible', + contentVisibility: 'visible', opacity: '1', top: 'auto', right: 'auto', diff --git a/.grok/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.grok/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 51c34224b..84e6ab1f5 100644 --- a/.grok/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.grok/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -25,6 +25,7 @@ import { checkElementOversizedH1, checkElementQuality, checkElementRadialSpotlight, + checkFlatTypeHierarchyFromDoc, checkCreamPalette, checkHtmlPatterns, checkKickerAboveHeadingFromDoc, @@ -59,18 +60,7 @@ function checkStaticPageTypography(document, window) { for (const font of overusedFound) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div')) { - const fontSize = parseFloat(window.getComputedStyle(el).fontSize); - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(document, el => window.getComputedStyle(el))); return findings; } diff --git a/.grok/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.grok/skills/impeccable/scripts/detector/registry/antipatterns.mjs index 88ff84a07..945307783 100644 --- a/.grok/skills/impeccable/scripts/detector/registry/antipatterns.mjs +++ b/.grok/skills/impeccable/scripts/detector/registry/antipatterns.mjs @@ -34,7 +34,7 @@ const ANTIPATTERNS = [ scopes: ['type'], name: 'Flat type hierarchy', description: - 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', + 'Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.', skillSection: 'Typography', skillGuideline: 'flat type hierarchy', }, diff --git a/.grok/skills/impeccable/scripts/detector/rules/checks.mjs b/.grok/skills/impeccable/scripts/detector/rules/checks.mjs index 8bfb2b1df..579e1c12e 100644 --- a/.grok/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.grok/skills/impeccable/scripts/detector/rules/checks.mjs @@ -3911,6 +3911,88 @@ function checkElementGlow(tag, style, effectiveBg) { // ─── Section 6: Page-Level Checks ─────────────────────────────────────────── +const TYPE_HIERARCHY_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption'; +const TYPE_HIERARCHY_MIN_ROLES = 3; +const TYPE_HIERARCHY_MIN_STEP_RATIO = 1.25; + +function typeHierarchyRole(el) { + const tag = String(el?.tagName || el?.nodeName || '').toLowerCase(); + return /^h[1-6]$/.test(tag) ? tag : 'body'; +} + +function hasTextContent(el) { + return String(el?.textContent || '').trim().length > 0; +} + +function isRenderedTypeElement(el, getStyle) { + for (let current = el; current; current = current.parentElement) { + const hiddenAttr = typeof current.getAttribute === 'function' && current.getAttribute('hidden') !== null; + if (current.hidden || hiddenAttr) return false; + const style = getStyle(current); + if (!style) continue; + const display = String(style.display || '').toLowerCase(); + const visibility = String(style.visibility || '').toLowerCase(); + const contentVisibility = String(style.contentVisibility || '').toLowerCase(); + if (display === 'none' || visibility === 'hidden' || visibility === 'collapse' || contentVisibility === 'hidden') return false; + const opacity = parseFloat(style.opacity); + if (Number.isFinite(opacity) && opacity <= 0.01) return false; + } + return true; +} + +function dominantTypeRoleSize(samples) { + const counts = new Map(); + for (const sample of samples) { + counts.set(sample.size, (counts.get(sample.size) || 0) + 1); + } + const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]); + if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) return null; + return ranked[0]?.[0] ?? null; +} + +function checkFlatTypeHierarchySamples(samples) { + const byRole = new Map(); + for (const sample of samples || []) { + const role = String(sample?.role || ''); + const size = Math.round(Number(sample?.size) * 10) / 10; + if (!role || !Number.isFinite(size) || size < 8 || size >= 200) continue; + if (!byRole.has(role)) byRole.set(role, []); + byRole.get(role).push({ role, size }); + } + + const roles = [...byRole.entries()].map(([role, roleSamples]) => ({ + role, + size: dominantTypeRoleSize(roleSamples), + })).filter(item => item.size !== null); + + if (roles.length < TYPE_HIERARCHY_MIN_ROLES) return []; + + const sorted = roles.slice().sort((a, b) => a.size - b.size || a.role.localeCompare(b.role)); + let largestStep = 1; + for (let i = 1; i < sorted.length; i++) { + largestStep = Math.max(largestStep, sorted[i].size / sorted[i - 1].size); + } + if (largestStep >= TYPE_HIERARCHY_MIN_STEP_RATIO) return []; + + const roleSizes = sorted.map(item => `${item.role} ${item.size}px`).join(', '); + return [{ + id: 'flat-type-hierarchy', + snippet: `Role sizes: ${roleSizes} (largest adjacent step ${largestStep.toFixed(2)}:1; target ${TYPE_HIERARCHY_MIN_STEP_RATIO}:1)`, + }]; +} + +function checkFlatTypeHierarchyFromDoc(root, getStyle, options = {}) { + const samples = []; + for (const el of root.querySelectorAll(TYPE_HIERARCHY_SELECTOR)) { + if (options.skipElement?.(el)) continue; + if (!hasTextContent(el) || !isRenderedTypeElement(el, getStyle)) continue; + const fontSize = parseFloat(getStyle(el)?.fontSize); + if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize >= 200) continue; + samples.push({ role: typeHierarchyRole(el), size: fontSize }); + } + return checkFlatTypeHierarchySamples(samples); +} + // Browser page-level checks — use document/getComputedStyle globals function checkTypography() { @@ -3949,17 +4031,10 @@ function checkTypography() { } } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { - const fs = parseFloat(getComputedStyle(el).fontSize); - if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } + for (const finding of checkFlatTypeHierarchyFromDoc(document, getComputedStyle, { + skipElement: el => el.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip, [id^="impeccable-live-"]'), + })) { + findings.push({ type: finding.id, detail: finding.snippet }); } return findings; @@ -4206,21 +4281,7 @@ function checkPageTypography(doc, win) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - // Flat type hierarchy - const sizes = new Set(); - const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div'); - for (const el of textEls) { - const fontSize = parseFloat(win.getComputedStyle(el).fontSize); - // Filter out sub-8px values (jsdom doesn't resolve relative units properly) - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(doc, el => win.getComputedStyle(el))); return findings; } @@ -5649,6 +5710,8 @@ export { checkKickerAboveHeadingFromDoc, checkElementMotion, checkElementGlow, + checkFlatTypeHierarchySamples, + checkFlatTypeHierarchyFromDoc, checkTypography, isCardLikeDOM, checkLayout, diff --git a/.hermes/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.hermes/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 3f5abd0a8..e6899f569 100644 --- a/.hermes/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.hermes/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -159,7 +159,7 @@ const ANTIPATTERNS = [ scopes: ['type'], name: 'Flat type hierarchy', description: - 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', + 'Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.', skillSection: 'Typography', skillGuideline: 'flat type hierarchy', }, @@ -5184,6 +5184,88 @@ function checkElementGlow(tag, style, effectiveBg) { // ─── Section 6: Page-Level Checks ─────────────────────────────────────────── +const TYPE_HIERARCHY_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption'; +const TYPE_HIERARCHY_MIN_ROLES = 3; +const TYPE_HIERARCHY_MIN_STEP_RATIO = 1.25; + +function typeHierarchyRole(el) { + const tag = String(el?.tagName || el?.nodeName || '').toLowerCase(); + return /^h[1-6]$/.test(tag) ? tag : 'body'; +} + +function hasTextContent(el) { + return String(el?.textContent || '').trim().length > 0; +} + +function isRenderedTypeElement(el, getStyle) { + for (let current = el; current; current = current.parentElement) { + const hiddenAttr = typeof current.getAttribute === 'function' && current.getAttribute('hidden') !== null; + if (current.hidden || hiddenAttr) return false; + const style = getStyle(current); + if (!style) continue; + const display = String(style.display || '').toLowerCase(); + const visibility = String(style.visibility || '').toLowerCase(); + const contentVisibility = String(style.contentVisibility || '').toLowerCase(); + if (display === 'none' || visibility === 'hidden' || visibility === 'collapse' || contentVisibility === 'hidden') return false; + const opacity = parseFloat(style.opacity); + if (Number.isFinite(opacity) && opacity <= 0.01) return false; + } + return true; +} + +function dominantTypeRoleSize(samples) { + const counts = new Map(); + for (const sample of samples) { + counts.set(sample.size, (counts.get(sample.size) || 0) + 1); + } + const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]); + if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) return null; + return ranked[0]?.[0] ?? null; +} + +function checkFlatTypeHierarchySamples(samples) { + const byRole = new Map(); + for (const sample of samples || []) { + const role = String(sample?.role || ''); + const size = Math.round(Number(sample?.size) * 10) / 10; + if (!role || !Number.isFinite(size) || size < 8 || size >= 200) continue; + if (!byRole.has(role)) byRole.set(role, []); + byRole.get(role).push({ role, size }); + } + + const roles = [...byRole.entries()].map(([role, roleSamples]) => ({ + role, + size: dominantTypeRoleSize(roleSamples), + })).filter(item => item.size !== null); + + if (roles.length < TYPE_HIERARCHY_MIN_ROLES) return []; + + const sorted = roles.slice().sort((a, b) => a.size - b.size || a.role.localeCompare(b.role)); + let largestStep = 1; + for (let i = 1; i < sorted.length; i++) { + largestStep = Math.max(largestStep, sorted[i].size / sorted[i - 1].size); + } + if (largestStep >= TYPE_HIERARCHY_MIN_STEP_RATIO) return []; + + const roleSizes = sorted.map(item => `${item.role} ${item.size}px`).join(', '); + return [{ + id: 'flat-type-hierarchy', + snippet: `Role sizes: ${roleSizes} (largest adjacent step ${largestStep.toFixed(2)}:1; target ${TYPE_HIERARCHY_MIN_STEP_RATIO}:1)`, + }]; +} + +function checkFlatTypeHierarchyFromDoc(root, getStyle, options = {}) { + const samples = []; + for (const el of root.querySelectorAll(TYPE_HIERARCHY_SELECTOR)) { + if (options.skipElement?.(el)) continue; + if (!hasTextContent(el) || !isRenderedTypeElement(el, getStyle)) continue; + const fontSize = parseFloat(getStyle(el)?.fontSize); + if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize >= 200) continue; + samples.push({ role: typeHierarchyRole(el), size: fontSize }); + } + return checkFlatTypeHierarchySamples(samples); +} + // Browser page-level checks — use document/getComputedStyle globals function checkTypography() { @@ -5222,17 +5304,10 @@ function checkTypography() { } } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { - const fs = parseFloat(getComputedStyle(el).fontSize); - if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } + for (const finding of checkFlatTypeHierarchyFromDoc(document, getComputedStyle, { + skipElement: el => el.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip, [id^="impeccable-live-"]'), + })) { + findings.push({ type: finding.id, detail: finding.snippet }); } return findings; @@ -5479,21 +5554,7 @@ function checkPageTypography(doc, win) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - // Flat type hierarchy - const sizes = new Set(); - const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div'); - for (const el of textEls) { - const fontSize = parseFloat(win.getComputedStyle(el).fontSize); - // Filter out sub-8px values (jsdom doesn't resolve relative units properly) - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(doc, el => win.getComputedStyle(el))); return findings; } diff --git a/.hermes/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.hermes/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 26ca4f390..df1334fdd 100644 --- a/.hermes/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.hermes/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -607,32 +607,6 @@ const REGEX_MATCHERS = [ ]; const REGEX_ANALYZERS = [ - // Flat type hierarchy - (content, filePath) => { - const sizes = new Set(); - const REM = 16; - let m; - const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi; - while ((m = sizeRe.exec(content)) !== null) { - const px = m[2] === 'px' ? +m[1] : +m[1] * REM; - if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10); - } - const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi; - while ((m = clampRe.exec(content)) !== null) { - sizes.add(Math.round((m[2] === 'px' ? +m[1] : +m[1] * REM) * 10) / 10); - sizes.add(Math.round((m[4] === 'px' ? +m[3] : +m[3] * REM) * 10) / 10); - } - const TW = { 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, 'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, 'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128 }; - for (const [cls, px] of Object.entries(TW)) { if (new RegExp(`\\b${cls}\\b`).test(content)) sizes.add(px); } - if (sizes.size < 3) return []; - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio >= 2.0) return []; - const lines = content.split('\n'); - let line = 1; - for (let i = 0; i < lines.length; i++) { if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) { line = i + 1; break; } } - return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)]; - }, // Monotonous spacing (regex) (content, filePath) => { const vals = []; @@ -1154,11 +1128,12 @@ const TEXT_CONTENT_ANALYZER_IDS = [ function runTextContentAnalyzers(content, filePath, options = {}) { const profile = options?.profile; if (!shouldRunPageAnalyzers(content, filePath)) return []; - // The 3 text-content analyzers are at indices 2-4 in REGEX_ANALYZERS - // (single-font's removal on 2026-07-29 shifted every index down one). + // The 3 text-content analyzers are at indices 1-3 in REGEX_ANALYZERS. + // flat-type-hierarchy left this source-only path in issue #619 because it + // needs rendered role and usage evidence. const findings = []; for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { - const analyzer = REGEX_ANALYZERS[2 + i]; + const analyzer = REGEX_ANALYZERS[1 + i]; const ruleId = TEXT_CONTENT_ANALYZER_IDS[i]; findings.push(...profileFindings(profile, { engine: 'regex', @@ -1284,7 +1259,6 @@ function detectText(content, filePath, options = {}) { // Page-level analyzers only run on full pages if (shouldRunPageAnalyzers(content, filePath)) { const analyzerIds = [ - 'flat-type-hierarchy', 'monotonous-spacing', 'em-dash-overuse', 'marketing-buzzword', diff --git a/.hermes/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.hermes/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index cc36ac9b5..7f6080b61 100644 --- a/.hermes/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.hermes/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -282,6 +282,7 @@ const STATIC_DEFAULT_STYLE = { marginLeft: '0px', position: 'static', visibility: 'visible', + contentVisibility: 'visible', opacity: '1', top: 'auto', right: 'auto', diff --git a/.hermes/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.hermes/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 51c34224b..84e6ab1f5 100644 --- a/.hermes/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.hermes/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -25,6 +25,7 @@ import { checkElementOversizedH1, checkElementQuality, checkElementRadialSpotlight, + checkFlatTypeHierarchyFromDoc, checkCreamPalette, checkHtmlPatterns, checkKickerAboveHeadingFromDoc, @@ -59,18 +60,7 @@ function checkStaticPageTypography(document, window) { for (const font of overusedFound) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div')) { - const fontSize = parseFloat(window.getComputedStyle(el).fontSize); - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(document, el => window.getComputedStyle(el))); return findings; } diff --git a/.hermes/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.hermes/skills/impeccable/scripts/detector/registry/antipatterns.mjs index 88ff84a07..945307783 100644 --- a/.hermes/skills/impeccable/scripts/detector/registry/antipatterns.mjs +++ b/.hermes/skills/impeccable/scripts/detector/registry/antipatterns.mjs @@ -34,7 +34,7 @@ const ANTIPATTERNS = [ scopes: ['type'], name: 'Flat type hierarchy', description: - 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', + 'Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.', skillSection: 'Typography', skillGuideline: 'flat type hierarchy', }, diff --git a/.hermes/skills/impeccable/scripts/detector/rules/checks.mjs b/.hermes/skills/impeccable/scripts/detector/rules/checks.mjs index 8bfb2b1df..579e1c12e 100644 --- a/.hermes/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.hermes/skills/impeccable/scripts/detector/rules/checks.mjs @@ -3911,6 +3911,88 @@ function checkElementGlow(tag, style, effectiveBg) { // ─── Section 6: Page-Level Checks ─────────────────────────────────────────── +const TYPE_HIERARCHY_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption'; +const TYPE_HIERARCHY_MIN_ROLES = 3; +const TYPE_HIERARCHY_MIN_STEP_RATIO = 1.25; + +function typeHierarchyRole(el) { + const tag = String(el?.tagName || el?.nodeName || '').toLowerCase(); + return /^h[1-6]$/.test(tag) ? tag : 'body'; +} + +function hasTextContent(el) { + return String(el?.textContent || '').trim().length > 0; +} + +function isRenderedTypeElement(el, getStyle) { + for (let current = el; current; current = current.parentElement) { + const hiddenAttr = typeof current.getAttribute === 'function' && current.getAttribute('hidden') !== null; + if (current.hidden || hiddenAttr) return false; + const style = getStyle(current); + if (!style) continue; + const display = String(style.display || '').toLowerCase(); + const visibility = String(style.visibility || '').toLowerCase(); + const contentVisibility = String(style.contentVisibility || '').toLowerCase(); + if (display === 'none' || visibility === 'hidden' || visibility === 'collapse' || contentVisibility === 'hidden') return false; + const opacity = parseFloat(style.opacity); + if (Number.isFinite(opacity) && opacity <= 0.01) return false; + } + return true; +} + +function dominantTypeRoleSize(samples) { + const counts = new Map(); + for (const sample of samples) { + counts.set(sample.size, (counts.get(sample.size) || 0) + 1); + } + const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]); + if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) return null; + return ranked[0]?.[0] ?? null; +} + +function checkFlatTypeHierarchySamples(samples) { + const byRole = new Map(); + for (const sample of samples || []) { + const role = String(sample?.role || ''); + const size = Math.round(Number(sample?.size) * 10) / 10; + if (!role || !Number.isFinite(size) || size < 8 || size >= 200) continue; + if (!byRole.has(role)) byRole.set(role, []); + byRole.get(role).push({ role, size }); + } + + const roles = [...byRole.entries()].map(([role, roleSamples]) => ({ + role, + size: dominantTypeRoleSize(roleSamples), + })).filter(item => item.size !== null); + + if (roles.length < TYPE_HIERARCHY_MIN_ROLES) return []; + + const sorted = roles.slice().sort((a, b) => a.size - b.size || a.role.localeCompare(b.role)); + let largestStep = 1; + for (let i = 1; i < sorted.length; i++) { + largestStep = Math.max(largestStep, sorted[i].size / sorted[i - 1].size); + } + if (largestStep >= TYPE_HIERARCHY_MIN_STEP_RATIO) return []; + + const roleSizes = sorted.map(item => `${item.role} ${item.size}px`).join(', '); + return [{ + id: 'flat-type-hierarchy', + snippet: `Role sizes: ${roleSizes} (largest adjacent step ${largestStep.toFixed(2)}:1; target ${TYPE_HIERARCHY_MIN_STEP_RATIO}:1)`, + }]; +} + +function checkFlatTypeHierarchyFromDoc(root, getStyle, options = {}) { + const samples = []; + for (const el of root.querySelectorAll(TYPE_HIERARCHY_SELECTOR)) { + if (options.skipElement?.(el)) continue; + if (!hasTextContent(el) || !isRenderedTypeElement(el, getStyle)) continue; + const fontSize = parseFloat(getStyle(el)?.fontSize); + if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize >= 200) continue; + samples.push({ role: typeHierarchyRole(el), size: fontSize }); + } + return checkFlatTypeHierarchySamples(samples); +} + // Browser page-level checks — use document/getComputedStyle globals function checkTypography() { @@ -3949,17 +4031,10 @@ function checkTypography() { } } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { - const fs = parseFloat(getComputedStyle(el).fontSize); - if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } + for (const finding of checkFlatTypeHierarchyFromDoc(document, getComputedStyle, { + skipElement: el => el.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip, [id^="impeccable-live-"]'), + })) { + findings.push({ type: finding.id, detail: finding.snippet }); } return findings; @@ -4206,21 +4281,7 @@ function checkPageTypography(doc, win) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - // Flat type hierarchy - const sizes = new Set(); - const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div'); - for (const el of textEls) { - const fontSize = parseFloat(win.getComputedStyle(el).fontSize); - // Filter out sub-8px values (jsdom doesn't resolve relative units properly) - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(doc, el => win.getComputedStyle(el))); return findings; } @@ -5649,6 +5710,8 @@ export { checkKickerAboveHeadingFromDoc, checkElementMotion, checkElementGlow, + checkFlatTypeHierarchySamples, + checkFlatTypeHierarchyFromDoc, checkTypography, isCardLikeDOM, checkLayout, diff --git a/.kiro/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.kiro/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 3f5abd0a8..e6899f569 100644 --- a/.kiro/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.kiro/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -159,7 +159,7 @@ const ANTIPATTERNS = [ scopes: ['type'], name: 'Flat type hierarchy', description: - 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', + 'Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.', skillSection: 'Typography', skillGuideline: 'flat type hierarchy', }, @@ -5184,6 +5184,88 @@ function checkElementGlow(tag, style, effectiveBg) { // ─── Section 6: Page-Level Checks ─────────────────────────────────────────── +const TYPE_HIERARCHY_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption'; +const TYPE_HIERARCHY_MIN_ROLES = 3; +const TYPE_HIERARCHY_MIN_STEP_RATIO = 1.25; + +function typeHierarchyRole(el) { + const tag = String(el?.tagName || el?.nodeName || '').toLowerCase(); + return /^h[1-6]$/.test(tag) ? tag : 'body'; +} + +function hasTextContent(el) { + return String(el?.textContent || '').trim().length > 0; +} + +function isRenderedTypeElement(el, getStyle) { + for (let current = el; current; current = current.parentElement) { + const hiddenAttr = typeof current.getAttribute === 'function' && current.getAttribute('hidden') !== null; + if (current.hidden || hiddenAttr) return false; + const style = getStyle(current); + if (!style) continue; + const display = String(style.display || '').toLowerCase(); + const visibility = String(style.visibility || '').toLowerCase(); + const contentVisibility = String(style.contentVisibility || '').toLowerCase(); + if (display === 'none' || visibility === 'hidden' || visibility === 'collapse' || contentVisibility === 'hidden') return false; + const opacity = parseFloat(style.opacity); + if (Number.isFinite(opacity) && opacity <= 0.01) return false; + } + return true; +} + +function dominantTypeRoleSize(samples) { + const counts = new Map(); + for (const sample of samples) { + counts.set(sample.size, (counts.get(sample.size) || 0) + 1); + } + const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]); + if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) return null; + return ranked[0]?.[0] ?? null; +} + +function checkFlatTypeHierarchySamples(samples) { + const byRole = new Map(); + for (const sample of samples || []) { + const role = String(sample?.role || ''); + const size = Math.round(Number(sample?.size) * 10) / 10; + if (!role || !Number.isFinite(size) || size < 8 || size >= 200) continue; + if (!byRole.has(role)) byRole.set(role, []); + byRole.get(role).push({ role, size }); + } + + const roles = [...byRole.entries()].map(([role, roleSamples]) => ({ + role, + size: dominantTypeRoleSize(roleSamples), + })).filter(item => item.size !== null); + + if (roles.length < TYPE_HIERARCHY_MIN_ROLES) return []; + + const sorted = roles.slice().sort((a, b) => a.size - b.size || a.role.localeCompare(b.role)); + let largestStep = 1; + for (let i = 1; i < sorted.length; i++) { + largestStep = Math.max(largestStep, sorted[i].size / sorted[i - 1].size); + } + if (largestStep >= TYPE_HIERARCHY_MIN_STEP_RATIO) return []; + + const roleSizes = sorted.map(item => `${item.role} ${item.size}px`).join(', '); + return [{ + id: 'flat-type-hierarchy', + snippet: `Role sizes: ${roleSizes} (largest adjacent step ${largestStep.toFixed(2)}:1; target ${TYPE_HIERARCHY_MIN_STEP_RATIO}:1)`, + }]; +} + +function checkFlatTypeHierarchyFromDoc(root, getStyle, options = {}) { + const samples = []; + for (const el of root.querySelectorAll(TYPE_HIERARCHY_SELECTOR)) { + if (options.skipElement?.(el)) continue; + if (!hasTextContent(el) || !isRenderedTypeElement(el, getStyle)) continue; + const fontSize = parseFloat(getStyle(el)?.fontSize); + if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize >= 200) continue; + samples.push({ role: typeHierarchyRole(el), size: fontSize }); + } + return checkFlatTypeHierarchySamples(samples); +} + // Browser page-level checks — use document/getComputedStyle globals function checkTypography() { @@ -5222,17 +5304,10 @@ function checkTypography() { } } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { - const fs = parseFloat(getComputedStyle(el).fontSize); - if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } + for (const finding of checkFlatTypeHierarchyFromDoc(document, getComputedStyle, { + skipElement: el => el.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip, [id^="impeccable-live-"]'), + })) { + findings.push({ type: finding.id, detail: finding.snippet }); } return findings; @@ -5479,21 +5554,7 @@ function checkPageTypography(doc, win) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - // Flat type hierarchy - const sizes = new Set(); - const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div'); - for (const el of textEls) { - const fontSize = parseFloat(win.getComputedStyle(el).fontSize); - // Filter out sub-8px values (jsdom doesn't resolve relative units properly) - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(doc, el => win.getComputedStyle(el))); return findings; } diff --git a/.kiro/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.kiro/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 26ca4f390..df1334fdd 100644 --- a/.kiro/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.kiro/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -607,32 +607,6 @@ const REGEX_MATCHERS = [ ]; const REGEX_ANALYZERS = [ - // Flat type hierarchy - (content, filePath) => { - const sizes = new Set(); - const REM = 16; - let m; - const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi; - while ((m = sizeRe.exec(content)) !== null) { - const px = m[2] === 'px' ? +m[1] : +m[1] * REM; - if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10); - } - const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi; - while ((m = clampRe.exec(content)) !== null) { - sizes.add(Math.round((m[2] === 'px' ? +m[1] : +m[1] * REM) * 10) / 10); - sizes.add(Math.round((m[4] === 'px' ? +m[3] : +m[3] * REM) * 10) / 10); - } - const TW = { 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, 'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, 'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128 }; - for (const [cls, px] of Object.entries(TW)) { if (new RegExp(`\\b${cls}\\b`).test(content)) sizes.add(px); } - if (sizes.size < 3) return []; - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio >= 2.0) return []; - const lines = content.split('\n'); - let line = 1; - for (let i = 0; i < lines.length; i++) { if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) { line = i + 1; break; } } - return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)]; - }, // Monotonous spacing (regex) (content, filePath) => { const vals = []; @@ -1154,11 +1128,12 @@ const TEXT_CONTENT_ANALYZER_IDS = [ function runTextContentAnalyzers(content, filePath, options = {}) { const profile = options?.profile; if (!shouldRunPageAnalyzers(content, filePath)) return []; - // The 3 text-content analyzers are at indices 2-4 in REGEX_ANALYZERS - // (single-font's removal on 2026-07-29 shifted every index down one). + // The 3 text-content analyzers are at indices 1-3 in REGEX_ANALYZERS. + // flat-type-hierarchy left this source-only path in issue #619 because it + // needs rendered role and usage evidence. const findings = []; for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { - const analyzer = REGEX_ANALYZERS[2 + i]; + const analyzer = REGEX_ANALYZERS[1 + i]; const ruleId = TEXT_CONTENT_ANALYZER_IDS[i]; findings.push(...profileFindings(profile, { engine: 'regex', @@ -1284,7 +1259,6 @@ function detectText(content, filePath, options = {}) { // Page-level analyzers only run on full pages if (shouldRunPageAnalyzers(content, filePath)) { const analyzerIds = [ - 'flat-type-hierarchy', 'monotonous-spacing', 'em-dash-overuse', 'marketing-buzzword', diff --git a/.kiro/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.kiro/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index cc36ac9b5..7f6080b61 100644 --- a/.kiro/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.kiro/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -282,6 +282,7 @@ const STATIC_DEFAULT_STYLE = { marginLeft: '0px', position: 'static', visibility: 'visible', + contentVisibility: 'visible', opacity: '1', top: 'auto', right: 'auto', diff --git a/.kiro/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.kiro/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 51c34224b..84e6ab1f5 100644 --- a/.kiro/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.kiro/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -25,6 +25,7 @@ import { checkElementOversizedH1, checkElementQuality, checkElementRadialSpotlight, + checkFlatTypeHierarchyFromDoc, checkCreamPalette, checkHtmlPatterns, checkKickerAboveHeadingFromDoc, @@ -59,18 +60,7 @@ function checkStaticPageTypography(document, window) { for (const font of overusedFound) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div')) { - const fontSize = parseFloat(window.getComputedStyle(el).fontSize); - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(document, el => window.getComputedStyle(el))); return findings; } diff --git a/.kiro/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.kiro/skills/impeccable/scripts/detector/registry/antipatterns.mjs index 88ff84a07..945307783 100644 --- a/.kiro/skills/impeccable/scripts/detector/registry/antipatterns.mjs +++ b/.kiro/skills/impeccable/scripts/detector/registry/antipatterns.mjs @@ -34,7 +34,7 @@ const ANTIPATTERNS = [ scopes: ['type'], name: 'Flat type hierarchy', description: - 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', + 'Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.', skillSection: 'Typography', skillGuideline: 'flat type hierarchy', }, diff --git a/.kiro/skills/impeccable/scripts/detector/rules/checks.mjs b/.kiro/skills/impeccable/scripts/detector/rules/checks.mjs index 8bfb2b1df..579e1c12e 100644 --- a/.kiro/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.kiro/skills/impeccable/scripts/detector/rules/checks.mjs @@ -3911,6 +3911,88 @@ function checkElementGlow(tag, style, effectiveBg) { // ─── Section 6: Page-Level Checks ─────────────────────────────────────────── +const TYPE_HIERARCHY_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption'; +const TYPE_HIERARCHY_MIN_ROLES = 3; +const TYPE_HIERARCHY_MIN_STEP_RATIO = 1.25; + +function typeHierarchyRole(el) { + const tag = String(el?.tagName || el?.nodeName || '').toLowerCase(); + return /^h[1-6]$/.test(tag) ? tag : 'body'; +} + +function hasTextContent(el) { + return String(el?.textContent || '').trim().length > 0; +} + +function isRenderedTypeElement(el, getStyle) { + for (let current = el; current; current = current.parentElement) { + const hiddenAttr = typeof current.getAttribute === 'function' && current.getAttribute('hidden') !== null; + if (current.hidden || hiddenAttr) return false; + const style = getStyle(current); + if (!style) continue; + const display = String(style.display || '').toLowerCase(); + const visibility = String(style.visibility || '').toLowerCase(); + const contentVisibility = String(style.contentVisibility || '').toLowerCase(); + if (display === 'none' || visibility === 'hidden' || visibility === 'collapse' || contentVisibility === 'hidden') return false; + const opacity = parseFloat(style.opacity); + if (Number.isFinite(opacity) && opacity <= 0.01) return false; + } + return true; +} + +function dominantTypeRoleSize(samples) { + const counts = new Map(); + for (const sample of samples) { + counts.set(sample.size, (counts.get(sample.size) || 0) + 1); + } + const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]); + if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) return null; + return ranked[0]?.[0] ?? null; +} + +function checkFlatTypeHierarchySamples(samples) { + const byRole = new Map(); + for (const sample of samples || []) { + const role = String(sample?.role || ''); + const size = Math.round(Number(sample?.size) * 10) / 10; + if (!role || !Number.isFinite(size) || size < 8 || size >= 200) continue; + if (!byRole.has(role)) byRole.set(role, []); + byRole.get(role).push({ role, size }); + } + + const roles = [...byRole.entries()].map(([role, roleSamples]) => ({ + role, + size: dominantTypeRoleSize(roleSamples), + })).filter(item => item.size !== null); + + if (roles.length < TYPE_HIERARCHY_MIN_ROLES) return []; + + const sorted = roles.slice().sort((a, b) => a.size - b.size || a.role.localeCompare(b.role)); + let largestStep = 1; + for (let i = 1; i < sorted.length; i++) { + largestStep = Math.max(largestStep, sorted[i].size / sorted[i - 1].size); + } + if (largestStep >= TYPE_HIERARCHY_MIN_STEP_RATIO) return []; + + const roleSizes = sorted.map(item => `${item.role} ${item.size}px`).join(', '); + return [{ + id: 'flat-type-hierarchy', + snippet: `Role sizes: ${roleSizes} (largest adjacent step ${largestStep.toFixed(2)}:1; target ${TYPE_HIERARCHY_MIN_STEP_RATIO}:1)`, + }]; +} + +function checkFlatTypeHierarchyFromDoc(root, getStyle, options = {}) { + const samples = []; + for (const el of root.querySelectorAll(TYPE_HIERARCHY_SELECTOR)) { + if (options.skipElement?.(el)) continue; + if (!hasTextContent(el) || !isRenderedTypeElement(el, getStyle)) continue; + const fontSize = parseFloat(getStyle(el)?.fontSize); + if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize >= 200) continue; + samples.push({ role: typeHierarchyRole(el), size: fontSize }); + } + return checkFlatTypeHierarchySamples(samples); +} + // Browser page-level checks — use document/getComputedStyle globals function checkTypography() { @@ -3949,17 +4031,10 @@ function checkTypography() { } } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { - const fs = parseFloat(getComputedStyle(el).fontSize); - if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } + for (const finding of checkFlatTypeHierarchyFromDoc(document, getComputedStyle, { + skipElement: el => el.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip, [id^="impeccable-live-"]'), + })) { + findings.push({ type: finding.id, detail: finding.snippet }); } return findings; @@ -4206,21 +4281,7 @@ function checkPageTypography(doc, win) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - // Flat type hierarchy - const sizes = new Set(); - const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div'); - for (const el of textEls) { - const fontSize = parseFloat(win.getComputedStyle(el).fontSize); - // Filter out sub-8px values (jsdom doesn't resolve relative units properly) - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(doc, el => win.getComputedStyle(el))); return findings; } @@ -5649,6 +5710,8 @@ export { checkKickerAboveHeadingFromDoc, checkElementMotion, checkElementGlow, + checkFlatTypeHierarchySamples, + checkFlatTypeHierarchyFromDoc, checkTypography, isCardLikeDOM, checkLayout, diff --git a/.opencode/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.opencode/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 3f5abd0a8..e6899f569 100644 --- a/.opencode/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.opencode/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -159,7 +159,7 @@ const ANTIPATTERNS = [ scopes: ['type'], name: 'Flat type hierarchy', description: - 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', + 'Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.', skillSection: 'Typography', skillGuideline: 'flat type hierarchy', }, @@ -5184,6 +5184,88 @@ function checkElementGlow(tag, style, effectiveBg) { // ─── Section 6: Page-Level Checks ─────────────────────────────────────────── +const TYPE_HIERARCHY_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption'; +const TYPE_HIERARCHY_MIN_ROLES = 3; +const TYPE_HIERARCHY_MIN_STEP_RATIO = 1.25; + +function typeHierarchyRole(el) { + const tag = String(el?.tagName || el?.nodeName || '').toLowerCase(); + return /^h[1-6]$/.test(tag) ? tag : 'body'; +} + +function hasTextContent(el) { + return String(el?.textContent || '').trim().length > 0; +} + +function isRenderedTypeElement(el, getStyle) { + for (let current = el; current; current = current.parentElement) { + const hiddenAttr = typeof current.getAttribute === 'function' && current.getAttribute('hidden') !== null; + if (current.hidden || hiddenAttr) return false; + const style = getStyle(current); + if (!style) continue; + const display = String(style.display || '').toLowerCase(); + const visibility = String(style.visibility || '').toLowerCase(); + const contentVisibility = String(style.contentVisibility || '').toLowerCase(); + if (display === 'none' || visibility === 'hidden' || visibility === 'collapse' || contentVisibility === 'hidden') return false; + const opacity = parseFloat(style.opacity); + if (Number.isFinite(opacity) && opacity <= 0.01) return false; + } + return true; +} + +function dominantTypeRoleSize(samples) { + const counts = new Map(); + for (const sample of samples) { + counts.set(sample.size, (counts.get(sample.size) || 0) + 1); + } + const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]); + if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) return null; + return ranked[0]?.[0] ?? null; +} + +function checkFlatTypeHierarchySamples(samples) { + const byRole = new Map(); + for (const sample of samples || []) { + const role = String(sample?.role || ''); + const size = Math.round(Number(sample?.size) * 10) / 10; + if (!role || !Number.isFinite(size) || size < 8 || size >= 200) continue; + if (!byRole.has(role)) byRole.set(role, []); + byRole.get(role).push({ role, size }); + } + + const roles = [...byRole.entries()].map(([role, roleSamples]) => ({ + role, + size: dominantTypeRoleSize(roleSamples), + })).filter(item => item.size !== null); + + if (roles.length < TYPE_HIERARCHY_MIN_ROLES) return []; + + const sorted = roles.slice().sort((a, b) => a.size - b.size || a.role.localeCompare(b.role)); + let largestStep = 1; + for (let i = 1; i < sorted.length; i++) { + largestStep = Math.max(largestStep, sorted[i].size / sorted[i - 1].size); + } + if (largestStep >= TYPE_HIERARCHY_MIN_STEP_RATIO) return []; + + const roleSizes = sorted.map(item => `${item.role} ${item.size}px`).join(', '); + return [{ + id: 'flat-type-hierarchy', + snippet: `Role sizes: ${roleSizes} (largest adjacent step ${largestStep.toFixed(2)}:1; target ${TYPE_HIERARCHY_MIN_STEP_RATIO}:1)`, + }]; +} + +function checkFlatTypeHierarchyFromDoc(root, getStyle, options = {}) { + const samples = []; + for (const el of root.querySelectorAll(TYPE_HIERARCHY_SELECTOR)) { + if (options.skipElement?.(el)) continue; + if (!hasTextContent(el) || !isRenderedTypeElement(el, getStyle)) continue; + const fontSize = parseFloat(getStyle(el)?.fontSize); + if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize >= 200) continue; + samples.push({ role: typeHierarchyRole(el), size: fontSize }); + } + return checkFlatTypeHierarchySamples(samples); +} + // Browser page-level checks — use document/getComputedStyle globals function checkTypography() { @@ -5222,17 +5304,10 @@ function checkTypography() { } } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { - const fs = parseFloat(getComputedStyle(el).fontSize); - if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } + for (const finding of checkFlatTypeHierarchyFromDoc(document, getComputedStyle, { + skipElement: el => el.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip, [id^="impeccable-live-"]'), + })) { + findings.push({ type: finding.id, detail: finding.snippet }); } return findings; @@ -5479,21 +5554,7 @@ function checkPageTypography(doc, win) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - // Flat type hierarchy - const sizes = new Set(); - const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div'); - for (const el of textEls) { - const fontSize = parseFloat(win.getComputedStyle(el).fontSize); - // Filter out sub-8px values (jsdom doesn't resolve relative units properly) - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(doc, el => win.getComputedStyle(el))); return findings; } diff --git a/.opencode/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.opencode/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 26ca4f390..df1334fdd 100644 --- a/.opencode/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.opencode/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -607,32 +607,6 @@ const REGEX_MATCHERS = [ ]; const REGEX_ANALYZERS = [ - // Flat type hierarchy - (content, filePath) => { - const sizes = new Set(); - const REM = 16; - let m; - const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi; - while ((m = sizeRe.exec(content)) !== null) { - const px = m[2] === 'px' ? +m[1] : +m[1] * REM; - if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10); - } - const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi; - while ((m = clampRe.exec(content)) !== null) { - sizes.add(Math.round((m[2] === 'px' ? +m[1] : +m[1] * REM) * 10) / 10); - sizes.add(Math.round((m[4] === 'px' ? +m[3] : +m[3] * REM) * 10) / 10); - } - const TW = { 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, 'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, 'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128 }; - for (const [cls, px] of Object.entries(TW)) { if (new RegExp(`\\b${cls}\\b`).test(content)) sizes.add(px); } - if (sizes.size < 3) return []; - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio >= 2.0) return []; - const lines = content.split('\n'); - let line = 1; - for (let i = 0; i < lines.length; i++) { if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) { line = i + 1; break; } } - return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)]; - }, // Monotonous spacing (regex) (content, filePath) => { const vals = []; @@ -1154,11 +1128,12 @@ const TEXT_CONTENT_ANALYZER_IDS = [ function runTextContentAnalyzers(content, filePath, options = {}) { const profile = options?.profile; if (!shouldRunPageAnalyzers(content, filePath)) return []; - // The 3 text-content analyzers are at indices 2-4 in REGEX_ANALYZERS - // (single-font's removal on 2026-07-29 shifted every index down one). + // The 3 text-content analyzers are at indices 1-3 in REGEX_ANALYZERS. + // flat-type-hierarchy left this source-only path in issue #619 because it + // needs rendered role and usage evidence. const findings = []; for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { - const analyzer = REGEX_ANALYZERS[2 + i]; + const analyzer = REGEX_ANALYZERS[1 + i]; const ruleId = TEXT_CONTENT_ANALYZER_IDS[i]; findings.push(...profileFindings(profile, { engine: 'regex', @@ -1284,7 +1259,6 @@ function detectText(content, filePath, options = {}) { // Page-level analyzers only run on full pages if (shouldRunPageAnalyzers(content, filePath)) { const analyzerIds = [ - 'flat-type-hierarchy', 'monotonous-spacing', 'em-dash-overuse', 'marketing-buzzword', diff --git a/.opencode/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.opencode/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index cc36ac9b5..7f6080b61 100644 --- a/.opencode/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.opencode/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -282,6 +282,7 @@ const STATIC_DEFAULT_STYLE = { marginLeft: '0px', position: 'static', visibility: 'visible', + contentVisibility: 'visible', opacity: '1', top: 'auto', right: 'auto', diff --git a/.opencode/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.opencode/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 51c34224b..84e6ab1f5 100644 --- a/.opencode/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.opencode/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -25,6 +25,7 @@ import { checkElementOversizedH1, checkElementQuality, checkElementRadialSpotlight, + checkFlatTypeHierarchyFromDoc, checkCreamPalette, checkHtmlPatterns, checkKickerAboveHeadingFromDoc, @@ -59,18 +60,7 @@ function checkStaticPageTypography(document, window) { for (const font of overusedFound) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div')) { - const fontSize = parseFloat(window.getComputedStyle(el).fontSize); - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(document, el => window.getComputedStyle(el))); return findings; } diff --git a/.opencode/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.opencode/skills/impeccable/scripts/detector/registry/antipatterns.mjs index 88ff84a07..945307783 100644 --- a/.opencode/skills/impeccable/scripts/detector/registry/antipatterns.mjs +++ b/.opencode/skills/impeccable/scripts/detector/registry/antipatterns.mjs @@ -34,7 +34,7 @@ const ANTIPATTERNS = [ scopes: ['type'], name: 'Flat type hierarchy', description: - 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', + 'Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.', skillSection: 'Typography', skillGuideline: 'flat type hierarchy', }, diff --git a/.opencode/skills/impeccable/scripts/detector/rules/checks.mjs b/.opencode/skills/impeccable/scripts/detector/rules/checks.mjs index 8bfb2b1df..579e1c12e 100644 --- a/.opencode/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.opencode/skills/impeccable/scripts/detector/rules/checks.mjs @@ -3911,6 +3911,88 @@ function checkElementGlow(tag, style, effectiveBg) { // ─── Section 6: Page-Level Checks ─────────────────────────────────────────── +const TYPE_HIERARCHY_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption'; +const TYPE_HIERARCHY_MIN_ROLES = 3; +const TYPE_HIERARCHY_MIN_STEP_RATIO = 1.25; + +function typeHierarchyRole(el) { + const tag = String(el?.tagName || el?.nodeName || '').toLowerCase(); + return /^h[1-6]$/.test(tag) ? tag : 'body'; +} + +function hasTextContent(el) { + return String(el?.textContent || '').trim().length > 0; +} + +function isRenderedTypeElement(el, getStyle) { + for (let current = el; current; current = current.parentElement) { + const hiddenAttr = typeof current.getAttribute === 'function' && current.getAttribute('hidden') !== null; + if (current.hidden || hiddenAttr) return false; + const style = getStyle(current); + if (!style) continue; + const display = String(style.display || '').toLowerCase(); + const visibility = String(style.visibility || '').toLowerCase(); + const contentVisibility = String(style.contentVisibility || '').toLowerCase(); + if (display === 'none' || visibility === 'hidden' || visibility === 'collapse' || contentVisibility === 'hidden') return false; + const opacity = parseFloat(style.opacity); + if (Number.isFinite(opacity) && opacity <= 0.01) return false; + } + return true; +} + +function dominantTypeRoleSize(samples) { + const counts = new Map(); + for (const sample of samples) { + counts.set(sample.size, (counts.get(sample.size) || 0) + 1); + } + const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]); + if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) return null; + return ranked[0]?.[0] ?? null; +} + +function checkFlatTypeHierarchySamples(samples) { + const byRole = new Map(); + for (const sample of samples || []) { + const role = String(sample?.role || ''); + const size = Math.round(Number(sample?.size) * 10) / 10; + if (!role || !Number.isFinite(size) || size < 8 || size >= 200) continue; + if (!byRole.has(role)) byRole.set(role, []); + byRole.get(role).push({ role, size }); + } + + const roles = [...byRole.entries()].map(([role, roleSamples]) => ({ + role, + size: dominantTypeRoleSize(roleSamples), + })).filter(item => item.size !== null); + + if (roles.length < TYPE_HIERARCHY_MIN_ROLES) return []; + + const sorted = roles.slice().sort((a, b) => a.size - b.size || a.role.localeCompare(b.role)); + let largestStep = 1; + for (let i = 1; i < sorted.length; i++) { + largestStep = Math.max(largestStep, sorted[i].size / sorted[i - 1].size); + } + if (largestStep >= TYPE_HIERARCHY_MIN_STEP_RATIO) return []; + + const roleSizes = sorted.map(item => `${item.role} ${item.size}px`).join(', '); + return [{ + id: 'flat-type-hierarchy', + snippet: `Role sizes: ${roleSizes} (largest adjacent step ${largestStep.toFixed(2)}:1; target ${TYPE_HIERARCHY_MIN_STEP_RATIO}:1)`, + }]; +} + +function checkFlatTypeHierarchyFromDoc(root, getStyle, options = {}) { + const samples = []; + for (const el of root.querySelectorAll(TYPE_HIERARCHY_SELECTOR)) { + if (options.skipElement?.(el)) continue; + if (!hasTextContent(el) || !isRenderedTypeElement(el, getStyle)) continue; + const fontSize = parseFloat(getStyle(el)?.fontSize); + if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize >= 200) continue; + samples.push({ role: typeHierarchyRole(el), size: fontSize }); + } + return checkFlatTypeHierarchySamples(samples); +} + // Browser page-level checks — use document/getComputedStyle globals function checkTypography() { @@ -3949,17 +4031,10 @@ function checkTypography() { } } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { - const fs = parseFloat(getComputedStyle(el).fontSize); - if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } + for (const finding of checkFlatTypeHierarchyFromDoc(document, getComputedStyle, { + skipElement: el => el.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip, [id^="impeccable-live-"]'), + })) { + findings.push({ type: finding.id, detail: finding.snippet }); } return findings; @@ -4206,21 +4281,7 @@ function checkPageTypography(doc, win) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - // Flat type hierarchy - const sizes = new Set(); - const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div'); - for (const el of textEls) { - const fontSize = parseFloat(win.getComputedStyle(el).fontSize); - // Filter out sub-8px values (jsdom doesn't resolve relative units properly) - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(doc, el => win.getComputedStyle(el))); return findings; } @@ -5649,6 +5710,8 @@ export { checkKickerAboveHeadingFromDoc, checkElementMotion, checkElementGlow, + checkFlatTypeHierarchySamples, + checkFlatTypeHierarchyFromDoc, checkTypography, isCardLikeDOM, checkLayout, diff --git a/.pi/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.pi/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 3f5abd0a8..e6899f569 100644 --- a/.pi/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.pi/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -159,7 +159,7 @@ const ANTIPATTERNS = [ scopes: ['type'], name: 'Flat type hierarchy', description: - 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', + 'Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.', skillSection: 'Typography', skillGuideline: 'flat type hierarchy', }, @@ -5184,6 +5184,88 @@ function checkElementGlow(tag, style, effectiveBg) { // ─── Section 6: Page-Level Checks ─────────────────────────────────────────── +const TYPE_HIERARCHY_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption'; +const TYPE_HIERARCHY_MIN_ROLES = 3; +const TYPE_HIERARCHY_MIN_STEP_RATIO = 1.25; + +function typeHierarchyRole(el) { + const tag = String(el?.tagName || el?.nodeName || '').toLowerCase(); + return /^h[1-6]$/.test(tag) ? tag : 'body'; +} + +function hasTextContent(el) { + return String(el?.textContent || '').trim().length > 0; +} + +function isRenderedTypeElement(el, getStyle) { + for (let current = el; current; current = current.parentElement) { + const hiddenAttr = typeof current.getAttribute === 'function' && current.getAttribute('hidden') !== null; + if (current.hidden || hiddenAttr) return false; + const style = getStyle(current); + if (!style) continue; + const display = String(style.display || '').toLowerCase(); + const visibility = String(style.visibility || '').toLowerCase(); + const contentVisibility = String(style.contentVisibility || '').toLowerCase(); + if (display === 'none' || visibility === 'hidden' || visibility === 'collapse' || contentVisibility === 'hidden') return false; + const opacity = parseFloat(style.opacity); + if (Number.isFinite(opacity) && opacity <= 0.01) return false; + } + return true; +} + +function dominantTypeRoleSize(samples) { + const counts = new Map(); + for (const sample of samples) { + counts.set(sample.size, (counts.get(sample.size) || 0) + 1); + } + const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]); + if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) return null; + return ranked[0]?.[0] ?? null; +} + +function checkFlatTypeHierarchySamples(samples) { + const byRole = new Map(); + for (const sample of samples || []) { + const role = String(sample?.role || ''); + const size = Math.round(Number(sample?.size) * 10) / 10; + if (!role || !Number.isFinite(size) || size < 8 || size >= 200) continue; + if (!byRole.has(role)) byRole.set(role, []); + byRole.get(role).push({ role, size }); + } + + const roles = [...byRole.entries()].map(([role, roleSamples]) => ({ + role, + size: dominantTypeRoleSize(roleSamples), + })).filter(item => item.size !== null); + + if (roles.length < TYPE_HIERARCHY_MIN_ROLES) return []; + + const sorted = roles.slice().sort((a, b) => a.size - b.size || a.role.localeCompare(b.role)); + let largestStep = 1; + for (let i = 1; i < sorted.length; i++) { + largestStep = Math.max(largestStep, sorted[i].size / sorted[i - 1].size); + } + if (largestStep >= TYPE_HIERARCHY_MIN_STEP_RATIO) return []; + + const roleSizes = sorted.map(item => `${item.role} ${item.size}px`).join(', '); + return [{ + id: 'flat-type-hierarchy', + snippet: `Role sizes: ${roleSizes} (largest adjacent step ${largestStep.toFixed(2)}:1; target ${TYPE_HIERARCHY_MIN_STEP_RATIO}:1)`, + }]; +} + +function checkFlatTypeHierarchyFromDoc(root, getStyle, options = {}) { + const samples = []; + for (const el of root.querySelectorAll(TYPE_HIERARCHY_SELECTOR)) { + if (options.skipElement?.(el)) continue; + if (!hasTextContent(el) || !isRenderedTypeElement(el, getStyle)) continue; + const fontSize = parseFloat(getStyle(el)?.fontSize); + if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize >= 200) continue; + samples.push({ role: typeHierarchyRole(el), size: fontSize }); + } + return checkFlatTypeHierarchySamples(samples); +} + // Browser page-level checks — use document/getComputedStyle globals function checkTypography() { @@ -5222,17 +5304,10 @@ function checkTypography() { } } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { - const fs = parseFloat(getComputedStyle(el).fontSize); - if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } + for (const finding of checkFlatTypeHierarchyFromDoc(document, getComputedStyle, { + skipElement: el => el.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip, [id^="impeccable-live-"]'), + })) { + findings.push({ type: finding.id, detail: finding.snippet }); } return findings; @@ -5479,21 +5554,7 @@ function checkPageTypography(doc, win) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - // Flat type hierarchy - const sizes = new Set(); - const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div'); - for (const el of textEls) { - const fontSize = parseFloat(win.getComputedStyle(el).fontSize); - // Filter out sub-8px values (jsdom doesn't resolve relative units properly) - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(doc, el => win.getComputedStyle(el))); return findings; } diff --git a/.pi/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.pi/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 26ca4f390..df1334fdd 100644 --- a/.pi/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.pi/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -607,32 +607,6 @@ const REGEX_MATCHERS = [ ]; const REGEX_ANALYZERS = [ - // Flat type hierarchy - (content, filePath) => { - const sizes = new Set(); - const REM = 16; - let m; - const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi; - while ((m = sizeRe.exec(content)) !== null) { - const px = m[2] === 'px' ? +m[1] : +m[1] * REM; - if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10); - } - const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi; - while ((m = clampRe.exec(content)) !== null) { - sizes.add(Math.round((m[2] === 'px' ? +m[1] : +m[1] * REM) * 10) / 10); - sizes.add(Math.round((m[4] === 'px' ? +m[3] : +m[3] * REM) * 10) / 10); - } - const TW = { 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, 'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, 'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128 }; - for (const [cls, px] of Object.entries(TW)) { if (new RegExp(`\\b${cls}\\b`).test(content)) sizes.add(px); } - if (sizes.size < 3) return []; - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio >= 2.0) return []; - const lines = content.split('\n'); - let line = 1; - for (let i = 0; i < lines.length; i++) { if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) { line = i + 1; break; } } - return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)]; - }, // Monotonous spacing (regex) (content, filePath) => { const vals = []; @@ -1154,11 +1128,12 @@ const TEXT_CONTENT_ANALYZER_IDS = [ function runTextContentAnalyzers(content, filePath, options = {}) { const profile = options?.profile; if (!shouldRunPageAnalyzers(content, filePath)) return []; - // The 3 text-content analyzers are at indices 2-4 in REGEX_ANALYZERS - // (single-font's removal on 2026-07-29 shifted every index down one). + // The 3 text-content analyzers are at indices 1-3 in REGEX_ANALYZERS. + // flat-type-hierarchy left this source-only path in issue #619 because it + // needs rendered role and usage evidence. const findings = []; for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { - const analyzer = REGEX_ANALYZERS[2 + i]; + const analyzer = REGEX_ANALYZERS[1 + i]; const ruleId = TEXT_CONTENT_ANALYZER_IDS[i]; findings.push(...profileFindings(profile, { engine: 'regex', @@ -1284,7 +1259,6 @@ function detectText(content, filePath, options = {}) { // Page-level analyzers only run on full pages if (shouldRunPageAnalyzers(content, filePath)) { const analyzerIds = [ - 'flat-type-hierarchy', 'monotonous-spacing', 'em-dash-overuse', 'marketing-buzzword', diff --git a/.pi/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.pi/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index cc36ac9b5..7f6080b61 100644 --- a/.pi/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.pi/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -282,6 +282,7 @@ const STATIC_DEFAULT_STYLE = { marginLeft: '0px', position: 'static', visibility: 'visible', + contentVisibility: 'visible', opacity: '1', top: 'auto', right: 'auto', diff --git a/.pi/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.pi/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 51c34224b..84e6ab1f5 100644 --- a/.pi/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.pi/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -25,6 +25,7 @@ import { checkElementOversizedH1, checkElementQuality, checkElementRadialSpotlight, + checkFlatTypeHierarchyFromDoc, checkCreamPalette, checkHtmlPatterns, checkKickerAboveHeadingFromDoc, @@ -59,18 +60,7 @@ function checkStaticPageTypography(document, window) { for (const font of overusedFound) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div')) { - const fontSize = parseFloat(window.getComputedStyle(el).fontSize); - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(document, el => window.getComputedStyle(el))); return findings; } diff --git a/.pi/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.pi/skills/impeccable/scripts/detector/registry/antipatterns.mjs index 88ff84a07..945307783 100644 --- a/.pi/skills/impeccable/scripts/detector/registry/antipatterns.mjs +++ b/.pi/skills/impeccable/scripts/detector/registry/antipatterns.mjs @@ -34,7 +34,7 @@ const ANTIPATTERNS = [ scopes: ['type'], name: 'Flat type hierarchy', description: - 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', + 'Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.', skillSection: 'Typography', skillGuideline: 'flat type hierarchy', }, diff --git a/.pi/skills/impeccable/scripts/detector/rules/checks.mjs b/.pi/skills/impeccable/scripts/detector/rules/checks.mjs index 8bfb2b1df..579e1c12e 100644 --- a/.pi/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.pi/skills/impeccable/scripts/detector/rules/checks.mjs @@ -3911,6 +3911,88 @@ function checkElementGlow(tag, style, effectiveBg) { // ─── Section 6: Page-Level Checks ─────────────────────────────────────────── +const TYPE_HIERARCHY_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption'; +const TYPE_HIERARCHY_MIN_ROLES = 3; +const TYPE_HIERARCHY_MIN_STEP_RATIO = 1.25; + +function typeHierarchyRole(el) { + const tag = String(el?.tagName || el?.nodeName || '').toLowerCase(); + return /^h[1-6]$/.test(tag) ? tag : 'body'; +} + +function hasTextContent(el) { + return String(el?.textContent || '').trim().length > 0; +} + +function isRenderedTypeElement(el, getStyle) { + for (let current = el; current; current = current.parentElement) { + const hiddenAttr = typeof current.getAttribute === 'function' && current.getAttribute('hidden') !== null; + if (current.hidden || hiddenAttr) return false; + const style = getStyle(current); + if (!style) continue; + const display = String(style.display || '').toLowerCase(); + const visibility = String(style.visibility || '').toLowerCase(); + const contentVisibility = String(style.contentVisibility || '').toLowerCase(); + if (display === 'none' || visibility === 'hidden' || visibility === 'collapse' || contentVisibility === 'hidden') return false; + const opacity = parseFloat(style.opacity); + if (Number.isFinite(opacity) && opacity <= 0.01) return false; + } + return true; +} + +function dominantTypeRoleSize(samples) { + const counts = new Map(); + for (const sample of samples) { + counts.set(sample.size, (counts.get(sample.size) || 0) + 1); + } + const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]); + if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) return null; + return ranked[0]?.[0] ?? null; +} + +function checkFlatTypeHierarchySamples(samples) { + const byRole = new Map(); + for (const sample of samples || []) { + const role = String(sample?.role || ''); + const size = Math.round(Number(sample?.size) * 10) / 10; + if (!role || !Number.isFinite(size) || size < 8 || size >= 200) continue; + if (!byRole.has(role)) byRole.set(role, []); + byRole.get(role).push({ role, size }); + } + + const roles = [...byRole.entries()].map(([role, roleSamples]) => ({ + role, + size: dominantTypeRoleSize(roleSamples), + })).filter(item => item.size !== null); + + if (roles.length < TYPE_HIERARCHY_MIN_ROLES) return []; + + const sorted = roles.slice().sort((a, b) => a.size - b.size || a.role.localeCompare(b.role)); + let largestStep = 1; + for (let i = 1; i < sorted.length; i++) { + largestStep = Math.max(largestStep, sorted[i].size / sorted[i - 1].size); + } + if (largestStep >= TYPE_HIERARCHY_MIN_STEP_RATIO) return []; + + const roleSizes = sorted.map(item => `${item.role} ${item.size}px`).join(', '); + return [{ + id: 'flat-type-hierarchy', + snippet: `Role sizes: ${roleSizes} (largest adjacent step ${largestStep.toFixed(2)}:1; target ${TYPE_HIERARCHY_MIN_STEP_RATIO}:1)`, + }]; +} + +function checkFlatTypeHierarchyFromDoc(root, getStyle, options = {}) { + const samples = []; + for (const el of root.querySelectorAll(TYPE_HIERARCHY_SELECTOR)) { + if (options.skipElement?.(el)) continue; + if (!hasTextContent(el) || !isRenderedTypeElement(el, getStyle)) continue; + const fontSize = parseFloat(getStyle(el)?.fontSize); + if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize >= 200) continue; + samples.push({ role: typeHierarchyRole(el), size: fontSize }); + } + return checkFlatTypeHierarchySamples(samples); +} + // Browser page-level checks — use document/getComputedStyle globals function checkTypography() { @@ -3949,17 +4031,10 @@ function checkTypography() { } } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { - const fs = parseFloat(getComputedStyle(el).fontSize); - if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } + for (const finding of checkFlatTypeHierarchyFromDoc(document, getComputedStyle, { + skipElement: el => el.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip, [id^="impeccable-live-"]'), + })) { + findings.push({ type: finding.id, detail: finding.snippet }); } return findings; @@ -4206,21 +4281,7 @@ function checkPageTypography(doc, win) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - // Flat type hierarchy - const sizes = new Set(); - const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div'); - for (const el of textEls) { - const fontSize = parseFloat(win.getComputedStyle(el).fontSize); - // Filter out sub-8px values (jsdom doesn't resolve relative units properly) - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(doc, el => win.getComputedStyle(el))); return findings; } @@ -5649,6 +5710,8 @@ export { checkKickerAboveHeadingFromDoc, checkElementMotion, checkElementGlow, + checkFlatTypeHierarchySamples, + checkFlatTypeHierarchyFromDoc, checkTypography, isCardLikeDOM, checkLayout, diff --git a/.qoder/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.qoder/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 3f5abd0a8..e6899f569 100644 --- a/.qoder/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.qoder/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -159,7 +159,7 @@ const ANTIPATTERNS = [ scopes: ['type'], name: 'Flat type hierarchy', description: - 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', + 'Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.', skillSection: 'Typography', skillGuideline: 'flat type hierarchy', }, @@ -5184,6 +5184,88 @@ function checkElementGlow(tag, style, effectiveBg) { // ─── Section 6: Page-Level Checks ─────────────────────────────────────────── +const TYPE_HIERARCHY_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption'; +const TYPE_HIERARCHY_MIN_ROLES = 3; +const TYPE_HIERARCHY_MIN_STEP_RATIO = 1.25; + +function typeHierarchyRole(el) { + const tag = String(el?.tagName || el?.nodeName || '').toLowerCase(); + return /^h[1-6]$/.test(tag) ? tag : 'body'; +} + +function hasTextContent(el) { + return String(el?.textContent || '').trim().length > 0; +} + +function isRenderedTypeElement(el, getStyle) { + for (let current = el; current; current = current.parentElement) { + const hiddenAttr = typeof current.getAttribute === 'function' && current.getAttribute('hidden') !== null; + if (current.hidden || hiddenAttr) return false; + const style = getStyle(current); + if (!style) continue; + const display = String(style.display || '').toLowerCase(); + const visibility = String(style.visibility || '').toLowerCase(); + const contentVisibility = String(style.contentVisibility || '').toLowerCase(); + if (display === 'none' || visibility === 'hidden' || visibility === 'collapse' || contentVisibility === 'hidden') return false; + const opacity = parseFloat(style.opacity); + if (Number.isFinite(opacity) && opacity <= 0.01) return false; + } + return true; +} + +function dominantTypeRoleSize(samples) { + const counts = new Map(); + for (const sample of samples) { + counts.set(sample.size, (counts.get(sample.size) || 0) + 1); + } + const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]); + if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) return null; + return ranked[0]?.[0] ?? null; +} + +function checkFlatTypeHierarchySamples(samples) { + const byRole = new Map(); + for (const sample of samples || []) { + const role = String(sample?.role || ''); + const size = Math.round(Number(sample?.size) * 10) / 10; + if (!role || !Number.isFinite(size) || size < 8 || size >= 200) continue; + if (!byRole.has(role)) byRole.set(role, []); + byRole.get(role).push({ role, size }); + } + + const roles = [...byRole.entries()].map(([role, roleSamples]) => ({ + role, + size: dominantTypeRoleSize(roleSamples), + })).filter(item => item.size !== null); + + if (roles.length < TYPE_HIERARCHY_MIN_ROLES) return []; + + const sorted = roles.slice().sort((a, b) => a.size - b.size || a.role.localeCompare(b.role)); + let largestStep = 1; + for (let i = 1; i < sorted.length; i++) { + largestStep = Math.max(largestStep, sorted[i].size / sorted[i - 1].size); + } + if (largestStep >= TYPE_HIERARCHY_MIN_STEP_RATIO) return []; + + const roleSizes = sorted.map(item => `${item.role} ${item.size}px`).join(', '); + return [{ + id: 'flat-type-hierarchy', + snippet: `Role sizes: ${roleSizes} (largest adjacent step ${largestStep.toFixed(2)}:1; target ${TYPE_HIERARCHY_MIN_STEP_RATIO}:1)`, + }]; +} + +function checkFlatTypeHierarchyFromDoc(root, getStyle, options = {}) { + const samples = []; + for (const el of root.querySelectorAll(TYPE_HIERARCHY_SELECTOR)) { + if (options.skipElement?.(el)) continue; + if (!hasTextContent(el) || !isRenderedTypeElement(el, getStyle)) continue; + const fontSize = parseFloat(getStyle(el)?.fontSize); + if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize >= 200) continue; + samples.push({ role: typeHierarchyRole(el), size: fontSize }); + } + return checkFlatTypeHierarchySamples(samples); +} + // Browser page-level checks — use document/getComputedStyle globals function checkTypography() { @@ -5222,17 +5304,10 @@ function checkTypography() { } } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { - const fs = parseFloat(getComputedStyle(el).fontSize); - if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } + for (const finding of checkFlatTypeHierarchyFromDoc(document, getComputedStyle, { + skipElement: el => el.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip, [id^="impeccable-live-"]'), + })) { + findings.push({ type: finding.id, detail: finding.snippet }); } return findings; @@ -5479,21 +5554,7 @@ function checkPageTypography(doc, win) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - // Flat type hierarchy - const sizes = new Set(); - const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div'); - for (const el of textEls) { - const fontSize = parseFloat(win.getComputedStyle(el).fontSize); - // Filter out sub-8px values (jsdom doesn't resolve relative units properly) - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(doc, el => win.getComputedStyle(el))); return findings; } diff --git a/.qoder/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.qoder/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 26ca4f390..df1334fdd 100644 --- a/.qoder/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.qoder/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -607,32 +607,6 @@ const REGEX_MATCHERS = [ ]; const REGEX_ANALYZERS = [ - // Flat type hierarchy - (content, filePath) => { - const sizes = new Set(); - const REM = 16; - let m; - const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi; - while ((m = sizeRe.exec(content)) !== null) { - const px = m[2] === 'px' ? +m[1] : +m[1] * REM; - if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10); - } - const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi; - while ((m = clampRe.exec(content)) !== null) { - sizes.add(Math.round((m[2] === 'px' ? +m[1] : +m[1] * REM) * 10) / 10); - sizes.add(Math.round((m[4] === 'px' ? +m[3] : +m[3] * REM) * 10) / 10); - } - const TW = { 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, 'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, 'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128 }; - for (const [cls, px] of Object.entries(TW)) { if (new RegExp(`\\b${cls}\\b`).test(content)) sizes.add(px); } - if (sizes.size < 3) return []; - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio >= 2.0) return []; - const lines = content.split('\n'); - let line = 1; - for (let i = 0; i < lines.length; i++) { if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) { line = i + 1; break; } } - return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)]; - }, // Monotonous spacing (regex) (content, filePath) => { const vals = []; @@ -1154,11 +1128,12 @@ const TEXT_CONTENT_ANALYZER_IDS = [ function runTextContentAnalyzers(content, filePath, options = {}) { const profile = options?.profile; if (!shouldRunPageAnalyzers(content, filePath)) return []; - // The 3 text-content analyzers are at indices 2-4 in REGEX_ANALYZERS - // (single-font's removal on 2026-07-29 shifted every index down one). + // The 3 text-content analyzers are at indices 1-3 in REGEX_ANALYZERS. + // flat-type-hierarchy left this source-only path in issue #619 because it + // needs rendered role and usage evidence. const findings = []; for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { - const analyzer = REGEX_ANALYZERS[2 + i]; + const analyzer = REGEX_ANALYZERS[1 + i]; const ruleId = TEXT_CONTENT_ANALYZER_IDS[i]; findings.push(...profileFindings(profile, { engine: 'regex', @@ -1284,7 +1259,6 @@ function detectText(content, filePath, options = {}) { // Page-level analyzers only run on full pages if (shouldRunPageAnalyzers(content, filePath)) { const analyzerIds = [ - 'flat-type-hierarchy', 'monotonous-spacing', 'em-dash-overuse', 'marketing-buzzword', diff --git a/.qoder/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.qoder/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index cc36ac9b5..7f6080b61 100644 --- a/.qoder/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.qoder/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -282,6 +282,7 @@ const STATIC_DEFAULT_STYLE = { marginLeft: '0px', position: 'static', visibility: 'visible', + contentVisibility: 'visible', opacity: '1', top: 'auto', right: 'auto', diff --git a/.qoder/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.qoder/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 51c34224b..84e6ab1f5 100644 --- a/.qoder/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.qoder/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -25,6 +25,7 @@ import { checkElementOversizedH1, checkElementQuality, checkElementRadialSpotlight, + checkFlatTypeHierarchyFromDoc, checkCreamPalette, checkHtmlPatterns, checkKickerAboveHeadingFromDoc, @@ -59,18 +60,7 @@ function checkStaticPageTypography(document, window) { for (const font of overusedFound) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div')) { - const fontSize = parseFloat(window.getComputedStyle(el).fontSize); - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(document, el => window.getComputedStyle(el))); return findings; } diff --git a/.qoder/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.qoder/skills/impeccable/scripts/detector/registry/antipatterns.mjs index 88ff84a07..945307783 100644 --- a/.qoder/skills/impeccable/scripts/detector/registry/antipatterns.mjs +++ b/.qoder/skills/impeccable/scripts/detector/registry/antipatterns.mjs @@ -34,7 +34,7 @@ const ANTIPATTERNS = [ scopes: ['type'], name: 'Flat type hierarchy', description: - 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', + 'Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.', skillSection: 'Typography', skillGuideline: 'flat type hierarchy', }, diff --git a/.qoder/skills/impeccable/scripts/detector/rules/checks.mjs b/.qoder/skills/impeccable/scripts/detector/rules/checks.mjs index 8bfb2b1df..579e1c12e 100644 --- a/.qoder/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.qoder/skills/impeccable/scripts/detector/rules/checks.mjs @@ -3911,6 +3911,88 @@ function checkElementGlow(tag, style, effectiveBg) { // ─── Section 6: Page-Level Checks ─────────────────────────────────────────── +const TYPE_HIERARCHY_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption'; +const TYPE_HIERARCHY_MIN_ROLES = 3; +const TYPE_HIERARCHY_MIN_STEP_RATIO = 1.25; + +function typeHierarchyRole(el) { + const tag = String(el?.tagName || el?.nodeName || '').toLowerCase(); + return /^h[1-6]$/.test(tag) ? tag : 'body'; +} + +function hasTextContent(el) { + return String(el?.textContent || '').trim().length > 0; +} + +function isRenderedTypeElement(el, getStyle) { + for (let current = el; current; current = current.parentElement) { + const hiddenAttr = typeof current.getAttribute === 'function' && current.getAttribute('hidden') !== null; + if (current.hidden || hiddenAttr) return false; + const style = getStyle(current); + if (!style) continue; + const display = String(style.display || '').toLowerCase(); + const visibility = String(style.visibility || '').toLowerCase(); + const contentVisibility = String(style.contentVisibility || '').toLowerCase(); + if (display === 'none' || visibility === 'hidden' || visibility === 'collapse' || contentVisibility === 'hidden') return false; + const opacity = parseFloat(style.opacity); + if (Number.isFinite(opacity) && opacity <= 0.01) return false; + } + return true; +} + +function dominantTypeRoleSize(samples) { + const counts = new Map(); + for (const sample of samples) { + counts.set(sample.size, (counts.get(sample.size) || 0) + 1); + } + const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]); + if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) return null; + return ranked[0]?.[0] ?? null; +} + +function checkFlatTypeHierarchySamples(samples) { + const byRole = new Map(); + for (const sample of samples || []) { + const role = String(sample?.role || ''); + const size = Math.round(Number(sample?.size) * 10) / 10; + if (!role || !Number.isFinite(size) || size < 8 || size >= 200) continue; + if (!byRole.has(role)) byRole.set(role, []); + byRole.get(role).push({ role, size }); + } + + const roles = [...byRole.entries()].map(([role, roleSamples]) => ({ + role, + size: dominantTypeRoleSize(roleSamples), + })).filter(item => item.size !== null); + + if (roles.length < TYPE_HIERARCHY_MIN_ROLES) return []; + + const sorted = roles.slice().sort((a, b) => a.size - b.size || a.role.localeCompare(b.role)); + let largestStep = 1; + for (let i = 1; i < sorted.length; i++) { + largestStep = Math.max(largestStep, sorted[i].size / sorted[i - 1].size); + } + if (largestStep >= TYPE_HIERARCHY_MIN_STEP_RATIO) return []; + + const roleSizes = sorted.map(item => `${item.role} ${item.size}px`).join(', '); + return [{ + id: 'flat-type-hierarchy', + snippet: `Role sizes: ${roleSizes} (largest adjacent step ${largestStep.toFixed(2)}:1; target ${TYPE_HIERARCHY_MIN_STEP_RATIO}:1)`, + }]; +} + +function checkFlatTypeHierarchyFromDoc(root, getStyle, options = {}) { + const samples = []; + for (const el of root.querySelectorAll(TYPE_HIERARCHY_SELECTOR)) { + if (options.skipElement?.(el)) continue; + if (!hasTextContent(el) || !isRenderedTypeElement(el, getStyle)) continue; + const fontSize = parseFloat(getStyle(el)?.fontSize); + if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize >= 200) continue; + samples.push({ role: typeHierarchyRole(el), size: fontSize }); + } + return checkFlatTypeHierarchySamples(samples); +} + // Browser page-level checks — use document/getComputedStyle globals function checkTypography() { @@ -3949,17 +4031,10 @@ function checkTypography() { } } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { - const fs = parseFloat(getComputedStyle(el).fontSize); - if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } + for (const finding of checkFlatTypeHierarchyFromDoc(document, getComputedStyle, { + skipElement: el => el.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip, [id^="impeccable-live-"]'), + })) { + findings.push({ type: finding.id, detail: finding.snippet }); } return findings; @@ -4206,21 +4281,7 @@ function checkPageTypography(doc, win) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - // Flat type hierarchy - const sizes = new Set(); - const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div'); - for (const el of textEls) { - const fontSize = parseFloat(win.getComputedStyle(el).fontSize); - // Filter out sub-8px values (jsdom doesn't resolve relative units properly) - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(doc, el => win.getComputedStyle(el))); return findings; } @@ -5649,6 +5710,8 @@ export { checkKickerAboveHeadingFromDoc, checkElementMotion, checkElementGlow, + checkFlatTypeHierarchySamples, + checkFlatTypeHierarchyFromDoc, checkTypography, isCardLikeDOM, checkLayout, diff --git a/.rovodev/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.rovodev/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 3f5abd0a8..e6899f569 100644 --- a/.rovodev/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.rovodev/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -159,7 +159,7 @@ const ANTIPATTERNS = [ scopes: ['type'], name: 'Flat type hierarchy', description: - 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', + 'Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.', skillSection: 'Typography', skillGuideline: 'flat type hierarchy', }, @@ -5184,6 +5184,88 @@ function checkElementGlow(tag, style, effectiveBg) { // ─── Section 6: Page-Level Checks ─────────────────────────────────────────── +const TYPE_HIERARCHY_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption'; +const TYPE_HIERARCHY_MIN_ROLES = 3; +const TYPE_HIERARCHY_MIN_STEP_RATIO = 1.25; + +function typeHierarchyRole(el) { + const tag = String(el?.tagName || el?.nodeName || '').toLowerCase(); + return /^h[1-6]$/.test(tag) ? tag : 'body'; +} + +function hasTextContent(el) { + return String(el?.textContent || '').trim().length > 0; +} + +function isRenderedTypeElement(el, getStyle) { + for (let current = el; current; current = current.parentElement) { + const hiddenAttr = typeof current.getAttribute === 'function' && current.getAttribute('hidden') !== null; + if (current.hidden || hiddenAttr) return false; + const style = getStyle(current); + if (!style) continue; + const display = String(style.display || '').toLowerCase(); + const visibility = String(style.visibility || '').toLowerCase(); + const contentVisibility = String(style.contentVisibility || '').toLowerCase(); + if (display === 'none' || visibility === 'hidden' || visibility === 'collapse' || contentVisibility === 'hidden') return false; + const opacity = parseFloat(style.opacity); + if (Number.isFinite(opacity) && opacity <= 0.01) return false; + } + return true; +} + +function dominantTypeRoleSize(samples) { + const counts = new Map(); + for (const sample of samples) { + counts.set(sample.size, (counts.get(sample.size) || 0) + 1); + } + const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]); + if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) return null; + return ranked[0]?.[0] ?? null; +} + +function checkFlatTypeHierarchySamples(samples) { + const byRole = new Map(); + for (const sample of samples || []) { + const role = String(sample?.role || ''); + const size = Math.round(Number(sample?.size) * 10) / 10; + if (!role || !Number.isFinite(size) || size < 8 || size >= 200) continue; + if (!byRole.has(role)) byRole.set(role, []); + byRole.get(role).push({ role, size }); + } + + const roles = [...byRole.entries()].map(([role, roleSamples]) => ({ + role, + size: dominantTypeRoleSize(roleSamples), + })).filter(item => item.size !== null); + + if (roles.length < TYPE_HIERARCHY_MIN_ROLES) return []; + + const sorted = roles.slice().sort((a, b) => a.size - b.size || a.role.localeCompare(b.role)); + let largestStep = 1; + for (let i = 1; i < sorted.length; i++) { + largestStep = Math.max(largestStep, sorted[i].size / sorted[i - 1].size); + } + if (largestStep >= TYPE_HIERARCHY_MIN_STEP_RATIO) return []; + + const roleSizes = sorted.map(item => `${item.role} ${item.size}px`).join(', '); + return [{ + id: 'flat-type-hierarchy', + snippet: `Role sizes: ${roleSizes} (largest adjacent step ${largestStep.toFixed(2)}:1; target ${TYPE_HIERARCHY_MIN_STEP_RATIO}:1)`, + }]; +} + +function checkFlatTypeHierarchyFromDoc(root, getStyle, options = {}) { + const samples = []; + for (const el of root.querySelectorAll(TYPE_HIERARCHY_SELECTOR)) { + if (options.skipElement?.(el)) continue; + if (!hasTextContent(el) || !isRenderedTypeElement(el, getStyle)) continue; + const fontSize = parseFloat(getStyle(el)?.fontSize); + if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize >= 200) continue; + samples.push({ role: typeHierarchyRole(el), size: fontSize }); + } + return checkFlatTypeHierarchySamples(samples); +} + // Browser page-level checks — use document/getComputedStyle globals function checkTypography() { @@ -5222,17 +5304,10 @@ function checkTypography() { } } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { - const fs = parseFloat(getComputedStyle(el).fontSize); - if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } + for (const finding of checkFlatTypeHierarchyFromDoc(document, getComputedStyle, { + skipElement: el => el.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip, [id^="impeccable-live-"]'), + })) { + findings.push({ type: finding.id, detail: finding.snippet }); } return findings; @@ -5479,21 +5554,7 @@ function checkPageTypography(doc, win) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - // Flat type hierarchy - const sizes = new Set(); - const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div'); - for (const el of textEls) { - const fontSize = parseFloat(win.getComputedStyle(el).fontSize); - // Filter out sub-8px values (jsdom doesn't resolve relative units properly) - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(doc, el => win.getComputedStyle(el))); return findings; } diff --git a/.rovodev/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.rovodev/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 26ca4f390..df1334fdd 100644 --- a/.rovodev/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.rovodev/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -607,32 +607,6 @@ const REGEX_MATCHERS = [ ]; const REGEX_ANALYZERS = [ - // Flat type hierarchy - (content, filePath) => { - const sizes = new Set(); - const REM = 16; - let m; - const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi; - while ((m = sizeRe.exec(content)) !== null) { - const px = m[2] === 'px' ? +m[1] : +m[1] * REM; - if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10); - } - const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi; - while ((m = clampRe.exec(content)) !== null) { - sizes.add(Math.round((m[2] === 'px' ? +m[1] : +m[1] * REM) * 10) / 10); - sizes.add(Math.round((m[4] === 'px' ? +m[3] : +m[3] * REM) * 10) / 10); - } - const TW = { 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, 'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, 'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128 }; - for (const [cls, px] of Object.entries(TW)) { if (new RegExp(`\\b${cls}\\b`).test(content)) sizes.add(px); } - if (sizes.size < 3) return []; - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio >= 2.0) return []; - const lines = content.split('\n'); - let line = 1; - for (let i = 0; i < lines.length; i++) { if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) { line = i + 1; break; } } - return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)]; - }, // Monotonous spacing (regex) (content, filePath) => { const vals = []; @@ -1154,11 +1128,12 @@ const TEXT_CONTENT_ANALYZER_IDS = [ function runTextContentAnalyzers(content, filePath, options = {}) { const profile = options?.profile; if (!shouldRunPageAnalyzers(content, filePath)) return []; - // The 3 text-content analyzers are at indices 2-4 in REGEX_ANALYZERS - // (single-font's removal on 2026-07-29 shifted every index down one). + // The 3 text-content analyzers are at indices 1-3 in REGEX_ANALYZERS. + // flat-type-hierarchy left this source-only path in issue #619 because it + // needs rendered role and usage evidence. const findings = []; for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { - const analyzer = REGEX_ANALYZERS[2 + i]; + const analyzer = REGEX_ANALYZERS[1 + i]; const ruleId = TEXT_CONTENT_ANALYZER_IDS[i]; findings.push(...profileFindings(profile, { engine: 'regex', @@ -1284,7 +1259,6 @@ function detectText(content, filePath, options = {}) { // Page-level analyzers only run on full pages if (shouldRunPageAnalyzers(content, filePath)) { const analyzerIds = [ - 'flat-type-hierarchy', 'monotonous-spacing', 'em-dash-overuse', 'marketing-buzzword', diff --git a/.rovodev/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.rovodev/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index cc36ac9b5..7f6080b61 100644 --- a/.rovodev/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.rovodev/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -282,6 +282,7 @@ const STATIC_DEFAULT_STYLE = { marginLeft: '0px', position: 'static', visibility: 'visible', + contentVisibility: 'visible', opacity: '1', top: 'auto', right: 'auto', diff --git a/.rovodev/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.rovodev/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 51c34224b..84e6ab1f5 100644 --- a/.rovodev/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.rovodev/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -25,6 +25,7 @@ import { checkElementOversizedH1, checkElementQuality, checkElementRadialSpotlight, + checkFlatTypeHierarchyFromDoc, checkCreamPalette, checkHtmlPatterns, checkKickerAboveHeadingFromDoc, @@ -59,18 +60,7 @@ function checkStaticPageTypography(document, window) { for (const font of overusedFound) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div')) { - const fontSize = parseFloat(window.getComputedStyle(el).fontSize); - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(document, el => window.getComputedStyle(el))); return findings; } diff --git a/.rovodev/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.rovodev/skills/impeccable/scripts/detector/registry/antipatterns.mjs index 88ff84a07..945307783 100644 --- a/.rovodev/skills/impeccable/scripts/detector/registry/antipatterns.mjs +++ b/.rovodev/skills/impeccable/scripts/detector/registry/antipatterns.mjs @@ -34,7 +34,7 @@ const ANTIPATTERNS = [ scopes: ['type'], name: 'Flat type hierarchy', description: - 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', + 'Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.', skillSection: 'Typography', skillGuideline: 'flat type hierarchy', }, diff --git a/.rovodev/skills/impeccable/scripts/detector/rules/checks.mjs b/.rovodev/skills/impeccable/scripts/detector/rules/checks.mjs index 8bfb2b1df..579e1c12e 100644 --- a/.rovodev/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.rovodev/skills/impeccable/scripts/detector/rules/checks.mjs @@ -3911,6 +3911,88 @@ function checkElementGlow(tag, style, effectiveBg) { // ─── Section 6: Page-Level Checks ─────────────────────────────────────────── +const TYPE_HIERARCHY_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption'; +const TYPE_HIERARCHY_MIN_ROLES = 3; +const TYPE_HIERARCHY_MIN_STEP_RATIO = 1.25; + +function typeHierarchyRole(el) { + const tag = String(el?.tagName || el?.nodeName || '').toLowerCase(); + return /^h[1-6]$/.test(tag) ? tag : 'body'; +} + +function hasTextContent(el) { + return String(el?.textContent || '').trim().length > 0; +} + +function isRenderedTypeElement(el, getStyle) { + for (let current = el; current; current = current.parentElement) { + const hiddenAttr = typeof current.getAttribute === 'function' && current.getAttribute('hidden') !== null; + if (current.hidden || hiddenAttr) return false; + const style = getStyle(current); + if (!style) continue; + const display = String(style.display || '').toLowerCase(); + const visibility = String(style.visibility || '').toLowerCase(); + const contentVisibility = String(style.contentVisibility || '').toLowerCase(); + if (display === 'none' || visibility === 'hidden' || visibility === 'collapse' || contentVisibility === 'hidden') return false; + const opacity = parseFloat(style.opacity); + if (Number.isFinite(opacity) && opacity <= 0.01) return false; + } + return true; +} + +function dominantTypeRoleSize(samples) { + const counts = new Map(); + for (const sample of samples) { + counts.set(sample.size, (counts.get(sample.size) || 0) + 1); + } + const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]); + if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) return null; + return ranked[0]?.[0] ?? null; +} + +function checkFlatTypeHierarchySamples(samples) { + const byRole = new Map(); + for (const sample of samples || []) { + const role = String(sample?.role || ''); + const size = Math.round(Number(sample?.size) * 10) / 10; + if (!role || !Number.isFinite(size) || size < 8 || size >= 200) continue; + if (!byRole.has(role)) byRole.set(role, []); + byRole.get(role).push({ role, size }); + } + + const roles = [...byRole.entries()].map(([role, roleSamples]) => ({ + role, + size: dominantTypeRoleSize(roleSamples), + })).filter(item => item.size !== null); + + if (roles.length < TYPE_HIERARCHY_MIN_ROLES) return []; + + const sorted = roles.slice().sort((a, b) => a.size - b.size || a.role.localeCompare(b.role)); + let largestStep = 1; + for (let i = 1; i < sorted.length; i++) { + largestStep = Math.max(largestStep, sorted[i].size / sorted[i - 1].size); + } + if (largestStep >= TYPE_HIERARCHY_MIN_STEP_RATIO) return []; + + const roleSizes = sorted.map(item => `${item.role} ${item.size}px`).join(', '); + return [{ + id: 'flat-type-hierarchy', + snippet: `Role sizes: ${roleSizes} (largest adjacent step ${largestStep.toFixed(2)}:1; target ${TYPE_HIERARCHY_MIN_STEP_RATIO}:1)`, + }]; +} + +function checkFlatTypeHierarchyFromDoc(root, getStyle, options = {}) { + const samples = []; + for (const el of root.querySelectorAll(TYPE_HIERARCHY_SELECTOR)) { + if (options.skipElement?.(el)) continue; + if (!hasTextContent(el) || !isRenderedTypeElement(el, getStyle)) continue; + const fontSize = parseFloat(getStyle(el)?.fontSize); + if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize >= 200) continue; + samples.push({ role: typeHierarchyRole(el), size: fontSize }); + } + return checkFlatTypeHierarchySamples(samples); +} + // Browser page-level checks — use document/getComputedStyle globals function checkTypography() { @@ -3949,17 +4031,10 @@ function checkTypography() { } } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { - const fs = parseFloat(getComputedStyle(el).fontSize); - if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } + for (const finding of checkFlatTypeHierarchyFromDoc(document, getComputedStyle, { + skipElement: el => el.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip, [id^="impeccable-live-"]'), + })) { + findings.push({ type: finding.id, detail: finding.snippet }); } return findings; @@ -4206,21 +4281,7 @@ function checkPageTypography(doc, win) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - // Flat type hierarchy - const sizes = new Set(); - const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div'); - for (const el of textEls) { - const fontSize = parseFloat(win.getComputedStyle(el).fontSize); - // Filter out sub-8px values (jsdom doesn't resolve relative units properly) - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(doc, el => win.getComputedStyle(el))); return findings; } @@ -5649,6 +5710,8 @@ export { checkKickerAboveHeadingFromDoc, checkElementMotion, checkElementGlow, + checkFlatTypeHierarchySamples, + checkFlatTypeHierarchyFromDoc, checkTypography, isCardLikeDOM, checkLayout, diff --git a/.trae-cn/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.trae-cn/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 3f5abd0a8..e6899f569 100644 --- a/.trae-cn/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.trae-cn/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -159,7 +159,7 @@ const ANTIPATTERNS = [ scopes: ['type'], name: 'Flat type hierarchy', description: - 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', + 'Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.', skillSection: 'Typography', skillGuideline: 'flat type hierarchy', }, @@ -5184,6 +5184,88 @@ function checkElementGlow(tag, style, effectiveBg) { // ─── Section 6: Page-Level Checks ─────────────────────────────────────────── +const TYPE_HIERARCHY_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption'; +const TYPE_HIERARCHY_MIN_ROLES = 3; +const TYPE_HIERARCHY_MIN_STEP_RATIO = 1.25; + +function typeHierarchyRole(el) { + const tag = String(el?.tagName || el?.nodeName || '').toLowerCase(); + return /^h[1-6]$/.test(tag) ? tag : 'body'; +} + +function hasTextContent(el) { + return String(el?.textContent || '').trim().length > 0; +} + +function isRenderedTypeElement(el, getStyle) { + for (let current = el; current; current = current.parentElement) { + const hiddenAttr = typeof current.getAttribute === 'function' && current.getAttribute('hidden') !== null; + if (current.hidden || hiddenAttr) return false; + const style = getStyle(current); + if (!style) continue; + const display = String(style.display || '').toLowerCase(); + const visibility = String(style.visibility || '').toLowerCase(); + const contentVisibility = String(style.contentVisibility || '').toLowerCase(); + if (display === 'none' || visibility === 'hidden' || visibility === 'collapse' || contentVisibility === 'hidden') return false; + const opacity = parseFloat(style.opacity); + if (Number.isFinite(opacity) && opacity <= 0.01) return false; + } + return true; +} + +function dominantTypeRoleSize(samples) { + const counts = new Map(); + for (const sample of samples) { + counts.set(sample.size, (counts.get(sample.size) || 0) + 1); + } + const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]); + if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) return null; + return ranked[0]?.[0] ?? null; +} + +function checkFlatTypeHierarchySamples(samples) { + const byRole = new Map(); + for (const sample of samples || []) { + const role = String(sample?.role || ''); + const size = Math.round(Number(sample?.size) * 10) / 10; + if (!role || !Number.isFinite(size) || size < 8 || size >= 200) continue; + if (!byRole.has(role)) byRole.set(role, []); + byRole.get(role).push({ role, size }); + } + + const roles = [...byRole.entries()].map(([role, roleSamples]) => ({ + role, + size: dominantTypeRoleSize(roleSamples), + })).filter(item => item.size !== null); + + if (roles.length < TYPE_HIERARCHY_MIN_ROLES) return []; + + const sorted = roles.slice().sort((a, b) => a.size - b.size || a.role.localeCompare(b.role)); + let largestStep = 1; + for (let i = 1; i < sorted.length; i++) { + largestStep = Math.max(largestStep, sorted[i].size / sorted[i - 1].size); + } + if (largestStep >= TYPE_HIERARCHY_MIN_STEP_RATIO) return []; + + const roleSizes = sorted.map(item => `${item.role} ${item.size}px`).join(', '); + return [{ + id: 'flat-type-hierarchy', + snippet: `Role sizes: ${roleSizes} (largest adjacent step ${largestStep.toFixed(2)}:1; target ${TYPE_HIERARCHY_MIN_STEP_RATIO}:1)`, + }]; +} + +function checkFlatTypeHierarchyFromDoc(root, getStyle, options = {}) { + const samples = []; + for (const el of root.querySelectorAll(TYPE_HIERARCHY_SELECTOR)) { + if (options.skipElement?.(el)) continue; + if (!hasTextContent(el) || !isRenderedTypeElement(el, getStyle)) continue; + const fontSize = parseFloat(getStyle(el)?.fontSize); + if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize >= 200) continue; + samples.push({ role: typeHierarchyRole(el), size: fontSize }); + } + return checkFlatTypeHierarchySamples(samples); +} + // Browser page-level checks — use document/getComputedStyle globals function checkTypography() { @@ -5222,17 +5304,10 @@ function checkTypography() { } } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { - const fs = parseFloat(getComputedStyle(el).fontSize); - if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } + for (const finding of checkFlatTypeHierarchyFromDoc(document, getComputedStyle, { + skipElement: el => el.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip, [id^="impeccable-live-"]'), + })) { + findings.push({ type: finding.id, detail: finding.snippet }); } return findings; @@ -5479,21 +5554,7 @@ function checkPageTypography(doc, win) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - // Flat type hierarchy - const sizes = new Set(); - const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div'); - for (const el of textEls) { - const fontSize = parseFloat(win.getComputedStyle(el).fontSize); - // Filter out sub-8px values (jsdom doesn't resolve relative units properly) - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(doc, el => win.getComputedStyle(el))); return findings; } diff --git a/.trae-cn/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.trae-cn/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 26ca4f390..df1334fdd 100644 --- a/.trae-cn/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.trae-cn/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -607,32 +607,6 @@ const REGEX_MATCHERS = [ ]; const REGEX_ANALYZERS = [ - // Flat type hierarchy - (content, filePath) => { - const sizes = new Set(); - const REM = 16; - let m; - const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi; - while ((m = sizeRe.exec(content)) !== null) { - const px = m[2] === 'px' ? +m[1] : +m[1] * REM; - if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10); - } - const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi; - while ((m = clampRe.exec(content)) !== null) { - sizes.add(Math.round((m[2] === 'px' ? +m[1] : +m[1] * REM) * 10) / 10); - sizes.add(Math.round((m[4] === 'px' ? +m[3] : +m[3] * REM) * 10) / 10); - } - const TW = { 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, 'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, 'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128 }; - for (const [cls, px] of Object.entries(TW)) { if (new RegExp(`\\b${cls}\\b`).test(content)) sizes.add(px); } - if (sizes.size < 3) return []; - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio >= 2.0) return []; - const lines = content.split('\n'); - let line = 1; - for (let i = 0; i < lines.length; i++) { if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) { line = i + 1; break; } } - return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)]; - }, // Monotonous spacing (regex) (content, filePath) => { const vals = []; @@ -1154,11 +1128,12 @@ const TEXT_CONTENT_ANALYZER_IDS = [ function runTextContentAnalyzers(content, filePath, options = {}) { const profile = options?.profile; if (!shouldRunPageAnalyzers(content, filePath)) return []; - // The 3 text-content analyzers are at indices 2-4 in REGEX_ANALYZERS - // (single-font's removal on 2026-07-29 shifted every index down one). + // The 3 text-content analyzers are at indices 1-3 in REGEX_ANALYZERS. + // flat-type-hierarchy left this source-only path in issue #619 because it + // needs rendered role and usage evidence. const findings = []; for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { - const analyzer = REGEX_ANALYZERS[2 + i]; + const analyzer = REGEX_ANALYZERS[1 + i]; const ruleId = TEXT_CONTENT_ANALYZER_IDS[i]; findings.push(...profileFindings(profile, { engine: 'regex', @@ -1284,7 +1259,6 @@ function detectText(content, filePath, options = {}) { // Page-level analyzers only run on full pages if (shouldRunPageAnalyzers(content, filePath)) { const analyzerIds = [ - 'flat-type-hierarchy', 'monotonous-spacing', 'em-dash-overuse', 'marketing-buzzword', diff --git a/.trae-cn/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.trae-cn/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index cc36ac9b5..7f6080b61 100644 --- a/.trae-cn/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.trae-cn/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -282,6 +282,7 @@ const STATIC_DEFAULT_STYLE = { marginLeft: '0px', position: 'static', visibility: 'visible', + contentVisibility: 'visible', opacity: '1', top: 'auto', right: 'auto', diff --git a/.trae-cn/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.trae-cn/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 51c34224b..84e6ab1f5 100644 --- a/.trae-cn/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.trae-cn/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -25,6 +25,7 @@ import { checkElementOversizedH1, checkElementQuality, checkElementRadialSpotlight, + checkFlatTypeHierarchyFromDoc, checkCreamPalette, checkHtmlPatterns, checkKickerAboveHeadingFromDoc, @@ -59,18 +60,7 @@ function checkStaticPageTypography(document, window) { for (const font of overusedFound) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div')) { - const fontSize = parseFloat(window.getComputedStyle(el).fontSize); - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(document, el => window.getComputedStyle(el))); return findings; } diff --git a/.trae-cn/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.trae-cn/skills/impeccable/scripts/detector/registry/antipatterns.mjs index 88ff84a07..945307783 100644 --- a/.trae-cn/skills/impeccable/scripts/detector/registry/antipatterns.mjs +++ b/.trae-cn/skills/impeccable/scripts/detector/registry/antipatterns.mjs @@ -34,7 +34,7 @@ const ANTIPATTERNS = [ scopes: ['type'], name: 'Flat type hierarchy', description: - 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', + 'Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.', skillSection: 'Typography', skillGuideline: 'flat type hierarchy', }, diff --git a/.trae-cn/skills/impeccable/scripts/detector/rules/checks.mjs b/.trae-cn/skills/impeccable/scripts/detector/rules/checks.mjs index 8bfb2b1df..579e1c12e 100644 --- a/.trae-cn/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.trae-cn/skills/impeccable/scripts/detector/rules/checks.mjs @@ -3911,6 +3911,88 @@ function checkElementGlow(tag, style, effectiveBg) { // ─── Section 6: Page-Level Checks ─────────────────────────────────────────── +const TYPE_HIERARCHY_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption'; +const TYPE_HIERARCHY_MIN_ROLES = 3; +const TYPE_HIERARCHY_MIN_STEP_RATIO = 1.25; + +function typeHierarchyRole(el) { + const tag = String(el?.tagName || el?.nodeName || '').toLowerCase(); + return /^h[1-6]$/.test(tag) ? tag : 'body'; +} + +function hasTextContent(el) { + return String(el?.textContent || '').trim().length > 0; +} + +function isRenderedTypeElement(el, getStyle) { + for (let current = el; current; current = current.parentElement) { + const hiddenAttr = typeof current.getAttribute === 'function' && current.getAttribute('hidden') !== null; + if (current.hidden || hiddenAttr) return false; + const style = getStyle(current); + if (!style) continue; + const display = String(style.display || '').toLowerCase(); + const visibility = String(style.visibility || '').toLowerCase(); + const contentVisibility = String(style.contentVisibility || '').toLowerCase(); + if (display === 'none' || visibility === 'hidden' || visibility === 'collapse' || contentVisibility === 'hidden') return false; + const opacity = parseFloat(style.opacity); + if (Number.isFinite(opacity) && opacity <= 0.01) return false; + } + return true; +} + +function dominantTypeRoleSize(samples) { + const counts = new Map(); + for (const sample of samples) { + counts.set(sample.size, (counts.get(sample.size) || 0) + 1); + } + const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]); + if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) return null; + return ranked[0]?.[0] ?? null; +} + +function checkFlatTypeHierarchySamples(samples) { + const byRole = new Map(); + for (const sample of samples || []) { + const role = String(sample?.role || ''); + const size = Math.round(Number(sample?.size) * 10) / 10; + if (!role || !Number.isFinite(size) || size < 8 || size >= 200) continue; + if (!byRole.has(role)) byRole.set(role, []); + byRole.get(role).push({ role, size }); + } + + const roles = [...byRole.entries()].map(([role, roleSamples]) => ({ + role, + size: dominantTypeRoleSize(roleSamples), + })).filter(item => item.size !== null); + + if (roles.length < TYPE_HIERARCHY_MIN_ROLES) return []; + + const sorted = roles.slice().sort((a, b) => a.size - b.size || a.role.localeCompare(b.role)); + let largestStep = 1; + for (let i = 1; i < sorted.length; i++) { + largestStep = Math.max(largestStep, sorted[i].size / sorted[i - 1].size); + } + if (largestStep >= TYPE_HIERARCHY_MIN_STEP_RATIO) return []; + + const roleSizes = sorted.map(item => `${item.role} ${item.size}px`).join(', '); + return [{ + id: 'flat-type-hierarchy', + snippet: `Role sizes: ${roleSizes} (largest adjacent step ${largestStep.toFixed(2)}:1; target ${TYPE_HIERARCHY_MIN_STEP_RATIO}:1)`, + }]; +} + +function checkFlatTypeHierarchyFromDoc(root, getStyle, options = {}) { + const samples = []; + for (const el of root.querySelectorAll(TYPE_HIERARCHY_SELECTOR)) { + if (options.skipElement?.(el)) continue; + if (!hasTextContent(el) || !isRenderedTypeElement(el, getStyle)) continue; + const fontSize = parseFloat(getStyle(el)?.fontSize); + if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize >= 200) continue; + samples.push({ role: typeHierarchyRole(el), size: fontSize }); + } + return checkFlatTypeHierarchySamples(samples); +} + // Browser page-level checks — use document/getComputedStyle globals function checkTypography() { @@ -3949,17 +4031,10 @@ function checkTypography() { } } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { - const fs = parseFloat(getComputedStyle(el).fontSize); - if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } + for (const finding of checkFlatTypeHierarchyFromDoc(document, getComputedStyle, { + skipElement: el => el.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip, [id^="impeccable-live-"]'), + })) { + findings.push({ type: finding.id, detail: finding.snippet }); } return findings; @@ -4206,21 +4281,7 @@ function checkPageTypography(doc, win) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - // Flat type hierarchy - const sizes = new Set(); - const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div'); - for (const el of textEls) { - const fontSize = parseFloat(win.getComputedStyle(el).fontSize); - // Filter out sub-8px values (jsdom doesn't resolve relative units properly) - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(doc, el => win.getComputedStyle(el))); return findings; } @@ -5649,6 +5710,8 @@ export { checkKickerAboveHeadingFromDoc, checkElementMotion, checkElementGlow, + checkFlatTypeHierarchySamples, + checkFlatTypeHierarchyFromDoc, checkTypography, isCardLikeDOM, checkLayout, diff --git a/.trae/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.trae/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 3f5abd0a8..e6899f569 100644 --- a/.trae/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.trae/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -159,7 +159,7 @@ const ANTIPATTERNS = [ scopes: ['type'], name: 'Flat type hierarchy', description: - 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', + 'Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.', skillSection: 'Typography', skillGuideline: 'flat type hierarchy', }, @@ -5184,6 +5184,88 @@ function checkElementGlow(tag, style, effectiveBg) { // ─── Section 6: Page-Level Checks ─────────────────────────────────────────── +const TYPE_HIERARCHY_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption'; +const TYPE_HIERARCHY_MIN_ROLES = 3; +const TYPE_HIERARCHY_MIN_STEP_RATIO = 1.25; + +function typeHierarchyRole(el) { + const tag = String(el?.tagName || el?.nodeName || '').toLowerCase(); + return /^h[1-6]$/.test(tag) ? tag : 'body'; +} + +function hasTextContent(el) { + return String(el?.textContent || '').trim().length > 0; +} + +function isRenderedTypeElement(el, getStyle) { + for (let current = el; current; current = current.parentElement) { + const hiddenAttr = typeof current.getAttribute === 'function' && current.getAttribute('hidden') !== null; + if (current.hidden || hiddenAttr) return false; + const style = getStyle(current); + if (!style) continue; + const display = String(style.display || '').toLowerCase(); + const visibility = String(style.visibility || '').toLowerCase(); + const contentVisibility = String(style.contentVisibility || '').toLowerCase(); + if (display === 'none' || visibility === 'hidden' || visibility === 'collapse' || contentVisibility === 'hidden') return false; + const opacity = parseFloat(style.opacity); + if (Number.isFinite(opacity) && opacity <= 0.01) return false; + } + return true; +} + +function dominantTypeRoleSize(samples) { + const counts = new Map(); + for (const sample of samples) { + counts.set(sample.size, (counts.get(sample.size) || 0) + 1); + } + const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]); + if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) return null; + return ranked[0]?.[0] ?? null; +} + +function checkFlatTypeHierarchySamples(samples) { + const byRole = new Map(); + for (const sample of samples || []) { + const role = String(sample?.role || ''); + const size = Math.round(Number(sample?.size) * 10) / 10; + if (!role || !Number.isFinite(size) || size < 8 || size >= 200) continue; + if (!byRole.has(role)) byRole.set(role, []); + byRole.get(role).push({ role, size }); + } + + const roles = [...byRole.entries()].map(([role, roleSamples]) => ({ + role, + size: dominantTypeRoleSize(roleSamples), + })).filter(item => item.size !== null); + + if (roles.length < TYPE_HIERARCHY_MIN_ROLES) return []; + + const sorted = roles.slice().sort((a, b) => a.size - b.size || a.role.localeCompare(b.role)); + let largestStep = 1; + for (let i = 1; i < sorted.length; i++) { + largestStep = Math.max(largestStep, sorted[i].size / sorted[i - 1].size); + } + if (largestStep >= TYPE_HIERARCHY_MIN_STEP_RATIO) return []; + + const roleSizes = sorted.map(item => `${item.role} ${item.size}px`).join(', '); + return [{ + id: 'flat-type-hierarchy', + snippet: `Role sizes: ${roleSizes} (largest adjacent step ${largestStep.toFixed(2)}:1; target ${TYPE_HIERARCHY_MIN_STEP_RATIO}:1)`, + }]; +} + +function checkFlatTypeHierarchyFromDoc(root, getStyle, options = {}) { + const samples = []; + for (const el of root.querySelectorAll(TYPE_HIERARCHY_SELECTOR)) { + if (options.skipElement?.(el)) continue; + if (!hasTextContent(el) || !isRenderedTypeElement(el, getStyle)) continue; + const fontSize = parseFloat(getStyle(el)?.fontSize); + if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize >= 200) continue; + samples.push({ role: typeHierarchyRole(el), size: fontSize }); + } + return checkFlatTypeHierarchySamples(samples); +} + // Browser page-level checks — use document/getComputedStyle globals function checkTypography() { @@ -5222,17 +5304,10 @@ function checkTypography() { } } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { - const fs = parseFloat(getComputedStyle(el).fontSize); - if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } + for (const finding of checkFlatTypeHierarchyFromDoc(document, getComputedStyle, { + skipElement: el => el.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip, [id^="impeccable-live-"]'), + })) { + findings.push({ type: finding.id, detail: finding.snippet }); } return findings; @@ -5479,21 +5554,7 @@ function checkPageTypography(doc, win) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - // Flat type hierarchy - const sizes = new Set(); - const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div'); - for (const el of textEls) { - const fontSize = parseFloat(win.getComputedStyle(el).fontSize); - // Filter out sub-8px values (jsdom doesn't resolve relative units properly) - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(doc, el => win.getComputedStyle(el))); return findings; } diff --git a/.trae/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.trae/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 26ca4f390..df1334fdd 100644 --- a/.trae/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.trae/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -607,32 +607,6 @@ const REGEX_MATCHERS = [ ]; const REGEX_ANALYZERS = [ - // Flat type hierarchy - (content, filePath) => { - const sizes = new Set(); - const REM = 16; - let m; - const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi; - while ((m = sizeRe.exec(content)) !== null) { - const px = m[2] === 'px' ? +m[1] : +m[1] * REM; - if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10); - } - const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi; - while ((m = clampRe.exec(content)) !== null) { - sizes.add(Math.round((m[2] === 'px' ? +m[1] : +m[1] * REM) * 10) / 10); - sizes.add(Math.round((m[4] === 'px' ? +m[3] : +m[3] * REM) * 10) / 10); - } - const TW = { 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, 'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, 'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128 }; - for (const [cls, px] of Object.entries(TW)) { if (new RegExp(`\\b${cls}\\b`).test(content)) sizes.add(px); } - if (sizes.size < 3) return []; - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio >= 2.0) return []; - const lines = content.split('\n'); - let line = 1; - for (let i = 0; i < lines.length; i++) { if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) { line = i + 1; break; } } - return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)]; - }, // Monotonous spacing (regex) (content, filePath) => { const vals = []; @@ -1154,11 +1128,12 @@ const TEXT_CONTENT_ANALYZER_IDS = [ function runTextContentAnalyzers(content, filePath, options = {}) { const profile = options?.profile; if (!shouldRunPageAnalyzers(content, filePath)) return []; - // The 3 text-content analyzers are at indices 2-4 in REGEX_ANALYZERS - // (single-font's removal on 2026-07-29 shifted every index down one). + // The 3 text-content analyzers are at indices 1-3 in REGEX_ANALYZERS. + // flat-type-hierarchy left this source-only path in issue #619 because it + // needs rendered role and usage evidence. const findings = []; for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { - const analyzer = REGEX_ANALYZERS[2 + i]; + const analyzer = REGEX_ANALYZERS[1 + i]; const ruleId = TEXT_CONTENT_ANALYZER_IDS[i]; findings.push(...profileFindings(profile, { engine: 'regex', @@ -1284,7 +1259,6 @@ function detectText(content, filePath, options = {}) { // Page-level analyzers only run on full pages if (shouldRunPageAnalyzers(content, filePath)) { const analyzerIds = [ - 'flat-type-hierarchy', 'monotonous-spacing', 'em-dash-overuse', 'marketing-buzzword', diff --git a/.trae/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.trae/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index cc36ac9b5..7f6080b61 100644 --- a/.trae/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.trae/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -282,6 +282,7 @@ const STATIC_DEFAULT_STYLE = { marginLeft: '0px', position: 'static', visibility: 'visible', + contentVisibility: 'visible', opacity: '1', top: 'auto', right: 'auto', diff --git a/.trae/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.trae/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 51c34224b..84e6ab1f5 100644 --- a/.trae/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.trae/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -25,6 +25,7 @@ import { checkElementOversizedH1, checkElementQuality, checkElementRadialSpotlight, + checkFlatTypeHierarchyFromDoc, checkCreamPalette, checkHtmlPatterns, checkKickerAboveHeadingFromDoc, @@ -59,18 +60,7 @@ function checkStaticPageTypography(document, window) { for (const font of overusedFound) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div')) { - const fontSize = parseFloat(window.getComputedStyle(el).fontSize); - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(document, el => window.getComputedStyle(el))); return findings; } diff --git a/.trae/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.trae/skills/impeccable/scripts/detector/registry/antipatterns.mjs index 88ff84a07..945307783 100644 --- a/.trae/skills/impeccable/scripts/detector/registry/antipatterns.mjs +++ b/.trae/skills/impeccable/scripts/detector/registry/antipatterns.mjs @@ -34,7 +34,7 @@ const ANTIPATTERNS = [ scopes: ['type'], name: 'Flat type hierarchy', description: - 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', + 'Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.', skillSection: 'Typography', skillGuideline: 'flat type hierarchy', }, diff --git a/.trae/skills/impeccable/scripts/detector/rules/checks.mjs b/.trae/skills/impeccable/scripts/detector/rules/checks.mjs index 8bfb2b1df..579e1c12e 100644 --- a/.trae/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.trae/skills/impeccable/scripts/detector/rules/checks.mjs @@ -3911,6 +3911,88 @@ function checkElementGlow(tag, style, effectiveBg) { // ─── Section 6: Page-Level Checks ─────────────────────────────────────────── +const TYPE_HIERARCHY_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption'; +const TYPE_HIERARCHY_MIN_ROLES = 3; +const TYPE_HIERARCHY_MIN_STEP_RATIO = 1.25; + +function typeHierarchyRole(el) { + const tag = String(el?.tagName || el?.nodeName || '').toLowerCase(); + return /^h[1-6]$/.test(tag) ? tag : 'body'; +} + +function hasTextContent(el) { + return String(el?.textContent || '').trim().length > 0; +} + +function isRenderedTypeElement(el, getStyle) { + for (let current = el; current; current = current.parentElement) { + const hiddenAttr = typeof current.getAttribute === 'function' && current.getAttribute('hidden') !== null; + if (current.hidden || hiddenAttr) return false; + const style = getStyle(current); + if (!style) continue; + const display = String(style.display || '').toLowerCase(); + const visibility = String(style.visibility || '').toLowerCase(); + const contentVisibility = String(style.contentVisibility || '').toLowerCase(); + if (display === 'none' || visibility === 'hidden' || visibility === 'collapse' || contentVisibility === 'hidden') return false; + const opacity = parseFloat(style.opacity); + if (Number.isFinite(opacity) && opacity <= 0.01) return false; + } + return true; +} + +function dominantTypeRoleSize(samples) { + const counts = new Map(); + for (const sample of samples) { + counts.set(sample.size, (counts.get(sample.size) || 0) + 1); + } + const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]); + if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) return null; + return ranked[0]?.[0] ?? null; +} + +function checkFlatTypeHierarchySamples(samples) { + const byRole = new Map(); + for (const sample of samples || []) { + const role = String(sample?.role || ''); + const size = Math.round(Number(sample?.size) * 10) / 10; + if (!role || !Number.isFinite(size) || size < 8 || size >= 200) continue; + if (!byRole.has(role)) byRole.set(role, []); + byRole.get(role).push({ role, size }); + } + + const roles = [...byRole.entries()].map(([role, roleSamples]) => ({ + role, + size: dominantTypeRoleSize(roleSamples), + })).filter(item => item.size !== null); + + if (roles.length < TYPE_HIERARCHY_MIN_ROLES) return []; + + const sorted = roles.slice().sort((a, b) => a.size - b.size || a.role.localeCompare(b.role)); + let largestStep = 1; + for (let i = 1; i < sorted.length; i++) { + largestStep = Math.max(largestStep, sorted[i].size / sorted[i - 1].size); + } + if (largestStep >= TYPE_HIERARCHY_MIN_STEP_RATIO) return []; + + const roleSizes = sorted.map(item => `${item.role} ${item.size}px`).join(', '); + return [{ + id: 'flat-type-hierarchy', + snippet: `Role sizes: ${roleSizes} (largest adjacent step ${largestStep.toFixed(2)}:1; target ${TYPE_HIERARCHY_MIN_STEP_RATIO}:1)`, + }]; +} + +function checkFlatTypeHierarchyFromDoc(root, getStyle, options = {}) { + const samples = []; + for (const el of root.querySelectorAll(TYPE_HIERARCHY_SELECTOR)) { + if (options.skipElement?.(el)) continue; + if (!hasTextContent(el) || !isRenderedTypeElement(el, getStyle)) continue; + const fontSize = parseFloat(getStyle(el)?.fontSize); + if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize >= 200) continue; + samples.push({ role: typeHierarchyRole(el), size: fontSize }); + } + return checkFlatTypeHierarchySamples(samples); +} + // Browser page-level checks — use document/getComputedStyle globals function checkTypography() { @@ -3949,17 +4031,10 @@ function checkTypography() { } } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { - const fs = parseFloat(getComputedStyle(el).fontSize); - if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } + for (const finding of checkFlatTypeHierarchyFromDoc(document, getComputedStyle, { + skipElement: el => el.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip, [id^="impeccable-live-"]'), + })) { + findings.push({ type: finding.id, detail: finding.snippet }); } return findings; @@ -4206,21 +4281,7 @@ function checkPageTypography(doc, win) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - // Flat type hierarchy - const sizes = new Set(); - const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div'); - for (const el of textEls) { - const fontSize = parseFloat(win.getComputedStyle(el).fontSize); - // Filter out sub-8px values (jsdom doesn't resolve relative units properly) - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(doc, el => win.getComputedStyle(el))); return findings; } @@ -5649,6 +5710,8 @@ export { checkKickerAboveHeadingFromDoc, checkElementMotion, checkElementGlow, + checkFlatTypeHierarchySamples, + checkFlatTypeHierarchyFromDoc, checkTypography, isCardLikeDOM, checkLayout, diff --git a/.vibe/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.vibe/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 3f5abd0a8..e6899f569 100644 --- a/.vibe/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.vibe/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -159,7 +159,7 @@ const ANTIPATTERNS = [ scopes: ['type'], name: 'Flat type hierarchy', description: - 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', + 'Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.', skillSection: 'Typography', skillGuideline: 'flat type hierarchy', }, @@ -5184,6 +5184,88 @@ function checkElementGlow(tag, style, effectiveBg) { // ─── Section 6: Page-Level Checks ─────────────────────────────────────────── +const TYPE_HIERARCHY_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption'; +const TYPE_HIERARCHY_MIN_ROLES = 3; +const TYPE_HIERARCHY_MIN_STEP_RATIO = 1.25; + +function typeHierarchyRole(el) { + const tag = String(el?.tagName || el?.nodeName || '').toLowerCase(); + return /^h[1-6]$/.test(tag) ? tag : 'body'; +} + +function hasTextContent(el) { + return String(el?.textContent || '').trim().length > 0; +} + +function isRenderedTypeElement(el, getStyle) { + for (let current = el; current; current = current.parentElement) { + const hiddenAttr = typeof current.getAttribute === 'function' && current.getAttribute('hidden') !== null; + if (current.hidden || hiddenAttr) return false; + const style = getStyle(current); + if (!style) continue; + const display = String(style.display || '').toLowerCase(); + const visibility = String(style.visibility || '').toLowerCase(); + const contentVisibility = String(style.contentVisibility || '').toLowerCase(); + if (display === 'none' || visibility === 'hidden' || visibility === 'collapse' || contentVisibility === 'hidden') return false; + const opacity = parseFloat(style.opacity); + if (Number.isFinite(opacity) && opacity <= 0.01) return false; + } + return true; +} + +function dominantTypeRoleSize(samples) { + const counts = new Map(); + for (const sample of samples) { + counts.set(sample.size, (counts.get(sample.size) || 0) + 1); + } + const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]); + if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) return null; + return ranked[0]?.[0] ?? null; +} + +function checkFlatTypeHierarchySamples(samples) { + const byRole = new Map(); + for (const sample of samples || []) { + const role = String(sample?.role || ''); + const size = Math.round(Number(sample?.size) * 10) / 10; + if (!role || !Number.isFinite(size) || size < 8 || size >= 200) continue; + if (!byRole.has(role)) byRole.set(role, []); + byRole.get(role).push({ role, size }); + } + + const roles = [...byRole.entries()].map(([role, roleSamples]) => ({ + role, + size: dominantTypeRoleSize(roleSamples), + })).filter(item => item.size !== null); + + if (roles.length < TYPE_HIERARCHY_MIN_ROLES) return []; + + const sorted = roles.slice().sort((a, b) => a.size - b.size || a.role.localeCompare(b.role)); + let largestStep = 1; + for (let i = 1; i < sorted.length; i++) { + largestStep = Math.max(largestStep, sorted[i].size / sorted[i - 1].size); + } + if (largestStep >= TYPE_HIERARCHY_MIN_STEP_RATIO) return []; + + const roleSizes = sorted.map(item => `${item.role} ${item.size}px`).join(', '); + return [{ + id: 'flat-type-hierarchy', + snippet: `Role sizes: ${roleSizes} (largest adjacent step ${largestStep.toFixed(2)}:1; target ${TYPE_HIERARCHY_MIN_STEP_RATIO}:1)`, + }]; +} + +function checkFlatTypeHierarchyFromDoc(root, getStyle, options = {}) { + const samples = []; + for (const el of root.querySelectorAll(TYPE_HIERARCHY_SELECTOR)) { + if (options.skipElement?.(el)) continue; + if (!hasTextContent(el) || !isRenderedTypeElement(el, getStyle)) continue; + const fontSize = parseFloat(getStyle(el)?.fontSize); + if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize >= 200) continue; + samples.push({ role: typeHierarchyRole(el), size: fontSize }); + } + return checkFlatTypeHierarchySamples(samples); +} + // Browser page-level checks — use document/getComputedStyle globals function checkTypography() { @@ -5222,17 +5304,10 @@ function checkTypography() { } } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { - const fs = parseFloat(getComputedStyle(el).fontSize); - if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } + for (const finding of checkFlatTypeHierarchyFromDoc(document, getComputedStyle, { + skipElement: el => el.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip, [id^="impeccable-live-"]'), + })) { + findings.push({ type: finding.id, detail: finding.snippet }); } return findings; @@ -5479,21 +5554,7 @@ function checkPageTypography(doc, win) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - // Flat type hierarchy - const sizes = new Set(); - const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div'); - for (const el of textEls) { - const fontSize = parseFloat(win.getComputedStyle(el).fontSize); - // Filter out sub-8px values (jsdom doesn't resolve relative units properly) - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(doc, el => win.getComputedStyle(el))); return findings; } diff --git a/.vibe/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.vibe/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 26ca4f390..df1334fdd 100644 --- a/.vibe/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.vibe/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -607,32 +607,6 @@ const REGEX_MATCHERS = [ ]; const REGEX_ANALYZERS = [ - // Flat type hierarchy - (content, filePath) => { - const sizes = new Set(); - const REM = 16; - let m; - const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi; - while ((m = sizeRe.exec(content)) !== null) { - const px = m[2] === 'px' ? +m[1] : +m[1] * REM; - if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10); - } - const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi; - while ((m = clampRe.exec(content)) !== null) { - sizes.add(Math.round((m[2] === 'px' ? +m[1] : +m[1] * REM) * 10) / 10); - sizes.add(Math.round((m[4] === 'px' ? +m[3] : +m[3] * REM) * 10) / 10); - } - const TW = { 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, 'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, 'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128 }; - for (const [cls, px] of Object.entries(TW)) { if (new RegExp(`\\b${cls}\\b`).test(content)) sizes.add(px); } - if (sizes.size < 3) return []; - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio >= 2.0) return []; - const lines = content.split('\n'); - let line = 1; - for (let i = 0; i < lines.length; i++) { if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) { line = i + 1; break; } } - return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)]; - }, // Monotonous spacing (regex) (content, filePath) => { const vals = []; @@ -1154,11 +1128,12 @@ const TEXT_CONTENT_ANALYZER_IDS = [ function runTextContentAnalyzers(content, filePath, options = {}) { const profile = options?.profile; if (!shouldRunPageAnalyzers(content, filePath)) return []; - // The 3 text-content analyzers are at indices 2-4 in REGEX_ANALYZERS - // (single-font's removal on 2026-07-29 shifted every index down one). + // The 3 text-content analyzers are at indices 1-3 in REGEX_ANALYZERS. + // flat-type-hierarchy left this source-only path in issue #619 because it + // needs rendered role and usage evidence. const findings = []; for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { - const analyzer = REGEX_ANALYZERS[2 + i]; + const analyzer = REGEX_ANALYZERS[1 + i]; const ruleId = TEXT_CONTENT_ANALYZER_IDS[i]; findings.push(...profileFindings(profile, { engine: 'regex', @@ -1284,7 +1259,6 @@ function detectText(content, filePath, options = {}) { // Page-level analyzers only run on full pages if (shouldRunPageAnalyzers(content, filePath)) { const analyzerIds = [ - 'flat-type-hierarchy', 'monotonous-spacing', 'em-dash-overuse', 'marketing-buzzword', diff --git a/.vibe/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.vibe/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index cc36ac9b5..7f6080b61 100644 --- a/.vibe/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.vibe/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -282,6 +282,7 @@ const STATIC_DEFAULT_STYLE = { marginLeft: '0px', position: 'static', visibility: 'visible', + contentVisibility: 'visible', opacity: '1', top: 'auto', right: 'auto', diff --git a/.vibe/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.vibe/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 51c34224b..84e6ab1f5 100644 --- a/.vibe/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.vibe/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -25,6 +25,7 @@ import { checkElementOversizedH1, checkElementQuality, checkElementRadialSpotlight, + checkFlatTypeHierarchyFromDoc, checkCreamPalette, checkHtmlPatterns, checkKickerAboveHeadingFromDoc, @@ -59,18 +60,7 @@ function checkStaticPageTypography(document, window) { for (const font of overusedFound) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div')) { - const fontSize = parseFloat(window.getComputedStyle(el).fontSize); - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(document, el => window.getComputedStyle(el))); return findings; } diff --git a/.vibe/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.vibe/skills/impeccable/scripts/detector/registry/antipatterns.mjs index 88ff84a07..945307783 100644 --- a/.vibe/skills/impeccable/scripts/detector/registry/antipatterns.mjs +++ b/.vibe/skills/impeccable/scripts/detector/registry/antipatterns.mjs @@ -34,7 +34,7 @@ const ANTIPATTERNS = [ scopes: ['type'], name: 'Flat type hierarchy', description: - 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', + 'Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.', skillSection: 'Typography', skillGuideline: 'flat type hierarchy', }, diff --git a/.vibe/skills/impeccable/scripts/detector/rules/checks.mjs b/.vibe/skills/impeccable/scripts/detector/rules/checks.mjs index 8bfb2b1df..579e1c12e 100644 --- a/.vibe/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.vibe/skills/impeccable/scripts/detector/rules/checks.mjs @@ -3911,6 +3911,88 @@ function checkElementGlow(tag, style, effectiveBg) { // ─── Section 6: Page-Level Checks ─────────────────────────────────────────── +const TYPE_HIERARCHY_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption'; +const TYPE_HIERARCHY_MIN_ROLES = 3; +const TYPE_HIERARCHY_MIN_STEP_RATIO = 1.25; + +function typeHierarchyRole(el) { + const tag = String(el?.tagName || el?.nodeName || '').toLowerCase(); + return /^h[1-6]$/.test(tag) ? tag : 'body'; +} + +function hasTextContent(el) { + return String(el?.textContent || '').trim().length > 0; +} + +function isRenderedTypeElement(el, getStyle) { + for (let current = el; current; current = current.parentElement) { + const hiddenAttr = typeof current.getAttribute === 'function' && current.getAttribute('hidden') !== null; + if (current.hidden || hiddenAttr) return false; + const style = getStyle(current); + if (!style) continue; + const display = String(style.display || '').toLowerCase(); + const visibility = String(style.visibility || '').toLowerCase(); + const contentVisibility = String(style.contentVisibility || '').toLowerCase(); + if (display === 'none' || visibility === 'hidden' || visibility === 'collapse' || contentVisibility === 'hidden') return false; + const opacity = parseFloat(style.opacity); + if (Number.isFinite(opacity) && opacity <= 0.01) return false; + } + return true; +} + +function dominantTypeRoleSize(samples) { + const counts = new Map(); + for (const sample of samples) { + counts.set(sample.size, (counts.get(sample.size) || 0) + 1); + } + const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]); + if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) return null; + return ranked[0]?.[0] ?? null; +} + +function checkFlatTypeHierarchySamples(samples) { + const byRole = new Map(); + for (const sample of samples || []) { + const role = String(sample?.role || ''); + const size = Math.round(Number(sample?.size) * 10) / 10; + if (!role || !Number.isFinite(size) || size < 8 || size >= 200) continue; + if (!byRole.has(role)) byRole.set(role, []); + byRole.get(role).push({ role, size }); + } + + const roles = [...byRole.entries()].map(([role, roleSamples]) => ({ + role, + size: dominantTypeRoleSize(roleSamples), + })).filter(item => item.size !== null); + + if (roles.length < TYPE_HIERARCHY_MIN_ROLES) return []; + + const sorted = roles.slice().sort((a, b) => a.size - b.size || a.role.localeCompare(b.role)); + let largestStep = 1; + for (let i = 1; i < sorted.length; i++) { + largestStep = Math.max(largestStep, sorted[i].size / sorted[i - 1].size); + } + if (largestStep >= TYPE_HIERARCHY_MIN_STEP_RATIO) return []; + + const roleSizes = sorted.map(item => `${item.role} ${item.size}px`).join(', '); + return [{ + id: 'flat-type-hierarchy', + snippet: `Role sizes: ${roleSizes} (largest adjacent step ${largestStep.toFixed(2)}:1; target ${TYPE_HIERARCHY_MIN_STEP_RATIO}:1)`, + }]; +} + +function checkFlatTypeHierarchyFromDoc(root, getStyle, options = {}) { + const samples = []; + for (const el of root.querySelectorAll(TYPE_HIERARCHY_SELECTOR)) { + if (options.skipElement?.(el)) continue; + if (!hasTextContent(el) || !isRenderedTypeElement(el, getStyle)) continue; + const fontSize = parseFloat(getStyle(el)?.fontSize); + if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize >= 200) continue; + samples.push({ role: typeHierarchyRole(el), size: fontSize }); + } + return checkFlatTypeHierarchySamples(samples); +} + // Browser page-level checks — use document/getComputedStyle globals function checkTypography() { @@ -3949,17 +4031,10 @@ function checkTypography() { } } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { - const fs = parseFloat(getComputedStyle(el).fontSize); - if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } + for (const finding of checkFlatTypeHierarchyFromDoc(document, getComputedStyle, { + skipElement: el => el.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip, [id^="impeccable-live-"]'), + })) { + findings.push({ type: finding.id, detail: finding.snippet }); } return findings; @@ -4206,21 +4281,7 @@ function checkPageTypography(doc, win) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - // Flat type hierarchy - const sizes = new Set(); - const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div'); - for (const el of textEls) { - const fontSize = parseFloat(win.getComputedStyle(el).fontSize); - // Filter out sub-8px values (jsdom doesn't resolve relative units properly) - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(doc, el => win.getComputedStyle(el))); return findings; } @@ -5649,6 +5710,8 @@ export { checkKickerAboveHeadingFromDoc, checkElementMotion, checkElementGlow, + checkFlatTypeHierarchySamples, + checkFlatTypeHierarchyFromDoc, checkTypography, isCardLikeDOM, checkLayout, diff --git a/plugin/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/plugin/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 3f5abd0a8..e6899f569 100644 --- a/plugin/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/plugin/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -159,7 +159,7 @@ const ANTIPATTERNS = [ scopes: ['type'], name: 'Flat type hierarchy', description: - 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', + 'Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.', skillSection: 'Typography', skillGuideline: 'flat type hierarchy', }, @@ -5184,6 +5184,88 @@ function checkElementGlow(tag, style, effectiveBg) { // ─── Section 6: Page-Level Checks ─────────────────────────────────────────── +const TYPE_HIERARCHY_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption'; +const TYPE_HIERARCHY_MIN_ROLES = 3; +const TYPE_HIERARCHY_MIN_STEP_RATIO = 1.25; + +function typeHierarchyRole(el) { + const tag = String(el?.tagName || el?.nodeName || '').toLowerCase(); + return /^h[1-6]$/.test(tag) ? tag : 'body'; +} + +function hasTextContent(el) { + return String(el?.textContent || '').trim().length > 0; +} + +function isRenderedTypeElement(el, getStyle) { + for (let current = el; current; current = current.parentElement) { + const hiddenAttr = typeof current.getAttribute === 'function' && current.getAttribute('hidden') !== null; + if (current.hidden || hiddenAttr) return false; + const style = getStyle(current); + if (!style) continue; + const display = String(style.display || '').toLowerCase(); + const visibility = String(style.visibility || '').toLowerCase(); + const contentVisibility = String(style.contentVisibility || '').toLowerCase(); + if (display === 'none' || visibility === 'hidden' || visibility === 'collapse' || contentVisibility === 'hidden') return false; + const opacity = parseFloat(style.opacity); + if (Number.isFinite(opacity) && opacity <= 0.01) return false; + } + return true; +} + +function dominantTypeRoleSize(samples) { + const counts = new Map(); + for (const sample of samples) { + counts.set(sample.size, (counts.get(sample.size) || 0) + 1); + } + const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]); + if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) return null; + return ranked[0]?.[0] ?? null; +} + +function checkFlatTypeHierarchySamples(samples) { + const byRole = new Map(); + for (const sample of samples || []) { + const role = String(sample?.role || ''); + const size = Math.round(Number(sample?.size) * 10) / 10; + if (!role || !Number.isFinite(size) || size < 8 || size >= 200) continue; + if (!byRole.has(role)) byRole.set(role, []); + byRole.get(role).push({ role, size }); + } + + const roles = [...byRole.entries()].map(([role, roleSamples]) => ({ + role, + size: dominantTypeRoleSize(roleSamples), + })).filter(item => item.size !== null); + + if (roles.length < TYPE_HIERARCHY_MIN_ROLES) return []; + + const sorted = roles.slice().sort((a, b) => a.size - b.size || a.role.localeCompare(b.role)); + let largestStep = 1; + for (let i = 1; i < sorted.length; i++) { + largestStep = Math.max(largestStep, sorted[i].size / sorted[i - 1].size); + } + if (largestStep >= TYPE_HIERARCHY_MIN_STEP_RATIO) return []; + + const roleSizes = sorted.map(item => `${item.role} ${item.size}px`).join(', '); + return [{ + id: 'flat-type-hierarchy', + snippet: `Role sizes: ${roleSizes} (largest adjacent step ${largestStep.toFixed(2)}:1; target ${TYPE_HIERARCHY_MIN_STEP_RATIO}:1)`, + }]; +} + +function checkFlatTypeHierarchyFromDoc(root, getStyle, options = {}) { + const samples = []; + for (const el of root.querySelectorAll(TYPE_HIERARCHY_SELECTOR)) { + if (options.skipElement?.(el)) continue; + if (!hasTextContent(el) || !isRenderedTypeElement(el, getStyle)) continue; + const fontSize = parseFloat(getStyle(el)?.fontSize); + if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize >= 200) continue; + samples.push({ role: typeHierarchyRole(el), size: fontSize }); + } + return checkFlatTypeHierarchySamples(samples); +} + // Browser page-level checks — use document/getComputedStyle globals function checkTypography() { @@ -5222,17 +5304,10 @@ function checkTypography() { } } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { - const fs = parseFloat(getComputedStyle(el).fontSize); - if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } + for (const finding of checkFlatTypeHierarchyFromDoc(document, getComputedStyle, { + skipElement: el => el.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip, [id^="impeccable-live-"]'), + })) { + findings.push({ type: finding.id, detail: finding.snippet }); } return findings; @@ -5479,21 +5554,7 @@ function checkPageTypography(doc, win) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - // Flat type hierarchy - const sizes = new Set(); - const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div'); - for (const el of textEls) { - const fontSize = parseFloat(win.getComputedStyle(el).fontSize); - // Filter out sub-8px values (jsdom doesn't resolve relative units properly) - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(doc, el => win.getComputedStyle(el))); return findings; } diff --git a/plugin/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/plugin/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 26ca4f390..df1334fdd 100644 --- a/plugin/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/plugin/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -607,32 +607,6 @@ const REGEX_MATCHERS = [ ]; const REGEX_ANALYZERS = [ - // Flat type hierarchy - (content, filePath) => { - const sizes = new Set(); - const REM = 16; - let m; - const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi; - while ((m = sizeRe.exec(content)) !== null) { - const px = m[2] === 'px' ? +m[1] : +m[1] * REM; - if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10); - } - const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi; - while ((m = clampRe.exec(content)) !== null) { - sizes.add(Math.round((m[2] === 'px' ? +m[1] : +m[1] * REM) * 10) / 10); - sizes.add(Math.round((m[4] === 'px' ? +m[3] : +m[3] * REM) * 10) / 10); - } - const TW = { 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, 'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, 'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128 }; - for (const [cls, px] of Object.entries(TW)) { if (new RegExp(`\\b${cls}\\b`).test(content)) sizes.add(px); } - if (sizes.size < 3) return []; - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio >= 2.0) return []; - const lines = content.split('\n'); - let line = 1; - for (let i = 0; i < lines.length; i++) { if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) { line = i + 1; break; } } - return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)]; - }, // Monotonous spacing (regex) (content, filePath) => { const vals = []; @@ -1154,11 +1128,12 @@ const TEXT_CONTENT_ANALYZER_IDS = [ function runTextContentAnalyzers(content, filePath, options = {}) { const profile = options?.profile; if (!shouldRunPageAnalyzers(content, filePath)) return []; - // The 3 text-content analyzers are at indices 2-4 in REGEX_ANALYZERS - // (single-font's removal on 2026-07-29 shifted every index down one). + // The 3 text-content analyzers are at indices 1-3 in REGEX_ANALYZERS. + // flat-type-hierarchy left this source-only path in issue #619 because it + // needs rendered role and usage evidence. const findings = []; for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { - const analyzer = REGEX_ANALYZERS[2 + i]; + const analyzer = REGEX_ANALYZERS[1 + i]; const ruleId = TEXT_CONTENT_ANALYZER_IDS[i]; findings.push(...profileFindings(profile, { engine: 'regex', @@ -1284,7 +1259,6 @@ function detectText(content, filePath, options = {}) { // Page-level analyzers only run on full pages if (shouldRunPageAnalyzers(content, filePath)) { const analyzerIds = [ - 'flat-type-hierarchy', 'monotonous-spacing', 'em-dash-overuse', 'marketing-buzzword', diff --git a/plugin/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/plugin/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index cc36ac9b5..7f6080b61 100644 --- a/plugin/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/plugin/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -282,6 +282,7 @@ const STATIC_DEFAULT_STYLE = { marginLeft: '0px', position: 'static', visibility: 'visible', + contentVisibility: 'visible', opacity: '1', top: 'auto', right: 'auto', diff --git a/plugin/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/plugin/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 51c34224b..84e6ab1f5 100644 --- a/plugin/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/plugin/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -25,6 +25,7 @@ import { checkElementOversizedH1, checkElementQuality, checkElementRadialSpotlight, + checkFlatTypeHierarchyFromDoc, checkCreamPalette, checkHtmlPatterns, checkKickerAboveHeadingFromDoc, @@ -59,18 +60,7 @@ function checkStaticPageTypography(document, window) { for (const font of overusedFound) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div')) { - const fontSize = parseFloat(window.getComputedStyle(el).fontSize); - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(document, el => window.getComputedStyle(el))); return findings; } diff --git a/plugin/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/plugin/skills/impeccable/scripts/detector/registry/antipatterns.mjs index 88ff84a07..945307783 100644 --- a/plugin/skills/impeccable/scripts/detector/registry/antipatterns.mjs +++ b/plugin/skills/impeccable/scripts/detector/registry/antipatterns.mjs @@ -34,7 +34,7 @@ const ANTIPATTERNS = [ scopes: ['type'], name: 'Flat type hierarchy', description: - 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', + 'Dominant heading and body roles are separated by less than 1.25× at every step, leaving the size hierarchy flat. Add at least one stronger size step.', skillSection: 'Typography', skillGuideline: 'flat type hierarchy', }, diff --git a/plugin/skills/impeccable/scripts/detector/rules/checks.mjs b/plugin/skills/impeccable/scripts/detector/rules/checks.mjs index 8bfb2b1df..579e1c12e 100644 --- a/plugin/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/plugin/skills/impeccable/scripts/detector/rules/checks.mjs @@ -3911,6 +3911,88 @@ function checkElementGlow(tag, style, effectiveBg) { // ─── Section 6: Page-Level Checks ─────────────────────────────────────────── +const TYPE_HIERARCHY_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption'; +const TYPE_HIERARCHY_MIN_ROLES = 3; +const TYPE_HIERARCHY_MIN_STEP_RATIO = 1.25; + +function typeHierarchyRole(el) { + const tag = String(el?.tagName || el?.nodeName || '').toLowerCase(); + return /^h[1-6]$/.test(tag) ? tag : 'body'; +} + +function hasTextContent(el) { + return String(el?.textContent || '').trim().length > 0; +} + +function isRenderedTypeElement(el, getStyle) { + for (let current = el; current; current = current.parentElement) { + const hiddenAttr = typeof current.getAttribute === 'function' && current.getAttribute('hidden') !== null; + if (current.hidden || hiddenAttr) return false; + const style = getStyle(current); + if (!style) continue; + const display = String(style.display || '').toLowerCase(); + const visibility = String(style.visibility || '').toLowerCase(); + const contentVisibility = String(style.contentVisibility || '').toLowerCase(); + if (display === 'none' || visibility === 'hidden' || visibility === 'collapse' || contentVisibility === 'hidden') return false; + const opacity = parseFloat(style.opacity); + if (Number.isFinite(opacity) && opacity <= 0.01) return false; + } + return true; +} + +function dominantTypeRoleSize(samples) { + const counts = new Map(); + for (const sample of samples) { + counts.set(sample.size, (counts.get(sample.size) || 0) + 1); + } + const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]); + if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) return null; + return ranked[0]?.[0] ?? null; +} + +function checkFlatTypeHierarchySamples(samples) { + const byRole = new Map(); + for (const sample of samples || []) { + const role = String(sample?.role || ''); + const size = Math.round(Number(sample?.size) * 10) / 10; + if (!role || !Number.isFinite(size) || size < 8 || size >= 200) continue; + if (!byRole.has(role)) byRole.set(role, []); + byRole.get(role).push({ role, size }); + } + + const roles = [...byRole.entries()].map(([role, roleSamples]) => ({ + role, + size: dominantTypeRoleSize(roleSamples), + })).filter(item => item.size !== null); + + if (roles.length < TYPE_HIERARCHY_MIN_ROLES) return []; + + const sorted = roles.slice().sort((a, b) => a.size - b.size || a.role.localeCompare(b.role)); + let largestStep = 1; + for (let i = 1; i < sorted.length; i++) { + largestStep = Math.max(largestStep, sorted[i].size / sorted[i - 1].size); + } + if (largestStep >= TYPE_HIERARCHY_MIN_STEP_RATIO) return []; + + const roleSizes = sorted.map(item => `${item.role} ${item.size}px`).join(', '); + return [{ + id: 'flat-type-hierarchy', + snippet: `Role sizes: ${roleSizes} (largest adjacent step ${largestStep.toFixed(2)}:1; target ${TYPE_HIERARCHY_MIN_STEP_RATIO}:1)`, + }]; +} + +function checkFlatTypeHierarchyFromDoc(root, getStyle, options = {}) { + const samples = []; + for (const el of root.querySelectorAll(TYPE_HIERARCHY_SELECTOR)) { + if (options.skipElement?.(el)) continue; + if (!hasTextContent(el) || !isRenderedTypeElement(el, getStyle)) continue; + const fontSize = parseFloat(getStyle(el)?.fontSize); + if (!Number.isFinite(fontSize) || fontSize < 8 || fontSize >= 200) continue; + samples.push({ role: typeHierarchyRole(el), size: fontSize }); + } + return checkFlatTypeHierarchySamples(samples); +} + // Browser page-level checks — use document/getComputedStyle globals function checkTypography() { @@ -3949,17 +4031,10 @@ function checkTypography() { } } - const sizes = new Set(); - for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { - const fs = parseFloat(getComputedStyle(el).fontSize); - if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } + for (const finding of checkFlatTypeHierarchyFromDoc(document, getComputedStyle, { + skipElement: el => el.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip, [id^="impeccable-live-"]'), + })) { + findings.push({ type: finding.id, detail: finding.snippet }); } return findings; @@ -4206,21 +4281,7 @@ function checkPageTypography(doc, win) { findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); } - // Flat type hierarchy - const sizes = new Set(); - const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div'); - for (const el of textEls) { - const fontSize = parseFloat(win.getComputedStyle(el).fontSize); - // Filter out sub-8px values (jsdom doesn't resolve relative units properly) - if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); - } - if (sizes.size >= 3) { - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio < 2.0) { - findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); - } - } + findings.push(...checkFlatTypeHierarchyFromDoc(doc, el => win.getComputedStyle(el))); return findings; } @@ -5649,6 +5710,8 @@ export { checkKickerAboveHeadingFromDoc, checkElementMotion, checkElementGlow, + checkFlatTypeHierarchySamples, + checkFlatTypeHierarchyFromDoc, checkTypography, isCardLikeDOM, checkLayout, From 9736a9f6e91bf2f532cafca8f3886df833cd5a78 Mon Sep 17 00:00:00 2001 From: 4nibhal <119706316+4nibhal@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:05:37 +0200 Subject: [PATCH 16/31] Fix OpenCode slash command bridge (#483) Add a first-class OpenCode command bridge across builds, installs, updates, linked installs, and pinned shortcuts. Preserve current provider behavior while backfilling missing or drifted command files.\n\nAI assistance: contributor and maintainer work used AI tools as disclosed in the PR discussion and commits. --- cli/bin/commands/skills.mjs | 104 +++++- docs/HARNESSES.md | 13 +- scripts/build.js | 6 + scripts/lib/root-commands-sync.mjs | 27 ++ scripts/lib/transformers/factory.js | 22 ++ scripts/test-suites.mjs | 3 + skill/scripts/pin.mjs | 122 ++++++- tests/copy-provider-commands.test.js | 306 ++++++++++++++++++ .../transformers/opencode-commands.test.js | 109 +++++++ tests/pin.test.mjs | 208 ++++++++++++ tests/root-commands-sync.test.js | 73 +++++ tests/skills-cli.test.js | 136 +++++++- 12 files changed, 1118 insertions(+), 11 deletions(-) create mode 100644 scripts/lib/root-commands-sync.mjs create mode 100644 tests/copy-provider-commands.test.js create mode 100644 tests/lib/transformers/opencode-commands.test.js create mode 100644 tests/root-commands-sync.test.js diff --git a/cli/bin/commands/skills.mjs b/cli/bin/commands/skills.mjs index 36831153e..faa98a572 100644 --- a/cli/bin/commands/skills.mjs +++ b/cli/bin/commands/skills.mjs @@ -9,7 +9,7 @@ */ import { execSync } from 'node:child_process'; -import { existsSync, readFileSync, readdirSync, statSync, accessSync, constants, lstatSync, unlinkSync, mkdirSync, mkdtempSync, writeFileSync, rmSync, rmdirSync, renameSync, createWriteStream, realpathSync, symlinkSync, readlinkSync, cpSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync, statSync, accessSync, constants, lstatSync, unlinkSync, mkdirSync, mkdtempSync, writeFileSync, rmSync, rmdirSync, renameSync, createWriteStream, realpathSync, symlinkSync, readlinkSync, cpSync, copyFileSync } from 'node:fs'; import { join, resolve, dirname, relative, isAbsolute, sep, delimiter } from 'node:path'; import { createInterface, emitKeypressEvents } from 'node:readline'; import { Readable } from 'node:stream'; @@ -738,6 +738,27 @@ function isUpToDate(root, providers, bundleDir, scope, agentScope = scope) { } } + // Provider command artifacts (e.g. OpenCode's commands/impeccable.md) are + // part of "current" too: an install whose skills match but whose bridge is + // missing or drifted must refresh, otherwise reinstall/update report + // success while the slash command stays absent (#474 backfill). Only + // bundle-shipped files are checked, so pinned or user commands never + // affect freshness. The commands dir sits next to the matched skills dir + // (project /.opencode, user , home-dir global override), so + // deriving it from localSkillsDir stays correct for every layout + // copyProviderCommands can write. + const bundleCommandsDir = join(bundleDir, provider, 'commands'); + if (existsSync(bundleCommandsDir)) { + const localCommandsDir = join(dirname(localSkillsDir), 'commands'); + for (const entry of readdirSync(bundleCommandsDir)) { + const bundleFile = join(bundleCommandsDir, entry); + if (!statSync(bundleFile).isFile()) continue; + const localFile = join(localCommandsDir, entry); + if (!existsSync(localFile)) return false; + if (hashSkillFile(bundleFile) !== hashSkillFile(localFile)) return false; + } + } + if (!providerAgentsUpToDate(bundleDir, root, provider, agentScope)) return false; } return true; @@ -1290,6 +1311,74 @@ function copyProviderSkills(bundleDir, root, targets, { scope } = {}) { return written; } +/** + * Copy each target provider's compiled command variant from an extracted + * bundle into the project or global config dir. OpenCode 1.18.10 discovers + * custom commands from `{command,commands}/**.md` under any active config + * dir, so the install mirrors `copyProviderSkills`: project scope writes + * `//commands/`, user scope writes + * `opencodeGlobalConfigDir(home)/commands` with the same + * `OPENCODE_CONFIG_DIR` → `$XDG_CONFIG_HOME/opencode` → `~/.config/opencode` + * precedence PR #417 established for skills. + * + * Migration guard: a pre-#406 global OpenCode install at + * `~/.opencode/commands/` is not scanned by OpenCode. After a global + * install, the commands just written are removed from the stranded + * legacy copy, sibling commands stay put, symlinked legacy dirs are + * skipped (deleting through a symlink would empty the real target), and + * a home-rooted git repo (`/commands/` IS a project install) + * is left alone. Symmetric to `copyProviderSkills` at + * `skills.mjs:1168-1186`. + */ +// Local commands dir for a provider. Project installs land at +// //commands; user-scope OpenCode installs must target the +// config dir OpenCode actually scans (OPENCODE_CONFIG_DIR → XDG → ~/.config). +function providerCommandsDir(root, providerEntry, scope) { + return scope === 'user' + ? join(opencodeGlobalConfigDir(root), 'commands') + : join(root, providerEntry.replace(/^\./, '.'), 'commands'); +} + +function copyProviderCommands(bundleDir, root, targets, { scope } = {}) { + let written = 0; + for (const target of targets) { + const providerEntry = PROVIDER_DIRS.includes(`.${target}`) + ? `.${target}` + : target; + const srcDir = join(bundleDir, providerEntry, 'commands'); + if (!existsSync(srcDir)) continue; + const localCommandsDir = providerCommandsDir(root, providerEntry, scope); + mkdirSync(localCommandsDir, { recursive: true }); + for (const entry of readdirSync(srcDir)) { + const src = join(srcDir, entry); + if (!statSync(src).isFile()) continue; + const dest = join(localCommandsDir, entry); + rmSync(dest, { recursive: true, force: true }); + copyFileSync(src, dest); + written++; + } + if (scope === 'user' && providerEntry === '.opencode') { + const legacyDir = join(root, '.opencode', 'commands'); + let migratable = false; + try { + migratable = existsSync(legacyDir) + && !lstatSync(legacyDir).isSymbolicLink() + && realpathSync(legacyDir) !== realpathSync(localCommandsDir) + && !existsSync(join(root, '.git')); + } catch { migratable = false; } + if (migratable) { + for (const entry of readdirSync(srcDir)) { + const src = join(srcDir, entry); + if (!statSync(src).isFile()) continue; + rmSync(join(legacyDir, entry), { recursive: true, force: true }); + } + try { rmdirSync(legacyDir); } catch { /* not empty: siblings stay */ } + } + } + } + return written; +} + // Native subagent definitions that ship in the bundle next to a provider's // skills. Claude Code's live at `.claude/agents/impeccable-*.md`; project // agents take precedence over user agents. GitHub Copilot's live at @@ -1918,6 +2007,13 @@ async function link(flags) { process.exit(1); } + // Linked installs are excluded from install/update refreshes (overwriting a + // symlink would destroy the link), so this is the only path that can deliver + // the OpenCode command bridge to them. A copy, not a symlink: the bridge is + // static and OpenCode scans the real commands dir. No-ops when the source + // checkout has no built commands (e.g. dist/ not built yet). + copyProviderCommands(source.bundleRoot, root, targets, { scope: 'project' }); + const parts = []; if (result.linked > 0) parts.push(`${result.linked} linked`); if (result.already > 0) parts.push(`${result.already} already linked`); @@ -1987,6 +2083,7 @@ async function install(flags) { migrateUnprefixImpeccable(installRoot, scope); updated = refreshProviderSkills(bundleDir, installRoot, copyTargets, scope); reportProviderAgents(copyProviderAgents(bundleDir, installRoot, copyTargets, { scope })); + copyProviderCommands(bundleDir, installRoot, copyTargets, { scope }); const v = getSkillsVersion(installRoot, scope); console.log(`Updated ${updated} skill(s)${v ? ` to v${v}` : ''}.`); } @@ -2058,6 +2155,7 @@ async function install(flags) { try { written = copyProviderSkills(bundleDir, installRoot, targets, { scope }); agentResults = copyProviderAgents(bundleDir, installRoot, targets, { scope }); + copyProviderCommands(bundleDir, installRoot, targets, { scope }); hookTargets = wantHooks ? copyProviderHooks(bundleDir, hookRoot, targets, { force, skillRoot: installRoot }) : []; } catch (e) { rmSync(bundleDir, { recursive: true, force: true }); @@ -2340,6 +2438,7 @@ async function update(flags = []) { const updated = refreshProviderSkills(tmpDir, root, copyProviders, scope); reportProviderAgents(copyProviderAgents(tmpDir, root, copyProviders, { scope: agentScope })); + copyProviderCommands(tmpDir, root, copyProviders, { scope }); const wantHooks = installHooks && await decideHookInstall(root, providers, { yes }); const hookTargets = wantHooks ? copyProviderHooks(tmpDir, root, providers, { force }) : []; @@ -2375,6 +2474,7 @@ function copyDirSync(src, dest) { export { collectInstallDetections, copyProviderAgents, + copyProviderCommands, copyProviderHooks, copyProviderSkills, decideHookInstall, @@ -2383,11 +2483,13 @@ export { expectedHookDests, extractZip, formatInstallDetectionLines, + isUpToDate, hermesGlobalHome, HOME_SKILLS_DIR_OVERRIDES, linkProviderSkills, mergeHookManifests, migrateUnprefixImpeccable, + opencodeGlobalConfigDir, resolveInstallTargets, resolveLinkSource, }; diff --git a/docs/HARNESSES.md b/docs/HARNESSES.md index 9371215f2..1f14fc8bd 100644 --- a/docs/HARNESSES.md +++ b/docs/HARNESSES.md @@ -46,14 +46,14 @@ Fields marked with * are spec-standard. Others are provider extensions. | `license`* | Yes | Yes | Ignored | No | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | | `compatibility`* | Yes | Yes | Ignored | No | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | | `metadata`* | Yes | Yes | Ignored | No | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| `allowed-tools`* | Yes | No | Ignored | No | No | Yes | No | No | Yes | Yes | Yes | Yes | Yes | Yes | -| `user-invocable` | Yes | No | No | No | Yes | Yes | No | No | Yes | No | Yes | Yes | Yes | No | -| `argument-hint` | Yes | No | No | No | Yes | Yes | No | No | Yes | No | Yes | Yes | No | No | +| `allowed-tools`* | Yes | No | Ignored | No | No | Yes | No | No | No | Yes | Yes | Yes | Yes | Yes | +| `user-invocable` | Yes | No | No | No | Yes | Yes | No | No | No | No | Yes | Yes | Yes | No | +| `argument-hint` | Yes | No | No | No | Yes | Yes | No | No | No | No | Yes | Yes | No | No | | `disable-model-invocation` | Yes | Yes | No | No | Yes | Yes | No | No | Yes | Yes | TBD | TBD | No | No | -| `model` | Yes | No | No | No | No | Yes | No | No | Yes | No | No | No | No | No | +| `model` | Yes | No | No | No | No | Yes | No | No | No | No | No | No | No | No | | `effort` | Yes | No | No | No | No | Yes | No | No | No | No | No | No | No | No | | `context` | Yes | No | No | No | No | No | No | No | No | No | No | No | No | No | -| `agent` | Yes | No | No | No | No | No | No | No | Yes | No | No | No | No | No | +| `agent` | Yes | No | No | No | No | No | No | No | No | No | No | No | No | No | | `hooks` | Yes | No | No | Yes | No | Yes | No | No | No | No | No | No | No | No | Notes: @@ -64,6 +64,7 @@ Notes: - Hermes Agent reads the Agent Skills spec as-is. Spec-defined fields (`name`, `description`, `license`, `compatibility`, `metadata`) are parsed and stored; harness-specific extensions (`user-invocable`, `argument-hint`, `allowed-tools`, `disable-model-invocation`, `model`, `effort`, `context`, `agent`, `hooks`) are unknown keys and silently ignored. Hermes has no hook surface, no per-skill tool ACL, and no slash-command equivalent of `user-invocable` (skills are loaded via `/skill ` or auto-loaded; sub-commands like `/impeccable polish` are routed from the skill body, not declared in frontmatter). Hermes adds two frontmatter fields not in the spec: `platforms:` (OS filter; default = all) and `environments:` (relevance gate over `kanban`, `docker`, `s6`). Unknown fields are silently ignored. - Kiro recognizes `user-invocable` and `disable-model-invocation` per community reports but does not formally document them. - Antigravity supports standard Agent Skills spec frontmatter fields (`name`, `description`, `license`, `compatibility`, `metadata`, `allowed-tools`). +- OpenCode 1.18.10 recognises only the spec subset on SKILL.md (`name`, `description`, `license`, `compatibility`, `metadata`). Claude-style extensions (`user-invocable`, `argument-hint`, `allowed-tools`, `model`, `agent`) are silently ignored; Impeccable still emits them today for other harnesses, but they have no effect in OpenCode. Use `commands/.md` (see Placeholder / Variable Substitution below) for slash UX; OpenCode honours only `description`, `agent`, `model`, `variant`, `subtask` on command files. - Unknown fields are silently ignored by all harnesses. ## Hook surface used by Impeccable @@ -133,8 +134,8 @@ Some harnesses have separate "custom commands" systems (distinct from skills) wi | Harness | Command system | Substitution syntax | |---------|---------------|-------------------| +| OpenCode | `.opencode/commands/` (Markdown) | `$ARGUMENTS`, `$1`-`$N`, `` !`shell` ``, `@file` | | Gemini CLI | `.gemini/commands/` (TOML) | `{{args}}`, `!{shell}`, `@{file}` | | Codex CLI | `.codex/prompts/` | `$ARGNAME` | -| OpenCode | `.opencode/commands/` | `$ARGUMENTS`, `$1`-`$N`, `` !`shell` `` | Our build system handles cross-provider placeholders at compile time via `replacePlaceholders()` for `{{model}}`, `{{config_file}}`, `{{ask_instruction}}`, and `{{available_commands}}`. diff --git a/scripts/build.js b/scripts/build.js index d4e238e96..5eda34c8c 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -20,6 +20,7 @@ import path from 'path'; import fs from 'fs'; import { fileURLToPath } from 'url'; import { readSourceFiles, readPatterns, stashPerProjectArtifacts, restorePerProjectArtifacts } from './lib/utils.js'; +import { syncRootCommands } from './lib/root-commands-sync.mjs'; import { createTransformer, PROVIDERS } from './lib/transformers/index.js'; import { hooksJsonFor, buildClaudePluginHooksManifest } from './lib/transformers/hooks.js'; import { createAllZips, createProviderZip } from './lib/zip.js'; @@ -657,6 +658,11 @@ async function build() { } } + const syncedCommands = syncRootCommands(DIST_DIR, ROOT_DIR, syncConfigs); + if (syncedCommands.length > 0) { + console.log(`📟 Synced provider commands to: ${syncedCommands.join(', ')}`); + } + const syncedHooks = syncRootHookManifests(ROOT_DIR); if (syncedHooks.length > 0) { console.log(`🪝 Synced hook manifests to: ${syncedHooks.join(', ')}`); diff --git a/scripts/lib/root-commands-sync.mjs b/scripts/lib/root-commands-sync.mjs new file mode 100644 index 000000000..ae31f83d3 --- /dev/null +++ b/scripts/lib/root-commands-sync.mjs @@ -0,0 +1,27 @@ +/** + * Mirror generated provider command files (e.g. OpenCode's + * commands/impeccable.md) from dist/ into the tracked root harness folders. + * Without this, the release sync ships skills/agents/hooks but no slash + * command bridge, so direct GitHub, npx-skills, and submodule installs of + * OpenCode stay bridge-less (#483). Per-entry copy like the skills sync: + * the destination directory is never removed, so repo-local or pinned + * command files are preserved. + */ +import fs from 'node:fs'; +import path from 'node:path'; + +export function syncRootCommands(distDir, rootDir, providers) { + const synced = []; + for (const { provider, configDir } of providers) { + const src = path.join(distDir, provider, configDir, 'commands'); + if (!fs.existsSync(src)) continue; + const dest = path.join(rootDir, configDir, 'commands'); + fs.mkdirSync(dest, { recursive: true }); + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + if (!entry.isFile()) continue; + fs.copyFileSync(path.join(src, entry.name), path.join(dest, entry.name)); + } + synced.push(configDir); + } + return synced; +} diff --git a/scripts/lib/transformers/factory.js b/scripts/lib/transformers/factory.js index 195e1b159..5dfc19c30 100644 --- a/scripts/lib/transformers/factory.js +++ b/scripts/lib/transformers/factory.js @@ -379,6 +379,28 @@ export function createTransformer(config) { } } + // Ship an explicit slash-command surface for OpenCode. OpenCode registers + // skill commands natively but its TUI autocomplete hides them by deliberate + // design (anomalyco/opencode#25439); this file also pins execution policy + // (agent: build, subtask: true) and routes through OpenCode's skill tool, + // which resolves the skill base dir for any install scope. Menu visibility + // is the only part contingent on OpenCode's design; the rest is intentional. + // Schema restricted to what OpenCode recognises (description, agent, model, + // variant, subtask). + if (provider === 'opencode' && skills.length > 0) { + const commandsDir = path.join(providerDir, `${configDir}/commands`); + ensureDir(commandsDir); + for (const skill of skills) { + const bridgeBody = `Call skill({ name: "${skill.name}" }) and follow its \`Setup\` and \`Commands\` sections to handle $ARGUMENTS.\n`; + const bridgeFrontmatter = generateYamlFrontmatter({ + description: skill.description, + agent: 'build', + subtask: true, + }); + writeFile(path.join(commandsDir, `${skill.name}.md`), `${bridgeFrontmatter}\n${bridgeBody}`.replace(/\n+$/, '\n')); + } + } + if (config.agentFormat) { const agentsDir = path.join(providerDir, `${configDir}/agents`); for (const skill of skills) { diff --git a/scripts/test-suites.mjs b/scripts/test-suites.mjs index 9a241e769..aae63d64d 100644 --- a/scripts/test-suites.mjs +++ b/scripts/test-suites.mjs @@ -36,13 +36,16 @@ export const SUITES = { files: [ 'tests/build.test.js', 'tests/cli-ignores.test.js', + 'tests/copy-provider-commands.test.js', 'tests/windows-path-fix.test.js', 'tests/lib/provider-blocks.test.js', 'tests/lib/transformers/provider-blocks.test.js', 'tests/lib/utils.test.js', 'tests/lib/impeccable-config.test.js', 'tests/lib/transformers/factory.test.js', + 'tests/lib/transformers/opencode-commands.test.js', 'tests/lib/transformers/providers.test.js', + 'tests/root-commands-sync.test.js', 'tests/skills-cli.test.js', 'tests/validate-plugin-versions.test.js', 'tests/validate-plugin-manifest.test.js', diff --git a/skill/scripts/pin.mjs b/skill/scripts/pin.mjs index d80043df7..6a4323194 100644 --- a/skill/scripts/pin.mjs +++ b/skill/scripts/pin.mjs @@ -14,8 +14,9 @@ */ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync } from 'node:fs'; -import { basename, join, resolve, dirname } from 'node:path'; +import { basename, join, resolve, dirname, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { homedir } from 'node:os'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -115,21 +116,119 @@ Invoke ${commandPrefix}impeccable ${command}, passing along any arguments provid `; } +// OpenCode 1.18.10 does not honor `user-invocable: true` on SKILL.md frontmatter +// (see docs/HARNESSES.md and opencode/packages/core/src/v1/config/command.ts), +// so a pinned skill there shows up in `opencode debug skill` but never in the +// slash menu. The fix is a sibling `commands/impeccable-.md` that uses the +// OpenCode command schema (description, agent, subtask). Body loads the skill +// via the skill tool and then the sub-command's reference file directly, so +// /impeccable- runs the same workflow /impeccable routes to. +const OPENCODE_PIN_MARKER = ''; +function generatePinnedOpencodeCommand(command, metadata) { + const desc = metadata[command]?.description || `Impeccable sub-command shortcut; runs the ${command} workflow via /impeccable.`; + return `--- +description: "${desc}" +agent: build +subtask: true +--- + +${OPENCODE_PIN_MARKER} + +Load the \`impeccable\` skill via the skill tool (name: "impeccable"), then run \`node /scripts/context.mjs\`, then load \`/reference/${command}.md\` and follow it. \`\` is the skill's base directory as reported by the skill tool response; substitute the actual absolute path before running or reading anything. + +$ARGUMENTS +`; +} + +// OpenCode's user-scope config dir. Mirrors the CLI's opencodeGlobalConfigDir +// precedence (OPENCODE_CONFIG_DIR → XDG_CONFIG_HOME/opencode → +// ~/.config/opencode); duplicated here because this script ships inside the +// installed skill and cannot import the CLI. +function opencodeUserConfigDir() { + if (process.env.OPENCODE_CONFIG_DIR) return process.env.OPENCODE_CONFIG_DIR; + if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, 'opencode'); + return join(homedir(), '.config', 'opencode'); +} + +/** + * Resolve every commands dir that should receive an OpenCode pin: the + * project-local dir when the project has the skill, plus the user config dir + * when Impeccable is installed globally (#406 layout). A user-scope skill is + * visible from every project, so its pinned commands belong next to it. + * With `forCleanup`, both commands dirs are included even when the skill is + * gone, so unpin can still reach a pin left behind by a removed install; + * removal stays safe because removePinnedOpencodeCommand is marker-guarded. + */ +function findOpencodeCommandsDirs(projectRoot, { forCleanup = false } = {}) { + const dirs = []; + const seen = new Set(); + const push = (commandsDir) => { + const key = resolve(commandsDir); + if (!seen.has(key)) { + seen.add(key); + dirs.push(commandsDir); + } + }; + if (forCleanup || existsSync(join(projectRoot, '.opencode', 'skills', 'impeccable'))) { + push(join(projectRoot, '.opencode', 'commands')); + } + const userConfig = opencodeUserConfigDir(); + if (forCleanup || existsSync(join(userConfig, 'skills', 'impeccable'))) { + push(join(userConfig, 'commands')); + } + return dirs; +} + +function writePinnedOpencodeCommand(commandsDir, command, metadata) { + const commandFile = join(commandsDir, `impeccable-${command}.md`); + if (existsSync(commandFile)) { + const existing = readFileSync(commandFile, 'utf-8'); + if (!existing.includes(OPENCODE_PIN_MARKER)) { + console.log(` SKIP: ${commandFile} (non-pinned command already exists)`); + return false; + } + } else { + mkdirSync(commandsDir, { recursive: true }); + } + writeFileSync(commandFile, generatePinnedOpencodeCommand(command, metadata)); + console.log(` + ${commandFile}`); + return true; +} + +function removePinnedOpencodeCommand(commandsDir, command) { + const commandFile = join(commandsDir, `impeccable-${command}.md`); + if (!existsSync(commandFile)) return false; + const content = readFileSync(commandFile, 'utf-8'); + if (!content.includes(OPENCODE_PIN_MARKER)) { + console.log(` SKIP: ${commandFile} (not a pinned command)`); + return false; + } + rmSync(commandFile, { force: true }); + console.log(` - ${commandFile}`); + return true; +} + /** * Pin a command: create shortcut skill in all harness dirs. */ function pin(command, projectRoot) { const metadata = loadCommandMetadata(); const harnessDirs = findHarnessDirs(projectRoot); + const opencodeCommandsDirs = findOpencodeCommandsDirs(projectRoot); - if (harnessDirs.length === 0) { + if (harnessDirs.length === 0 && opencodeCommandsDirs.length === 0) { console.log('No harness directories with impeccable installed found.'); return false; } let created = 0; + // OpenCode is handled separately below because its shortcut format is a + // slash command, not a SKILL.md. Excluding it from the skill loop here + // prevents a duplicate `.opencode/skills//SKILL.md` that OpenCode + // would never surface as `/`. for (const skillsDir of harnessDirs) { + if (skillsDir.includes(`${sep}.opencode${sep}`)) continue; const commandPrefix = commandPrefixForSkillsDir(skillsDir); const content = generatePinnedSkill(command, metadata, commandPrefix, commandPrefix === '$'); // Check if skill already exists (and isn't a pin) @@ -151,6 +250,12 @@ function pin(command, projectRoot) { created++; } + // OpenCode: write a slash command bridge, not a skill shortcut. Covers both + // project installs and user-scope (global config) installs. + for (const commandsDir of opencodeCommandsDirs) { + if (writePinnedOpencodeCommand(commandsDir, command, metadata)) created++; + } + if (created > 0) { console.log(`\nPinned '${command}' as a standalone shortcut in ${created} location(s).`); console.log('Use the pinned command directly in each harness.'); @@ -160,13 +265,17 @@ function pin(command, projectRoot) { } /** - * Unpin a command: remove shortcut skill from all harness dirs. + * Unpin a command: remove shortcut skill in all harness dirs. */ function unpin(command, projectRoot) { const harnessDirs = findHarnessDirs(projectRoot); let removed = 0; + // OpenCode has its own cleanup path below; skip the skill loop here so a + // stray `.opencode/skills//SKILL.md` written by an older Impeccable + // version is never silently dropped here. for (const skillsDir of harnessDirs) { + if (skillsDir.includes(`${sep}.opencode${sep}`)) continue; const skillDir = join(skillsDir, command); if (!existsSync(skillDir)) continue; @@ -185,6 +294,13 @@ function unpin(command, projectRoot) { removed++; } + // OpenCode: remove the pinned command file if it's one of ours, in every + // scope it could have been written to — even when the skill itself is + // already gone, since removal is marker-guarded. + for (const commandsDir of findOpencodeCommandsDirs(projectRoot, { forCleanup: true })) { + if (removePinnedOpencodeCommand(commandsDir, command)) removed++; + } + if (removed > 0) { console.log(`\nUnpinned '${command}' from ${removed} location(s).`); console.log(`Use Impeccable's '${command}' workflow directly to access it.`); diff --git a/tests/copy-provider-commands.test.js b/tests/copy-provider-commands.test.js new file mode 100644 index 000000000..d9cd33de5 --- /dev/null +++ b/tests/copy-provider-commands.test.js @@ -0,0 +1,306 @@ +/** + * Tests for copyProviderCommands. Mirrors the PR #417 migration guards for the + * skills path, applied to /commands. OpenCode discovers custom + * commands from {command,commands}/**.md in the active config dir, so a + * global install must target $OPENCODE_CONFIG_DIR/commands, $XDG_CONFIG_HOME/ + * opencode/commands, or ~/.config/opencode/commands (in that order), never + * ~/.opencode/commands which OpenCode does not scan. + */ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + readFileSync, + existsSync, + symlinkSync, + rmSync, + realpathSync, + lstatSync, +} from 'fs'; +import { tmpdir } from 'os'; + +import { + copyProviderCommands, + isUpToDate, + opencodeGlobalConfigDir, +} from '../cli/bin/commands/skills.mjs'; + +function setupBundleWithCommand(bundleDir, providerName, commandNames) { + mkdirSync(path.join(bundleDir, providerName, 'commands'), { recursive: true }); + for (const name of commandNames) { + const file = path.join(bundleDir, providerName, 'commands', `${name}.md`); + writeFileSync( + file, + `description: Impeccable ${name} bridge\nagent: build\nsubtask: true\n\nbody ${name}\n`, + ); + } +} + +beforeEach(() => { + process.env.IMPECCABLE_BUNDLE_PATH = ''; + delete process.env.OPENCODE_CONFIG_DIR; + delete process.env.XDG_CONFIG_HOME; +}); + +afterEach(() => { + delete process.env.OPENCODE_CONFIG_DIR; + delete process.env.XDG_CONFIG_HOME; +}); + +describe('copyProviderCommands', () => { + test('writes commands to project .opencode/commands by default', () => { + const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); + const project = mkdtempSync(path.join(tmpdir(), 'imp-cmd-proj-')); + setupBundleWithCommand(bundle, '.opencode', ['impeccable']); + try { + const written = copyProviderCommands(bundle, project, ['opencode'], { scope: 'project' }); + expect(written).toBe(1); + const dest = path.join(project, '.opencode', 'commands', 'impeccable.md'); + expect(existsSync(dest)).toBe(true); + expect(readFileSync(dest, 'utf8')).toContain('impeccable bridge'); + } finally { + rmSync(bundle, { recursive: true, force: true }); + rmSync(project, { recursive: true, force: true }); + } + }); + + test('writes commands to ~/.config/opencode/commands for global scope', () => { + const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); + const home = mkdtempSync(path.join(tmpdir(), 'imp-cmd-home-')); + setupBundleWithCommand(bundle, '.opencode', ['impeccable']); + try { + const written = copyProviderCommands(bundle, home, ['opencode'], { scope: 'user' }); + expect(written).toBe(1); + const dest = path.join(home, '.config', 'opencode', 'commands', 'impeccable.md'); + expect(existsSync(dest)).toBe(true); + } finally { + rmSync(bundle, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } + }); + + test('honours OPENCODE_CONFIG_DIR for global scope', () => { + const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); + const home = mkdtempSync(path.join(tmpdir(), 'imp-cmd-home-')); + const customDir = mkdtempSync(path.join(tmpdir(), 'imp-cmd-custom-')); + setupBundleWithCommand(bundle, '.opencode', ['impeccable']); + try { + process.env.OPENCODE_CONFIG_DIR = customDir; + const written = copyProviderCommands(bundle, home, ['opencode'], { scope: 'user' }); + expect(written).toBe(1); + const dest = path.join(customDir, 'commands', 'impeccable.md'); + expect(existsSync(dest)).toBe(true); + expect(existsSync(path.join(home, '.config', 'opencode', 'commands'))).toBe(false); + } finally { + rmSync(bundle, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + rmSync(customDir, { recursive: true, force: true }); + } + }); + + test('honours XDG_CONFIG_HOME/opencode/commands when OPENCODE_CONFIG_DIR is unset', () => { + const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); + const home = mkdtempSync(path.join(tmpdir(), 'imp-cmd-home-')); + const xdgRoot = mkdtempSync(path.join(tmpdir(), 'imp-cmd-xdg-')); + setupBundleWithCommand(bundle, '.opencode', ['impeccable']); + try { + process.env.XDG_CONFIG_HOME = xdgRoot; + const written = copyProviderCommands(bundle, home, ['opencode'], { scope: 'user' }); + expect(written).toBe(1); + const dest = path.join(xdgRoot, 'opencode', 'commands', 'impeccable.md'); + expect(existsSync(dest)).toBe(true); + } finally { + rmSync(bundle, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + rmSync(xdgRoot, { recursive: true, force: true }); + } + }); + + test('migrates legacy ~/.opencode/commands entries without disturbing siblings', () => { + const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); + const home = mkdtempSync(path.join(tmpdir(), 'imp-cmd-home-')); + setupBundleWithCommand(bundle, '.opencode', ['impeccable']); + // Pre-seed a legacy copy with both a command we want to replace and a + // sibling the install must NOT touch. + const legacyDir = path.join(home, '.opencode', 'commands'); + mkdirSync(legacyDir, { recursive: true }); + writeFileSync(path.join(legacyDir, 'impeccable.md'), 'stale impeccable\n'); + writeFileSync(path.join(legacyDir, 'unrelated-command.md'), 'unrelated\n'); + try { + const written = copyProviderCommands(bundle, home, ['opencode'], { scope: 'user' }); + expect(written).toBe(1); + const dest = path.join(home, '.config', 'opencode', 'commands', 'impeccable.md'); + expect(existsSync(dest)).toBe(true); + expect(existsSync(path.join(legacyDir, 'impeccable.md'))).toBe(false); + expect(existsSync(path.join(legacyDir, 'unrelated-command.md'))).toBe(true); + expect(readFileSync(path.join(legacyDir, 'unrelated-command.md'), 'utf8')).toBe('unrelated\n'); + } finally { + rmSync(bundle, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } + }); + + test('does not migrate a symlinked legacy dir (shared storage)', () => { + const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); + const home = mkdtempSync(path.join(tmpdir(), 'imp-cmd-home-')); + const shared = mkdtempSync(path.join(tmpdir(), 'imp-cmd-shared-')); + setupBundleWithCommand(bundle, '.opencode', ['impeccable']); + mkdirSync(path.join(home, '.opencode'), { recursive: true }); + symlinkSync(shared, path.join(home, '.opencode', 'commands'), 'dir'); + writeFileSync(path.join(shared, 'unrelated-command.md'), 'unrelated\n'); + try { + copyProviderCommands(bundle, home, ['opencode'], { scope: 'user' }); + expect(existsSync(path.join(shared, 'unrelated-command.md'))).toBe(true); + expect(lstatSync(path.join(home, '.opencode', 'commands')).isSymbolicLink()).toBe(true); + } finally { + rmSync(bundle, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + rmSync(shared, { recursive: true, force: true }); + } + }); + + test('returns 0 when the bundle has no commands dir', () => { + const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); + const project = mkdtempSync(path.join(tmpdir(), 'imp-cmd-proj-')); + try { + const written = copyProviderCommands(bundle, project, ['opencode'], { scope: 'project' }); + expect(written).toBe(0); + expect(existsSync(path.join(project, '.opencode', 'commands'))).toBe(false); + } finally { + rmSync(bundle, { recursive: true, force: true }); + rmSync(project, { recursive: true, force: true }); + } + }); + + test('ignores providers without a commands directory', () => { + const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); + const project = mkdtempSync(path.join(tmpdir(), 'imp-cmd-proj-')); + mkdirSync(path.join(bundle, 'claude'), { recursive: true }); + try { + const written = copyProviderCommands(bundle, project, ['claude'], { scope: 'project' }); + expect(written).toBe(0); + } finally { + rmSync(bundle, { recursive: true, force: true }); + rmSync(project, { recursive: true, force: true }); + } + }); +}); + +describe('isUpToDate command awareness', () => { + function setupBundleWithSkill(bundleDir, providerName, { withCommands = true } = {}) { + const skillDir = path.join(bundleDir, providerName, 'skills', 'impeccable'); + mkdirSync(path.join(skillDir, 'scripts'), { recursive: true }); + writeFileSync(path.join(skillDir, 'SKILL.md'), '---\nname: impeccable\n---\nBundle skill.\n'); + writeFileSync(path.join(skillDir, 'scripts', 'context.mjs'), 'console.log("bundle");\n'); + if (withCommands) setupBundleWithCommand(bundleDir, providerName, ['impeccable']); + } + + function mirrorBundleSkills(bundleDir, root, providerName) { + fs.cpSync( + path.join(bundleDir, providerName, 'skills'), + path.join(root, providerName, 'skills'), + { recursive: true }, + ); + } + + function mirrorBundleCommands(bundleDir, root, providerName) { + fs.cpSync( + path.join(bundleDir, providerName, 'commands'), + path.join(root, providerName, 'commands'), + { recursive: true }, + ); + } + + test('returns false when skills match but the command bridge is missing', () => { + const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); + const project = mkdtempSync(path.join(tmpdir(), 'imp-cmd-proj-')); + setupBundleWithSkill(bundle, '.opencode'); + mirrorBundleSkills(bundle, project, '.opencode'); + try { + expect(isUpToDate(project, ['.opencode'], bundle, 'project')).toBe(false); + } finally { + rmSync(bundle, { recursive: true, force: true }); + rmSync(project, { recursive: true, force: true }); + } + }); + + test('returns true when skills and commands match the bundle', () => { + const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); + const project = mkdtempSync(path.join(tmpdir(), 'imp-cmd-proj-')); + setupBundleWithSkill(bundle, '.opencode'); + mirrorBundleSkills(bundle, project, '.opencode'); + mirrorBundleCommands(bundle, project, '.opencode'); + try { + expect(isUpToDate(project, ['.opencode'], bundle, 'project')).toBe(true); + } finally { + rmSync(bundle, { recursive: true, force: true }); + rmSync(project, { recursive: true, force: true }); + } + }); + + test('returns false when the command bridge content drifted', () => { + const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); + const project = mkdtempSync(path.join(tmpdir(), 'imp-cmd-proj-')); + setupBundleWithSkill(bundle, '.opencode'); + mirrorBundleSkills(bundle, project, '.opencode'); + mirrorBundleCommands(bundle, project, '.opencode'); + writeFileSync(path.join(project, '.opencode', 'commands', 'impeccable.md'), 'user edit drift\n'); + try { + expect(isUpToDate(project, ['.opencode'], bundle, 'project')).toBe(false); + } finally { + rmSync(bundle, { recursive: true, force: true }); + rmSync(project, { recursive: true, force: true }); + } + }); + + test('ignores local-only command files such as pinned shortcuts', () => { + const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); + const project = mkdtempSync(path.join(tmpdir(), 'imp-cmd-proj-')); + setupBundleWithSkill(bundle, '.opencode'); + mirrorBundleSkills(bundle, project, '.opencode'); + mirrorBundleCommands(bundle, project, '.opencode'); + writeFileSync(path.join(project, '.opencode', 'commands', 'impeccable-audit.md'), 'pinned\n'); + try { + expect(isUpToDate(project, ['.opencode'], bundle, 'project')).toBe(true); + } finally { + rmSync(bundle, { recursive: true, force: true }); + rmSync(project, { recursive: true, force: true }); + } + }); + + test('ignores providers whose bundle has no commands directory', () => { + const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); + const project = mkdtempSync(path.join(tmpdir(), 'imp-cmd-proj-')); + setupBundleWithSkill(bundle, '.opencode', { withCommands: false }); + mirrorBundleSkills(bundle, project, '.opencode'); + try { + expect(isUpToDate(project, ['.opencode'], bundle, 'project')).toBe(true); + } finally { + rmSync(bundle, { recursive: true, force: true }); + rmSync(project, { recursive: true, force: true }); + } + }); + + test('user scope resolves the commands dir via OPENCODE_CONFIG_DIR', () => { + const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-')); + const home = mkdtempSync(path.join(tmpdir(), 'imp-cmd-home-')); + const custom = mkdtempSync(path.join(tmpdir(), 'imp-cmd-custom-')); + setupBundleWithSkill(bundle, '.opencode'); + process.env.OPENCODE_CONFIG_DIR = custom; + // User-scope OpenCode skills live at /skills (HOME_SKILLS_DIR_OVERRIDES). + fs.cpSync(path.join(bundle, '.opencode', 'skills'), path.join(custom, 'skills'), { recursive: true }); + try { + expect(isUpToDate(home, ['.opencode'], bundle, 'user')).toBe(false); + fs.cpSync(path.join(bundle, '.opencode', 'commands'), path.join(custom, 'commands'), { recursive: true }); + expect(isUpToDate(home, ['.opencode'], bundle, 'user')).toBe(true); + } finally { + rmSync(bundle, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + rmSync(custom, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/lib/transformers/opencode-commands.test.js b/tests/lib/transformers/opencode-commands.test.js new file mode 100644 index 000000000..90d04c1b9 --- /dev/null +++ b/tests/lib/transformers/opencode-commands.test.js @@ -0,0 +1,109 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import fs from 'fs'; +import path from 'path'; +import { createTransformer } from '../../../scripts/lib/transformers/factory.js'; +import { PROVIDERS } from '../../../scripts/lib/transformers/providers.js'; + +const config = PROVIDERS.opencode; +const transform = createTransformer(config); + +const TEST_DIR = path.join(process.cwd(), 'test-tmp-opencode-commands'); +const COMMAND_PATH = path.join( + TEST_DIR, + `${config.provider}/${config.configDir}/commands/impeccable.md`, +); + +const SAMPLE_SKILL = { + name: 'impeccable', + description: 'Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface.', + body: '# Impeccable\n\nSkill body here.', + references: [], + scripts: [], + agents: [], +}; + +beforeEach(() => { + if (fs.existsSync(TEST_DIR)) { + fs.rmSync(TEST_DIR, { recursive: true, force: true }); + } +}); + +afterEach(() => { + if (fs.existsSync(TEST_DIR)) { + fs.rmSync(TEST_DIR, { recursive: true, force: true }); + } +}); + +describe('opencode commands bridge', () => { + test('emits .opencode/commands/impeccable.md alongside the skill', () => { + transform([SAMPLE_SKILL], TEST_DIR); + expect(fs.existsSync(COMMAND_PATH)).toBe(true); + }); + + test('command frontmatter uses only fields OpenCode recognises', () => { + transform([SAMPLE_SKILL], TEST_DIR); + const content = fs.readFileSync(COMMAND_PATH, 'utf-8'); + const fm = content.match(/^---\n([\s\S]*?)\n---/); + expect(fm).not.toBeNull(); + const lines = fm[1].split('\n').map(l => l.trim()).filter(Boolean); + const keys = lines.map(l => l.split(':')[0]); + // OpenCode only recognises: description, agent, model, variant, subtask (per + // opencode/packages/core/src/v1/config/command.ts:5-13). + const allowed = new Set(['description', 'agent', 'model', 'variant', 'subtask']); + for (const key of keys) { + expect(allowed.has(key)).toBe(true); + } + }); + + test('command description mirrors the skill description exactly', () => { + transform([SAMPLE_SKILL], TEST_DIR); + const content = fs.readFileSync(COMMAND_PATH, 'utf-8'); + const fm = content.match(/^---\n([\s\S]*?)\n---/)[1]; + const line = fm.split('\n').find(l => l.startsWith('description:')); + const value = line.slice('description:'.length).trim().replace(/^"(.*)"$/, '$1'); + expect(value).toBe(SAMPLE_SKILL.description); + }); + + test('command body delegates to the impeccable skill', () => { + transform([SAMPLE_SKILL], TEST_DIR); + const content = fs.readFileSync(COMMAND_PATH, 'utf-8'); + const body = content.replace(/^---\n[\s\S]*?\n---\n/, ''); + expect(body).toContain('skill({'); + expect(body).toContain("name: \"impeccable\""); + expect(body).toContain('Setup'); + expect(body).toContain('Commands'); + expect(body).toContain('$ARGUMENTS'); + }); + + test('command declares agent: build and subtask: true', () => { + transform([SAMPLE_SKILL], TEST_DIR); + const content = fs.readFileSync(COMMAND_PATH, 'utf-8'); + expect(content).toMatch(/^agent: build$/m); + expect(content).toMatch(/^subtask: true$/m); + }); + + test('does not emit Claude-only frontmatter fields on the command', () => { + transform([SAMPLE_SKILL], TEST_DIR); + const content = fs.readFileSync(COMMAND_PATH, 'utf-8'); + const fm = content.match(/^---\n([\s\S]*?)\n---/)[1]; + expect(fm).not.toMatch(/^version:/m); + expect(fm).not.toMatch(/^user-invocable:/m); + expect(fm).not.toMatch(/^argument-hint:/m); + expect(fm).not.toMatch(/^allowed-tools:/m); + }); + + test('emits no command when the skill is empty', () => { + transform([], TEST_DIR); + expect(fs.existsSync(path.dirname(COMMAND_PATH))).toBe(false); + }); + + test('keeps emitting the skill alongside the command', () => { + transform([SAMPLE_SKILL], TEST_DIR); + const skillPath = path.join( + TEST_DIR, + `${config.provider}/${config.configDir}/skills/impeccable/SKILL.md`, + ); + expect(fs.existsSync(skillPath)).toBe(true); + expect(fs.existsSync(COMMAND_PATH)).toBe(true); + }); +}); diff --git a/tests/pin.test.mjs b/tests/pin.test.mjs index 8c6012458..f97212735 100644 --- a/tests/pin.test.mjs +++ b/tests/pin.test.mjs @@ -8,6 +8,18 @@ import { spawnSync } from 'node:child_process'; const ROOT = process.cwd(); const PIN_SCRIPT = path.join(ROOT, 'skill', 'scripts', 'pin.mjs'); +// Neutralize any real user-scope OpenCode config so tests never write into the +// developer's actual global install. Points the resolution at a path that does +// not exist unless a test creates it. +function cleanEnv(overrides = {}) { + return { + ...process.env, + OPENCODE_CONFIG_DIR: path.join(os.tmpdir(), 'impeccable-pin-no-config'), + XDG_CONFIG_HOME: path.join(os.tmpdir(), 'impeccable-pin-no-xdg'), + ...overrides, + }; +} + describe('pin command provider syntax', () => { let project; @@ -27,6 +39,7 @@ describe('pin command provider syntax', () => { const result = spawnSync(process.execPath, [PIN_SCRIPT, 'pin', 'audit'], { cwd: project, encoding: 'utf8', + env: cleanEnv(), }); assert.equal(result.status, 0, result.stderr || result.stdout); @@ -49,3 +62,198 @@ describe('pin command provider syntax', () => { } }); }); + +describe('pin command OpenCode target', () => { + let project; + + beforeEach(() => { + project = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-pin-oc-')); + fs.writeFileSync(path.join(project, 'package.json'), '{}\n'); + fs.mkdirSync(path.join(project, '.opencode', 'skills', 'impeccable'), { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(project, { recursive: true, force: true }); + }); + + it('writes a slash command bridge for OpenCode, not a skill shortcut', () => { + const result = spawnSync(process.execPath, [PIN_SCRIPT, 'pin', 'audit'], { + cwd: project, + encoding: 'utf8', + env: cleanEnv(), + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + + const commandPath = path.join(project, '.opencode', 'commands', 'impeccable-audit.md'); + assert.ok(fs.existsSync(commandPath), `expected ${commandPath}`); + const content = fs.readFileSync(commandPath, 'utf8'); + assert.match(content, /---\ndescription:.*audit/); + assert.match(content, /agent: build/); + assert.match(content, /subtask: true/); + assert.match(content, /\/reference\/audit\.md/); + assert.doesNotMatch(content, /user-invocable:/); + assert.doesNotMatch(content, /argument-hint:/); + + const skillPath = path.join(project, '.opencode', 'skills', 'audit', 'SKILL.md'); + assert.equal(fs.existsSync(skillPath), false, 'OpenCode pin must not create a skill shortcut'); + }); + + it('unpin removes only the OpenCode command bridge', () => { + spawnSync(process.execPath, [PIN_SCRIPT, 'pin', 'audit'], { cwd: project, encoding: 'utf8', env: cleanEnv() }); + const commandPath = path.join(project, '.opencode', 'commands', 'impeccable-audit.md'); + assert.ok(fs.existsSync(commandPath)); + + const result = spawnSync(process.execPath, [PIN_SCRIPT, 'unpin', 'audit'], { + cwd: project, + encoding: 'utf8', + env: cleanEnv(), + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(fs.existsSync(commandPath), false); + }); + + it('unpin cleans the project command after the skill was removed', () => { + spawnSync(process.execPath, [PIN_SCRIPT, 'pin', 'audit'], { cwd: project, encoding: 'utf8', env: cleanEnv() }); + const commandPath = path.join(project, '.opencode', 'commands', 'impeccable-audit.md'); + assert.ok(fs.existsSync(commandPath)); + + // Skill removed before unpin (e.g. uninstall): cleanup must still find the pin. + fs.rmSync(path.join(project, '.opencode', 'skills', 'impeccable'), { recursive: true, force: true }); + + const result = spawnSync(process.execPath, [PIN_SCRIPT, 'unpin', 'audit'], { + cwd: project, + encoding: 'utf8', + env: cleanEnv(), + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(fs.existsSync(commandPath), false, 'stale pin must be removed after skill removal'); + }); + + it('unpin after skill removal leaves non-pinned user commands alone', () => { + const commandsDir = path.join(project, '.opencode', 'commands'); + fs.mkdirSync(commandsDir, { recursive: true }); + const commandPath = path.join(commandsDir, 'impeccable-audit.md'); + fs.writeFileSync(commandPath, 'my own command, not a pin\n'); + fs.rmSync(path.join(project, '.opencode', 'skills', 'impeccable'), { recursive: true, force: true }); + + const result = spawnSync(process.execPath, [PIN_SCRIPT, 'unpin', 'audit'], { + cwd: project, + encoding: 'utf8', + env: cleanEnv(), + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.ok(fs.existsSync(commandPath), 'non-pinned user command must survive cleanup'); + }); +}); + +describe('pin command OpenCode user scope', () => { + let project; + let config; + + beforeEach(() => { + project = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-pin-usr-')); + fs.writeFileSync(path.join(project, 'package.json'), '{}\n'); + config = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-pin-cfg-')); + }); + + afterEach(() => { + fs.rmSync(project, { recursive: true, force: true }); + fs.rmSync(config, { recursive: true, force: true }); + }); + + function installUserScopeSkill(dir = config) { + fs.mkdirSync(path.join(dir, 'skills', 'impeccable'), { recursive: true }); + } + + it('pins into the user config dir when only a global install exists', () => { + installUserScopeSkill(); + const result = spawnSync(process.execPath, [PIN_SCRIPT, 'pin', 'audit'], { + cwd: project, + encoding: 'utf8', + env: cleanEnv({ OPENCODE_CONFIG_DIR: config }), + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.doesNotMatch(result.stdout, /No harness directories/); + const commandPath = path.join(config, 'commands', 'impeccable-audit.md'); + assert.ok(fs.existsSync(commandPath), `expected ${commandPath}`); + assert.match(fs.readFileSync(commandPath, 'utf8'), /impeccable-pinned-command/); + assert.equal( + fs.existsSync(path.join(project, '.opencode', 'commands')), + false, + 'must not create a project commands dir for a user-scope install', + ); + }); + + it('unpin removes the user-scope pinned command', () => { + installUserScopeSkill(); + spawnSync(process.execPath, [PIN_SCRIPT, 'pin', 'audit'], { + cwd: project, + encoding: 'utf8', + env: cleanEnv({ OPENCODE_CONFIG_DIR: config }), + }); + const commandPath = path.join(config, 'commands', 'impeccable-audit.md'); + assert.ok(fs.existsSync(commandPath)); + + const result = spawnSync(process.execPath, [PIN_SCRIPT, 'unpin', 'audit'], { + cwd: project, + encoding: 'utf8', + env: cleanEnv({ OPENCODE_CONFIG_DIR: config }), + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(fs.existsSync(commandPath), false); + }); + + it('unpin removes the user-scope pinned command after the global skill was removed', () => { + installUserScopeSkill(); + spawnSync(process.execPath, [PIN_SCRIPT, 'pin', 'audit'], { + cwd: project, + encoding: 'utf8', + env: cleanEnv({ OPENCODE_CONFIG_DIR: config }), + }); + const commandPath = path.join(config, 'commands', 'impeccable-audit.md'); + assert.ok(fs.existsSync(commandPath)); + + // Global skill removed before unpin: cleanup must still find the pin. + fs.rmSync(path.join(config, 'skills', 'impeccable'), { recursive: true, force: true }); + + const result = spawnSync(process.execPath, [PIN_SCRIPT, 'unpin', 'audit'], { + cwd: project, + encoding: 'utf8', + env: cleanEnv({ OPENCODE_CONFIG_DIR: config }), + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(fs.existsSync(commandPath), false, 'stale user-scope pin must be removed after skill removal'); + }); + + it('pins in both scopes when project and user installs coexist', () => { + installUserScopeSkill(); + fs.mkdirSync(path.join(project, '.opencode', 'skills', 'impeccable'), { recursive: true }); + const result = spawnSync(process.execPath, [PIN_SCRIPT, 'pin', 'audit'], { + cwd: project, + encoding: 'utf8', + env: cleanEnv({ OPENCODE_CONFIG_DIR: config }), + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.ok(fs.existsSync(path.join(config, 'commands', 'impeccable-audit.md')), 'user-scope pin'); + assert.ok(fs.existsSync(path.join(project, '.opencode', 'commands', 'impeccable-audit.md')), 'project pin'); + }); + + it('honours XDG_CONFIG_HOME when OPENCODE_CONFIG_DIR is unset', () => { + const xdg = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-pin-xdg-')); + installUserScopeSkill(path.join(xdg, 'opencode')); + try { + const result = spawnSync(process.execPath, [PIN_SCRIPT, 'pin', 'audit'], { + cwd: project, + encoding: 'utf8', + env: cleanEnv({ OPENCODE_CONFIG_DIR: undefined, XDG_CONFIG_HOME: xdg }), + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.ok(fs.existsSync(path.join(xdg, 'opencode', 'commands', 'impeccable-audit.md'))); + } finally { + fs.rmSync(xdg, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/root-commands-sync.test.js b/tests/root-commands-sync.test.js new file mode 100644 index 000000000..389607e15 --- /dev/null +++ b/tests/root-commands-sync.test.js @@ -0,0 +1,73 @@ +/** + * Tests for syncRootCommands. The post-merge release sync must mirror + * generated provider command files (e.g. OpenCode's commands/impeccable.md) + * into the tracked root harness folders, or direct GitHub / submodule / + * npx-skills installs ship OpenCode without the slash command bridge (#483). + */ +import { describe, test, expect } from 'bun:test'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +import { syncRootCommands } from '../scripts/lib/root-commands-sync.mjs'; + +function setupDist(distDir, provider, configDir, commands) { + if (commands === null) return; + const dir = join(distDir, provider, configDir, 'commands'); + mkdirSync(dir, { recursive: true }); + for (const [name, body] of Object.entries(commands)) { + writeFileSync(join(dir, name), body); + } +} + +describe('syncRootCommands', () => { + test('mirrors generated command files into the root harness folder', () => { + const dist = mkdtempSync(join(tmpdir(), 'imp-sync-dist-')); + const root = mkdtempSync(join(tmpdir(), 'imp-sync-root-')); + setupDist(dist, 'opencode', '.opencode', { 'impeccable.md': 'bridge v1\n' }); + try { + const synced = syncRootCommands(dist, root, [{ provider: 'opencode', configDir: '.opencode' }]); + expect(synced).toEqual(['.opencode']); + expect(readFileSync(join(root, '.opencode', 'commands', 'impeccable.md'), 'utf8')).toBe('bridge v1\n'); + } finally { + rmSync(dist, { recursive: true, force: true }); + rmSync(root, { recursive: true, force: true }); + } + }); + + test('preserves repo-local or pinned command files already at the destination', () => { + const dist = mkdtempSync(join(tmpdir(), 'imp-sync-dist-')); + const root = mkdtempSync(join(tmpdir(), 'imp-sync-root-')); + setupDist(dist, 'opencode', '.opencode', { 'impeccable.md': 'bridge v2\n' }); + const destDir = join(root, '.opencode', 'commands'); + mkdirSync(destDir, { recursive: true }); + writeFileSync(join(destDir, 'impeccable-audit.md'), 'pinned by user\n'); + writeFileSync(join(destDir, 'impeccable.md'), 'stale bridge\n'); + try { + syncRootCommands(dist, root, [{ provider: 'opencode', configDir: '.opencode' }]); + expect(readFileSync(join(destDir, 'impeccable.md'), 'utf8')).toBe('bridge v2\n'); + expect(readFileSync(join(destDir, 'impeccable-audit.md'), 'utf8')).toBe('pinned by user\n'); + } finally { + rmSync(dist, { recursive: true, force: true }); + rmSync(root, { recursive: true, force: true }); + } + }); + + test('skips providers whose dist variant has no commands dir', () => { + const dist = mkdtempSync(join(tmpdir(), 'imp-sync-dist-')); + const root = mkdtempSync(join(tmpdir(), 'imp-sync-root-')); + setupDist(dist, 'opencode', '.opencode', { 'impeccable.md': 'bridge\n' }); + setupDist(dist, 'claude-code', '.claude', null); + try { + const synced = syncRootCommands(dist, root, [ + { provider: 'opencode', configDir: '.opencode' }, + { provider: 'claude-code', configDir: '.claude' }, + ]); + expect(synced).toEqual(['.opencode']); + expect(existsSync(join(root, '.claude', 'commands'))).toBe(false); + } finally { + rmSync(dist, { recursive: true, force: true }); + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/skills-cli.test.js b/tests/skills-cli.test.js index cd315ba90..781407461 100644 --- a/tests/skills-cli.test.js +++ b/tests/skills-cli.test.js @@ -11,7 +11,7 @@ */ import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; import { execSync, execFileSync } from 'child_process'; -import { mkdtempSync, existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, chmodSync, rmSync, lstatSync, realpathSync, readlinkSync, symlinkSync, statSync } from 'fs'; +import { mkdtempSync, existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, chmodSync, rmSync, lstatSync, realpathSync, readlinkSync, symlinkSync, statSync, cpSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { @@ -69,6 +69,18 @@ function createFakeLinkSource(root, providers = ['.claude']) { for (const provider of providers) { writeSkill(join(root, '.impeccable', 'dist', 'universal'), provider, 'impeccable'); } + if (providers.includes('.opencode')) { + const commandsDir = join(root, '.impeccable', 'dist', 'universal', '.opencode', 'commands'); + mkdirSync(commandsDir, { recursive: true }); + writeFileSync(join(commandsDir, 'impeccable.md'), [ + 'description: Impeccable impeccable bridge', + 'agent: build', + 'subtask: true', + '', + 'body impeccable', + '', + ].join('\n')); + } } function createFakeUniversalBundle(root, providers = ['.claude', '.agents', '.cursor']) { @@ -111,6 +123,18 @@ function createFakeUniversalBundle(root, providers = ['.claude', '.agents', '.cu hooks: { PostToolUse: [{ matcher: 'apply_patch', hooks: [{ type: 'command', command: 'node ".codex/skills/impeccable/scripts/hook.mjs"' }] }] }, }, null, 2)); } + if (providers.includes('.opencode')) { + const commandsDir = join(bundleRoot, '.opencode', 'commands'); + mkdirSync(commandsDir, { recursive: true }); + writeFileSync(join(commandsDir, 'impeccable.md'), [ + 'description: Impeccable impeccable bridge', + 'agent: build', + 'subtask: true', + '', + 'body impeccable', + '', + ].join('\n')); + } if (providers.includes('.grok')) { mkdirSync(join(bundleRoot, '.grok', 'hooks'), { recursive: true }); writeFileSync(join(bundleRoot, '.grok', 'hooks', 'impeccable.json'), JSON.stringify({ @@ -560,6 +584,23 @@ describe('skills install: already-installed detection', () => { // ─── Submodule/link installs ──────────────────────────────────────────────── describe('skills link: submodule installs', () => { + test('writes the OpenCode command bridge alongside linked skills', () => { + const tmp = mkdtempSync(join(tmpdir(), 'imp-test-link-bridge-')); + execSync('git init', { cwd: tmp }); + createFakeLinkSource(tmp, ['.opencode']); + + const output = run('skills link --source=.impeccable --providers=opencode -y', { cwd: tmp }); + expect(output).toContain('Linked impeccable into: .opencode'); + + const dest = join(tmp, '.opencode', 'skills', 'impeccable'); + expect(lstatSync(dest).isSymbolicLink()).toBe(true); + const bridge = join(tmp, '.opencode', 'commands', 'impeccable.md'); + expect(existsSync(bridge)).toBe(true); + expect(readFileSync(bridge, 'utf8')).toContain('impeccable bridge'); + + rmSync(tmp, { recursive: true, force: true }); + }, 15000); + test('creates relative skill symlinks from dist/universal', () => { const tmp = mkdtempSync(join(tmpdir(), 'imp-test-link-')); execSync('git init', { cwd: tmp }); @@ -1754,6 +1795,99 @@ describe('skills install/update: local universal bundle e2e', () => { rmSync(tmp, { recursive: true, force: true }); }, 15000); + test('reinstall backfills a missing OpenCode command bridge when skills are current', () => { + const tmp = mkdtempSync(join(tmpdir(), 'imp-test-reinstall-backfill-')); + execSync('git init', { cwd: tmp }); + const bundleRoot = createFakeUniversalBundle(tmp, ['.opencode']); + const env = { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot }; + + run('skills install -y --providers=opencode --no-hooks', { cwd: tmp, env }); + const bridge = join(tmp, '.opencode', 'commands', 'impeccable.md'); + expect(existsSync(bridge)).toBe(true); + rmSync(bridge); + + const output = run('skills install -y --providers=opencode --no-hooks', { cwd: tmp, env }); + expect(output).toContain('already installed'); + expect(existsSync(bridge)).toBe(true); + + rmSync(tmp, { recursive: true, force: true }); + }, 15000); + + test('skills update backfills a missing OpenCode command bridge when skills are current', () => { + const tmp = mkdtempSync(join(tmpdir(), 'imp-test-update-backfill-')); + execSync('git init', { cwd: tmp }); + const bundleRoot = createFakeUniversalBundle(tmp, ['.opencode']); + const env = { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot }; + + run('skills install -y --providers=opencode --no-hooks', { cwd: tmp, env }); + const bridge = join(tmp, '.opencode', 'commands', 'impeccable.md'); + expect(existsSync(bridge)).toBe(true); + rmSync(bridge); + + run('skills update -y --no-hooks', { cwd: tmp, env }); + expect(existsSync(bridge)).toBe(true); + + rmSync(tmp, { recursive: true, force: true }); + }, 15000); + + test('skills update restores a command bridge whose content drifted', () => { + const tmp = mkdtempSync(join(tmpdir(), 'imp-test-update-drifted-bridge-')); + execSync('git init', { cwd: tmp }); + const bundleRoot = createFakeUniversalBundle(tmp, ['.opencode']); + const env = { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot }; + + run('skills install -y --providers=opencode --no-hooks', { cwd: tmp, env }); + const bridge = join(tmp, '.opencode', 'commands', 'impeccable.md'); + writeFileSync(bridge, 'user edit drift\n'); + + run('skills update -y --no-hooks', { cwd: tmp, env }); + expect(readFileSync(bridge, 'utf8')).toContain('impeccable bridge'); + + rmSync(tmp, { recursive: true, force: true }); + }, 15000); + + test('skills check from the home dir recognises a global OpenCode install as current', () => { + // Bugbot scenario: `skills check` runs scope-less, so a home-rooted run + // matches the GLOBAL skills dir via HOME_SKILLS_DIR_OVERRIDES. The command + // bridge must be resolved next to that matched skills dir, not at + // /.opencode/commands. os.homedir() only honours HOME at process + // start, so this must run through the CLI subprocess, not in-process. + const home = mkdtempSync(join(tmpdir(), 'imp-test-check-home-')); + execSync('git init', { cwd: home }); + const bundleRoot = createFakeUniversalBundle(home, ['.opencode']); + const configHome = mkdtempSync(join(tmpdir(), 'imp-test-check-config-')); + cpSync(join(bundleRoot, '.opencode', 'skills'), join(configHome, 'skills'), { recursive: true }); + cpSync(join(bundleRoot, '.opencode', 'commands'), join(configHome, 'commands'), { recursive: true }); + + const output = run('skills check', { + cwd: home, + env: { ...process.env, HOME: home, OPENCODE_CONFIG_DIR: configHome, IMPECCABLE_BUNDLE_PATH: bundleRoot }, + }); + expect(output).toContain('Skills are up to date'); + + rmSync(home, { recursive: true, force: true }); + rmSync(configHome, { recursive: true, force: true }); + }, 15000); + + test('skills update leaves an intact command bridge and pinned siblings alone', () => { + const tmp = mkdtempSync(join(tmpdir(), 'imp-test-update-bridge-intact-')); + execSync('git init', { cwd: tmp }); + const bundleRoot = createFakeUniversalBundle(tmp, ['.opencode']); + const env = { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot }; + + run('skills install -y --providers=opencode --no-hooks', { cwd: tmp, env }); + const bridge = join(tmp, '.opencode', 'commands', 'impeccable.md'); + const pinned = join(tmp, '.opencode', 'commands', 'impeccable-audit.md'); + writeFileSync(pinned, 'pinned by user\n'); + + const output = run('skills update -y --no-hooks', { cwd: tmp, env }); + expect(output).toContain('Skills are up to date'); + expect(readFileSync(bridge, 'utf8')).toContain('impeccable bridge'); + expect(readFileSync(pinned, 'utf8')).toBe('pinned by user\n'); + + rmSync(tmp, { recursive: true, force: true }); + }, 15000); + test('skills update reports malformed hook manifests cleanly on the up-to-date path', () => { const tmp = mkdtempSync(join(tmpdir(), 'imp-test-update-bad-hooks-')); execSync('git init', { cwd: tmp }); From 4981192613887272043216b2cefe69e2d4f8d981 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:06:17 +0000 Subject: [PATCH 17/31] Sync generated provider output --- .agents/skills/impeccable/scripts/pin.mjs | 122 +++++++++++++++++++- .claude/skills/impeccable/scripts/pin.mjs | 122 +++++++++++++++++++- .cursor/skills/impeccable/scripts/pin.mjs | 122 +++++++++++++++++++- .gemini/skills/impeccable/scripts/pin.mjs | 122 +++++++++++++++++++- .github/skills/impeccable/scripts/pin.mjs | 122 +++++++++++++++++++- .grok/skills/impeccable/scripts/pin.mjs | 122 +++++++++++++++++++- .hermes/skills/impeccable/scripts/pin.mjs | 122 +++++++++++++++++++- .kiro/skills/impeccable/scripts/pin.mjs | 122 +++++++++++++++++++- .opencode/commands/impeccable.md | 6 + .opencode/skills/impeccable/scripts/pin.mjs | 122 +++++++++++++++++++- .pi/skills/impeccable/scripts/pin.mjs | 122 +++++++++++++++++++- .qoder/skills/impeccable/scripts/pin.mjs | 122 +++++++++++++++++++- .rovodev/skills/impeccable/scripts/pin.mjs | 122 +++++++++++++++++++- .trae-cn/skills/impeccable/scripts/pin.mjs | 122 +++++++++++++++++++- .trae/skills/impeccable/scripts/pin.mjs | 122 +++++++++++++++++++- .vibe/skills/impeccable/scripts/pin.mjs | 122 +++++++++++++++++++- plugin/skills/impeccable/scripts/pin.mjs | 122 +++++++++++++++++++- 17 files changed, 1910 insertions(+), 48 deletions(-) create mode 100644 .opencode/commands/impeccable.md diff --git a/.agents/skills/impeccable/scripts/pin.mjs b/.agents/skills/impeccable/scripts/pin.mjs index d80043df7..6a4323194 100644 --- a/.agents/skills/impeccable/scripts/pin.mjs +++ b/.agents/skills/impeccable/scripts/pin.mjs @@ -14,8 +14,9 @@ */ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync } from 'node:fs'; -import { basename, join, resolve, dirname } from 'node:path'; +import { basename, join, resolve, dirname, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { homedir } from 'node:os'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -115,21 +116,119 @@ Invoke ${commandPrefix}impeccable ${command}, passing along any arguments provid `; } +// OpenCode 1.18.10 does not honor `user-invocable: true` on SKILL.md frontmatter +// (see docs/HARNESSES.md and opencode/packages/core/src/v1/config/command.ts), +// so a pinned skill there shows up in `opencode debug skill` but never in the +// slash menu. The fix is a sibling `commands/impeccable-.md` that uses the +// OpenCode command schema (description, agent, subtask). Body loads the skill +// via the skill tool and then the sub-command's reference file directly, so +// /impeccable- runs the same workflow /impeccable routes to. +const OPENCODE_PIN_MARKER = ''; +function generatePinnedOpencodeCommand(command, metadata) { + const desc = metadata[command]?.description || `Impeccable sub-command shortcut; runs the ${command} workflow via /impeccable.`; + return `--- +description: "${desc}" +agent: build +subtask: true +--- + +${OPENCODE_PIN_MARKER} + +Load the \`impeccable\` skill via the skill tool (name: "impeccable"), then run \`node /scripts/context.mjs\`, then load \`/reference/${command}.md\` and follow it. \`\` is the skill's base directory as reported by the skill tool response; substitute the actual absolute path before running or reading anything. + +$ARGUMENTS +`; +} + +// OpenCode's user-scope config dir. Mirrors the CLI's opencodeGlobalConfigDir +// precedence (OPENCODE_CONFIG_DIR → XDG_CONFIG_HOME/opencode → +// ~/.config/opencode); duplicated here because this script ships inside the +// installed skill and cannot import the CLI. +function opencodeUserConfigDir() { + if (process.env.OPENCODE_CONFIG_DIR) return process.env.OPENCODE_CONFIG_DIR; + if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, 'opencode'); + return join(homedir(), '.config', 'opencode'); +} + +/** + * Resolve every commands dir that should receive an OpenCode pin: the + * project-local dir when the project has the skill, plus the user config dir + * when Impeccable is installed globally (#406 layout). A user-scope skill is + * visible from every project, so its pinned commands belong next to it. + * With `forCleanup`, both commands dirs are included even when the skill is + * gone, so unpin can still reach a pin left behind by a removed install; + * removal stays safe because removePinnedOpencodeCommand is marker-guarded. + */ +function findOpencodeCommandsDirs(projectRoot, { forCleanup = false } = {}) { + const dirs = []; + const seen = new Set(); + const push = (commandsDir) => { + const key = resolve(commandsDir); + if (!seen.has(key)) { + seen.add(key); + dirs.push(commandsDir); + } + }; + if (forCleanup || existsSync(join(projectRoot, '.opencode', 'skills', 'impeccable'))) { + push(join(projectRoot, '.opencode', 'commands')); + } + const userConfig = opencodeUserConfigDir(); + if (forCleanup || existsSync(join(userConfig, 'skills', 'impeccable'))) { + push(join(userConfig, 'commands')); + } + return dirs; +} + +function writePinnedOpencodeCommand(commandsDir, command, metadata) { + const commandFile = join(commandsDir, `impeccable-${command}.md`); + if (existsSync(commandFile)) { + const existing = readFileSync(commandFile, 'utf-8'); + if (!existing.includes(OPENCODE_PIN_MARKER)) { + console.log(` SKIP: ${commandFile} (non-pinned command already exists)`); + return false; + } + } else { + mkdirSync(commandsDir, { recursive: true }); + } + writeFileSync(commandFile, generatePinnedOpencodeCommand(command, metadata)); + console.log(` + ${commandFile}`); + return true; +} + +function removePinnedOpencodeCommand(commandsDir, command) { + const commandFile = join(commandsDir, `impeccable-${command}.md`); + if (!existsSync(commandFile)) return false; + const content = readFileSync(commandFile, 'utf-8'); + if (!content.includes(OPENCODE_PIN_MARKER)) { + console.log(` SKIP: ${commandFile} (not a pinned command)`); + return false; + } + rmSync(commandFile, { force: true }); + console.log(` - ${commandFile}`); + return true; +} + /** * Pin a command: create shortcut skill in all harness dirs. */ function pin(command, projectRoot) { const metadata = loadCommandMetadata(); const harnessDirs = findHarnessDirs(projectRoot); + const opencodeCommandsDirs = findOpencodeCommandsDirs(projectRoot); - if (harnessDirs.length === 0) { + if (harnessDirs.length === 0 && opencodeCommandsDirs.length === 0) { console.log('No harness directories with impeccable installed found.'); return false; } let created = 0; + // OpenCode is handled separately below because its shortcut format is a + // slash command, not a SKILL.md. Excluding it from the skill loop here + // prevents a duplicate `.opencode/skills//SKILL.md` that OpenCode + // would never surface as `/`. for (const skillsDir of harnessDirs) { + if (skillsDir.includes(`${sep}.opencode${sep}`)) continue; const commandPrefix = commandPrefixForSkillsDir(skillsDir); const content = generatePinnedSkill(command, metadata, commandPrefix, commandPrefix === '$'); // Check if skill already exists (and isn't a pin) @@ -151,6 +250,12 @@ function pin(command, projectRoot) { created++; } + // OpenCode: write a slash command bridge, not a skill shortcut. Covers both + // project installs and user-scope (global config) installs. + for (const commandsDir of opencodeCommandsDirs) { + if (writePinnedOpencodeCommand(commandsDir, command, metadata)) created++; + } + if (created > 0) { console.log(`\nPinned '${command}' as a standalone shortcut in ${created} location(s).`); console.log('Use the pinned command directly in each harness.'); @@ -160,13 +265,17 @@ function pin(command, projectRoot) { } /** - * Unpin a command: remove shortcut skill from all harness dirs. + * Unpin a command: remove shortcut skill in all harness dirs. */ function unpin(command, projectRoot) { const harnessDirs = findHarnessDirs(projectRoot); let removed = 0; + // OpenCode has its own cleanup path below; skip the skill loop here so a + // stray `.opencode/skills//SKILL.md` written by an older Impeccable + // version is never silently dropped here. for (const skillsDir of harnessDirs) { + if (skillsDir.includes(`${sep}.opencode${sep}`)) continue; const skillDir = join(skillsDir, command); if (!existsSync(skillDir)) continue; @@ -185,6 +294,13 @@ function unpin(command, projectRoot) { removed++; } + // OpenCode: remove the pinned command file if it's one of ours, in every + // scope it could have been written to — even when the skill itself is + // already gone, since removal is marker-guarded. + for (const commandsDir of findOpencodeCommandsDirs(projectRoot, { forCleanup: true })) { + if (removePinnedOpencodeCommand(commandsDir, command)) removed++; + } + if (removed > 0) { console.log(`\nUnpinned '${command}' from ${removed} location(s).`); console.log(`Use Impeccable's '${command}' workflow directly to access it.`); diff --git a/.claude/skills/impeccable/scripts/pin.mjs b/.claude/skills/impeccable/scripts/pin.mjs index d80043df7..6a4323194 100644 --- a/.claude/skills/impeccable/scripts/pin.mjs +++ b/.claude/skills/impeccable/scripts/pin.mjs @@ -14,8 +14,9 @@ */ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync } from 'node:fs'; -import { basename, join, resolve, dirname } from 'node:path'; +import { basename, join, resolve, dirname, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { homedir } from 'node:os'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -115,21 +116,119 @@ Invoke ${commandPrefix}impeccable ${command}, passing along any arguments provid `; } +// OpenCode 1.18.10 does not honor `user-invocable: true` on SKILL.md frontmatter +// (see docs/HARNESSES.md and opencode/packages/core/src/v1/config/command.ts), +// so a pinned skill there shows up in `opencode debug skill` but never in the +// slash menu. The fix is a sibling `commands/impeccable-.md` that uses the +// OpenCode command schema (description, agent, subtask). Body loads the skill +// via the skill tool and then the sub-command's reference file directly, so +// /impeccable- runs the same workflow /impeccable routes to. +const OPENCODE_PIN_MARKER = ''; +function generatePinnedOpencodeCommand(command, metadata) { + const desc = metadata[command]?.description || `Impeccable sub-command shortcut; runs the ${command} workflow via /impeccable.`; + return `--- +description: "${desc}" +agent: build +subtask: true +--- + +${OPENCODE_PIN_MARKER} + +Load the \`impeccable\` skill via the skill tool (name: "impeccable"), then run \`node /scripts/context.mjs\`, then load \`/reference/${command}.md\` and follow it. \`\` is the skill's base directory as reported by the skill tool response; substitute the actual absolute path before running or reading anything. + +$ARGUMENTS +`; +} + +// OpenCode's user-scope config dir. Mirrors the CLI's opencodeGlobalConfigDir +// precedence (OPENCODE_CONFIG_DIR → XDG_CONFIG_HOME/opencode → +// ~/.config/opencode); duplicated here because this script ships inside the +// installed skill and cannot import the CLI. +function opencodeUserConfigDir() { + if (process.env.OPENCODE_CONFIG_DIR) return process.env.OPENCODE_CONFIG_DIR; + if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, 'opencode'); + return join(homedir(), '.config', 'opencode'); +} + +/** + * Resolve every commands dir that should receive an OpenCode pin: the + * project-local dir when the project has the skill, plus the user config dir + * when Impeccable is installed globally (#406 layout). A user-scope skill is + * visible from every project, so its pinned commands belong next to it. + * With `forCleanup`, both commands dirs are included even when the skill is + * gone, so unpin can still reach a pin left behind by a removed install; + * removal stays safe because removePinnedOpencodeCommand is marker-guarded. + */ +function findOpencodeCommandsDirs(projectRoot, { forCleanup = false } = {}) { + const dirs = []; + const seen = new Set(); + const push = (commandsDir) => { + const key = resolve(commandsDir); + if (!seen.has(key)) { + seen.add(key); + dirs.push(commandsDir); + } + }; + if (forCleanup || existsSync(join(projectRoot, '.opencode', 'skills', 'impeccable'))) { + push(join(projectRoot, '.opencode', 'commands')); + } + const userConfig = opencodeUserConfigDir(); + if (forCleanup || existsSync(join(userConfig, 'skills', 'impeccable'))) { + push(join(userConfig, 'commands')); + } + return dirs; +} + +function writePinnedOpencodeCommand(commandsDir, command, metadata) { + const commandFile = join(commandsDir, `impeccable-${command}.md`); + if (existsSync(commandFile)) { + const existing = readFileSync(commandFile, 'utf-8'); + if (!existing.includes(OPENCODE_PIN_MARKER)) { + console.log(` SKIP: ${commandFile} (non-pinned command already exists)`); + return false; + } + } else { + mkdirSync(commandsDir, { recursive: true }); + } + writeFileSync(commandFile, generatePinnedOpencodeCommand(command, metadata)); + console.log(` + ${commandFile}`); + return true; +} + +function removePinnedOpencodeCommand(commandsDir, command) { + const commandFile = join(commandsDir, `impeccable-${command}.md`); + if (!existsSync(commandFile)) return false; + const content = readFileSync(commandFile, 'utf-8'); + if (!content.includes(OPENCODE_PIN_MARKER)) { + console.log(` SKIP: ${commandFile} (not a pinned command)`); + return false; + } + rmSync(commandFile, { force: true }); + console.log(` - ${commandFile}`); + return true; +} + /** * Pin a command: create shortcut skill in all harness dirs. */ function pin(command, projectRoot) { const metadata = loadCommandMetadata(); const harnessDirs = findHarnessDirs(projectRoot); + const opencodeCommandsDirs = findOpencodeCommandsDirs(projectRoot); - if (harnessDirs.length === 0) { + if (harnessDirs.length === 0 && opencodeCommandsDirs.length === 0) { console.log('No harness directories with impeccable installed found.'); return false; } let created = 0; + // OpenCode is handled separately below because its shortcut format is a + // slash command, not a SKILL.md. Excluding it from the skill loop here + // prevents a duplicate `.opencode/skills//SKILL.md` that OpenCode + // would never surface as `/`. for (const skillsDir of harnessDirs) { + if (skillsDir.includes(`${sep}.opencode${sep}`)) continue; const commandPrefix = commandPrefixForSkillsDir(skillsDir); const content = generatePinnedSkill(command, metadata, commandPrefix, commandPrefix === '$'); // Check if skill already exists (and isn't a pin) @@ -151,6 +250,12 @@ function pin(command, projectRoot) { created++; } + // OpenCode: write a slash command bridge, not a skill shortcut. Covers both + // project installs and user-scope (global config) installs. + for (const commandsDir of opencodeCommandsDirs) { + if (writePinnedOpencodeCommand(commandsDir, command, metadata)) created++; + } + if (created > 0) { console.log(`\nPinned '${command}' as a standalone shortcut in ${created} location(s).`); console.log('Use the pinned command directly in each harness.'); @@ -160,13 +265,17 @@ function pin(command, projectRoot) { } /** - * Unpin a command: remove shortcut skill from all harness dirs. + * Unpin a command: remove shortcut skill in all harness dirs. */ function unpin(command, projectRoot) { const harnessDirs = findHarnessDirs(projectRoot); let removed = 0; + // OpenCode has its own cleanup path below; skip the skill loop here so a + // stray `.opencode/skills//SKILL.md` written by an older Impeccable + // version is never silently dropped here. for (const skillsDir of harnessDirs) { + if (skillsDir.includes(`${sep}.opencode${sep}`)) continue; const skillDir = join(skillsDir, command); if (!existsSync(skillDir)) continue; @@ -185,6 +294,13 @@ function unpin(command, projectRoot) { removed++; } + // OpenCode: remove the pinned command file if it's one of ours, in every + // scope it could have been written to — even when the skill itself is + // already gone, since removal is marker-guarded. + for (const commandsDir of findOpencodeCommandsDirs(projectRoot, { forCleanup: true })) { + if (removePinnedOpencodeCommand(commandsDir, command)) removed++; + } + if (removed > 0) { console.log(`\nUnpinned '${command}' from ${removed} location(s).`); console.log(`Use Impeccable's '${command}' workflow directly to access it.`); diff --git a/.cursor/skills/impeccable/scripts/pin.mjs b/.cursor/skills/impeccable/scripts/pin.mjs index d80043df7..6a4323194 100644 --- a/.cursor/skills/impeccable/scripts/pin.mjs +++ b/.cursor/skills/impeccable/scripts/pin.mjs @@ -14,8 +14,9 @@ */ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync } from 'node:fs'; -import { basename, join, resolve, dirname } from 'node:path'; +import { basename, join, resolve, dirname, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { homedir } from 'node:os'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -115,21 +116,119 @@ Invoke ${commandPrefix}impeccable ${command}, passing along any arguments provid `; } +// OpenCode 1.18.10 does not honor `user-invocable: true` on SKILL.md frontmatter +// (see docs/HARNESSES.md and opencode/packages/core/src/v1/config/command.ts), +// so a pinned skill there shows up in `opencode debug skill` but never in the +// slash menu. The fix is a sibling `commands/impeccable-.md` that uses the +// OpenCode command schema (description, agent, subtask). Body loads the skill +// via the skill tool and then the sub-command's reference file directly, so +// /impeccable- runs the same workflow /impeccable routes to. +const OPENCODE_PIN_MARKER = ''; +function generatePinnedOpencodeCommand(command, metadata) { + const desc = metadata[command]?.description || `Impeccable sub-command shortcut; runs the ${command} workflow via /impeccable.`; + return `--- +description: "${desc}" +agent: build +subtask: true +--- + +${OPENCODE_PIN_MARKER} + +Load the \`impeccable\` skill via the skill tool (name: "impeccable"), then run \`node /scripts/context.mjs\`, then load \`/reference/${command}.md\` and follow it. \`\` is the skill's base directory as reported by the skill tool response; substitute the actual absolute path before running or reading anything. + +$ARGUMENTS +`; +} + +// OpenCode's user-scope config dir. Mirrors the CLI's opencodeGlobalConfigDir +// precedence (OPENCODE_CONFIG_DIR → XDG_CONFIG_HOME/opencode → +// ~/.config/opencode); duplicated here because this script ships inside the +// installed skill and cannot import the CLI. +function opencodeUserConfigDir() { + if (process.env.OPENCODE_CONFIG_DIR) return process.env.OPENCODE_CONFIG_DIR; + if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, 'opencode'); + return join(homedir(), '.config', 'opencode'); +} + +/** + * Resolve every commands dir that should receive an OpenCode pin: the + * project-local dir when the project has the skill, plus the user config dir + * when Impeccable is installed globally (#406 layout). A user-scope skill is + * visible from every project, so its pinned commands belong next to it. + * With `forCleanup`, both commands dirs are included even when the skill is + * gone, so unpin can still reach a pin left behind by a removed install; + * removal stays safe because removePinnedOpencodeCommand is marker-guarded. + */ +function findOpencodeCommandsDirs(projectRoot, { forCleanup = false } = {}) { + const dirs = []; + const seen = new Set(); + const push = (commandsDir) => { + const key = resolve(commandsDir); + if (!seen.has(key)) { + seen.add(key); + dirs.push(commandsDir); + } + }; + if (forCleanup || existsSync(join(projectRoot, '.opencode', 'skills', 'impeccable'))) { + push(join(projectRoot, '.opencode', 'commands')); + } + const userConfig = opencodeUserConfigDir(); + if (forCleanup || existsSync(join(userConfig, 'skills', 'impeccable'))) { + push(join(userConfig, 'commands')); + } + return dirs; +} + +function writePinnedOpencodeCommand(commandsDir, command, metadata) { + const commandFile = join(commandsDir, `impeccable-${command}.md`); + if (existsSync(commandFile)) { + const existing = readFileSync(commandFile, 'utf-8'); + if (!existing.includes(OPENCODE_PIN_MARKER)) { + console.log(` SKIP: ${commandFile} (non-pinned command already exists)`); + return false; + } + } else { + mkdirSync(commandsDir, { recursive: true }); + } + writeFileSync(commandFile, generatePinnedOpencodeCommand(command, metadata)); + console.log(` + ${commandFile}`); + return true; +} + +function removePinnedOpencodeCommand(commandsDir, command) { + const commandFile = join(commandsDir, `impeccable-${command}.md`); + if (!existsSync(commandFile)) return false; + const content = readFileSync(commandFile, 'utf-8'); + if (!content.includes(OPENCODE_PIN_MARKER)) { + console.log(` SKIP: ${commandFile} (not a pinned command)`); + return false; + } + rmSync(commandFile, { force: true }); + console.log(` - ${commandFile}`); + return true; +} + /** * Pin a command: create shortcut skill in all harness dirs. */ function pin(command, projectRoot) { const metadata = loadCommandMetadata(); const harnessDirs = findHarnessDirs(projectRoot); + const opencodeCommandsDirs = findOpencodeCommandsDirs(projectRoot); - if (harnessDirs.length === 0) { + if (harnessDirs.length === 0 && opencodeCommandsDirs.length === 0) { console.log('No harness directories with impeccable installed found.'); return false; } let created = 0; + // OpenCode is handled separately below because its shortcut format is a + // slash command, not a SKILL.md. Excluding it from the skill loop here + // prevents a duplicate `.opencode/skills//SKILL.md` that OpenCode + // would never surface as `/`. for (const skillsDir of harnessDirs) { + if (skillsDir.includes(`${sep}.opencode${sep}`)) continue; const commandPrefix = commandPrefixForSkillsDir(skillsDir); const content = generatePinnedSkill(command, metadata, commandPrefix, commandPrefix === '$'); // Check if skill already exists (and isn't a pin) @@ -151,6 +250,12 @@ function pin(command, projectRoot) { created++; } + // OpenCode: write a slash command bridge, not a skill shortcut. Covers both + // project installs and user-scope (global config) installs. + for (const commandsDir of opencodeCommandsDirs) { + if (writePinnedOpencodeCommand(commandsDir, command, metadata)) created++; + } + if (created > 0) { console.log(`\nPinned '${command}' as a standalone shortcut in ${created} location(s).`); console.log('Use the pinned command directly in each harness.'); @@ -160,13 +265,17 @@ function pin(command, projectRoot) { } /** - * Unpin a command: remove shortcut skill from all harness dirs. + * Unpin a command: remove shortcut skill in all harness dirs. */ function unpin(command, projectRoot) { const harnessDirs = findHarnessDirs(projectRoot); let removed = 0; + // OpenCode has its own cleanup path below; skip the skill loop here so a + // stray `.opencode/skills//SKILL.md` written by an older Impeccable + // version is never silently dropped here. for (const skillsDir of harnessDirs) { + if (skillsDir.includes(`${sep}.opencode${sep}`)) continue; const skillDir = join(skillsDir, command); if (!existsSync(skillDir)) continue; @@ -185,6 +294,13 @@ function unpin(command, projectRoot) { removed++; } + // OpenCode: remove the pinned command file if it's one of ours, in every + // scope it could have been written to — even when the skill itself is + // already gone, since removal is marker-guarded. + for (const commandsDir of findOpencodeCommandsDirs(projectRoot, { forCleanup: true })) { + if (removePinnedOpencodeCommand(commandsDir, command)) removed++; + } + if (removed > 0) { console.log(`\nUnpinned '${command}' from ${removed} location(s).`); console.log(`Use Impeccable's '${command}' workflow directly to access it.`); diff --git a/.gemini/skills/impeccable/scripts/pin.mjs b/.gemini/skills/impeccable/scripts/pin.mjs index d80043df7..6a4323194 100644 --- a/.gemini/skills/impeccable/scripts/pin.mjs +++ b/.gemini/skills/impeccable/scripts/pin.mjs @@ -14,8 +14,9 @@ */ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync } from 'node:fs'; -import { basename, join, resolve, dirname } from 'node:path'; +import { basename, join, resolve, dirname, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { homedir } from 'node:os'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -115,21 +116,119 @@ Invoke ${commandPrefix}impeccable ${command}, passing along any arguments provid `; } +// OpenCode 1.18.10 does not honor `user-invocable: true` on SKILL.md frontmatter +// (see docs/HARNESSES.md and opencode/packages/core/src/v1/config/command.ts), +// so a pinned skill there shows up in `opencode debug skill` but never in the +// slash menu. The fix is a sibling `commands/impeccable-.md` that uses the +// OpenCode command schema (description, agent, subtask). Body loads the skill +// via the skill tool and then the sub-command's reference file directly, so +// /impeccable- runs the same workflow /impeccable routes to. +const OPENCODE_PIN_MARKER = ''; +function generatePinnedOpencodeCommand(command, metadata) { + const desc = metadata[command]?.description || `Impeccable sub-command shortcut; runs the ${command} workflow via /impeccable.`; + return `--- +description: "${desc}" +agent: build +subtask: true +--- + +${OPENCODE_PIN_MARKER} + +Load the \`impeccable\` skill via the skill tool (name: "impeccable"), then run \`node /scripts/context.mjs\`, then load \`/reference/${command}.md\` and follow it. \`\` is the skill's base directory as reported by the skill tool response; substitute the actual absolute path before running or reading anything. + +$ARGUMENTS +`; +} + +// OpenCode's user-scope config dir. Mirrors the CLI's opencodeGlobalConfigDir +// precedence (OPENCODE_CONFIG_DIR → XDG_CONFIG_HOME/opencode → +// ~/.config/opencode); duplicated here because this script ships inside the +// installed skill and cannot import the CLI. +function opencodeUserConfigDir() { + if (process.env.OPENCODE_CONFIG_DIR) return process.env.OPENCODE_CONFIG_DIR; + if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, 'opencode'); + return join(homedir(), '.config', 'opencode'); +} + +/** + * Resolve every commands dir that should receive an OpenCode pin: the + * project-local dir when the project has the skill, plus the user config dir + * when Impeccable is installed globally (#406 layout). A user-scope skill is + * visible from every project, so its pinned commands belong next to it. + * With `forCleanup`, both commands dirs are included even when the skill is + * gone, so unpin can still reach a pin left behind by a removed install; + * removal stays safe because removePinnedOpencodeCommand is marker-guarded. + */ +function findOpencodeCommandsDirs(projectRoot, { forCleanup = false } = {}) { + const dirs = []; + const seen = new Set(); + const push = (commandsDir) => { + const key = resolve(commandsDir); + if (!seen.has(key)) { + seen.add(key); + dirs.push(commandsDir); + } + }; + if (forCleanup || existsSync(join(projectRoot, '.opencode', 'skills', 'impeccable'))) { + push(join(projectRoot, '.opencode', 'commands')); + } + const userConfig = opencodeUserConfigDir(); + if (forCleanup || existsSync(join(userConfig, 'skills', 'impeccable'))) { + push(join(userConfig, 'commands')); + } + return dirs; +} + +function writePinnedOpencodeCommand(commandsDir, command, metadata) { + const commandFile = join(commandsDir, `impeccable-${command}.md`); + if (existsSync(commandFile)) { + const existing = readFileSync(commandFile, 'utf-8'); + if (!existing.includes(OPENCODE_PIN_MARKER)) { + console.log(` SKIP: ${commandFile} (non-pinned command already exists)`); + return false; + } + } else { + mkdirSync(commandsDir, { recursive: true }); + } + writeFileSync(commandFile, generatePinnedOpencodeCommand(command, metadata)); + console.log(` + ${commandFile}`); + return true; +} + +function removePinnedOpencodeCommand(commandsDir, command) { + const commandFile = join(commandsDir, `impeccable-${command}.md`); + if (!existsSync(commandFile)) return false; + const content = readFileSync(commandFile, 'utf-8'); + if (!content.includes(OPENCODE_PIN_MARKER)) { + console.log(` SKIP: ${commandFile} (not a pinned command)`); + return false; + } + rmSync(commandFile, { force: true }); + console.log(` - ${commandFile}`); + return true; +} + /** * Pin a command: create shortcut skill in all harness dirs. */ function pin(command, projectRoot) { const metadata = loadCommandMetadata(); const harnessDirs = findHarnessDirs(projectRoot); + const opencodeCommandsDirs = findOpencodeCommandsDirs(projectRoot); - if (harnessDirs.length === 0) { + if (harnessDirs.length === 0 && opencodeCommandsDirs.length === 0) { console.log('No harness directories with impeccable installed found.'); return false; } let created = 0; + // OpenCode is handled separately below because its shortcut format is a + // slash command, not a SKILL.md. Excluding it from the skill loop here + // prevents a duplicate `.opencode/skills//SKILL.md` that OpenCode + // would never surface as `/`. for (const skillsDir of harnessDirs) { + if (skillsDir.includes(`${sep}.opencode${sep}`)) continue; const commandPrefix = commandPrefixForSkillsDir(skillsDir); const content = generatePinnedSkill(command, metadata, commandPrefix, commandPrefix === '$'); // Check if skill already exists (and isn't a pin) @@ -151,6 +250,12 @@ function pin(command, projectRoot) { created++; } + // OpenCode: write a slash command bridge, not a skill shortcut. Covers both + // project installs and user-scope (global config) installs. + for (const commandsDir of opencodeCommandsDirs) { + if (writePinnedOpencodeCommand(commandsDir, command, metadata)) created++; + } + if (created > 0) { console.log(`\nPinned '${command}' as a standalone shortcut in ${created} location(s).`); console.log('Use the pinned command directly in each harness.'); @@ -160,13 +265,17 @@ function pin(command, projectRoot) { } /** - * Unpin a command: remove shortcut skill from all harness dirs. + * Unpin a command: remove shortcut skill in all harness dirs. */ function unpin(command, projectRoot) { const harnessDirs = findHarnessDirs(projectRoot); let removed = 0; + // OpenCode has its own cleanup path below; skip the skill loop here so a + // stray `.opencode/skills//SKILL.md` written by an older Impeccable + // version is never silently dropped here. for (const skillsDir of harnessDirs) { + if (skillsDir.includes(`${sep}.opencode${sep}`)) continue; const skillDir = join(skillsDir, command); if (!existsSync(skillDir)) continue; @@ -185,6 +294,13 @@ function unpin(command, projectRoot) { removed++; } + // OpenCode: remove the pinned command file if it's one of ours, in every + // scope it could have been written to — even when the skill itself is + // already gone, since removal is marker-guarded. + for (const commandsDir of findOpencodeCommandsDirs(projectRoot, { forCleanup: true })) { + if (removePinnedOpencodeCommand(commandsDir, command)) removed++; + } + if (removed > 0) { console.log(`\nUnpinned '${command}' from ${removed} location(s).`); console.log(`Use Impeccable's '${command}' workflow directly to access it.`); diff --git a/.github/skills/impeccable/scripts/pin.mjs b/.github/skills/impeccable/scripts/pin.mjs index d80043df7..6a4323194 100644 --- a/.github/skills/impeccable/scripts/pin.mjs +++ b/.github/skills/impeccable/scripts/pin.mjs @@ -14,8 +14,9 @@ */ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync } from 'node:fs'; -import { basename, join, resolve, dirname } from 'node:path'; +import { basename, join, resolve, dirname, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { homedir } from 'node:os'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -115,21 +116,119 @@ Invoke ${commandPrefix}impeccable ${command}, passing along any arguments provid `; } +// OpenCode 1.18.10 does not honor `user-invocable: true` on SKILL.md frontmatter +// (see docs/HARNESSES.md and opencode/packages/core/src/v1/config/command.ts), +// so a pinned skill there shows up in `opencode debug skill` but never in the +// slash menu. The fix is a sibling `commands/impeccable-.md` that uses the +// OpenCode command schema (description, agent, subtask). Body loads the skill +// via the skill tool and then the sub-command's reference file directly, so +// /impeccable- runs the same workflow /impeccable routes to. +const OPENCODE_PIN_MARKER = ''; +function generatePinnedOpencodeCommand(command, metadata) { + const desc = metadata[command]?.description || `Impeccable sub-command shortcut; runs the ${command} workflow via /impeccable.`; + return `--- +description: "${desc}" +agent: build +subtask: true +--- + +${OPENCODE_PIN_MARKER} + +Load the \`impeccable\` skill via the skill tool (name: "impeccable"), then run \`node /scripts/context.mjs\`, then load \`/reference/${command}.md\` and follow it. \`\` is the skill's base directory as reported by the skill tool response; substitute the actual absolute path before running or reading anything. + +$ARGUMENTS +`; +} + +// OpenCode's user-scope config dir. Mirrors the CLI's opencodeGlobalConfigDir +// precedence (OPENCODE_CONFIG_DIR → XDG_CONFIG_HOME/opencode → +// ~/.config/opencode); duplicated here because this script ships inside the +// installed skill and cannot import the CLI. +function opencodeUserConfigDir() { + if (process.env.OPENCODE_CONFIG_DIR) return process.env.OPENCODE_CONFIG_DIR; + if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, 'opencode'); + return join(homedir(), '.config', 'opencode'); +} + +/** + * Resolve every commands dir that should receive an OpenCode pin: the + * project-local dir when the project has the skill, plus the user config dir + * when Impeccable is installed globally (#406 layout). A user-scope skill is + * visible from every project, so its pinned commands belong next to it. + * With `forCleanup`, both commands dirs are included even when the skill is + * gone, so unpin can still reach a pin left behind by a removed install; + * removal stays safe because removePinnedOpencodeCommand is marker-guarded. + */ +function findOpencodeCommandsDirs(projectRoot, { forCleanup = false } = {}) { + const dirs = []; + const seen = new Set(); + const push = (commandsDir) => { + const key = resolve(commandsDir); + if (!seen.has(key)) { + seen.add(key); + dirs.push(commandsDir); + } + }; + if (forCleanup || existsSync(join(projectRoot, '.opencode', 'skills', 'impeccable'))) { + push(join(projectRoot, '.opencode', 'commands')); + } + const userConfig = opencodeUserConfigDir(); + if (forCleanup || existsSync(join(userConfig, 'skills', 'impeccable'))) { + push(join(userConfig, 'commands')); + } + return dirs; +} + +function writePinnedOpencodeCommand(commandsDir, command, metadata) { + const commandFile = join(commandsDir, `impeccable-${command}.md`); + if (existsSync(commandFile)) { + const existing = readFileSync(commandFile, 'utf-8'); + if (!existing.includes(OPENCODE_PIN_MARKER)) { + console.log(` SKIP: ${commandFile} (non-pinned command already exists)`); + return false; + } + } else { + mkdirSync(commandsDir, { recursive: true }); + } + writeFileSync(commandFile, generatePinnedOpencodeCommand(command, metadata)); + console.log(` + ${commandFile}`); + return true; +} + +function removePinnedOpencodeCommand(commandsDir, command) { + const commandFile = join(commandsDir, `impeccable-${command}.md`); + if (!existsSync(commandFile)) return false; + const content = readFileSync(commandFile, 'utf-8'); + if (!content.includes(OPENCODE_PIN_MARKER)) { + console.log(` SKIP: ${commandFile} (not a pinned command)`); + return false; + } + rmSync(commandFile, { force: true }); + console.log(` - ${commandFile}`); + return true; +} + /** * Pin a command: create shortcut skill in all harness dirs. */ function pin(command, projectRoot) { const metadata = loadCommandMetadata(); const harnessDirs = findHarnessDirs(projectRoot); + const opencodeCommandsDirs = findOpencodeCommandsDirs(projectRoot); - if (harnessDirs.length === 0) { + if (harnessDirs.length === 0 && opencodeCommandsDirs.length === 0) { console.log('No harness directories with impeccable installed found.'); return false; } let created = 0; + // OpenCode is handled separately below because its shortcut format is a + // slash command, not a SKILL.md. Excluding it from the skill loop here + // prevents a duplicate `.opencode/skills//SKILL.md` that OpenCode + // would never surface as `/`. for (const skillsDir of harnessDirs) { + if (skillsDir.includes(`${sep}.opencode${sep}`)) continue; const commandPrefix = commandPrefixForSkillsDir(skillsDir); const content = generatePinnedSkill(command, metadata, commandPrefix, commandPrefix === '$'); // Check if skill already exists (and isn't a pin) @@ -151,6 +250,12 @@ function pin(command, projectRoot) { created++; } + // OpenCode: write a slash command bridge, not a skill shortcut. Covers both + // project installs and user-scope (global config) installs. + for (const commandsDir of opencodeCommandsDirs) { + if (writePinnedOpencodeCommand(commandsDir, command, metadata)) created++; + } + if (created > 0) { console.log(`\nPinned '${command}' as a standalone shortcut in ${created} location(s).`); console.log('Use the pinned command directly in each harness.'); @@ -160,13 +265,17 @@ function pin(command, projectRoot) { } /** - * Unpin a command: remove shortcut skill from all harness dirs. + * Unpin a command: remove shortcut skill in all harness dirs. */ function unpin(command, projectRoot) { const harnessDirs = findHarnessDirs(projectRoot); let removed = 0; + // OpenCode has its own cleanup path below; skip the skill loop here so a + // stray `.opencode/skills//SKILL.md` written by an older Impeccable + // version is never silently dropped here. for (const skillsDir of harnessDirs) { + if (skillsDir.includes(`${sep}.opencode${sep}`)) continue; const skillDir = join(skillsDir, command); if (!existsSync(skillDir)) continue; @@ -185,6 +294,13 @@ function unpin(command, projectRoot) { removed++; } + // OpenCode: remove the pinned command file if it's one of ours, in every + // scope it could have been written to — even when the skill itself is + // already gone, since removal is marker-guarded. + for (const commandsDir of findOpencodeCommandsDirs(projectRoot, { forCleanup: true })) { + if (removePinnedOpencodeCommand(commandsDir, command)) removed++; + } + if (removed > 0) { console.log(`\nUnpinned '${command}' from ${removed} location(s).`); console.log(`Use Impeccable's '${command}' workflow directly to access it.`); diff --git a/.grok/skills/impeccable/scripts/pin.mjs b/.grok/skills/impeccable/scripts/pin.mjs index d80043df7..6a4323194 100644 --- a/.grok/skills/impeccable/scripts/pin.mjs +++ b/.grok/skills/impeccable/scripts/pin.mjs @@ -14,8 +14,9 @@ */ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync } from 'node:fs'; -import { basename, join, resolve, dirname } from 'node:path'; +import { basename, join, resolve, dirname, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { homedir } from 'node:os'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -115,21 +116,119 @@ Invoke ${commandPrefix}impeccable ${command}, passing along any arguments provid `; } +// OpenCode 1.18.10 does not honor `user-invocable: true` on SKILL.md frontmatter +// (see docs/HARNESSES.md and opencode/packages/core/src/v1/config/command.ts), +// so a pinned skill there shows up in `opencode debug skill` but never in the +// slash menu. The fix is a sibling `commands/impeccable-.md` that uses the +// OpenCode command schema (description, agent, subtask). Body loads the skill +// via the skill tool and then the sub-command's reference file directly, so +// /impeccable- runs the same workflow /impeccable routes to. +const OPENCODE_PIN_MARKER = ''; +function generatePinnedOpencodeCommand(command, metadata) { + const desc = metadata[command]?.description || `Impeccable sub-command shortcut; runs the ${command} workflow via /impeccable.`; + return `--- +description: "${desc}" +agent: build +subtask: true +--- + +${OPENCODE_PIN_MARKER} + +Load the \`impeccable\` skill via the skill tool (name: "impeccable"), then run \`node /scripts/context.mjs\`, then load \`/reference/${command}.md\` and follow it. \`\` is the skill's base directory as reported by the skill tool response; substitute the actual absolute path before running or reading anything. + +$ARGUMENTS +`; +} + +// OpenCode's user-scope config dir. Mirrors the CLI's opencodeGlobalConfigDir +// precedence (OPENCODE_CONFIG_DIR → XDG_CONFIG_HOME/opencode → +// ~/.config/opencode); duplicated here because this script ships inside the +// installed skill and cannot import the CLI. +function opencodeUserConfigDir() { + if (process.env.OPENCODE_CONFIG_DIR) return process.env.OPENCODE_CONFIG_DIR; + if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, 'opencode'); + return join(homedir(), '.config', 'opencode'); +} + +/** + * Resolve every commands dir that should receive an OpenCode pin: the + * project-local dir when the project has the skill, plus the user config dir + * when Impeccable is installed globally (#406 layout). A user-scope skill is + * visible from every project, so its pinned commands belong next to it. + * With `forCleanup`, both commands dirs are included even when the skill is + * gone, so unpin can still reach a pin left behind by a removed install; + * removal stays safe because removePinnedOpencodeCommand is marker-guarded. + */ +function findOpencodeCommandsDirs(projectRoot, { forCleanup = false } = {}) { + const dirs = []; + const seen = new Set(); + const push = (commandsDir) => { + const key = resolve(commandsDir); + if (!seen.has(key)) { + seen.add(key); + dirs.push(commandsDir); + } + }; + if (forCleanup || existsSync(join(projectRoot, '.opencode', 'skills', 'impeccable'))) { + push(join(projectRoot, '.opencode', 'commands')); + } + const userConfig = opencodeUserConfigDir(); + if (forCleanup || existsSync(join(userConfig, 'skills', 'impeccable'))) { + push(join(userConfig, 'commands')); + } + return dirs; +} + +function writePinnedOpencodeCommand(commandsDir, command, metadata) { + const commandFile = join(commandsDir, `impeccable-${command}.md`); + if (existsSync(commandFile)) { + const existing = readFileSync(commandFile, 'utf-8'); + if (!existing.includes(OPENCODE_PIN_MARKER)) { + console.log(` SKIP: ${commandFile} (non-pinned command already exists)`); + return false; + } + } else { + mkdirSync(commandsDir, { recursive: true }); + } + writeFileSync(commandFile, generatePinnedOpencodeCommand(command, metadata)); + console.log(` + ${commandFile}`); + return true; +} + +function removePinnedOpencodeCommand(commandsDir, command) { + const commandFile = join(commandsDir, `impeccable-${command}.md`); + if (!existsSync(commandFile)) return false; + const content = readFileSync(commandFile, 'utf-8'); + if (!content.includes(OPENCODE_PIN_MARKER)) { + console.log(` SKIP: ${commandFile} (not a pinned command)`); + return false; + } + rmSync(commandFile, { force: true }); + console.log(` - ${commandFile}`); + return true; +} + /** * Pin a command: create shortcut skill in all harness dirs. */ function pin(command, projectRoot) { const metadata = loadCommandMetadata(); const harnessDirs = findHarnessDirs(projectRoot); + const opencodeCommandsDirs = findOpencodeCommandsDirs(projectRoot); - if (harnessDirs.length === 0) { + if (harnessDirs.length === 0 && opencodeCommandsDirs.length === 0) { console.log('No harness directories with impeccable installed found.'); return false; } let created = 0; + // OpenCode is handled separately below because its shortcut format is a + // slash command, not a SKILL.md. Excluding it from the skill loop here + // prevents a duplicate `.opencode/skills//SKILL.md` that OpenCode + // would never surface as `/`. for (const skillsDir of harnessDirs) { + if (skillsDir.includes(`${sep}.opencode${sep}`)) continue; const commandPrefix = commandPrefixForSkillsDir(skillsDir); const content = generatePinnedSkill(command, metadata, commandPrefix, commandPrefix === '$'); // Check if skill already exists (and isn't a pin) @@ -151,6 +250,12 @@ function pin(command, projectRoot) { created++; } + // OpenCode: write a slash command bridge, not a skill shortcut. Covers both + // project installs and user-scope (global config) installs. + for (const commandsDir of opencodeCommandsDirs) { + if (writePinnedOpencodeCommand(commandsDir, command, metadata)) created++; + } + if (created > 0) { console.log(`\nPinned '${command}' as a standalone shortcut in ${created} location(s).`); console.log('Use the pinned command directly in each harness.'); @@ -160,13 +265,17 @@ function pin(command, projectRoot) { } /** - * Unpin a command: remove shortcut skill from all harness dirs. + * Unpin a command: remove shortcut skill in all harness dirs. */ function unpin(command, projectRoot) { const harnessDirs = findHarnessDirs(projectRoot); let removed = 0; + // OpenCode has its own cleanup path below; skip the skill loop here so a + // stray `.opencode/skills//SKILL.md` written by an older Impeccable + // version is never silently dropped here. for (const skillsDir of harnessDirs) { + if (skillsDir.includes(`${sep}.opencode${sep}`)) continue; const skillDir = join(skillsDir, command); if (!existsSync(skillDir)) continue; @@ -185,6 +294,13 @@ function unpin(command, projectRoot) { removed++; } + // OpenCode: remove the pinned command file if it's one of ours, in every + // scope it could have been written to — even when the skill itself is + // already gone, since removal is marker-guarded. + for (const commandsDir of findOpencodeCommandsDirs(projectRoot, { forCleanup: true })) { + if (removePinnedOpencodeCommand(commandsDir, command)) removed++; + } + if (removed > 0) { console.log(`\nUnpinned '${command}' from ${removed} location(s).`); console.log(`Use Impeccable's '${command}' workflow directly to access it.`); diff --git a/.hermes/skills/impeccable/scripts/pin.mjs b/.hermes/skills/impeccable/scripts/pin.mjs index d80043df7..6a4323194 100644 --- a/.hermes/skills/impeccable/scripts/pin.mjs +++ b/.hermes/skills/impeccable/scripts/pin.mjs @@ -14,8 +14,9 @@ */ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync } from 'node:fs'; -import { basename, join, resolve, dirname } from 'node:path'; +import { basename, join, resolve, dirname, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { homedir } from 'node:os'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -115,21 +116,119 @@ Invoke ${commandPrefix}impeccable ${command}, passing along any arguments provid `; } +// OpenCode 1.18.10 does not honor `user-invocable: true` on SKILL.md frontmatter +// (see docs/HARNESSES.md and opencode/packages/core/src/v1/config/command.ts), +// so a pinned skill there shows up in `opencode debug skill` but never in the +// slash menu. The fix is a sibling `commands/impeccable-.md` that uses the +// OpenCode command schema (description, agent, subtask). Body loads the skill +// via the skill tool and then the sub-command's reference file directly, so +// /impeccable- runs the same workflow /impeccable routes to. +const OPENCODE_PIN_MARKER = ''; +function generatePinnedOpencodeCommand(command, metadata) { + const desc = metadata[command]?.description || `Impeccable sub-command shortcut; runs the ${command} workflow via /impeccable.`; + return `--- +description: "${desc}" +agent: build +subtask: true +--- + +${OPENCODE_PIN_MARKER} + +Load the \`impeccable\` skill via the skill tool (name: "impeccable"), then run \`node /scripts/context.mjs\`, then load \`/reference/${command}.md\` and follow it. \`\` is the skill's base directory as reported by the skill tool response; substitute the actual absolute path before running or reading anything. + +$ARGUMENTS +`; +} + +// OpenCode's user-scope config dir. Mirrors the CLI's opencodeGlobalConfigDir +// precedence (OPENCODE_CONFIG_DIR → XDG_CONFIG_HOME/opencode → +// ~/.config/opencode); duplicated here because this script ships inside the +// installed skill and cannot import the CLI. +function opencodeUserConfigDir() { + if (process.env.OPENCODE_CONFIG_DIR) return process.env.OPENCODE_CONFIG_DIR; + if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, 'opencode'); + return join(homedir(), '.config', 'opencode'); +} + +/** + * Resolve every commands dir that should receive an OpenCode pin: the + * project-local dir when the project has the skill, plus the user config dir + * when Impeccable is installed globally (#406 layout). A user-scope skill is + * visible from every project, so its pinned commands belong next to it. + * With `forCleanup`, both commands dirs are included even when the skill is + * gone, so unpin can still reach a pin left behind by a removed install; + * removal stays safe because removePinnedOpencodeCommand is marker-guarded. + */ +function findOpencodeCommandsDirs(projectRoot, { forCleanup = false } = {}) { + const dirs = []; + const seen = new Set(); + const push = (commandsDir) => { + const key = resolve(commandsDir); + if (!seen.has(key)) { + seen.add(key); + dirs.push(commandsDir); + } + }; + if (forCleanup || existsSync(join(projectRoot, '.opencode', 'skills', 'impeccable'))) { + push(join(projectRoot, '.opencode', 'commands')); + } + const userConfig = opencodeUserConfigDir(); + if (forCleanup || existsSync(join(userConfig, 'skills', 'impeccable'))) { + push(join(userConfig, 'commands')); + } + return dirs; +} + +function writePinnedOpencodeCommand(commandsDir, command, metadata) { + const commandFile = join(commandsDir, `impeccable-${command}.md`); + if (existsSync(commandFile)) { + const existing = readFileSync(commandFile, 'utf-8'); + if (!existing.includes(OPENCODE_PIN_MARKER)) { + console.log(` SKIP: ${commandFile} (non-pinned command already exists)`); + return false; + } + } else { + mkdirSync(commandsDir, { recursive: true }); + } + writeFileSync(commandFile, generatePinnedOpencodeCommand(command, metadata)); + console.log(` + ${commandFile}`); + return true; +} + +function removePinnedOpencodeCommand(commandsDir, command) { + const commandFile = join(commandsDir, `impeccable-${command}.md`); + if (!existsSync(commandFile)) return false; + const content = readFileSync(commandFile, 'utf-8'); + if (!content.includes(OPENCODE_PIN_MARKER)) { + console.log(` SKIP: ${commandFile} (not a pinned command)`); + return false; + } + rmSync(commandFile, { force: true }); + console.log(` - ${commandFile}`); + return true; +} + /** * Pin a command: create shortcut skill in all harness dirs. */ function pin(command, projectRoot) { const metadata = loadCommandMetadata(); const harnessDirs = findHarnessDirs(projectRoot); + const opencodeCommandsDirs = findOpencodeCommandsDirs(projectRoot); - if (harnessDirs.length === 0) { + if (harnessDirs.length === 0 && opencodeCommandsDirs.length === 0) { console.log('No harness directories with impeccable installed found.'); return false; } let created = 0; + // OpenCode is handled separately below because its shortcut format is a + // slash command, not a SKILL.md. Excluding it from the skill loop here + // prevents a duplicate `.opencode/skills//SKILL.md` that OpenCode + // would never surface as `/`. for (const skillsDir of harnessDirs) { + if (skillsDir.includes(`${sep}.opencode${sep}`)) continue; const commandPrefix = commandPrefixForSkillsDir(skillsDir); const content = generatePinnedSkill(command, metadata, commandPrefix, commandPrefix === '$'); // Check if skill already exists (and isn't a pin) @@ -151,6 +250,12 @@ function pin(command, projectRoot) { created++; } + // OpenCode: write a slash command bridge, not a skill shortcut. Covers both + // project installs and user-scope (global config) installs. + for (const commandsDir of opencodeCommandsDirs) { + if (writePinnedOpencodeCommand(commandsDir, command, metadata)) created++; + } + if (created > 0) { console.log(`\nPinned '${command}' as a standalone shortcut in ${created} location(s).`); console.log('Use the pinned command directly in each harness.'); @@ -160,13 +265,17 @@ function pin(command, projectRoot) { } /** - * Unpin a command: remove shortcut skill from all harness dirs. + * Unpin a command: remove shortcut skill in all harness dirs. */ function unpin(command, projectRoot) { const harnessDirs = findHarnessDirs(projectRoot); let removed = 0; + // OpenCode has its own cleanup path below; skip the skill loop here so a + // stray `.opencode/skills//SKILL.md` written by an older Impeccable + // version is never silently dropped here. for (const skillsDir of harnessDirs) { + if (skillsDir.includes(`${sep}.opencode${sep}`)) continue; const skillDir = join(skillsDir, command); if (!existsSync(skillDir)) continue; @@ -185,6 +294,13 @@ function unpin(command, projectRoot) { removed++; } + // OpenCode: remove the pinned command file if it's one of ours, in every + // scope it could have been written to — even when the skill itself is + // already gone, since removal is marker-guarded. + for (const commandsDir of findOpencodeCommandsDirs(projectRoot, { forCleanup: true })) { + if (removePinnedOpencodeCommand(commandsDir, command)) removed++; + } + if (removed > 0) { console.log(`\nUnpinned '${command}' from ${removed} location(s).`); console.log(`Use Impeccable's '${command}' workflow directly to access it.`); diff --git a/.kiro/skills/impeccable/scripts/pin.mjs b/.kiro/skills/impeccable/scripts/pin.mjs index d80043df7..6a4323194 100644 --- a/.kiro/skills/impeccable/scripts/pin.mjs +++ b/.kiro/skills/impeccable/scripts/pin.mjs @@ -14,8 +14,9 @@ */ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync } from 'node:fs'; -import { basename, join, resolve, dirname } from 'node:path'; +import { basename, join, resolve, dirname, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { homedir } from 'node:os'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -115,21 +116,119 @@ Invoke ${commandPrefix}impeccable ${command}, passing along any arguments provid `; } +// OpenCode 1.18.10 does not honor `user-invocable: true` on SKILL.md frontmatter +// (see docs/HARNESSES.md and opencode/packages/core/src/v1/config/command.ts), +// so a pinned skill there shows up in `opencode debug skill` but never in the +// slash menu. The fix is a sibling `commands/impeccable-.md` that uses the +// OpenCode command schema (description, agent, subtask). Body loads the skill +// via the skill tool and then the sub-command's reference file directly, so +// /impeccable- runs the same workflow /impeccable routes to. +const OPENCODE_PIN_MARKER = ''; +function generatePinnedOpencodeCommand(command, metadata) { + const desc = metadata[command]?.description || `Impeccable sub-command shortcut; runs the ${command} workflow via /impeccable.`; + return `--- +description: "${desc}" +agent: build +subtask: true +--- + +${OPENCODE_PIN_MARKER} + +Load the \`impeccable\` skill via the skill tool (name: "impeccable"), then run \`node /scripts/context.mjs\`, then load \`/reference/${command}.md\` and follow it. \`\` is the skill's base directory as reported by the skill tool response; substitute the actual absolute path before running or reading anything. + +$ARGUMENTS +`; +} + +// OpenCode's user-scope config dir. Mirrors the CLI's opencodeGlobalConfigDir +// precedence (OPENCODE_CONFIG_DIR → XDG_CONFIG_HOME/opencode → +// ~/.config/opencode); duplicated here because this script ships inside the +// installed skill and cannot import the CLI. +function opencodeUserConfigDir() { + if (process.env.OPENCODE_CONFIG_DIR) return process.env.OPENCODE_CONFIG_DIR; + if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, 'opencode'); + return join(homedir(), '.config', 'opencode'); +} + +/** + * Resolve every commands dir that should receive an OpenCode pin: the + * project-local dir when the project has the skill, plus the user config dir + * when Impeccable is installed globally (#406 layout). A user-scope skill is + * visible from every project, so its pinned commands belong next to it. + * With `forCleanup`, both commands dirs are included even when the skill is + * gone, so unpin can still reach a pin left behind by a removed install; + * removal stays safe because removePinnedOpencodeCommand is marker-guarded. + */ +function findOpencodeCommandsDirs(projectRoot, { forCleanup = false } = {}) { + const dirs = []; + const seen = new Set(); + const push = (commandsDir) => { + const key = resolve(commandsDir); + if (!seen.has(key)) { + seen.add(key); + dirs.push(commandsDir); + } + }; + if (forCleanup || existsSync(join(projectRoot, '.opencode', 'skills', 'impeccable'))) { + push(join(projectRoot, '.opencode', 'commands')); + } + const userConfig = opencodeUserConfigDir(); + if (forCleanup || existsSync(join(userConfig, 'skills', 'impeccable'))) { + push(join(userConfig, 'commands')); + } + return dirs; +} + +function writePinnedOpencodeCommand(commandsDir, command, metadata) { + const commandFile = join(commandsDir, `impeccable-${command}.md`); + if (existsSync(commandFile)) { + const existing = readFileSync(commandFile, 'utf-8'); + if (!existing.includes(OPENCODE_PIN_MARKER)) { + console.log(` SKIP: ${commandFile} (non-pinned command already exists)`); + return false; + } + } else { + mkdirSync(commandsDir, { recursive: true }); + } + writeFileSync(commandFile, generatePinnedOpencodeCommand(command, metadata)); + console.log(` + ${commandFile}`); + return true; +} + +function removePinnedOpencodeCommand(commandsDir, command) { + const commandFile = join(commandsDir, `impeccable-${command}.md`); + if (!existsSync(commandFile)) return false; + const content = readFileSync(commandFile, 'utf-8'); + if (!content.includes(OPENCODE_PIN_MARKER)) { + console.log(` SKIP: ${commandFile} (not a pinned command)`); + return false; + } + rmSync(commandFile, { force: true }); + console.log(` - ${commandFile}`); + return true; +} + /** * Pin a command: create shortcut skill in all harness dirs. */ function pin(command, projectRoot) { const metadata = loadCommandMetadata(); const harnessDirs = findHarnessDirs(projectRoot); + const opencodeCommandsDirs = findOpencodeCommandsDirs(projectRoot); - if (harnessDirs.length === 0) { + if (harnessDirs.length === 0 && opencodeCommandsDirs.length === 0) { console.log('No harness directories with impeccable installed found.'); return false; } let created = 0; + // OpenCode is handled separately below because its shortcut format is a + // slash command, not a SKILL.md. Excluding it from the skill loop here + // prevents a duplicate `.opencode/skills//SKILL.md` that OpenCode + // would never surface as `/`. for (const skillsDir of harnessDirs) { + if (skillsDir.includes(`${sep}.opencode${sep}`)) continue; const commandPrefix = commandPrefixForSkillsDir(skillsDir); const content = generatePinnedSkill(command, metadata, commandPrefix, commandPrefix === '$'); // Check if skill already exists (and isn't a pin) @@ -151,6 +250,12 @@ function pin(command, projectRoot) { created++; } + // OpenCode: write a slash command bridge, not a skill shortcut. Covers both + // project installs and user-scope (global config) installs. + for (const commandsDir of opencodeCommandsDirs) { + if (writePinnedOpencodeCommand(commandsDir, command, metadata)) created++; + } + if (created > 0) { console.log(`\nPinned '${command}' as a standalone shortcut in ${created} location(s).`); console.log('Use the pinned command directly in each harness.'); @@ -160,13 +265,17 @@ function pin(command, projectRoot) { } /** - * Unpin a command: remove shortcut skill from all harness dirs. + * Unpin a command: remove shortcut skill in all harness dirs. */ function unpin(command, projectRoot) { const harnessDirs = findHarnessDirs(projectRoot); let removed = 0; + // OpenCode has its own cleanup path below; skip the skill loop here so a + // stray `.opencode/skills//SKILL.md` written by an older Impeccable + // version is never silently dropped here. for (const skillsDir of harnessDirs) { + if (skillsDir.includes(`${sep}.opencode${sep}`)) continue; const skillDir = join(skillsDir, command); if (!existsSync(skillDir)) continue; @@ -185,6 +294,13 @@ function unpin(command, projectRoot) { removed++; } + // OpenCode: remove the pinned command file if it's one of ours, in every + // scope it could have been written to — even when the skill itself is + // already gone, since removal is marker-guarded. + for (const commandsDir of findOpencodeCommandsDirs(projectRoot, { forCleanup: true })) { + if (removePinnedOpencodeCommand(commandsDir, command)) removed++; + } + if (removed > 0) { console.log(`\nUnpinned '${command}' from ${removed} location(s).`); console.log(`Use Impeccable's '${command}' workflow directly to access it.`); diff --git a/.opencode/commands/impeccable.md b/.opencode/commands/impeccable.md new file mode 100644 index 000000000..33f4a6f0a --- /dev/null +++ b/.opencode/commands/impeccable.md @@ -0,0 +1,6 @@ +--- +description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks. +agent: build +subtask: true +--- +Call skill({ name: "impeccable" }) and follow its `Setup` and `Commands` sections to handle $ARGUMENTS. diff --git a/.opencode/skills/impeccable/scripts/pin.mjs b/.opencode/skills/impeccable/scripts/pin.mjs index d80043df7..6a4323194 100644 --- a/.opencode/skills/impeccable/scripts/pin.mjs +++ b/.opencode/skills/impeccable/scripts/pin.mjs @@ -14,8 +14,9 @@ */ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync } from 'node:fs'; -import { basename, join, resolve, dirname } from 'node:path'; +import { basename, join, resolve, dirname, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { homedir } from 'node:os'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -115,21 +116,119 @@ Invoke ${commandPrefix}impeccable ${command}, passing along any arguments provid `; } +// OpenCode 1.18.10 does not honor `user-invocable: true` on SKILL.md frontmatter +// (see docs/HARNESSES.md and opencode/packages/core/src/v1/config/command.ts), +// so a pinned skill there shows up in `opencode debug skill` but never in the +// slash menu. The fix is a sibling `commands/impeccable-.md` that uses the +// OpenCode command schema (description, agent, subtask). Body loads the skill +// via the skill tool and then the sub-command's reference file directly, so +// /impeccable- runs the same workflow /impeccable routes to. +const OPENCODE_PIN_MARKER = ''; +function generatePinnedOpencodeCommand(command, metadata) { + const desc = metadata[command]?.description || `Impeccable sub-command shortcut; runs the ${command} workflow via /impeccable.`; + return `--- +description: "${desc}" +agent: build +subtask: true +--- + +${OPENCODE_PIN_MARKER} + +Load the \`impeccable\` skill via the skill tool (name: "impeccable"), then run \`node /scripts/context.mjs\`, then load \`/reference/${command}.md\` and follow it. \`\` is the skill's base directory as reported by the skill tool response; substitute the actual absolute path before running or reading anything. + +$ARGUMENTS +`; +} + +// OpenCode's user-scope config dir. Mirrors the CLI's opencodeGlobalConfigDir +// precedence (OPENCODE_CONFIG_DIR → XDG_CONFIG_HOME/opencode → +// ~/.config/opencode); duplicated here because this script ships inside the +// installed skill and cannot import the CLI. +function opencodeUserConfigDir() { + if (process.env.OPENCODE_CONFIG_DIR) return process.env.OPENCODE_CONFIG_DIR; + if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, 'opencode'); + return join(homedir(), '.config', 'opencode'); +} + +/** + * Resolve every commands dir that should receive an OpenCode pin: the + * project-local dir when the project has the skill, plus the user config dir + * when Impeccable is installed globally (#406 layout). A user-scope skill is + * visible from every project, so its pinned commands belong next to it. + * With `forCleanup`, both commands dirs are included even when the skill is + * gone, so unpin can still reach a pin left behind by a removed install; + * removal stays safe because removePinnedOpencodeCommand is marker-guarded. + */ +function findOpencodeCommandsDirs(projectRoot, { forCleanup = false } = {}) { + const dirs = []; + const seen = new Set(); + const push = (commandsDir) => { + const key = resolve(commandsDir); + if (!seen.has(key)) { + seen.add(key); + dirs.push(commandsDir); + } + }; + if (forCleanup || existsSync(join(projectRoot, '.opencode', 'skills', 'impeccable'))) { + push(join(projectRoot, '.opencode', 'commands')); + } + const userConfig = opencodeUserConfigDir(); + if (forCleanup || existsSync(join(userConfig, 'skills', 'impeccable'))) { + push(join(userConfig, 'commands')); + } + return dirs; +} + +function writePinnedOpencodeCommand(commandsDir, command, metadata) { + const commandFile = join(commandsDir, `impeccable-${command}.md`); + if (existsSync(commandFile)) { + const existing = readFileSync(commandFile, 'utf-8'); + if (!existing.includes(OPENCODE_PIN_MARKER)) { + console.log(` SKIP: ${commandFile} (non-pinned command already exists)`); + return false; + } + } else { + mkdirSync(commandsDir, { recursive: true }); + } + writeFileSync(commandFile, generatePinnedOpencodeCommand(command, metadata)); + console.log(` + ${commandFile}`); + return true; +} + +function removePinnedOpencodeCommand(commandsDir, command) { + const commandFile = join(commandsDir, `impeccable-${command}.md`); + if (!existsSync(commandFile)) return false; + const content = readFileSync(commandFile, 'utf-8'); + if (!content.includes(OPENCODE_PIN_MARKER)) { + console.log(` SKIP: ${commandFile} (not a pinned command)`); + return false; + } + rmSync(commandFile, { force: true }); + console.log(` - ${commandFile}`); + return true; +} + /** * Pin a command: create shortcut skill in all harness dirs. */ function pin(command, projectRoot) { const metadata = loadCommandMetadata(); const harnessDirs = findHarnessDirs(projectRoot); + const opencodeCommandsDirs = findOpencodeCommandsDirs(projectRoot); - if (harnessDirs.length === 0) { + if (harnessDirs.length === 0 && opencodeCommandsDirs.length === 0) { console.log('No harness directories with impeccable installed found.'); return false; } let created = 0; + // OpenCode is handled separately below because its shortcut format is a + // slash command, not a SKILL.md. Excluding it from the skill loop here + // prevents a duplicate `.opencode/skills//SKILL.md` that OpenCode + // would never surface as `/`. for (const skillsDir of harnessDirs) { + if (skillsDir.includes(`${sep}.opencode${sep}`)) continue; const commandPrefix = commandPrefixForSkillsDir(skillsDir); const content = generatePinnedSkill(command, metadata, commandPrefix, commandPrefix === '$'); // Check if skill already exists (and isn't a pin) @@ -151,6 +250,12 @@ function pin(command, projectRoot) { created++; } + // OpenCode: write a slash command bridge, not a skill shortcut. Covers both + // project installs and user-scope (global config) installs. + for (const commandsDir of opencodeCommandsDirs) { + if (writePinnedOpencodeCommand(commandsDir, command, metadata)) created++; + } + if (created > 0) { console.log(`\nPinned '${command}' as a standalone shortcut in ${created} location(s).`); console.log('Use the pinned command directly in each harness.'); @@ -160,13 +265,17 @@ function pin(command, projectRoot) { } /** - * Unpin a command: remove shortcut skill from all harness dirs. + * Unpin a command: remove shortcut skill in all harness dirs. */ function unpin(command, projectRoot) { const harnessDirs = findHarnessDirs(projectRoot); let removed = 0; + // OpenCode has its own cleanup path below; skip the skill loop here so a + // stray `.opencode/skills//SKILL.md` written by an older Impeccable + // version is never silently dropped here. for (const skillsDir of harnessDirs) { + if (skillsDir.includes(`${sep}.opencode${sep}`)) continue; const skillDir = join(skillsDir, command); if (!existsSync(skillDir)) continue; @@ -185,6 +294,13 @@ function unpin(command, projectRoot) { removed++; } + // OpenCode: remove the pinned command file if it's one of ours, in every + // scope it could have been written to — even when the skill itself is + // already gone, since removal is marker-guarded. + for (const commandsDir of findOpencodeCommandsDirs(projectRoot, { forCleanup: true })) { + if (removePinnedOpencodeCommand(commandsDir, command)) removed++; + } + if (removed > 0) { console.log(`\nUnpinned '${command}' from ${removed} location(s).`); console.log(`Use Impeccable's '${command}' workflow directly to access it.`); diff --git a/.pi/skills/impeccable/scripts/pin.mjs b/.pi/skills/impeccable/scripts/pin.mjs index d80043df7..6a4323194 100644 --- a/.pi/skills/impeccable/scripts/pin.mjs +++ b/.pi/skills/impeccable/scripts/pin.mjs @@ -14,8 +14,9 @@ */ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync } from 'node:fs'; -import { basename, join, resolve, dirname } from 'node:path'; +import { basename, join, resolve, dirname, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { homedir } from 'node:os'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -115,21 +116,119 @@ Invoke ${commandPrefix}impeccable ${command}, passing along any arguments provid `; } +// OpenCode 1.18.10 does not honor `user-invocable: true` on SKILL.md frontmatter +// (see docs/HARNESSES.md and opencode/packages/core/src/v1/config/command.ts), +// so a pinned skill there shows up in `opencode debug skill` but never in the +// slash menu. The fix is a sibling `commands/impeccable-.md` that uses the +// OpenCode command schema (description, agent, subtask). Body loads the skill +// via the skill tool and then the sub-command's reference file directly, so +// /impeccable- runs the same workflow /impeccable routes to. +const OPENCODE_PIN_MARKER = ''; +function generatePinnedOpencodeCommand(command, metadata) { + const desc = metadata[command]?.description || `Impeccable sub-command shortcut; runs the ${command} workflow via /impeccable.`; + return `--- +description: "${desc}" +agent: build +subtask: true +--- + +${OPENCODE_PIN_MARKER} + +Load the \`impeccable\` skill via the skill tool (name: "impeccable"), then run \`node /scripts/context.mjs\`, then load \`/reference/${command}.md\` and follow it. \`\` is the skill's base directory as reported by the skill tool response; substitute the actual absolute path before running or reading anything. + +$ARGUMENTS +`; +} + +// OpenCode's user-scope config dir. Mirrors the CLI's opencodeGlobalConfigDir +// precedence (OPENCODE_CONFIG_DIR → XDG_CONFIG_HOME/opencode → +// ~/.config/opencode); duplicated here because this script ships inside the +// installed skill and cannot import the CLI. +function opencodeUserConfigDir() { + if (process.env.OPENCODE_CONFIG_DIR) return process.env.OPENCODE_CONFIG_DIR; + if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, 'opencode'); + return join(homedir(), '.config', 'opencode'); +} + +/** + * Resolve every commands dir that should receive an OpenCode pin: the + * project-local dir when the project has the skill, plus the user config dir + * when Impeccable is installed globally (#406 layout). A user-scope skill is + * visible from every project, so its pinned commands belong next to it. + * With `forCleanup`, both commands dirs are included even when the skill is + * gone, so unpin can still reach a pin left behind by a removed install; + * removal stays safe because removePinnedOpencodeCommand is marker-guarded. + */ +function findOpencodeCommandsDirs(projectRoot, { forCleanup = false } = {}) { + const dirs = []; + const seen = new Set(); + const push = (commandsDir) => { + const key = resolve(commandsDir); + if (!seen.has(key)) { + seen.add(key); + dirs.push(commandsDir); + } + }; + if (forCleanup || existsSync(join(projectRoot, '.opencode', 'skills', 'impeccable'))) { + push(join(projectRoot, '.opencode', 'commands')); + } + const userConfig = opencodeUserConfigDir(); + if (forCleanup || existsSync(join(userConfig, 'skills', 'impeccable'))) { + push(join(userConfig, 'commands')); + } + return dirs; +} + +function writePinnedOpencodeCommand(commandsDir, command, metadata) { + const commandFile = join(commandsDir, `impeccable-${command}.md`); + if (existsSync(commandFile)) { + const existing = readFileSync(commandFile, 'utf-8'); + if (!existing.includes(OPENCODE_PIN_MARKER)) { + console.log(` SKIP: ${commandFile} (non-pinned command already exists)`); + return false; + } + } else { + mkdirSync(commandsDir, { recursive: true }); + } + writeFileSync(commandFile, generatePinnedOpencodeCommand(command, metadata)); + console.log(` + ${commandFile}`); + return true; +} + +function removePinnedOpencodeCommand(commandsDir, command) { + const commandFile = join(commandsDir, `impeccable-${command}.md`); + if (!existsSync(commandFile)) return false; + const content = readFileSync(commandFile, 'utf-8'); + if (!content.includes(OPENCODE_PIN_MARKER)) { + console.log(` SKIP: ${commandFile} (not a pinned command)`); + return false; + } + rmSync(commandFile, { force: true }); + console.log(` - ${commandFile}`); + return true; +} + /** * Pin a command: create shortcut skill in all harness dirs. */ function pin(command, projectRoot) { const metadata = loadCommandMetadata(); const harnessDirs = findHarnessDirs(projectRoot); + const opencodeCommandsDirs = findOpencodeCommandsDirs(projectRoot); - if (harnessDirs.length === 0) { + if (harnessDirs.length === 0 && opencodeCommandsDirs.length === 0) { console.log('No harness directories with impeccable installed found.'); return false; } let created = 0; + // OpenCode is handled separately below because its shortcut format is a + // slash command, not a SKILL.md. Excluding it from the skill loop here + // prevents a duplicate `.opencode/skills//SKILL.md` that OpenCode + // would never surface as `/`. for (const skillsDir of harnessDirs) { + if (skillsDir.includes(`${sep}.opencode${sep}`)) continue; const commandPrefix = commandPrefixForSkillsDir(skillsDir); const content = generatePinnedSkill(command, metadata, commandPrefix, commandPrefix === '$'); // Check if skill already exists (and isn't a pin) @@ -151,6 +250,12 @@ function pin(command, projectRoot) { created++; } + // OpenCode: write a slash command bridge, not a skill shortcut. Covers both + // project installs and user-scope (global config) installs. + for (const commandsDir of opencodeCommandsDirs) { + if (writePinnedOpencodeCommand(commandsDir, command, metadata)) created++; + } + if (created > 0) { console.log(`\nPinned '${command}' as a standalone shortcut in ${created} location(s).`); console.log('Use the pinned command directly in each harness.'); @@ -160,13 +265,17 @@ function pin(command, projectRoot) { } /** - * Unpin a command: remove shortcut skill from all harness dirs. + * Unpin a command: remove shortcut skill in all harness dirs. */ function unpin(command, projectRoot) { const harnessDirs = findHarnessDirs(projectRoot); let removed = 0; + // OpenCode has its own cleanup path below; skip the skill loop here so a + // stray `.opencode/skills//SKILL.md` written by an older Impeccable + // version is never silently dropped here. for (const skillsDir of harnessDirs) { + if (skillsDir.includes(`${sep}.opencode${sep}`)) continue; const skillDir = join(skillsDir, command); if (!existsSync(skillDir)) continue; @@ -185,6 +294,13 @@ function unpin(command, projectRoot) { removed++; } + // OpenCode: remove the pinned command file if it's one of ours, in every + // scope it could have been written to — even when the skill itself is + // already gone, since removal is marker-guarded. + for (const commandsDir of findOpencodeCommandsDirs(projectRoot, { forCleanup: true })) { + if (removePinnedOpencodeCommand(commandsDir, command)) removed++; + } + if (removed > 0) { console.log(`\nUnpinned '${command}' from ${removed} location(s).`); console.log(`Use Impeccable's '${command}' workflow directly to access it.`); diff --git a/.qoder/skills/impeccable/scripts/pin.mjs b/.qoder/skills/impeccable/scripts/pin.mjs index d80043df7..6a4323194 100644 --- a/.qoder/skills/impeccable/scripts/pin.mjs +++ b/.qoder/skills/impeccable/scripts/pin.mjs @@ -14,8 +14,9 @@ */ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync } from 'node:fs'; -import { basename, join, resolve, dirname } from 'node:path'; +import { basename, join, resolve, dirname, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { homedir } from 'node:os'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -115,21 +116,119 @@ Invoke ${commandPrefix}impeccable ${command}, passing along any arguments provid `; } +// OpenCode 1.18.10 does not honor `user-invocable: true` on SKILL.md frontmatter +// (see docs/HARNESSES.md and opencode/packages/core/src/v1/config/command.ts), +// so a pinned skill there shows up in `opencode debug skill` but never in the +// slash menu. The fix is a sibling `commands/impeccable-.md` that uses the +// OpenCode command schema (description, agent, subtask). Body loads the skill +// via the skill tool and then the sub-command's reference file directly, so +// /impeccable- runs the same workflow /impeccable routes to. +const OPENCODE_PIN_MARKER = ''; +function generatePinnedOpencodeCommand(command, metadata) { + const desc = metadata[command]?.description || `Impeccable sub-command shortcut; runs the ${command} workflow via /impeccable.`; + return `--- +description: "${desc}" +agent: build +subtask: true +--- + +${OPENCODE_PIN_MARKER} + +Load the \`impeccable\` skill via the skill tool (name: "impeccable"), then run \`node /scripts/context.mjs\`, then load \`/reference/${command}.md\` and follow it. \`\` is the skill's base directory as reported by the skill tool response; substitute the actual absolute path before running or reading anything. + +$ARGUMENTS +`; +} + +// OpenCode's user-scope config dir. Mirrors the CLI's opencodeGlobalConfigDir +// precedence (OPENCODE_CONFIG_DIR → XDG_CONFIG_HOME/opencode → +// ~/.config/opencode); duplicated here because this script ships inside the +// installed skill and cannot import the CLI. +function opencodeUserConfigDir() { + if (process.env.OPENCODE_CONFIG_DIR) return process.env.OPENCODE_CONFIG_DIR; + if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, 'opencode'); + return join(homedir(), '.config', 'opencode'); +} + +/** + * Resolve every commands dir that should receive an OpenCode pin: the + * project-local dir when the project has the skill, plus the user config dir + * when Impeccable is installed globally (#406 layout). A user-scope skill is + * visible from every project, so its pinned commands belong next to it. + * With `forCleanup`, both commands dirs are included even when the skill is + * gone, so unpin can still reach a pin left behind by a removed install; + * removal stays safe because removePinnedOpencodeCommand is marker-guarded. + */ +function findOpencodeCommandsDirs(projectRoot, { forCleanup = false } = {}) { + const dirs = []; + const seen = new Set(); + const push = (commandsDir) => { + const key = resolve(commandsDir); + if (!seen.has(key)) { + seen.add(key); + dirs.push(commandsDir); + } + }; + if (forCleanup || existsSync(join(projectRoot, '.opencode', 'skills', 'impeccable'))) { + push(join(projectRoot, '.opencode', 'commands')); + } + const userConfig = opencodeUserConfigDir(); + if (forCleanup || existsSync(join(userConfig, 'skills', 'impeccable'))) { + push(join(userConfig, 'commands')); + } + return dirs; +} + +function writePinnedOpencodeCommand(commandsDir, command, metadata) { + const commandFile = join(commandsDir, `impeccable-${command}.md`); + if (existsSync(commandFile)) { + const existing = readFileSync(commandFile, 'utf-8'); + if (!existing.includes(OPENCODE_PIN_MARKER)) { + console.log(` SKIP: ${commandFile} (non-pinned command already exists)`); + return false; + } + } else { + mkdirSync(commandsDir, { recursive: true }); + } + writeFileSync(commandFile, generatePinnedOpencodeCommand(command, metadata)); + console.log(` + ${commandFile}`); + return true; +} + +function removePinnedOpencodeCommand(commandsDir, command) { + const commandFile = join(commandsDir, `impeccable-${command}.md`); + if (!existsSync(commandFile)) return false; + const content = readFileSync(commandFile, 'utf-8'); + if (!content.includes(OPENCODE_PIN_MARKER)) { + console.log(` SKIP: ${commandFile} (not a pinned command)`); + return false; + } + rmSync(commandFile, { force: true }); + console.log(` - ${commandFile}`); + return true; +} + /** * Pin a command: create shortcut skill in all harness dirs. */ function pin(command, projectRoot) { const metadata = loadCommandMetadata(); const harnessDirs = findHarnessDirs(projectRoot); + const opencodeCommandsDirs = findOpencodeCommandsDirs(projectRoot); - if (harnessDirs.length === 0) { + if (harnessDirs.length === 0 && opencodeCommandsDirs.length === 0) { console.log('No harness directories with impeccable installed found.'); return false; } let created = 0; + // OpenCode is handled separately below because its shortcut format is a + // slash command, not a SKILL.md. Excluding it from the skill loop here + // prevents a duplicate `.opencode/skills//SKILL.md` that OpenCode + // would never surface as `/`. for (const skillsDir of harnessDirs) { + if (skillsDir.includes(`${sep}.opencode${sep}`)) continue; const commandPrefix = commandPrefixForSkillsDir(skillsDir); const content = generatePinnedSkill(command, metadata, commandPrefix, commandPrefix === '$'); // Check if skill already exists (and isn't a pin) @@ -151,6 +250,12 @@ function pin(command, projectRoot) { created++; } + // OpenCode: write a slash command bridge, not a skill shortcut. Covers both + // project installs and user-scope (global config) installs. + for (const commandsDir of opencodeCommandsDirs) { + if (writePinnedOpencodeCommand(commandsDir, command, metadata)) created++; + } + if (created > 0) { console.log(`\nPinned '${command}' as a standalone shortcut in ${created} location(s).`); console.log('Use the pinned command directly in each harness.'); @@ -160,13 +265,17 @@ function pin(command, projectRoot) { } /** - * Unpin a command: remove shortcut skill from all harness dirs. + * Unpin a command: remove shortcut skill in all harness dirs. */ function unpin(command, projectRoot) { const harnessDirs = findHarnessDirs(projectRoot); let removed = 0; + // OpenCode has its own cleanup path below; skip the skill loop here so a + // stray `.opencode/skills//SKILL.md` written by an older Impeccable + // version is never silently dropped here. for (const skillsDir of harnessDirs) { + if (skillsDir.includes(`${sep}.opencode${sep}`)) continue; const skillDir = join(skillsDir, command); if (!existsSync(skillDir)) continue; @@ -185,6 +294,13 @@ function unpin(command, projectRoot) { removed++; } + // OpenCode: remove the pinned command file if it's one of ours, in every + // scope it could have been written to — even when the skill itself is + // already gone, since removal is marker-guarded. + for (const commandsDir of findOpencodeCommandsDirs(projectRoot, { forCleanup: true })) { + if (removePinnedOpencodeCommand(commandsDir, command)) removed++; + } + if (removed > 0) { console.log(`\nUnpinned '${command}' from ${removed} location(s).`); console.log(`Use Impeccable's '${command}' workflow directly to access it.`); diff --git a/.rovodev/skills/impeccable/scripts/pin.mjs b/.rovodev/skills/impeccable/scripts/pin.mjs index d80043df7..6a4323194 100644 --- a/.rovodev/skills/impeccable/scripts/pin.mjs +++ b/.rovodev/skills/impeccable/scripts/pin.mjs @@ -14,8 +14,9 @@ */ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync } from 'node:fs'; -import { basename, join, resolve, dirname } from 'node:path'; +import { basename, join, resolve, dirname, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { homedir } from 'node:os'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -115,21 +116,119 @@ Invoke ${commandPrefix}impeccable ${command}, passing along any arguments provid `; } +// OpenCode 1.18.10 does not honor `user-invocable: true` on SKILL.md frontmatter +// (see docs/HARNESSES.md and opencode/packages/core/src/v1/config/command.ts), +// so a pinned skill there shows up in `opencode debug skill` but never in the +// slash menu. The fix is a sibling `commands/impeccable-.md` that uses the +// OpenCode command schema (description, agent, subtask). Body loads the skill +// via the skill tool and then the sub-command's reference file directly, so +// /impeccable- runs the same workflow /impeccable routes to. +const OPENCODE_PIN_MARKER = ''; +function generatePinnedOpencodeCommand(command, metadata) { + const desc = metadata[command]?.description || `Impeccable sub-command shortcut; runs the ${command} workflow via /impeccable.`; + return `--- +description: "${desc}" +agent: build +subtask: true +--- + +${OPENCODE_PIN_MARKER} + +Load the \`impeccable\` skill via the skill tool (name: "impeccable"), then run \`node /scripts/context.mjs\`, then load \`/reference/${command}.md\` and follow it. \`\` is the skill's base directory as reported by the skill tool response; substitute the actual absolute path before running or reading anything. + +$ARGUMENTS +`; +} + +// OpenCode's user-scope config dir. Mirrors the CLI's opencodeGlobalConfigDir +// precedence (OPENCODE_CONFIG_DIR → XDG_CONFIG_HOME/opencode → +// ~/.config/opencode); duplicated here because this script ships inside the +// installed skill and cannot import the CLI. +function opencodeUserConfigDir() { + if (process.env.OPENCODE_CONFIG_DIR) return process.env.OPENCODE_CONFIG_DIR; + if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, 'opencode'); + return join(homedir(), '.config', 'opencode'); +} + +/** + * Resolve every commands dir that should receive an OpenCode pin: the + * project-local dir when the project has the skill, plus the user config dir + * when Impeccable is installed globally (#406 layout). A user-scope skill is + * visible from every project, so its pinned commands belong next to it. + * With `forCleanup`, both commands dirs are included even when the skill is + * gone, so unpin can still reach a pin left behind by a removed install; + * removal stays safe because removePinnedOpencodeCommand is marker-guarded. + */ +function findOpencodeCommandsDirs(projectRoot, { forCleanup = false } = {}) { + const dirs = []; + const seen = new Set(); + const push = (commandsDir) => { + const key = resolve(commandsDir); + if (!seen.has(key)) { + seen.add(key); + dirs.push(commandsDir); + } + }; + if (forCleanup || existsSync(join(projectRoot, '.opencode', 'skills', 'impeccable'))) { + push(join(projectRoot, '.opencode', 'commands')); + } + const userConfig = opencodeUserConfigDir(); + if (forCleanup || existsSync(join(userConfig, 'skills', 'impeccable'))) { + push(join(userConfig, 'commands')); + } + return dirs; +} + +function writePinnedOpencodeCommand(commandsDir, command, metadata) { + const commandFile = join(commandsDir, `impeccable-${command}.md`); + if (existsSync(commandFile)) { + const existing = readFileSync(commandFile, 'utf-8'); + if (!existing.includes(OPENCODE_PIN_MARKER)) { + console.log(` SKIP: ${commandFile} (non-pinned command already exists)`); + return false; + } + } else { + mkdirSync(commandsDir, { recursive: true }); + } + writeFileSync(commandFile, generatePinnedOpencodeCommand(command, metadata)); + console.log(` + ${commandFile}`); + return true; +} + +function removePinnedOpencodeCommand(commandsDir, command) { + const commandFile = join(commandsDir, `impeccable-${command}.md`); + if (!existsSync(commandFile)) return false; + const content = readFileSync(commandFile, 'utf-8'); + if (!content.includes(OPENCODE_PIN_MARKER)) { + console.log(` SKIP: ${commandFile} (not a pinned command)`); + return false; + } + rmSync(commandFile, { force: true }); + console.log(` - ${commandFile}`); + return true; +} + /** * Pin a command: create shortcut skill in all harness dirs. */ function pin(command, projectRoot) { const metadata = loadCommandMetadata(); const harnessDirs = findHarnessDirs(projectRoot); + const opencodeCommandsDirs = findOpencodeCommandsDirs(projectRoot); - if (harnessDirs.length === 0) { + if (harnessDirs.length === 0 && opencodeCommandsDirs.length === 0) { console.log('No harness directories with impeccable installed found.'); return false; } let created = 0; + // OpenCode is handled separately below because its shortcut format is a + // slash command, not a SKILL.md. Excluding it from the skill loop here + // prevents a duplicate `.opencode/skills//SKILL.md` that OpenCode + // would never surface as `/`. for (const skillsDir of harnessDirs) { + if (skillsDir.includes(`${sep}.opencode${sep}`)) continue; const commandPrefix = commandPrefixForSkillsDir(skillsDir); const content = generatePinnedSkill(command, metadata, commandPrefix, commandPrefix === '$'); // Check if skill already exists (and isn't a pin) @@ -151,6 +250,12 @@ function pin(command, projectRoot) { created++; } + // OpenCode: write a slash command bridge, not a skill shortcut. Covers both + // project installs and user-scope (global config) installs. + for (const commandsDir of opencodeCommandsDirs) { + if (writePinnedOpencodeCommand(commandsDir, command, metadata)) created++; + } + if (created > 0) { console.log(`\nPinned '${command}' as a standalone shortcut in ${created} location(s).`); console.log('Use the pinned command directly in each harness.'); @@ -160,13 +265,17 @@ function pin(command, projectRoot) { } /** - * Unpin a command: remove shortcut skill from all harness dirs. + * Unpin a command: remove shortcut skill in all harness dirs. */ function unpin(command, projectRoot) { const harnessDirs = findHarnessDirs(projectRoot); let removed = 0; + // OpenCode has its own cleanup path below; skip the skill loop here so a + // stray `.opencode/skills//SKILL.md` written by an older Impeccable + // version is never silently dropped here. for (const skillsDir of harnessDirs) { + if (skillsDir.includes(`${sep}.opencode${sep}`)) continue; const skillDir = join(skillsDir, command); if (!existsSync(skillDir)) continue; @@ -185,6 +294,13 @@ function unpin(command, projectRoot) { removed++; } + // OpenCode: remove the pinned command file if it's one of ours, in every + // scope it could have been written to — even when the skill itself is + // already gone, since removal is marker-guarded. + for (const commandsDir of findOpencodeCommandsDirs(projectRoot, { forCleanup: true })) { + if (removePinnedOpencodeCommand(commandsDir, command)) removed++; + } + if (removed > 0) { console.log(`\nUnpinned '${command}' from ${removed} location(s).`); console.log(`Use Impeccable's '${command}' workflow directly to access it.`); diff --git a/.trae-cn/skills/impeccable/scripts/pin.mjs b/.trae-cn/skills/impeccable/scripts/pin.mjs index d80043df7..6a4323194 100644 --- a/.trae-cn/skills/impeccable/scripts/pin.mjs +++ b/.trae-cn/skills/impeccable/scripts/pin.mjs @@ -14,8 +14,9 @@ */ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync } from 'node:fs'; -import { basename, join, resolve, dirname } from 'node:path'; +import { basename, join, resolve, dirname, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { homedir } from 'node:os'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -115,21 +116,119 @@ Invoke ${commandPrefix}impeccable ${command}, passing along any arguments provid `; } +// OpenCode 1.18.10 does not honor `user-invocable: true` on SKILL.md frontmatter +// (see docs/HARNESSES.md and opencode/packages/core/src/v1/config/command.ts), +// so a pinned skill there shows up in `opencode debug skill` but never in the +// slash menu. The fix is a sibling `commands/impeccable-.md` that uses the +// OpenCode command schema (description, agent, subtask). Body loads the skill +// via the skill tool and then the sub-command's reference file directly, so +// /impeccable- runs the same workflow /impeccable routes to. +const OPENCODE_PIN_MARKER = ''; +function generatePinnedOpencodeCommand(command, metadata) { + const desc = metadata[command]?.description || `Impeccable sub-command shortcut; runs the ${command} workflow via /impeccable.`; + return `--- +description: "${desc}" +agent: build +subtask: true +--- + +${OPENCODE_PIN_MARKER} + +Load the \`impeccable\` skill via the skill tool (name: "impeccable"), then run \`node /scripts/context.mjs\`, then load \`/reference/${command}.md\` and follow it. \`\` is the skill's base directory as reported by the skill tool response; substitute the actual absolute path before running or reading anything. + +$ARGUMENTS +`; +} + +// OpenCode's user-scope config dir. Mirrors the CLI's opencodeGlobalConfigDir +// precedence (OPENCODE_CONFIG_DIR → XDG_CONFIG_HOME/opencode → +// ~/.config/opencode); duplicated here because this script ships inside the +// installed skill and cannot import the CLI. +function opencodeUserConfigDir() { + if (process.env.OPENCODE_CONFIG_DIR) return process.env.OPENCODE_CONFIG_DIR; + if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, 'opencode'); + return join(homedir(), '.config', 'opencode'); +} + +/** + * Resolve every commands dir that should receive an OpenCode pin: the + * project-local dir when the project has the skill, plus the user config dir + * when Impeccable is installed globally (#406 layout). A user-scope skill is + * visible from every project, so its pinned commands belong next to it. + * With `forCleanup`, both commands dirs are included even when the skill is + * gone, so unpin can still reach a pin left behind by a removed install; + * removal stays safe because removePinnedOpencodeCommand is marker-guarded. + */ +function findOpencodeCommandsDirs(projectRoot, { forCleanup = false } = {}) { + const dirs = []; + const seen = new Set(); + const push = (commandsDir) => { + const key = resolve(commandsDir); + if (!seen.has(key)) { + seen.add(key); + dirs.push(commandsDir); + } + }; + if (forCleanup || existsSync(join(projectRoot, '.opencode', 'skills', 'impeccable'))) { + push(join(projectRoot, '.opencode', 'commands')); + } + const userConfig = opencodeUserConfigDir(); + if (forCleanup || existsSync(join(userConfig, 'skills', 'impeccable'))) { + push(join(userConfig, 'commands')); + } + return dirs; +} + +function writePinnedOpencodeCommand(commandsDir, command, metadata) { + const commandFile = join(commandsDir, `impeccable-${command}.md`); + if (existsSync(commandFile)) { + const existing = readFileSync(commandFile, 'utf-8'); + if (!existing.includes(OPENCODE_PIN_MARKER)) { + console.log(` SKIP: ${commandFile} (non-pinned command already exists)`); + return false; + } + } else { + mkdirSync(commandsDir, { recursive: true }); + } + writeFileSync(commandFile, generatePinnedOpencodeCommand(command, metadata)); + console.log(` + ${commandFile}`); + return true; +} + +function removePinnedOpencodeCommand(commandsDir, command) { + const commandFile = join(commandsDir, `impeccable-${command}.md`); + if (!existsSync(commandFile)) return false; + const content = readFileSync(commandFile, 'utf-8'); + if (!content.includes(OPENCODE_PIN_MARKER)) { + console.log(` SKIP: ${commandFile} (not a pinned command)`); + return false; + } + rmSync(commandFile, { force: true }); + console.log(` - ${commandFile}`); + return true; +} + /** * Pin a command: create shortcut skill in all harness dirs. */ function pin(command, projectRoot) { const metadata = loadCommandMetadata(); const harnessDirs = findHarnessDirs(projectRoot); + const opencodeCommandsDirs = findOpencodeCommandsDirs(projectRoot); - if (harnessDirs.length === 0) { + if (harnessDirs.length === 0 && opencodeCommandsDirs.length === 0) { console.log('No harness directories with impeccable installed found.'); return false; } let created = 0; + // OpenCode is handled separately below because its shortcut format is a + // slash command, not a SKILL.md. Excluding it from the skill loop here + // prevents a duplicate `.opencode/skills//SKILL.md` that OpenCode + // would never surface as `/`. for (const skillsDir of harnessDirs) { + if (skillsDir.includes(`${sep}.opencode${sep}`)) continue; const commandPrefix = commandPrefixForSkillsDir(skillsDir); const content = generatePinnedSkill(command, metadata, commandPrefix, commandPrefix === '$'); // Check if skill already exists (and isn't a pin) @@ -151,6 +250,12 @@ function pin(command, projectRoot) { created++; } + // OpenCode: write a slash command bridge, not a skill shortcut. Covers both + // project installs and user-scope (global config) installs. + for (const commandsDir of opencodeCommandsDirs) { + if (writePinnedOpencodeCommand(commandsDir, command, metadata)) created++; + } + if (created > 0) { console.log(`\nPinned '${command}' as a standalone shortcut in ${created} location(s).`); console.log('Use the pinned command directly in each harness.'); @@ -160,13 +265,17 @@ function pin(command, projectRoot) { } /** - * Unpin a command: remove shortcut skill from all harness dirs. + * Unpin a command: remove shortcut skill in all harness dirs. */ function unpin(command, projectRoot) { const harnessDirs = findHarnessDirs(projectRoot); let removed = 0; + // OpenCode has its own cleanup path below; skip the skill loop here so a + // stray `.opencode/skills//SKILL.md` written by an older Impeccable + // version is never silently dropped here. for (const skillsDir of harnessDirs) { + if (skillsDir.includes(`${sep}.opencode${sep}`)) continue; const skillDir = join(skillsDir, command); if (!existsSync(skillDir)) continue; @@ -185,6 +294,13 @@ function unpin(command, projectRoot) { removed++; } + // OpenCode: remove the pinned command file if it's one of ours, in every + // scope it could have been written to — even when the skill itself is + // already gone, since removal is marker-guarded. + for (const commandsDir of findOpencodeCommandsDirs(projectRoot, { forCleanup: true })) { + if (removePinnedOpencodeCommand(commandsDir, command)) removed++; + } + if (removed > 0) { console.log(`\nUnpinned '${command}' from ${removed} location(s).`); console.log(`Use Impeccable's '${command}' workflow directly to access it.`); diff --git a/.trae/skills/impeccable/scripts/pin.mjs b/.trae/skills/impeccable/scripts/pin.mjs index d80043df7..6a4323194 100644 --- a/.trae/skills/impeccable/scripts/pin.mjs +++ b/.trae/skills/impeccable/scripts/pin.mjs @@ -14,8 +14,9 @@ */ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync } from 'node:fs'; -import { basename, join, resolve, dirname } from 'node:path'; +import { basename, join, resolve, dirname, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { homedir } from 'node:os'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -115,21 +116,119 @@ Invoke ${commandPrefix}impeccable ${command}, passing along any arguments provid `; } +// OpenCode 1.18.10 does not honor `user-invocable: true` on SKILL.md frontmatter +// (see docs/HARNESSES.md and opencode/packages/core/src/v1/config/command.ts), +// so a pinned skill there shows up in `opencode debug skill` but never in the +// slash menu. The fix is a sibling `commands/impeccable-.md` that uses the +// OpenCode command schema (description, agent, subtask). Body loads the skill +// via the skill tool and then the sub-command's reference file directly, so +// /impeccable- runs the same workflow /impeccable routes to. +const OPENCODE_PIN_MARKER = ''; +function generatePinnedOpencodeCommand(command, metadata) { + const desc = metadata[command]?.description || `Impeccable sub-command shortcut; runs the ${command} workflow via /impeccable.`; + return `--- +description: "${desc}" +agent: build +subtask: true +--- + +${OPENCODE_PIN_MARKER} + +Load the \`impeccable\` skill via the skill tool (name: "impeccable"), then run \`node /scripts/context.mjs\`, then load \`/reference/${command}.md\` and follow it. \`\` is the skill's base directory as reported by the skill tool response; substitute the actual absolute path before running or reading anything. + +$ARGUMENTS +`; +} + +// OpenCode's user-scope config dir. Mirrors the CLI's opencodeGlobalConfigDir +// precedence (OPENCODE_CONFIG_DIR → XDG_CONFIG_HOME/opencode → +// ~/.config/opencode); duplicated here because this script ships inside the +// installed skill and cannot import the CLI. +function opencodeUserConfigDir() { + if (process.env.OPENCODE_CONFIG_DIR) return process.env.OPENCODE_CONFIG_DIR; + if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, 'opencode'); + return join(homedir(), '.config', 'opencode'); +} + +/** + * Resolve every commands dir that should receive an OpenCode pin: the + * project-local dir when the project has the skill, plus the user config dir + * when Impeccable is installed globally (#406 layout). A user-scope skill is + * visible from every project, so its pinned commands belong next to it. + * With `forCleanup`, both commands dirs are included even when the skill is + * gone, so unpin can still reach a pin left behind by a removed install; + * removal stays safe because removePinnedOpencodeCommand is marker-guarded. + */ +function findOpencodeCommandsDirs(projectRoot, { forCleanup = false } = {}) { + const dirs = []; + const seen = new Set(); + const push = (commandsDir) => { + const key = resolve(commandsDir); + if (!seen.has(key)) { + seen.add(key); + dirs.push(commandsDir); + } + }; + if (forCleanup || existsSync(join(projectRoot, '.opencode', 'skills', 'impeccable'))) { + push(join(projectRoot, '.opencode', 'commands')); + } + const userConfig = opencodeUserConfigDir(); + if (forCleanup || existsSync(join(userConfig, 'skills', 'impeccable'))) { + push(join(userConfig, 'commands')); + } + return dirs; +} + +function writePinnedOpencodeCommand(commandsDir, command, metadata) { + const commandFile = join(commandsDir, `impeccable-${command}.md`); + if (existsSync(commandFile)) { + const existing = readFileSync(commandFile, 'utf-8'); + if (!existing.includes(OPENCODE_PIN_MARKER)) { + console.log(` SKIP: ${commandFile} (non-pinned command already exists)`); + return false; + } + } else { + mkdirSync(commandsDir, { recursive: true }); + } + writeFileSync(commandFile, generatePinnedOpencodeCommand(command, metadata)); + console.log(` + ${commandFile}`); + return true; +} + +function removePinnedOpencodeCommand(commandsDir, command) { + const commandFile = join(commandsDir, `impeccable-${command}.md`); + if (!existsSync(commandFile)) return false; + const content = readFileSync(commandFile, 'utf-8'); + if (!content.includes(OPENCODE_PIN_MARKER)) { + console.log(` SKIP: ${commandFile} (not a pinned command)`); + return false; + } + rmSync(commandFile, { force: true }); + console.log(` - ${commandFile}`); + return true; +} + /** * Pin a command: create shortcut skill in all harness dirs. */ function pin(command, projectRoot) { const metadata = loadCommandMetadata(); const harnessDirs = findHarnessDirs(projectRoot); + const opencodeCommandsDirs = findOpencodeCommandsDirs(projectRoot); - if (harnessDirs.length === 0) { + if (harnessDirs.length === 0 && opencodeCommandsDirs.length === 0) { console.log('No harness directories with impeccable installed found.'); return false; } let created = 0; + // OpenCode is handled separately below because its shortcut format is a + // slash command, not a SKILL.md. Excluding it from the skill loop here + // prevents a duplicate `.opencode/skills//SKILL.md` that OpenCode + // would never surface as `/`. for (const skillsDir of harnessDirs) { + if (skillsDir.includes(`${sep}.opencode${sep}`)) continue; const commandPrefix = commandPrefixForSkillsDir(skillsDir); const content = generatePinnedSkill(command, metadata, commandPrefix, commandPrefix === '$'); // Check if skill already exists (and isn't a pin) @@ -151,6 +250,12 @@ function pin(command, projectRoot) { created++; } + // OpenCode: write a slash command bridge, not a skill shortcut. Covers both + // project installs and user-scope (global config) installs. + for (const commandsDir of opencodeCommandsDirs) { + if (writePinnedOpencodeCommand(commandsDir, command, metadata)) created++; + } + if (created > 0) { console.log(`\nPinned '${command}' as a standalone shortcut in ${created} location(s).`); console.log('Use the pinned command directly in each harness.'); @@ -160,13 +265,17 @@ function pin(command, projectRoot) { } /** - * Unpin a command: remove shortcut skill from all harness dirs. + * Unpin a command: remove shortcut skill in all harness dirs. */ function unpin(command, projectRoot) { const harnessDirs = findHarnessDirs(projectRoot); let removed = 0; + // OpenCode has its own cleanup path below; skip the skill loop here so a + // stray `.opencode/skills//SKILL.md` written by an older Impeccable + // version is never silently dropped here. for (const skillsDir of harnessDirs) { + if (skillsDir.includes(`${sep}.opencode${sep}`)) continue; const skillDir = join(skillsDir, command); if (!existsSync(skillDir)) continue; @@ -185,6 +294,13 @@ function unpin(command, projectRoot) { removed++; } + // OpenCode: remove the pinned command file if it's one of ours, in every + // scope it could have been written to — even when the skill itself is + // already gone, since removal is marker-guarded. + for (const commandsDir of findOpencodeCommandsDirs(projectRoot, { forCleanup: true })) { + if (removePinnedOpencodeCommand(commandsDir, command)) removed++; + } + if (removed > 0) { console.log(`\nUnpinned '${command}' from ${removed} location(s).`); console.log(`Use Impeccable's '${command}' workflow directly to access it.`); diff --git a/.vibe/skills/impeccable/scripts/pin.mjs b/.vibe/skills/impeccable/scripts/pin.mjs index d80043df7..6a4323194 100644 --- a/.vibe/skills/impeccable/scripts/pin.mjs +++ b/.vibe/skills/impeccable/scripts/pin.mjs @@ -14,8 +14,9 @@ */ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync } from 'node:fs'; -import { basename, join, resolve, dirname } from 'node:path'; +import { basename, join, resolve, dirname, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { homedir } from 'node:os'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -115,21 +116,119 @@ Invoke ${commandPrefix}impeccable ${command}, passing along any arguments provid `; } +// OpenCode 1.18.10 does not honor `user-invocable: true` on SKILL.md frontmatter +// (see docs/HARNESSES.md and opencode/packages/core/src/v1/config/command.ts), +// so a pinned skill there shows up in `opencode debug skill` but never in the +// slash menu. The fix is a sibling `commands/impeccable-.md` that uses the +// OpenCode command schema (description, agent, subtask). Body loads the skill +// via the skill tool and then the sub-command's reference file directly, so +// /impeccable- runs the same workflow /impeccable routes to. +const OPENCODE_PIN_MARKER = ''; +function generatePinnedOpencodeCommand(command, metadata) { + const desc = metadata[command]?.description || `Impeccable sub-command shortcut; runs the ${command} workflow via /impeccable.`; + return `--- +description: "${desc}" +agent: build +subtask: true +--- + +${OPENCODE_PIN_MARKER} + +Load the \`impeccable\` skill via the skill tool (name: "impeccable"), then run \`node /scripts/context.mjs\`, then load \`/reference/${command}.md\` and follow it. \`\` is the skill's base directory as reported by the skill tool response; substitute the actual absolute path before running or reading anything. + +$ARGUMENTS +`; +} + +// OpenCode's user-scope config dir. Mirrors the CLI's opencodeGlobalConfigDir +// precedence (OPENCODE_CONFIG_DIR → XDG_CONFIG_HOME/opencode → +// ~/.config/opencode); duplicated here because this script ships inside the +// installed skill and cannot import the CLI. +function opencodeUserConfigDir() { + if (process.env.OPENCODE_CONFIG_DIR) return process.env.OPENCODE_CONFIG_DIR; + if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, 'opencode'); + return join(homedir(), '.config', 'opencode'); +} + +/** + * Resolve every commands dir that should receive an OpenCode pin: the + * project-local dir when the project has the skill, plus the user config dir + * when Impeccable is installed globally (#406 layout). A user-scope skill is + * visible from every project, so its pinned commands belong next to it. + * With `forCleanup`, both commands dirs are included even when the skill is + * gone, so unpin can still reach a pin left behind by a removed install; + * removal stays safe because removePinnedOpencodeCommand is marker-guarded. + */ +function findOpencodeCommandsDirs(projectRoot, { forCleanup = false } = {}) { + const dirs = []; + const seen = new Set(); + const push = (commandsDir) => { + const key = resolve(commandsDir); + if (!seen.has(key)) { + seen.add(key); + dirs.push(commandsDir); + } + }; + if (forCleanup || existsSync(join(projectRoot, '.opencode', 'skills', 'impeccable'))) { + push(join(projectRoot, '.opencode', 'commands')); + } + const userConfig = opencodeUserConfigDir(); + if (forCleanup || existsSync(join(userConfig, 'skills', 'impeccable'))) { + push(join(userConfig, 'commands')); + } + return dirs; +} + +function writePinnedOpencodeCommand(commandsDir, command, metadata) { + const commandFile = join(commandsDir, `impeccable-${command}.md`); + if (existsSync(commandFile)) { + const existing = readFileSync(commandFile, 'utf-8'); + if (!existing.includes(OPENCODE_PIN_MARKER)) { + console.log(` SKIP: ${commandFile} (non-pinned command already exists)`); + return false; + } + } else { + mkdirSync(commandsDir, { recursive: true }); + } + writeFileSync(commandFile, generatePinnedOpencodeCommand(command, metadata)); + console.log(` + ${commandFile}`); + return true; +} + +function removePinnedOpencodeCommand(commandsDir, command) { + const commandFile = join(commandsDir, `impeccable-${command}.md`); + if (!existsSync(commandFile)) return false; + const content = readFileSync(commandFile, 'utf-8'); + if (!content.includes(OPENCODE_PIN_MARKER)) { + console.log(` SKIP: ${commandFile} (not a pinned command)`); + return false; + } + rmSync(commandFile, { force: true }); + console.log(` - ${commandFile}`); + return true; +} + /** * Pin a command: create shortcut skill in all harness dirs. */ function pin(command, projectRoot) { const metadata = loadCommandMetadata(); const harnessDirs = findHarnessDirs(projectRoot); + const opencodeCommandsDirs = findOpencodeCommandsDirs(projectRoot); - if (harnessDirs.length === 0) { + if (harnessDirs.length === 0 && opencodeCommandsDirs.length === 0) { console.log('No harness directories with impeccable installed found.'); return false; } let created = 0; + // OpenCode is handled separately below because its shortcut format is a + // slash command, not a SKILL.md. Excluding it from the skill loop here + // prevents a duplicate `.opencode/skills//SKILL.md` that OpenCode + // would never surface as `/`. for (const skillsDir of harnessDirs) { + if (skillsDir.includes(`${sep}.opencode${sep}`)) continue; const commandPrefix = commandPrefixForSkillsDir(skillsDir); const content = generatePinnedSkill(command, metadata, commandPrefix, commandPrefix === '$'); // Check if skill already exists (and isn't a pin) @@ -151,6 +250,12 @@ function pin(command, projectRoot) { created++; } + // OpenCode: write a slash command bridge, not a skill shortcut. Covers both + // project installs and user-scope (global config) installs. + for (const commandsDir of opencodeCommandsDirs) { + if (writePinnedOpencodeCommand(commandsDir, command, metadata)) created++; + } + if (created > 0) { console.log(`\nPinned '${command}' as a standalone shortcut in ${created} location(s).`); console.log('Use the pinned command directly in each harness.'); @@ -160,13 +265,17 @@ function pin(command, projectRoot) { } /** - * Unpin a command: remove shortcut skill from all harness dirs. + * Unpin a command: remove shortcut skill in all harness dirs. */ function unpin(command, projectRoot) { const harnessDirs = findHarnessDirs(projectRoot); let removed = 0; + // OpenCode has its own cleanup path below; skip the skill loop here so a + // stray `.opencode/skills//SKILL.md` written by an older Impeccable + // version is never silently dropped here. for (const skillsDir of harnessDirs) { + if (skillsDir.includes(`${sep}.opencode${sep}`)) continue; const skillDir = join(skillsDir, command); if (!existsSync(skillDir)) continue; @@ -185,6 +294,13 @@ function unpin(command, projectRoot) { removed++; } + // OpenCode: remove the pinned command file if it's one of ours, in every + // scope it could have been written to — even when the skill itself is + // already gone, since removal is marker-guarded. + for (const commandsDir of findOpencodeCommandsDirs(projectRoot, { forCleanup: true })) { + if (removePinnedOpencodeCommand(commandsDir, command)) removed++; + } + if (removed > 0) { console.log(`\nUnpinned '${command}' from ${removed} location(s).`); console.log(`Use Impeccable's '${command}' workflow directly to access it.`); diff --git a/plugin/skills/impeccable/scripts/pin.mjs b/plugin/skills/impeccable/scripts/pin.mjs index d80043df7..6a4323194 100644 --- a/plugin/skills/impeccable/scripts/pin.mjs +++ b/plugin/skills/impeccable/scripts/pin.mjs @@ -14,8 +14,9 @@ */ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync } from 'node:fs'; -import { basename, join, resolve, dirname } from 'node:path'; +import { basename, join, resolve, dirname, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { homedir } from 'node:os'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -115,21 +116,119 @@ Invoke ${commandPrefix}impeccable ${command}, passing along any arguments provid `; } +// OpenCode 1.18.10 does not honor `user-invocable: true` on SKILL.md frontmatter +// (see docs/HARNESSES.md and opencode/packages/core/src/v1/config/command.ts), +// so a pinned skill there shows up in `opencode debug skill` but never in the +// slash menu. The fix is a sibling `commands/impeccable-.md` that uses the +// OpenCode command schema (description, agent, subtask). Body loads the skill +// via the skill tool and then the sub-command's reference file directly, so +// /impeccable- runs the same workflow /impeccable routes to. +const OPENCODE_PIN_MARKER = ''; +function generatePinnedOpencodeCommand(command, metadata) { + const desc = metadata[command]?.description || `Impeccable sub-command shortcut; runs the ${command} workflow via /impeccable.`; + return `--- +description: "${desc}" +agent: build +subtask: true +--- + +${OPENCODE_PIN_MARKER} + +Load the \`impeccable\` skill via the skill tool (name: "impeccable"), then run \`node /scripts/context.mjs\`, then load \`/reference/${command}.md\` and follow it. \`\` is the skill's base directory as reported by the skill tool response; substitute the actual absolute path before running or reading anything. + +$ARGUMENTS +`; +} + +// OpenCode's user-scope config dir. Mirrors the CLI's opencodeGlobalConfigDir +// precedence (OPENCODE_CONFIG_DIR → XDG_CONFIG_HOME/opencode → +// ~/.config/opencode); duplicated here because this script ships inside the +// installed skill and cannot import the CLI. +function opencodeUserConfigDir() { + if (process.env.OPENCODE_CONFIG_DIR) return process.env.OPENCODE_CONFIG_DIR; + if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, 'opencode'); + return join(homedir(), '.config', 'opencode'); +} + +/** + * Resolve every commands dir that should receive an OpenCode pin: the + * project-local dir when the project has the skill, plus the user config dir + * when Impeccable is installed globally (#406 layout). A user-scope skill is + * visible from every project, so its pinned commands belong next to it. + * With `forCleanup`, both commands dirs are included even when the skill is + * gone, so unpin can still reach a pin left behind by a removed install; + * removal stays safe because removePinnedOpencodeCommand is marker-guarded. + */ +function findOpencodeCommandsDirs(projectRoot, { forCleanup = false } = {}) { + const dirs = []; + const seen = new Set(); + const push = (commandsDir) => { + const key = resolve(commandsDir); + if (!seen.has(key)) { + seen.add(key); + dirs.push(commandsDir); + } + }; + if (forCleanup || existsSync(join(projectRoot, '.opencode', 'skills', 'impeccable'))) { + push(join(projectRoot, '.opencode', 'commands')); + } + const userConfig = opencodeUserConfigDir(); + if (forCleanup || existsSync(join(userConfig, 'skills', 'impeccable'))) { + push(join(userConfig, 'commands')); + } + return dirs; +} + +function writePinnedOpencodeCommand(commandsDir, command, metadata) { + const commandFile = join(commandsDir, `impeccable-${command}.md`); + if (existsSync(commandFile)) { + const existing = readFileSync(commandFile, 'utf-8'); + if (!existing.includes(OPENCODE_PIN_MARKER)) { + console.log(` SKIP: ${commandFile} (non-pinned command already exists)`); + return false; + } + } else { + mkdirSync(commandsDir, { recursive: true }); + } + writeFileSync(commandFile, generatePinnedOpencodeCommand(command, metadata)); + console.log(` + ${commandFile}`); + return true; +} + +function removePinnedOpencodeCommand(commandsDir, command) { + const commandFile = join(commandsDir, `impeccable-${command}.md`); + if (!existsSync(commandFile)) return false; + const content = readFileSync(commandFile, 'utf-8'); + if (!content.includes(OPENCODE_PIN_MARKER)) { + console.log(` SKIP: ${commandFile} (not a pinned command)`); + return false; + } + rmSync(commandFile, { force: true }); + console.log(` - ${commandFile}`); + return true; +} + /** * Pin a command: create shortcut skill in all harness dirs. */ function pin(command, projectRoot) { const metadata = loadCommandMetadata(); const harnessDirs = findHarnessDirs(projectRoot); + const opencodeCommandsDirs = findOpencodeCommandsDirs(projectRoot); - if (harnessDirs.length === 0) { + if (harnessDirs.length === 0 && opencodeCommandsDirs.length === 0) { console.log('No harness directories with impeccable installed found.'); return false; } let created = 0; + // OpenCode is handled separately below because its shortcut format is a + // slash command, not a SKILL.md. Excluding it from the skill loop here + // prevents a duplicate `.opencode/skills//SKILL.md` that OpenCode + // would never surface as `/`. for (const skillsDir of harnessDirs) { + if (skillsDir.includes(`${sep}.opencode${sep}`)) continue; const commandPrefix = commandPrefixForSkillsDir(skillsDir); const content = generatePinnedSkill(command, metadata, commandPrefix, commandPrefix === '$'); // Check if skill already exists (and isn't a pin) @@ -151,6 +250,12 @@ function pin(command, projectRoot) { created++; } + // OpenCode: write a slash command bridge, not a skill shortcut. Covers both + // project installs and user-scope (global config) installs. + for (const commandsDir of opencodeCommandsDirs) { + if (writePinnedOpencodeCommand(commandsDir, command, metadata)) created++; + } + if (created > 0) { console.log(`\nPinned '${command}' as a standalone shortcut in ${created} location(s).`); console.log('Use the pinned command directly in each harness.'); @@ -160,13 +265,17 @@ function pin(command, projectRoot) { } /** - * Unpin a command: remove shortcut skill from all harness dirs. + * Unpin a command: remove shortcut skill in all harness dirs. */ function unpin(command, projectRoot) { const harnessDirs = findHarnessDirs(projectRoot); let removed = 0; + // OpenCode has its own cleanup path below; skip the skill loop here so a + // stray `.opencode/skills//SKILL.md` written by an older Impeccable + // version is never silently dropped here. for (const skillsDir of harnessDirs) { + if (skillsDir.includes(`${sep}.opencode${sep}`)) continue; const skillDir = join(skillsDir, command); if (!existsSync(skillDir)) continue; @@ -185,6 +294,13 @@ function unpin(command, projectRoot) { removed++; } + // OpenCode: remove the pinned command file if it's one of ours, in every + // scope it could have been written to — even when the skill itself is + // already gone, since removal is marker-guarded. + for (const commandsDir of findOpencodeCommandsDirs(projectRoot, { forCleanup: true })) { + if (removePinnedOpencodeCommand(commandsDir, command)) removed++; + } + if (removed > 0) { console.log(`\nUnpinned '${command}' from ${removed} location(s).`); console.log(`Use Impeccable's '${command}' workflow directly to access it.`); From 482368511ace07982a7cd3a23dd60cf62d6f68c8 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Tue, 1 Sep 2026 20:21:01 -0400 Subject: [PATCH 18/31] Fix Codex skill version metadata (#703) Move Codex and .agents skill versions under metadata while keeping all version readers compatible with legacy top-level frontmatter.\n\nAI assistance: prepared with Codex under maintainer direction. --- cli/bin/commands/skills.mjs | 38 ++++++++++++++++++++++-- scripts/lib/transformers/factory.js | 12 +++++++- scripts/lib/transformers/providers.js | 4 +++ scripts/lib/utils.js | 3 ++ scripts/lib/validate-plugin-versions.js | 38 ++++++++++++++++++++---- skill/scripts/context.mjs | 36 ++++++++++++++++++++-- tests/context.test.mjs | 20 +++++++++++-- tests/lib/transformers/providers.test.js | 10 +++++-- tests/lib/utils.test.js | 9 ++++++ tests/skills-cli.test.js | 27 +++++++++++++++++ tests/validate-plugin-versions.test.js | 10 +++++++ 11 files changed, 192 insertions(+), 15 deletions(-) diff --git a/cli/bin/commands/skills.mjs b/cli/bin/commands/skills.mjs index faa98a572..e15904104 100644 --- a/cli/bin/commands/skills.mjs +++ b/cli/bin/commands/skills.mjs @@ -557,6 +557,39 @@ async function showHelp() { // ─── version helpers ───────────────────────────────────────────────────────── +function parseSkillFrontmatterVersion(content) { + const match = String(content).match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---(?:[ \t]*\r?\n|[ \t]*$)/); + if (!match) return null; + + let metadataVersion = null; + let topLevelVersion = null; + let inMetadata = false; + let metadataIndent = null; + + for (const line of match[1].split(/\r?\n/)) { + if (!line.trim() || line.trimStart().startsWith('#')) continue; + const indentText = line.match(/^[ \t]*/)[0]; + const indent = indentText.replace(/\t/g, ' ').length; + + if (indent === 0) { + inMetadata = /^metadata:\s*(?:#.*)?$/.test(line); + metadataIndent = null; + const version = line.match(/^version:\s*(.+?)\s*$/); + if (version) topLevelVersion = version[1]; + continue; + } + + if (!inMetadata) continue; + if (metadataIndent === null) metadataIndent = indent; + if (indent !== metadataIndent) continue; + const version = line.trim().match(/^version:\s*(.+?)\s*$/); + if (version) metadataVersion = version[1]; + } + + const value = metadataVersion || topLevelVersion; + return value ? value.trim().replace(/^(["'])(.*)\1$/, '$2') : null; +} + /** * Read the skills version from the impeccable SKILL.md frontmatter. */ @@ -566,8 +599,8 @@ function getSkillsVersion(root, scope) { const skillMd = join(skillsDir, 'impeccable', 'SKILL.md'); if (!existsSync(skillMd)) continue; const content = readFileSync(skillMd, 'utf-8'); - const match = content.match(/^version:\s*(.+)$/m); - if (match) return match[1].trim().replace(/^["']|["']$/g, ''); + const version = parseSkillFrontmatterVersion(content); + if (version) return version; } } return null; @@ -2483,6 +2516,7 @@ export { expectedHookDests, extractZip, formatInstallDetectionLines, + getSkillsVersion, isUpToDate, hermesGlobalHome, HOME_SKILLS_DIR_OVERRIDES, diff --git a/scripts/lib/transformers/factory.js b/scripts/lib/transformers/factory.js index 5dfc19c30..684597ff5 100644 --- a/scripts/lib/transformers/factory.js +++ b/scripts/lib/transformers/factory.js @@ -241,6 +241,7 @@ export function createTransformer(config) { providerTags = [provider], writeOpenAIMetadata = false, includeVersion = true, + versionInMetadata = false, } = config; const placeholderKey = placeholderProvider || provider; @@ -274,7 +275,9 @@ export function createTransformer(config) { name: skillName, description: skill.description, }; - if (skillsVersion && includeVersion) frontmatterObj.version = skillsVersion; + if (skillsVersion && includeVersion && !versionInMetadata) { + frontmatterObj.version = skillsVersion; + } for (const spec of activeFields) { if (spec.condition && !spec.condition(skill)) continue; @@ -282,6 +285,13 @@ export function createTransformer(config) { if (val) frontmatterObj[spec.yamlKey] = val; } + if (skillsVersion && includeVersion && versionInMetadata) { + frontmatterObj.metadata = { + ...(frontmatterObj.metadata || {}), + version: skillsVersion, + }; + } + // Replace {{command_hint}} in argument-hint with command names from metadata, // grouped by category with middle dots between groups for natural line-breaking. if (frontmatterObj['argument-hint']?.includes('{{command_hint}}')) { diff --git a/scripts/lib/transformers/providers.js b/scripts/lib/transformers/providers.js index 4fdadc515..9a7591c17 100644 --- a/scripts/lib/transformers/providers.js +++ b/scripts/lib/transformers/providers.js @@ -48,6 +48,9 @@ export const PROVIDERS = { configDir: '.codex', displayName: 'Codex', frontmatterFields: [], + // Codex's validator rejects unknown top-level keys. Version remains + // available to Impeccable's updater under the spec-defined metadata map. + versionInMetadata: true, writeOpenAIMetadata: true, // No agentFormat: the Codex subagent ships nested inside the skill's own // agents/ folder (see CODEX_SKILL_PROVIDERS in factory.js), which Codex @@ -63,6 +66,7 @@ export const PROVIDERS = { displayName: 'Codex Repo Skills', placeholderProvider: 'codex', frontmatterFields: [], + versionInMetadata: true, writeOpenAIMetadata: true, }, github: { diff --git a/scripts/lib/utils.js b/scripts/lib/utils.js index d96eff378..faa28cdbd 100644 --- a/scripts/lib/utils.js +++ b/scripts/lib/utils.js @@ -787,6 +787,9 @@ export function generateYamlFrontmatter(data) { lines.push(` - ${formatYamlScalar(item)}`); } } + } else if (value && typeof value === 'object') { + lines.push(`${key}:`); + appendYamlObject(lines, value, 2); } else if (typeof value === 'boolean') { lines.push(`${key}: ${value}`); } else { diff --git a/scripts/lib/validate-plugin-versions.js b/scripts/lib/validate-plugin-versions.js index bfb1645fa..be40131b1 100644 --- a/scripts/lib/validate-plugin-versions.js +++ b/scripts/lib/validate-plugin-versions.js @@ -25,17 +25,43 @@ import fs from 'fs'; import path from 'path'; /** - * Pull the `version:` value out of a SKILL.md leading frontmatter block. + * Pull the version value out of a SKILL.md leading frontmatter block. * CRLF-tolerant (`\r?\n`) to match the shared parseFrontmatter in * scripts/lib/utils.js — a bundle saved with CRLF line endings must not read - * as a null version and trip a false mismatch. `(.+)` stops at the line - * terminator (so a trailing `\r` is excluded), and `.trim()` mops up the rest. + * as a null version and trip a false mismatch. Codex builds carry the version + * under `metadata.version`; legacy provider builds keep the top-level key. */ export function readSkillFrontmatterVersion(content) { - const fm = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + const fm = String(content).match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---(?:[ \t]*\r?\n|[ \t]*$)/); if (!fm) return null; - const line = fm[1].match(/^version:\s*(.+)/m); - return line ? line[1].trim().replace(/^['"]|['"]$/g, '') : null; + + let metadataVersion = null; + let topLevelVersion = null; + let inMetadata = false; + let metadataIndent = null; + + for (const line of fm[1].split(/\r?\n/)) { + if (!line.trim() || line.trimStart().startsWith('#')) continue; + const indentText = line.match(/^[ \t]*/)[0]; + const indent = indentText.replace(/\t/g, ' ').length; + + if (indent === 0) { + inMetadata = /^metadata:\s*(?:#.*)?$/.test(line); + metadataIndent = null; + const version = line.match(/^version:\s*(.+?)\s*$/); + if (version) topLevelVersion = version[1]; + continue; + } + + if (!inMetadata) continue; + if (metadataIndent === null) metadataIndent = indent; + if (indent !== metadataIndent) continue; + const version = line.trim().match(/^version:\s*(.+?)\s*$/); + if (version) metadataVersion = version[1]; + } + + const value = metadataVersion || topLevelVersion; + return value ? value.trim().replace(/^(["'])(.*)\1$/, '$2') : null; } /** diff --git a/skill/scripts/context.mjs b/skill/scripts/context.mjs index 41112429a..cb16553c2 100644 --- a/skill/scripts/context.mjs +++ b/skill/scripts/context.mjs @@ -962,13 +962,45 @@ export function extractPlatform(product) { * (this file lives at `/scripts/context.mjs`). Returns null when the * frontmatter is missing or unreadable. */ +function parseSkillFrontmatterVersion(content) { + const match = String(content).match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---(?:[ \t]*\r?\n|[ \t]*$)/); + if (!match) return null; + + let metadataVersion = null; + let topLevelVersion = null; + let inMetadata = false; + let metadataIndent = null; + + for (const line of match[1].split(/\r?\n/)) { + if (!line.trim() || line.trimStart().startsWith('#')) continue; + const indentText = line.match(/^[ \t]*/)[0]; + const indent = indentText.replace(/\t/g, ' ').length; + + if (indent === 0) { + inMetadata = /^metadata:\s*(?:#.*)?$/.test(line); + metadataIndent = null; + const version = line.match(/^version:\s*(.+?)\s*$/); + if (version) topLevelVersion = version[1]; + continue; + } + + if (!inMetadata) continue; + if (metadataIndent === null) metadataIndent = indent; + if (indent !== metadataIndent) continue; + const version = line.trim().match(/^version:\s*(.+?)\s*$/); + if (version) metadataVersion = version[1]; + } + + const value = metadataVersion || topLevelVersion; + return value ? value.trim().replace(/^(["'])(.*)\1$/, '$2') : null; +} + function readLocalSkillVersion() { try { const here = path.dirname(fileURLToPath(import.meta.url)); const skillMd = path.join(here, '..', 'SKILL.md'); const content = fs.readFileSync(skillMd, 'utf-8'); - const match = content.match(/^version:\s*(.+)$/m); - return match ? match[1].trim().replace(/^["']|["']$/g, '') : null; + return parseSkillFrontmatterVersion(content); } catch { return null; } diff --git a/tests/context.test.mjs b/tests/context.test.mjs index 76db0648d..c747164df 100644 --- a/tests/context.test.mjs +++ b/tests/context.test.mjs @@ -1465,11 +1465,11 @@ describe('context.mjs update check', () => { const cachePath = () => path.join(scratch, 'update-check.json'); - function setup(cacheObj, { disable = false, host } = {}) { + function setup(cacheObj, { disable = false, host, skillFrontmatter } = {}) { const skillScript = stageContextBundle(path.join(scratch, 'skill', 'scripts')); fs.writeFileSync( path.join(scratch, 'skill', 'SKILL.md'), - `---\nname: impeccable\nversion: ${LOCAL_VERSION}\n---\n\nbody\n`, + `---\n${skillFrontmatter || `name: impeccable\nversion: ${LOCAL_VERSION}`}\n---\n\nbody\n`, ); fs.writeFileSync(cachePath(), JSON.stringify(cacheObj)); const project = path.join(scratch, 'project'); @@ -1522,6 +1522,22 @@ describe('context.mjs update check', () => { assert.match(res.stdout, /^# PRODUCT\.md/); }); + it('reads metadata.version and prefers it over the legacy top-level key', () => { + const metadataOnly = run( + { lastCheck: Date.now(), latestVersion: '2.0.0' }, + { skillFrontmatter: `name: impeccable\nmetadata:\n version: ${LOCAL_VERSION}` }, + ); + assert.equal(metadataOnly.status, 0); + assert.match(metadataOnly.stdout, /installed v1\.0\.0, latest v2\.0\.0/); + + const both = run( + { lastCheck: Date.now(), latestVersion: '2.0.0' }, + { skillFrontmatter: `name: impeccable\nversion: 9.0.0\nmetadata:\n version: ${LOCAL_VERSION}` }, + ); + assert.equal(both.status, 0); + assert.match(both.stdout, /installed v1\.0\.0, latest v2\.0\.0/); + }); + // The directive used to say "ask once" and "if they agree, run it" while also // saying to continue without waiting. Nothing gated the run on an answer that // could not arrive, so the command read as the next step. It now forbids diff --git a/tests/lib/transformers/providers.test.js b/tests/lib/transformers/providers.test.js index 3906de04d..a9b2bd991 100644 --- a/tests/lib/transformers/providers.test.js +++ b/tests/lib/transformers/providers.test.js @@ -68,8 +68,14 @@ for (const [key, config] of Object.entries(PROVIDERS)) { test('should emit skillsVersion in generated skill frontmatter', () => { const skills = [{ name: 'test', description: 'Test', body: 'Body' }]; transform(skills, TEST_DIR, { skillsVersion: '1.2.3-test' }); - const parsed = parseFrontmatter(fs.readFileSync(skillPath(config, 'test'), 'utf-8')); - expect(parsed.frontmatter.version).toBe('1.2.3-test'); + const content = fs.readFileSync(skillPath(config, 'test'), 'utf-8'); + const parsed = parseFrontmatter(content); + if (key === 'codex' || key === 'agents') { + expect(parsed.frontmatter.version).toBeUndefined(); + expect(content).toContain('metadata:\n version: 1.2.3-test'); + } else { + expect(parsed.frontmatter.version).toBe('1.2.3-test'); + } }); // Field-specific tests based on provider config diff --git a/tests/lib/utils.test.js b/tests/lib/utils.test.js index d7e5d6ed8..93ac0a67c 100644 --- a/tests/lib/utils.test.js +++ b/tests/lib/utils.test.js @@ -162,6 +162,15 @@ describe('generateYamlFrontmatter', () => { expect(result).toContain('user-invocable: true'); }); + test('should generate nested metadata', () => { + const result = generateYamlFrontmatter({ + name: 'test', + metadata: { version: '1.2.3' }, + }); + + expect(result).toContain('metadata:\n version: 1.2.3'); + }); + test('should roundtrip: generate and parse back', () => { const original = { name: 'roundtrip-test', diff --git a/tests/skills-cli.test.js b/tests/skills-cli.test.js index 781407461..6524cea5a 100644 --- a/tests/skills-cli.test.js +++ b/tests/skills-cli.test.js @@ -24,6 +24,7 @@ import { downloadFile, expectedHookDests, formatInstallDetectionLines, + getSkillsVersion, mergeHookManifests, migrateUnprefixImpeccable, resolveInstallTargets, @@ -169,6 +170,32 @@ function createPrefixedInstall(root, { prefix = 'i-', providers = ['.claude'], f } } +describe('skills version discovery', () => { + test('prefers metadata.version while accepting legacy top-level version', () => { + const tmp = mkdtempSync(join(tmpdir(), 'imp-test-skill-version-')); + const skillDir = join(tmp, '.agents', 'skills', 'impeccable'); + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, 'SKILL.md'), [ + '---', + 'name: impeccable', + 'version: 0.9.0', + 'metadata:', + ' version: 1.2.3', + '---', + '', + 'Body.', + ].join('\n')); + + try { + expect(getSkillsVersion(tmp, 'project')).toBe('1.2.3'); + writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: impeccable\nversion: 0.9.0\n---\nBody.\n'); + expect(getSkillsVersion(tmp, 'project')).toBe('0.9.0'); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); +}); + // ─── Already-installed detection ───────────────────────────────────────────── // Remote e2e blocks (real bundle downloads from impeccable.style) run only diff --git a/tests/validate-plugin-versions.test.js b/tests/validate-plugin-versions.test.js index 128e88903..efea502dc 100644 --- a/tests/validate-plugin-versions.test.js +++ b/tests/validate-plugin-versions.test.js @@ -157,6 +157,16 @@ describe('readSkillFrontmatterVersion', () => { expect(readSkillFrontmatterVersion('---\nversion: "3.7.1"\n---\n')).toBe('3.7.1'); }); + test('prefers metadata.version while accepting the legacy top-level key', () => { + const content = '---\nname: impeccable\nversion: 3.0.0\nmetadata:\n version: 3.7.1\n---\n'; + expect(readSkillFrontmatterVersion(content)).toBe('3.7.1'); + }); + + test('reads metadata.version after nested metadata fields', () => { + const content = '---\nname: impeccable\nmetadata:\n interface:\n display_name: Impeccable\n version: "3.7.1"\n---\n'; + expect(readSkillFrontmatterVersion(content)).toBe('3.7.1'); + }); + test('returns null when there is no frontmatter block', () => { expect(readSkillFrontmatterVersion('no frontmatter here')).toBeNull(); }); From 1c137cd8d26619115c2925e8c342167b8b3d0bcf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:21:36 +0000 Subject: [PATCH 19/31] Sync generated provider output --- .agents/skills/impeccable/SKILL.md | 3 +- .agents/skills/impeccable/scripts/context.mjs | 36 +++++++++++++++++-- .claude/skills/impeccable/scripts/context.mjs | 36 +++++++++++++++++-- .cursor/skills/impeccable/scripts/context.mjs | 36 +++++++++++++++++-- .gemini/skills/impeccable/scripts/context.mjs | 36 +++++++++++++++++-- .github/skills/impeccable/scripts/context.mjs | 36 +++++++++++++++++-- .grok/skills/impeccable/scripts/context.mjs | 36 +++++++++++++++++-- .hermes/skills/impeccable/scripts/context.mjs | 36 +++++++++++++++++-- .kiro/skills/impeccable/scripts/context.mjs | 36 +++++++++++++++++-- .../skills/impeccable/scripts/context.mjs | 36 +++++++++++++++++-- .pi/skills/impeccable/scripts/context.mjs | 36 +++++++++++++++++-- .qoder/skills/impeccable/scripts/context.mjs | 36 +++++++++++++++++-- .../skills/impeccable/scripts/context.mjs | 36 +++++++++++++++++-- .../skills/impeccable/scripts/context.mjs | 36 +++++++++++++++++-- .trae/skills/impeccable/scripts/context.mjs | 36 +++++++++++++++++-- .vibe/skills/impeccable/scripts/context.mjs | 36 +++++++++++++++++-- plugin/skills/impeccable/scripts/context.mjs | 36 +++++++++++++++++-- 17 files changed, 546 insertions(+), 33 deletions(-) diff --git a/.agents/skills/impeccable/SKILL.md b/.agents/skills/impeccable/SKILL.md index ea13a0d42..1b81f8efa 100644 --- a/.agents/skills/impeccable/SKILL.md +++ b/.agents/skills/impeccable/SKILL.md @@ -1,7 +1,8 @@ --- name: impeccable description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks. -version: 4.1.2 +metadata: + version: 4.1.2 --- This skill gives you the tools and permission to create design that earns to be called out-of-distribution craft: Whereas before, your design work would have been safe, timid and measured, you now approach every design task as an award-winning design director with impeccable understanding for what makes exceptional design work: production-grade code, peak creativity, a clear POV, deep understanding of the needs of the client and users, and exceptional craft. diff --git a/.agents/skills/impeccable/scripts/context.mjs b/.agents/skills/impeccable/scripts/context.mjs index 41112429a..cb16553c2 100644 --- a/.agents/skills/impeccable/scripts/context.mjs +++ b/.agents/skills/impeccable/scripts/context.mjs @@ -962,13 +962,45 @@ export function extractPlatform(product) { * (this file lives at `/scripts/context.mjs`). Returns null when the * frontmatter is missing or unreadable. */ +function parseSkillFrontmatterVersion(content) { + const match = String(content).match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---(?:[ \t]*\r?\n|[ \t]*$)/); + if (!match) return null; + + let metadataVersion = null; + let topLevelVersion = null; + let inMetadata = false; + let metadataIndent = null; + + for (const line of match[1].split(/\r?\n/)) { + if (!line.trim() || line.trimStart().startsWith('#')) continue; + const indentText = line.match(/^[ \t]*/)[0]; + const indent = indentText.replace(/\t/g, ' ').length; + + if (indent === 0) { + inMetadata = /^metadata:\s*(?:#.*)?$/.test(line); + metadataIndent = null; + const version = line.match(/^version:\s*(.+?)\s*$/); + if (version) topLevelVersion = version[1]; + continue; + } + + if (!inMetadata) continue; + if (metadataIndent === null) metadataIndent = indent; + if (indent !== metadataIndent) continue; + const version = line.trim().match(/^version:\s*(.+?)\s*$/); + if (version) metadataVersion = version[1]; + } + + const value = metadataVersion || topLevelVersion; + return value ? value.trim().replace(/^(["'])(.*)\1$/, '$2') : null; +} + function readLocalSkillVersion() { try { const here = path.dirname(fileURLToPath(import.meta.url)); const skillMd = path.join(here, '..', 'SKILL.md'); const content = fs.readFileSync(skillMd, 'utf-8'); - const match = content.match(/^version:\s*(.+)$/m); - return match ? match[1].trim().replace(/^["']|["']$/g, '') : null; + return parseSkillFrontmatterVersion(content); } catch { return null; } diff --git a/.claude/skills/impeccable/scripts/context.mjs b/.claude/skills/impeccable/scripts/context.mjs index 41112429a..cb16553c2 100644 --- a/.claude/skills/impeccable/scripts/context.mjs +++ b/.claude/skills/impeccable/scripts/context.mjs @@ -962,13 +962,45 @@ export function extractPlatform(product) { * (this file lives at `/scripts/context.mjs`). Returns null when the * frontmatter is missing or unreadable. */ +function parseSkillFrontmatterVersion(content) { + const match = String(content).match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---(?:[ \t]*\r?\n|[ \t]*$)/); + if (!match) return null; + + let metadataVersion = null; + let topLevelVersion = null; + let inMetadata = false; + let metadataIndent = null; + + for (const line of match[1].split(/\r?\n/)) { + if (!line.trim() || line.trimStart().startsWith('#')) continue; + const indentText = line.match(/^[ \t]*/)[0]; + const indent = indentText.replace(/\t/g, ' ').length; + + if (indent === 0) { + inMetadata = /^metadata:\s*(?:#.*)?$/.test(line); + metadataIndent = null; + const version = line.match(/^version:\s*(.+?)\s*$/); + if (version) topLevelVersion = version[1]; + continue; + } + + if (!inMetadata) continue; + if (metadataIndent === null) metadataIndent = indent; + if (indent !== metadataIndent) continue; + const version = line.trim().match(/^version:\s*(.+?)\s*$/); + if (version) metadataVersion = version[1]; + } + + const value = metadataVersion || topLevelVersion; + return value ? value.trim().replace(/^(["'])(.*)\1$/, '$2') : null; +} + function readLocalSkillVersion() { try { const here = path.dirname(fileURLToPath(import.meta.url)); const skillMd = path.join(here, '..', 'SKILL.md'); const content = fs.readFileSync(skillMd, 'utf-8'); - const match = content.match(/^version:\s*(.+)$/m); - return match ? match[1].trim().replace(/^["']|["']$/g, '') : null; + return parseSkillFrontmatterVersion(content); } catch { return null; } diff --git a/.cursor/skills/impeccable/scripts/context.mjs b/.cursor/skills/impeccable/scripts/context.mjs index 41112429a..cb16553c2 100644 --- a/.cursor/skills/impeccable/scripts/context.mjs +++ b/.cursor/skills/impeccable/scripts/context.mjs @@ -962,13 +962,45 @@ export function extractPlatform(product) { * (this file lives at `/scripts/context.mjs`). Returns null when the * frontmatter is missing or unreadable. */ +function parseSkillFrontmatterVersion(content) { + const match = String(content).match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---(?:[ \t]*\r?\n|[ \t]*$)/); + if (!match) return null; + + let metadataVersion = null; + let topLevelVersion = null; + let inMetadata = false; + let metadataIndent = null; + + for (const line of match[1].split(/\r?\n/)) { + if (!line.trim() || line.trimStart().startsWith('#')) continue; + const indentText = line.match(/^[ \t]*/)[0]; + const indent = indentText.replace(/\t/g, ' ').length; + + if (indent === 0) { + inMetadata = /^metadata:\s*(?:#.*)?$/.test(line); + metadataIndent = null; + const version = line.match(/^version:\s*(.+?)\s*$/); + if (version) topLevelVersion = version[1]; + continue; + } + + if (!inMetadata) continue; + if (metadataIndent === null) metadataIndent = indent; + if (indent !== metadataIndent) continue; + const version = line.trim().match(/^version:\s*(.+?)\s*$/); + if (version) metadataVersion = version[1]; + } + + const value = metadataVersion || topLevelVersion; + return value ? value.trim().replace(/^(["'])(.*)\1$/, '$2') : null; +} + function readLocalSkillVersion() { try { const here = path.dirname(fileURLToPath(import.meta.url)); const skillMd = path.join(here, '..', 'SKILL.md'); const content = fs.readFileSync(skillMd, 'utf-8'); - const match = content.match(/^version:\s*(.+)$/m); - return match ? match[1].trim().replace(/^["']|["']$/g, '') : null; + return parseSkillFrontmatterVersion(content); } catch { return null; } diff --git a/.gemini/skills/impeccable/scripts/context.mjs b/.gemini/skills/impeccable/scripts/context.mjs index 41112429a..cb16553c2 100644 --- a/.gemini/skills/impeccable/scripts/context.mjs +++ b/.gemini/skills/impeccable/scripts/context.mjs @@ -962,13 +962,45 @@ export function extractPlatform(product) { * (this file lives at `/scripts/context.mjs`). Returns null when the * frontmatter is missing or unreadable. */ +function parseSkillFrontmatterVersion(content) { + const match = String(content).match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---(?:[ \t]*\r?\n|[ \t]*$)/); + if (!match) return null; + + let metadataVersion = null; + let topLevelVersion = null; + let inMetadata = false; + let metadataIndent = null; + + for (const line of match[1].split(/\r?\n/)) { + if (!line.trim() || line.trimStart().startsWith('#')) continue; + const indentText = line.match(/^[ \t]*/)[0]; + const indent = indentText.replace(/\t/g, ' ').length; + + if (indent === 0) { + inMetadata = /^metadata:\s*(?:#.*)?$/.test(line); + metadataIndent = null; + const version = line.match(/^version:\s*(.+?)\s*$/); + if (version) topLevelVersion = version[1]; + continue; + } + + if (!inMetadata) continue; + if (metadataIndent === null) metadataIndent = indent; + if (indent !== metadataIndent) continue; + const version = line.trim().match(/^version:\s*(.+?)\s*$/); + if (version) metadataVersion = version[1]; + } + + const value = metadataVersion || topLevelVersion; + return value ? value.trim().replace(/^(["'])(.*)\1$/, '$2') : null; +} + function readLocalSkillVersion() { try { const here = path.dirname(fileURLToPath(import.meta.url)); const skillMd = path.join(here, '..', 'SKILL.md'); const content = fs.readFileSync(skillMd, 'utf-8'); - const match = content.match(/^version:\s*(.+)$/m); - return match ? match[1].trim().replace(/^["']|["']$/g, '') : null; + return parseSkillFrontmatterVersion(content); } catch { return null; } diff --git a/.github/skills/impeccable/scripts/context.mjs b/.github/skills/impeccable/scripts/context.mjs index 41112429a..cb16553c2 100644 --- a/.github/skills/impeccable/scripts/context.mjs +++ b/.github/skills/impeccable/scripts/context.mjs @@ -962,13 +962,45 @@ export function extractPlatform(product) { * (this file lives at `/scripts/context.mjs`). Returns null when the * frontmatter is missing or unreadable. */ +function parseSkillFrontmatterVersion(content) { + const match = String(content).match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---(?:[ \t]*\r?\n|[ \t]*$)/); + if (!match) return null; + + let metadataVersion = null; + let topLevelVersion = null; + let inMetadata = false; + let metadataIndent = null; + + for (const line of match[1].split(/\r?\n/)) { + if (!line.trim() || line.trimStart().startsWith('#')) continue; + const indentText = line.match(/^[ \t]*/)[0]; + const indent = indentText.replace(/\t/g, ' ').length; + + if (indent === 0) { + inMetadata = /^metadata:\s*(?:#.*)?$/.test(line); + metadataIndent = null; + const version = line.match(/^version:\s*(.+?)\s*$/); + if (version) topLevelVersion = version[1]; + continue; + } + + if (!inMetadata) continue; + if (metadataIndent === null) metadataIndent = indent; + if (indent !== metadataIndent) continue; + const version = line.trim().match(/^version:\s*(.+?)\s*$/); + if (version) metadataVersion = version[1]; + } + + const value = metadataVersion || topLevelVersion; + return value ? value.trim().replace(/^(["'])(.*)\1$/, '$2') : null; +} + function readLocalSkillVersion() { try { const here = path.dirname(fileURLToPath(import.meta.url)); const skillMd = path.join(here, '..', 'SKILL.md'); const content = fs.readFileSync(skillMd, 'utf-8'); - const match = content.match(/^version:\s*(.+)$/m); - return match ? match[1].trim().replace(/^["']|["']$/g, '') : null; + return parseSkillFrontmatterVersion(content); } catch { return null; } diff --git a/.grok/skills/impeccable/scripts/context.mjs b/.grok/skills/impeccable/scripts/context.mjs index 41112429a..cb16553c2 100644 --- a/.grok/skills/impeccable/scripts/context.mjs +++ b/.grok/skills/impeccable/scripts/context.mjs @@ -962,13 +962,45 @@ export function extractPlatform(product) { * (this file lives at `/scripts/context.mjs`). Returns null when the * frontmatter is missing or unreadable. */ +function parseSkillFrontmatterVersion(content) { + const match = String(content).match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---(?:[ \t]*\r?\n|[ \t]*$)/); + if (!match) return null; + + let metadataVersion = null; + let topLevelVersion = null; + let inMetadata = false; + let metadataIndent = null; + + for (const line of match[1].split(/\r?\n/)) { + if (!line.trim() || line.trimStart().startsWith('#')) continue; + const indentText = line.match(/^[ \t]*/)[0]; + const indent = indentText.replace(/\t/g, ' ').length; + + if (indent === 0) { + inMetadata = /^metadata:\s*(?:#.*)?$/.test(line); + metadataIndent = null; + const version = line.match(/^version:\s*(.+?)\s*$/); + if (version) topLevelVersion = version[1]; + continue; + } + + if (!inMetadata) continue; + if (metadataIndent === null) metadataIndent = indent; + if (indent !== metadataIndent) continue; + const version = line.trim().match(/^version:\s*(.+?)\s*$/); + if (version) metadataVersion = version[1]; + } + + const value = metadataVersion || topLevelVersion; + return value ? value.trim().replace(/^(["'])(.*)\1$/, '$2') : null; +} + function readLocalSkillVersion() { try { const here = path.dirname(fileURLToPath(import.meta.url)); const skillMd = path.join(here, '..', 'SKILL.md'); const content = fs.readFileSync(skillMd, 'utf-8'); - const match = content.match(/^version:\s*(.+)$/m); - return match ? match[1].trim().replace(/^["']|["']$/g, '') : null; + return parseSkillFrontmatterVersion(content); } catch { return null; } diff --git a/.hermes/skills/impeccable/scripts/context.mjs b/.hermes/skills/impeccable/scripts/context.mjs index 41112429a..cb16553c2 100644 --- a/.hermes/skills/impeccable/scripts/context.mjs +++ b/.hermes/skills/impeccable/scripts/context.mjs @@ -962,13 +962,45 @@ export function extractPlatform(product) { * (this file lives at `/scripts/context.mjs`). Returns null when the * frontmatter is missing or unreadable. */ +function parseSkillFrontmatterVersion(content) { + const match = String(content).match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---(?:[ \t]*\r?\n|[ \t]*$)/); + if (!match) return null; + + let metadataVersion = null; + let topLevelVersion = null; + let inMetadata = false; + let metadataIndent = null; + + for (const line of match[1].split(/\r?\n/)) { + if (!line.trim() || line.trimStart().startsWith('#')) continue; + const indentText = line.match(/^[ \t]*/)[0]; + const indent = indentText.replace(/\t/g, ' ').length; + + if (indent === 0) { + inMetadata = /^metadata:\s*(?:#.*)?$/.test(line); + metadataIndent = null; + const version = line.match(/^version:\s*(.+?)\s*$/); + if (version) topLevelVersion = version[1]; + continue; + } + + if (!inMetadata) continue; + if (metadataIndent === null) metadataIndent = indent; + if (indent !== metadataIndent) continue; + const version = line.trim().match(/^version:\s*(.+?)\s*$/); + if (version) metadataVersion = version[1]; + } + + const value = metadataVersion || topLevelVersion; + return value ? value.trim().replace(/^(["'])(.*)\1$/, '$2') : null; +} + function readLocalSkillVersion() { try { const here = path.dirname(fileURLToPath(import.meta.url)); const skillMd = path.join(here, '..', 'SKILL.md'); const content = fs.readFileSync(skillMd, 'utf-8'); - const match = content.match(/^version:\s*(.+)$/m); - return match ? match[1].trim().replace(/^["']|["']$/g, '') : null; + return parseSkillFrontmatterVersion(content); } catch { return null; } diff --git a/.kiro/skills/impeccable/scripts/context.mjs b/.kiro/skills/impeccable/scripts/context.mjs index 41112429a..cb16553c2 100644 --- a/.kiro/skills/impeccable/scripts/context.mjs +++ b/.kiro/skills/impeccable/scripts/context.mjs @@ -962,13 +962,45 @@ export function extractPlatform(product) { * (this file lives at `/scripts/context.mjs`). Returns null when the * frontmatter is missing or unreadable. */ +function parseSkillFrontmatterVersion(content) { + const match = String(content).match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---(?:[ \t]*\r?\n|[ \t]*$)/); + if (!match) return null; + + let metadataVersion = null; + let topLevelVersion = null; + let inMetadata = false; + let metadataIndent = null; + + for (const line of match[1].split(/\r?\n/)) { + if (!line.trim() || line.trimStart().startsWith('#')) continue; + const indentText = line.match(/^[ \t]*/)[0]; + const indent = indentText.replace(/\t/g, ' ').length; + + if (indent === 0) { + inMetadata = /^metadata:\s*(?:#.*)?$/.test(line); + metadataIndent = null; + const version = line.match(/^version:\s*(.+?)\s*$/); + if (version) topLevelVersion = version[1]; + continue; + } + + if (!inMetadata) continue; + if (metadataIndent === null) metadataIndent = indent; + if (indent !== metadataIndent) continue; + const version = line.trim().match(/^version:\s*(.+?)\s*$/); + if (version) metadataVersion = version[1]; + } + + const value = metadataVersion || topLevelVersion; + return value ? value.trim().replace(/^(["'])(.*)\1$/, '$2') : null; +} + function readLocalSkillVersion() { try { const here = path.dirname(fileURLToPath(import.meta.url)); const skillMd = path.join(here, '..', 'SKILL.md'); const content = fs.readFileSync(skillMd, 'utf-8'); - const match = content.match(/^version:\s*(.+)$/m); - return match ? match[1].trim().replace(/^["']|["']$/g, '') : null; + return parseSkillFrontmatterVersion(content); } catch { return null; } diff --git a/.opencode/skills/impeccable/scripts/context.mjs b/.opencode/skills/impeccable/scripts/context.mjs index 41112429a..cb16553c2 100644 --- a/.opencode/skills/impeccable/scripts/context.mjs +++ b/.opencode/skills/impeccable/scripts/context.mjs @@ -962,13 +962,45 @@ export function extractPlatform(product) { * (this file lives at `/scripts/context.mjs`). Returns null when the * frontmatter is missing or unreadable. */ +function parseSkillFrontmatterVersion(content) { + const match = String(content).match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---(?:[ \t]*\r?\n|[ \t]*$)/); + if (!match) return null; + + let metadataVersion = null; + let topLevelVersion = null; + let inMetadata = false; + let metadataIndent = null; + + for (const line of match[1].split(/\r?\n/)) { + if (!line.trim() || line.trimStart().startsWith('#')) continue; + const indentText = line.match(/^[ \t]*/)[0]; + const indent = indentText.replace(/\t/g, ' ').length; + + if (indent === 0) { + inMetadata = /^metadata:\s*(?:#.*)?$/.test(line); + metadataIndent = null; + const version = line.match(/^version:\s*(.+?)\s*$/); + if (version) topLevelVersion = version[1]; + continue; + } + + if (!inMetadata) continue; + if (metadataIndent === null) metadataIndent = indent; + if (indent !== metadataIndent) continue; + const version = line.trim().match(/^version:\s*(.+?)\s*$/); + if (version) metadataVersion = version[1]; + } + + const value = metadataVersion || topLevelVersion; + return value ? value.trim().replace(/^(["'])(.*)\1$/, '$2') : null; +} + function readLocalSkillVersion() { try { const here = path.dirname(fileURLToPath(import.meta.url)); const skillMd = path.join(here, '..', 'SKILL.md'); const content = fs.readFileSync(skillMd, 'utf-8'); - const match = content.match(/^version:\s*(.+)$/m); - return match ? match[1].trim().replace(/^["']|["']$/g, '') : null; + return parseSkillFrontmatterVersion(content); } catch { return null; } diff --git a/.pi/skills/impeccable/scripts/context.mjs b/.pi/skills/impeccable/scripts/context.mjs index 41112429a..cb16553c2 100644 --- a/.pi/skills/impeccable/scripts/context.mjs +++ b/.pi/skills/impeccable/scripts/context.mjs @@ -962,13 +962,45 @@ export function extractPlatform(product) { * (this file lives at `/scripts/context.mjs`). Returns null when the * frontmatter is missing or unreadable. */ +function parseSkillFrontmatterVersion(content) { + const match = String(content).match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---(?:[ \t]*\r?\n|[ \t]*$)/); + if (!match) return null; + + let metadataVersion = null; + let topLevelVersion = null; + let inMetadata = false; + let metadataIndent = null; + + for (const line of match[1].split(/\r?\n/)) { + if (!line.trim() || line.trimStart().startsWith('#')) continue; + const indentText = line.match(/^[ \t]*/)[0]; + const indent = indentText.replace(/\t/g, ' ').length; + + if (indent === 0) { + inMetadata = /^metadata:\s*(?:#.*)?$/.test(line); + metadataIndent = null; + const version = line.match(/^version:\s*(.+?)\s*$/); + if (version) topLevelVersion = version[1]; + continue; + } + + if (!inMetadata) continue; + if (metadataIndent === null) metadataIndent = indent; + if (indent !== metadataIndent) continue; + const version = line.trim().match(/^version:\s*(.+?)\s*$/); + if (version) metadataVersion = version[1]; + } + + const value = metadataVersion || topLevelVersion; + return value ? value.trim().replace(/^(["'])(.*)\1$/, '$2') : null; +} + function readLocalSkillVersion() { try { const here = path.dirname(fileURLToPath(import.meta.url)); const skillMd = path.join(here, '..', 'SKILL.md'); const content = fs.readFileSync(skillMd, 'utf-8'); - const match = content.match(/^version:\s*(.+)$/m); - return match ? match[1].trim().replace(/^["']|["']$/g, '') : null; + return parseSkillFrontmatterVersion(content); } catch { return null; } diff --git a/.qoder/skills/impeccable/scripts/context.mjs b/.qoder/skills/impeccable/scripts/context.mjs index 41112429a..cb16553c2 100644 --- a/.qoder/skills/impeccable/scripts/context.mjs +++ b/.qoder/skills/impeccable/scripts/context.mjs @@ -962,13 +962,45 @@ export function extractPlatform(product) { * (this file lives at `/scripts/context.mjs`). Returns null when the * frontmatter is missing or unreadable. */ +function parseSkillFrontmatterVersion(content) { + const match = String(content).match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---(?:[ \t]*\r?\n|[ \t]*$)/); + if (!match) return null; + + let metadataVersion = null; + let topLevelVersion = null; + let inMetadata = false; + let metadataIndent = null; + + for (const line of match[1].split(/\r?\n/)) { + if (!line.trim() || line.trimStart().startsWith('#')) continue; + const indentText = line.match(/^[ \t]*/)[0]; + const indent = indentText.replace(/\t/g, ' ').length; + + if (indent === 0) { + inMetadata = /^metadata:\s*(?:#.*)?$/.test(line); + metadataIndent = null; + const version = line.match(/^version:\s*(.+?)\s*$/); + if (version) topLevelVersion = version[1]; + continue; + } + + if (!inMetadata) continue; + if (metadataIndent === null) metadataIndent = indent; + if (indent !== metadataIndent) continue; + const version = line.trim().match(/^version:\s*(.+?)\s*$/); + if (version) metadataVersion = version[1]; + } + + const value = metadataVersion || topLevelVersion; + return value ? value.trim().replace(/^(["'])(.*)\1$/, '$2') : null; +} + function readLocalSkillVersion() { try { const here = path.dirname(fileURLToPath(import.meta.url)); const skillMd = path.join(here, '..', 'SKILL.md'); const content = fs.readFileSync(skillMd, 'utf-8'); - const match = content.match(/^version:\s*(.+)$/m); - return match ? match[1].trim().replace(/^["']|["']$/g, '') : null; + return parseSkillFrontmatterVersion(content); } catch { return null; } diff --git a/.rovodev/skills/impeccable/scripts/context.mjs b/.rovodev/skills/impeccable/scripts/context.mjs index 41112429a..cb16553c2 100644 --- a/.rovodev/skills/impeccable/scripts/context.mjs +++ b/.rovodev/skills/impeccable/scripts/context.mjs @@ -962,13 +962,45 @@ export function extractPlatform(product) { * (this file lives at `/scripts/context.mjs`). Returns null when the * frontmatter is missing or unreadable. */ +function parseSkillFrontmatterVersion(content) { + const match = String(content).match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---(?:[ \t]*\r?\n|[ \t]*$)/); + if (!match) return null; + + let metadataVersion = null; + let topLevelVersion = null; + let inMetadata = false; + let metadataIndent = null; + + for (const line of match[1].split(/\r?\n/)) { + if (!line.trim() || line.trimStart().startsWith('#')) continue; + const indentText = line.match(/^[ \t]*/)[0]; + const indent = indentText.replace(/\t/g, ' ').length; + + if (indent === 0) { + inMetadata = /^metadata:\s*(?:#.*)?$/.test(line); + metadataIndent = null; + const version = line.match(/^version:\s*(.+?)\s*$/); + if (version) topLevelVersion = version[1]; + continue; + } + + if (!inMetadata) continue; + if (metadataIndent === null) metadataIndent = indent; + if (indent !== metadataIndent) continue; + const version = line.trim().match(/^version:\s*(.+?)\s*$/); + if (version) metadataVersion = version[1]; + } + + const value = metadataVersion || topLevelVersion; + return value ? value.trim().replace(/^(["'])(.*)\1$/, '$2') : null; +} + function readLocalSkillVersion() { try { const here = path.dirname(fileURLToPath(import.meta.url)); const skillMd = path.join(here, '..', 'SKILL.md'); const content = fs.readFileSync(skillMd, 'utf-8'); - const match = content.match(/^version:\s*(.+)$/m); - return match ? match[1].trim().replace(/^["']|["']$/g, '') : null; + return parseSkillFrontmatterVersion(content); } catch { return null; } diff --git a/.trae-cn/skills/impeccable/scripts/context.mjs b/.trae-cn/skills/impeccable/scripts/context.mjs index 41112429a..cb16553c2 100644 --- a/.trae-cn/skills/impeccable/scripts/context.mjs +++ b/.trae-cn/skills/impeccable/scripts/context.mjs @@ -962,13 +962,45 @@ export function extractPlatform(product) { * (this file lives at `/scripts/context.mjs`). Returns null when the * frontmatter is missing or unreadable. */ +function parseSkillFrontmatterVersion(content) { + const match = String(content).match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---(?:[ \t]*\r?\n|[ \t]*$)/); + if (!match) return null; + + let metadataVersion = null; + let topLevelVersion = null; + let inMetadata = false; + let metadataIndent = null; + + for (const line of match[1].split(/\r?\n/)) { + if (!line.trim() || line.trimStart().startsWith('#')) continue; + const indentText = line.match(/^[ \t]*/)[0]; + const indent = indentText.replace(/\t/g, ' ').length; + + if (indent === 0) { + inMetadata = /^metadata:\s*(?:#.*)?$/.test(line); + metadataIndent = null; + const version = line.match(/^version:\s*(.+?)\s*$/); + if (version) topLevelVersion = version[1]; + continue; + } + + if (!inMetadata) continue; + if (metadataIndent === null) metadataIndent = indent; + if (indent !== metadataIndent) continue; + const version = line.trim().match(/^version:\s*(.+?)\s*$/); + if (version) metadataVersion = version[1]; + } + + const value = metadataVersion || topLevelVersion; + return value ? value.trim().replace(/^(["'])(.*)\1$/, '$2') : null; +} + function readLocalSkillVersion() { try { const here = path.dirname(fileURLToPath(import.meta.url)); const skillMd = path.join(here, '..', 'SKILL.md'); const content = fs.readFileSync(skillMd, 'utf-8'); - const match = content.match(/^version:\s*(.+)$/m); - return match ? match[1].trim().replace(/^["']|["']$/g, '') : null; + return parseSkillFrontmatterVersion(content); } catch { return null; } diff --git a/.trae/skills/impeccable/scripts/context.mjs b/.trae/skills/impeccable/scripts/context.mjs index 41112429a..cb16553c2 100644 --- a/.trae/skills/impeccable/scripts/context.mjs +++ b/.trae/skills/impeccable/scripts/context.mjs @@ -962,13 +962,45 @@ export function extractPlatform(product) { * (this file lives at `/scripts/context.mjs`). Returns null when the * frontmatter is missing or unreadable. */ +function parseSkillFrontmatterVersion(content) { + const match = String(content).match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---(?:[ \t]*\r?\n|[ \t]*$)/); + if (!match) return null; + + let metadataVersion = null; + let topLevelVersion = null; + let inMetadata = false; + let metadataIndent = null; + + for (const line of match[1].split(/\r?\n/)) { + if (!line.trim() || line.trimStart().startsWith('#')) continue; + const indentText = line.match(/^[ \t]*/)[0]; + const indent = indentText.replace(/\t/g, ' ').length; + + if (indent === 0) { + inMetadata = /^metadata:\s*(?:#.*)?$/.test(line); + metadataIndent = null; + const version = line.match(/^version:\s*(.+?)\s*$/); + if (version) topLevelVersion = version[1]; + continue; + } + + if (!inMetadata) continue; + if (metadataIndent === null) metadataIndent = indent; + if (indent !== metadataIndent) continue; + const version = line.trim().match(/^version:\s*(.+?)\s*$/); + if (version) metadataVersion = version[1]; + } + + const value = metadataVersion || topLevelVersion; + return value ? value.trim().replace(/^(["'])(.*)\1$/, '$2') : null; +} + function readLocalSkillVersion() { try { const here = path.dirname(fileURLToPath(import.meta.url)); const skillMd = path.join(here, '..', 'SKILL.md'); const content = fs.readFileSync(skillMd, 'utf-8'); - const match = content.match(/^version:\s*(.+)$/m); - return match ? match[1].trim().replace(/^["']|["']$/g, '') : null; + return parseSkillFrontmatterVersion(content); } catch { return null; } diff --git a/.vibe/skills/impeccable/scripts/context.mjs b/.vibe/skills/impeccable/scripts/context.mjs index 41112429a..cb16553c2 100644 --- a/.vibe/skills/impeccable/scripts/context.mjs +++ b/.vibe/skills/impeccable/scripts/context.mjs @@ -962,13 +962,45 @@ export function extractPlatform(product) { * (this file lives at `/scripts/context.mjs`). Returns null when the * frontmatter is missing or unreadable. */ +function parseSkillFrontmatterVersion(content) { + const match = String(content).match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---(?:[ \t]*\r?\n|[ \t]*$)/); + if (!match) return null; + + let metadataVersion = null; + let topLevelVersion = null; + let inMetadata = false; + let metadataIndent = null; + + for (const line of match[1].split(/\r?\n/)) { + if (!line.trim() || line.trimStart().startsWith('#')) continue; + const indentText = line.match(/^[ \t]*/)[0]; + const indent = indentText.replace(/\t/g, ' ').length; + + if (indent === 0) { + inMetadata = /^metadata:\s*(?:#.*)?$/.test(line); + metadataIndent = null; + const version = line.match(/^version:\s*(.+?)\s*$/); + if (version) topLevelVersion = version[1]; + continue; + } + + if (!inMetadata) continue; + if (metadataIndent === null) metadataIndent = indent; + if (indent !== metadataIndent) continue; + const version = line.trim().match(/^version:\s*(.+?)\s*$/); + if (version) metadataVersion = version[1]; + } + + const value = metadataVersion || topLevelVersion; + return value ? value.trim().replace(/^(["'])(.*)\1$/, '$2') : null; +} + function readLocalSkillVersion() { try { const here = path.dirname(fileURLToPath(import.meta.url)); const skillMd = path.join(here, '..', 'SKILL.md'); const content = fs.readFileSync(skillMd, 'utf-8'); - const match = content.match(/^version:\s*(.+)$/m); - return match ? match[1].trim().replace(/^["']|["']$/g, '') : null; + return parseSkillFrontmatterVersion(content); } catch { return null; } diff --git a/plugin/skills/impeccable/scripts/context.mjs b/plugin/skills/impeccable/scripts/context.mjs index 41112429a..cb16553c2 100644 --- a/plugin/skills/impeccable/scripts/context.mjs +++ b/plugin/skills/impeccable/scripts/context.mjs @@ -962,13 +962,45 @@ export function extractPlatform(product) { * (this file lives at `/scripts/context.mjs`). Returns null when the * frontmatter is missing or unreadable. */ +function parseSkillFrontmatterVersion(content) { + const match = String(content).match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---(?:[ \t]*\r?\n|[ \t]*$)/); + if (!match) return null; + + let metadataVersion = null; + let topLevelVersion = null; + let inMetadata = false; + let metadataIndent = null; + + for (const line of match[1].split(/\r?\n/)) { + if (!line.trim() || line.trimStart().startsWith('#')) continue; + const indentText = line.match(/^[ \t]*/)[0]; + const indent = indentText.replace(/\t/g, ' ').length; + + if (indent === 0) { + inMetadata = /^metadata:\s*(?:#.*)?$/.test(line); + metadataIndent = null; + const version = line.match(/^version:\s*(.+?)\s*$/); + if (version) topLevelVersion = version[1]; + continue; + } + + if (!inMetadata) continue; + if (metadataIndent === null) metadataIndent = indent; + if (indent !== metadataIndent) continue; + const version = line.trim().match(/^version:\s*(.+?)\s*$/); + if (version) metadataVersion = version[1]; + } + + const value = metadataVersion || topLevelVersion; + return value ? value.trim().replace(/^(["'])(.*)\1$/, '$2') : null; +} + function readLocalSkillVersion() { try { const here = path.dirname(fileURLToPath(import.meta.url)); const skillMd = path.join(here, '..', 'SKILL.md'); const content = fs.readFileSync(skillMd, 'utf-8'); - const match = content.match(/^version:\s*(.+)$/m); - return match ? match[1].trim().replace(/^["']|["']$/g, '') : null; + return parseSkillFrontmatterVersion(content); } catch { return null; } From c0f495212236129c2e92aaf7714a3a9914569d13 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Tue, 1 Sep 2026 17:26:46 -0700 Subject: [PATCH 20/31] Release: skill v4.1.3 AI-assisted release preparation under maintainer direction. --- .agent/skills/impeccable/SKILL.md | 6 +- .../skills/impeccable/reference/critique.md | 6 +- .../reference/degraded/asset-producer.md | 73 +- .../reference/degraded/finish-reviewer.md | 8 +- .agent/skills/impeccable/reference/hooks.md | 2 +- .../skills/impeccable/reference/new-work.md | 49 +- .agent/skills/impeccable/reference/polish.md | 12 +- .agent/skills/impeccable/reference/routing.md | 2 +- .../skills/impeccable/reference/visualize.md | 28 +- .../skills/impeccable/scripts/build-phase.mjs | 1022 ++ .../skills/impeccable/scripts/comp-diff.mjs | 391 + .../skills/impeccable/scripts/comp-spec.mjs | 513 + .../impeccable/scripts/concept-seed.mjs | 102 +- .agent/skills/impeccable/scripts/context.mjs | 57 +- .../impeccable/scripts/critique-storage.mjs | 279 +- .../scripts/data/font-index-failures.json | 121 + .../impeccable/scripts/data/font-index.json | 1 + .agent/skills/impeccable/scripts/detect.mjs | 9 + .../detector/browser/injected/index.mjs | 133 +- .../scripts/detector/design-system.mjs | 4 +- .../detector/detect-antipatterns-browser.js | 470 +- .../detector/engines/browser/detect-url.mjs | 66 +- .../detector/engines/regex/detect-text.mjs | 34 +- .../engines/static-html/css-cascade.mjs | 1 + .../engines/static-html/detect-html.mjs | 20 +- .../detector/registry/antipatterns.mjs | 20 +- .../scripts/detector/rules/checks.mjs | 302 +- .../scripts/detector/shared/constants.mjs | 21 +- .../impeccable/scripts/embed-prompt.mjs | 103 +- .../skills/impeccable/scripts/font-match.mjs | 457 + .../impeccable/scripts/generate-image.mjs | 184 +- .../skills/impeccable/scripts/hook-admin.mjs | 24 +- .agent/skills/impeccable/scripts/hook-lib.mjs | 51 +- .../impeccable/scripts/lib/design-parser.mjs | 48 +- .../scripts/lib/font-fingerprint.mjs | 564 + .../impeccable/scripts/lib/font-index.mjs | 130 + .../impeccable/scripts/lib/hero-checks.mjs | 246 + .../impeccable/scripts/lib/image-metrics.mjs | 306 + .../scripts/lib/live-path-globs.mjs | 37 + .agent/skills/impeccable/scripts/lib/png.mjs | 281 + .../skills/impeccable/scripts/lib/raster.mjs | 194 + .../impeccable/scripts/live-browser-dom.js | 21 + .../scripts/live-browser-ignores.js | 242 + .../scripts/live-browser-session.js | 29 +- .../skills/impeccable/scripts/live-browser.js | 596 +- .../skills/impeccable/scripts/live-inject.mjs | 44 +- .../skills/impeccable/scripts/live-server.mjs | 16 +- .agent/skills/impeccable/scripts/live.mjs | 35 +- .../scripts/live/browser-script-parts.mjs | 9 +- .../scripts/live/project-ignores.mjs | 139 + .agent/skills/impeccable/scripts/pin.mjs | 122 +- .../impeccable/scripts/serve-question.mjs | 81 +- .agents/skills/impeccable/SKILL.md | 2 +- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .claude/skills/impeccable/SKILL.md | 2 +- .cursor/skills/impeccable/SKILL.md | 2 +- .gemini/skills/impeccable/SKILL.md | 2 +- .../agents/impeccable-asset-producer.agent.md | 73 +- .../impeccable-finish-reviewer.agent.md | 8 +- .github/skills/impeccable/SKILL.md | 2 +- .grok/skills/impeccable/SKILL.md | 2 +- .hermes/skills/impeccable/SKILL.md | 2 +- .kiro/skills/impeccable/SKILL.md | 2 +- .opencode/skills/impeccable/SKILL.md | 2 +- .pi/skills/impeccable/SKILL.md | 2 +- .qoder/skills/impeccable/SKILL.md | 2 +- .rovodev/skills/impeccable/SKILL.md | 2 +- .trae-cn/skills/impeccable/SKILL.md | 2 +- .trae/skills/impeccable/SKILL.md | 2 +- .veto/skills/impeccable/SKILL.md | 81 + .veto/skills/impeccable/reference/adapt.md | 312 + .../impeccable/reference/adapt.native.md | 58 + .veto/skills/impeccable/reference/android.md | 46 + .veto/skills/impeccable/reference/animate.md | 89 + .veto/skills/impeccable/reference/audit.md | 136 + .../impeccable/reference/audit.native.md | 139 + .veto/skills/impeccable/reference/bolder.md | 33 + .veto/skills/impeccable/reference/clarify.md | 94 + .veto/skills/impeccable/reference/colorize.md | 86 + .../impeccable/reference/craft-floor.md | 44 + .veto/skills/impeccable/reference/craft.md | 5 + .veto/skills/impeccable/reference/critique.md | 806 + .../reference/degraded/asset-producer.md | 37 + .../reference/degraded/documenter.md | 24 + .../reference/degraded/finish-reviewer.md | 38 + .../reference/degraded/manual-edit-applier.md | 92 + .veto/skills/impeccable/reference/delight.md | 70 + .veto/skills/impeccable/reference/distill.md | 111 + .veto/skills/impeccable/reference/doctor.md | 54 + .veto/skills/impeccable/reference/document.md | 416 + .veto/skills/impeccable/reference/extract.md | 69 + .veto/skills/impeccable/reference/harden.md | 336 + .veto/skills/impeccable/reference/hooks.md | 111 + .veto/skills/impeccable/reference/init.md | 131 + .veto/skills/impeccable/reference/ios.md | 51 + .veto/skills/impeccable/reference/layout.md | 84 + .../skills/impeccable/reference/live-setup.md | 102 + .veto/skills/impeccable/reference/live.md | 323 + .veto/skills/impeccable/reference/new-work.md | 145 + .veto/skills/impeccable/reference/onboard.md | 234 + .veto/skills/impeccable/reference/operate.md | 61 + .veto/skills/impeccable/reference/optimize.md | 258 + .../skills/impeccable/reference/overdrive.md | 127 + .veto/skills/impeccable/reference/polish.md | 105 + .veto/skills/impeccable/reference/quieter.md | 99 + .veto/skills/impeccable/reference/routing.md | 18 + .veto/skills/impeccable/reference/shape.md | 59 + .veto/skills/impeccable/reference/typeset.md | 80 + .../skills/impeccable/reference/visualize.md | 46 + .../skills/impeccable/scripts/build-phase.mjs | 1022 ++ .../impeccable/scripts/command-metadata.json | 94 + .veto/skills/impeccable/scripts/comp-diff.mjs | 391 + .veto/skills/impeccable/scripts/comp-spec.mjs | 513 + .../impeccable/scripts/concept-seed.mjs | 814 + .../impeccable/scripts/context-signals.mjs | 325 + .veto/skills/impeccable/scripts/context.mjs | 1597 ++ .../impeccable/scripts/critique-storage.mjs | 473 + .../scripts/data/font-index-failures.json | 121 + .../impeccable/scripts/data/font-index.json | 1 + .../skills/impeccable/scripts/detect-csp.mjs | 198 + .veto/skills/impeccable/scripts/detect.mjs | 30 + .../detector/browser/injected/index.mjs | 2204 +++ .../impeccable/scripts/detector/cli/main.mjs | 432 + .../scripts/detector/design-system.mjs | 1311 ++ .../detector/detect-antipatterns-browser.js | 9104 +++++++++++ .../scripts/detector/detect-antipatterns.mjs | 51 + .../detector/engines/browser/detect-url.mjs | 434 + .../detector/engines/regex/detect-text.mjs | 1293 ++ .../engines/static-html/css-cascade.mjs | 1242 ++ .../engines/static-html/detect-html.mjs | 278 + .../engines/visual/screenshot-contrast.mjs | 189 + .../impeccable/scripts/detector/findings.mjs | 18 + .../scripts/detector/node/file-system.mjs | 213 + .../scripts/detector/profile/profiler.mjs | 166 + .../detector/registry/antipatterns.mjs | 635 + .../scripts/detector/rules/checks.mjs | 5744 +++++++ .../scripts/detector/shared/color.mjs | 596 + .../scripts/detector/shared/constants.mjs | 127 + .../scripts/detector/shared/fonts.mjs | 30 + .../detector/shared/inline-ignores.mjs | 148 + .../scripts/detector/shared/page.mjs | 7 + .veto/skills/impeccable/scripts/doctor.mjs | 329 + .../impeccable/scripts/embed-prompt.mjs | 166 + .../skills/impeccable/scripts/font-match.mjs | 457 + .../impeccable/scripts/generate-image.mjs | 447 + .../skills/impeccable/scripts/hook-admin.mjs | 819 + .../impeccable/scripts/hook-before-edit.mjs | 538 + .veto/skills/impeccable/scripts/hook-lib.mjs | 2490 +++ .veto/skills/impeccable/scripts/hook.mjs | 79 + .../scripts/lib/artifact-schema.mjs | 93 + .../scripts/lib/composition-catalog.mjs | 200 + .../scripts/lib/concept-catalog.mjs | 396 + .../impeccable/scripts/lib/design-parser.mjs | 880 ++ .../scripts/lib/font-fingerprint.mjs | 564 + .../impeccable/scripts/lib/font-index.mjs | 130 + .../impeccable/scripts/lib/hero-checks.mjs | 246 + .../impeccable/scripts/lib/image-metrics.mjs | 306 + .../scripts/lib/impeccable-config.mjs | 640 + .../scripts/lib/impeccable-paths.mjs | 137 + .../impeccable/scripts/lib/is-generated.mjs | 72 + .../scripts/lib/live-path-globs.mjs | 37 + .../scripts/lib/open-system-browser.mjs | 26 + .veto/skills/impeccable/scripts/lib/png.mjs | 281 + .../impeccable/scripts/lib/provider.mjs | 5 + .../skills/impeccable/scripts/lib/raster.mjs | 194 + .../impeccable/scripts/lib/roll-selection.mjs | 369 + .../impeccable/scripts/lib/staleness-deep.mjs | 485 + .../scripts/lib/staleness-notice.mjs | 169 + .../impeccable/scripts/lib/staleness.mjs | 533 + .../impeccable/scripts/lib/surface-briefs.mjs | 149 + .../impeccable/scripts/lib/target-args.mjs | 42 + .../impeccable/scripts/lib/target-slug.mjs | 33 + .../scripts/lib/template-extensions.mjs | 146 + .../skills/impeccable/scripts/live-accept.mjs | 938 ++ .../impeccable/scripts/live-browser-dom.js | 167 + .../scripts/live-browser-ignores.js | 242 + .../scripts/live-browser-session.js | 144 + .../skills/impeccable/scripts/live-browser.js | 12793 ++++++++++++++++ .../scripts/live-commit-manual-edits.mjs | 1200 ++ .../impeccable/scripts/live-complete.mjs | 107 + .../scripts/live-copy-edit-agent.mjs | 800 + .../scripts/live-discard-manual-edits.mjs | 51 + .../skills/impeccable/scripts/live-inject.mjs | 463 + .../skills/impeccable/scripts/live-insert.mjs | 292 + .../scripts/live-manual-edit-evidence.mjs | 368 + .veto/skills/impeccable/scripts/live-poll.mjs | 430 + .../skills/impeccable/scripts/live-resume.mjs | 123 + .../skills/impeccable/scripts/live-server.mjs | 1698 ++ .../skills/impeccable/scripts/live-status.mjs | 71 + .../skills/impeccable/scripts/live-target.mjs | 30 + .veto/skills/impeccable/scripts/live-wrap.mjs | 927 ++ .veto/skills/impeccable/scripts/live.mjs | 334 + .../impeccable/scripts/live/accept-css.mjs | 617 + .../impeccable/scripts/live/accept-verify.mjs | 60 + .../scripts/live/browser-script-parts.mjs | 84 + .../impeccable/scripts/live/completion.mjs | 28 + .../scripts/live/event-validation.mjs | 199 + .../scripts/live/frameworks/astro.mjs | 47 + .../scripts/live/frameworks/detect-utils.mjs | 73 + .../scripts/live/frameworks/index.mjs | 143 + .../scripts/live/frameworks/journal.mjs | 197 + .../scripts/live/frameworks/nextjs.mjs | 49 + .../scripts/live/frameworks/nuxt.mjs | 161 + .../scripts/live/frameworks/script-src.mjs | 17 + .../scripts/live/frameworks/static-html.mjs | 26 + .../scripts/live/frameworks/sveltekit.mjs | 71 + .../scripts/live/frameworks/tag-strategy.mjs | 247 + .../live/frameworks/tanstack-start.mjs | 70 + .../scripts/live/frameworks/vite-generic.mjs | 42 + .../scripts/live/generation-preflight.mjs | 149 + .../impeccable/scripts/live/insert-ui.mjs | 458 + .../impeccable/scripts/live/instructions.mjs | 142 + .../impeccable/scripts/live/manual-apply.mjs | 939 ++ .../scripts/live/manual-edit-routes.mjs | 357 + .../scripts/live/manual-edits-buffer.mjs | 152 + .../impeccable/scripts/live/poll-lanes.mjs | 14 + .../scripts/live/project-ignores.mjs | 139 + .../skills/impeccable/scripts/live/roots.mjs | 508 + .../impeccable/scripts/live/session-store.mjs | 563 + .../impeccable/scripts/live/source-lock.mjs | 105 + .../impeccable/scripts/live/source-search.mjs | 105 + .../impeccable/scripts/live/svelte-ast.mjs | 969 ++ .../scripts/live/svelte-component.mjs | 1366 ++ .../scripts/live/sveltekit-adapter.mjs | 304 + .../scripts/live/tanstack-adapter.mjs | 259 + .../impeccable/scripts/live/ui-surfaces.mjs | 75 + .../impeccable/scripts/live/vocabulary.mjs | 171 + .../scripts/modern-screenshot.umd.js | 14 + .veto/skills/impeccable/scripts/palette.mjs | 628 + .veto/skills/impeccable/scripts/pin.mjs | 340 + .../impeccable/scripts/serve-question.mjs | 1783 +++ .../impeccable/scripts/surface-brief.mjs | 74 + .vibe/skills/impeccable/SKILL.md | 2 +- plugin/.claude-plugin/plugin.json | 2 +- plugin/.grok-plugin/plugin.json | 2 +- plugin/skills/impeccable/SKILL.md | 2 +- 237 files changed, 86485 insertions(+), 739 deletions(-) create mode 100644 .agent/skills/impeccable/scripts/build-phase.mjs create mode 100644 .agent/skills/impeccable/scripts/comp-diff.mjs create mode 100644 .agent/skills/impeccable/scripts/comp-spec.mjs create mode 100644 .agent/skills/impeccable/scripts/data/font-index-failures.json create mode 100644 .agent/skills/impeccable/scripts/data/font-index.json create mode 100644 .agent/skills/impeccable/scripts/font-match.mjs create mode 100644 .agent/skills/impeccable/scripts/lib/font-fingerprint.mjs create mode 100644 .agent/skills/impeccable/scripts/lib/font-index.mjs create mode 100644 .agent/skills/impeccable/scripts/lib/hero-checks.mjs create mode 100644 .agent/skills/impeccable/scripts/lib/image-metrics.mjs create mode 100644 .agent/skills/impeccable/scripts/lib/live-path-globs.mjs create mode 100644 .agent/skills/impeccable/scripts/lib/png.mjs create mode 100644 .agent/skills/impeccable/scripts/lib/raster.mjs create mode 100644 .agent/skills/impeccable/scripts/live-browser-ignores.js create mode 100644 .agent/skills/impeccable/scripts/live/project-ignores.mjs create mode 100644 .veto/skills/impeccable/SKILL.md create mode 100644 .veto/skills/impeccable/reference/adapt.md create mode 100644 .veto/skills/impeccable/reference/adapt.native.md create mode 100644 .veto/skills/impeccable/reference/android.md create mode 100644 .veto/skills/impeccable/reference/animate.md create mode 100644 .veto/skills/impeccable/reference/audit.md create mode 100644 .veto/skills/impeccable/reference/audit.native.md create mode 100644 .veto/skills/impeccable/reference/bolder.md create mode 100644 .veto/skills/impeccable/reference/clarify.md create mode 100644 .veto/skills/impeccable/reference/colorize.md create mode 100644 .veto/skills/impeccable/reference/craft-floor.md create mode 100644 .veto/skills/impeccable/reference/craft.md create mode 100644 .veto/skills/impeccable/reference/critique.md create mode 100644 .veto/skills/impeccable/reference/degraded/asset-producer.md create mode 100644 .veto/skills/impeccable/reference/degraded/documenter.md create mode 100644 .veto/skills/impeccable/reference/degraded/finish-reviewer.md create mode 100644 .veto/skills/impeccable/reference/degraded/manual-edit-applier.md create mode 100644 .veto/skills/impeccable/reference/delight.md create mode 100644 .veto/skills/impeccable/reference/distill.md create mode 100644 .veto/skills/impeccable/reference/doctor.md create mode 100644 .veto/skills/impeccable/reference/document.md create mode 100644 .veto/skills/impeccable/reference/extract.md create mode 100644 .veto/skills/impeccable/reference/harden.md create mode 100644 .veto/skills/impeccable/reference/hooks.md create mode 100644 .veto/skills/impeccable/reference/init.md create mode 100644 .veto/skills/impeccable/reference/ios.md create mode 100644 .veto/skills/impeccable/reference/layout.md create mode 100644 .veto/skills/impeccable/reference/live-setup.md create mode 100644 .veto/skills/impeccable/reference/live.md create mode 100644 .veto/skills/impeccable/reference/new-work.md create mode 100644 .veto/skills/impeccable/reference/onboard.md create mode 100644 .veto/skills/impeccable/reference/operate.md create mode 100644 .veto/skills/impeccable/reference/optimize.md create mode 100644 .veto/skills/impeccable/reference/overdrive.md create mode 100644 .veto/skills/impeccable/reference/polish.md create mode 100644 .veto/skills/impeccable/reference/quieter.md create mode 100644 .veto/skills/impeccable/reference/routing.md create mode 100644 .veto/skills/impeccable/reference/shape.md create mode 100644 .veto/skills/impeccable/reference/typeset.md create mode 100644 .veto/skills/impeccable/reference/visualize.md create mode 100644 .veto/skills/impeccable/scripts/build-phase.mjs create mode 100644 .veto/skills/impeccable/scripts/command-metadata.json create mode 100644 .veto/skills/impeccable/scripts/comp-diff.mjs create mode 100644 .veto/skills/impeccable/scripts/comp-spec.mjs create mode 100644 .veto/skills/impeccable/scripts/concept-seed.mjs create mode 100644 .veto/skills/impeccable/scripts/context-signals.mjs create mode 100644 .veto/skills/impeccable/scripts/context.mjs create mode 100644 .veto/skills/impeccable/scripts/critique-storage.mjs create mode 100644 .veto/skills/impeccable/scripts/data/font-index-failures.json create mode 100644 .veto/skills/impeccable/scripts/data/font-index.json create mode 100644 .veto/skills/impeccable/scripts/detect-csp.mjs create mode 100644 .veto/skills/impeccable/scripts/detect.mjs create mode 100644 .veto/skills/impeccable/scripts/detector/browser/injected/index.mjs create mode 100644 .veto/skills/impeccable/scripts/detector/cli/main.mjs create mode 100644 .veto/skills/impeccable/scripts/detector/design-system.mjs create mode 100644 .veto/skills/impeccable/scripts/detector/detect-antipatterns-browser.js create mode 100644 .veto/skills/impeccable/scripts/detector/detect-antipatterns.mjs create mode 100644 .veto/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs create mode 100644 .veto/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs create mode 100644 .veto/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs create mode 100644 .veto/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs create mode 100644 .veto/skills/impeccable/scripts/detector/engines/visual/screenshot-contrast.mjs create mode 100644 .veto/skills/impeccable/scripts/detector/findings.mjs create mode 100644 .veto/skills/impeccable/scripts/detector/node/file-system.mjs create mode 100644 .veto/skills/impeccable/scripts/detector/profile/profiler.mjs create mode 100644 .veto/skills/impeccable/scripts/detector/registry/antipatterns.mjs create mode 100644 .veto/skills/impeccable/scripts/detector/rules/checks.mjs create mode 100644 .veto/skills/impeccable/scripts/detector/shared/color.mjs create mode 100644 .veto/skills/impeccable/scripts/detector/shared/constants.mjs create mode 100644 .veto/skills/impeccable/scripts/detector/shared/fonts.mjs create mode 100644 .veto/skills/impeccable/scripts/detector/shared/inline-ignores.mjs create mode 100644 .veto/skills/impeccable/scripts/detector/shared/page.mjs create mode 100644 .veto/skills/impeccable/scripts/doctor.mjs create mode 100644 .veto/skills/impeccable/scripts/embed-prompt.mjs create mode 100644 .veto/skills/impeccable/scripts/font-match.mjs create mode 100644 .veto/skills/impeccable/scripts/generate-image.mjs create mode 100644 .veto/skills/impeccable/scripts/hook-admin.mjs create mode 100644 .veto/skills/impeccable/scripts/hook-before-edit.mjs create mode 100644 .veto/skills/impeccable/scripts/hook-lib.mjs create mode 100644 .veto/skills/impeccable/scripts/hook.mjs create mode 100644 .veto/skills/impeccable/scripts/lib/artifact-schema.mjs create mode 100644 .veto/skills/impeccable/scripts/lib/composition-catalog.mjs create mode 100644 .veto/skills/impeccable/scripts/lib/concept-catalog.mjs create mode 100644 .veto/skills/impeccable/scripts/lib/design-parser.mjs create mode 100644 .veto/skills/impeccable/scripts/lib/font-fingerprint.mjs create mode 100644 .veto/skills/impeccable/scripts/lib/font-index.mjs create mode 100644 .veto/skills/impeccable/scripts/lib/hero-checks.mjs create mode 100644 .veto/skills/impeccable/scripts/lib/image-metrics.mjs create mode 100644 .veto/skills/impeccable/scripts/lib/impeccable-config.mjs create mode 100644 .veto/skills/impeccable/scripts/lib/impeccable-paths.mjs create mode 100644 .veto/skills/impeccable/scripts/lib/is-generated.mjs create mode 100644 .veto/skills/impeccable/scripts/lib/live-path-globs.mjs create mode 100644 .veto/skills/impeccable/scripts/lib/open-system-browser.mjs create mode 100644 .veto/skills/impeccable/scripts/lib/png.mjs create mode 100644 .veto/skills/impeccable/scripts/lib/provider.mjs create mode 100644 .veto/skills/impeccable/scripts/lib/raster.mjs create mode 100644 .veto/skills/impeccable/scripts/lib/roll-selection.mjs create mode 100644 .veto/skills/impeccable/scripts/lib/staleness-deep.mjs create mode 100644 .veto/skills/impeccable/scripts/lib/staleness-notice.mjs create mode 100644 .veto/skills/impeccable/scripts/lib/staleness.mjs create mode 100644 .veto/skills/impeccable/scripts/lib/surface-briefs.mjs create mode 100644 .veto/skills/impeccable/scripts/lib/target-args.mjs create mode 100644 .veto/skills/impeccable/scripts/lib/target-slug.mjs create mode 100644 .veto/skills/impeccable/scripts/lib/template-extensions.mjs create mode 100644 .veto/skills/impeccable/scripts/live-accept.mjs create mode 100644 .veto/skills/impeccable/scripts/live-browser-dom.js create mode 100644 .veto/skills/impeccable/scripts/live-browser-ignores.js create mode 100644 .veto/skills/impeccable/scripts/live-browser-session.js create mode 100644 .veto/skills/impeccable/scripts/live-browser.js create mode 100644 .veto/skills/impeccable/scripts/live-commit-manual-edits.mjs create mode 100644 .veto/skills/impeccable/scripts/live-complete.mjs create mode 100644 .veto/skills/impeccable/scripts/live-copy-edit-agent.mjs create mode 100644 .veto/skills/impeccable/scripts/live-discard-manual-edits.mjs create mode 100644 .veto/skills/impeccable/scripts/live-inject.mjs create mode 100644 .veto/skills/impeccable/scripts/live-insert.mjs create mode 100644 .veto/skills/impeccable/scripts/live-manual-edit-evidence.mjs create mode 100644 .veto/skills/impeccable/scripts/live-poll.mjs create mode 100644 .veto/skills/impeccable/scripts/live-resume.mjs create mode 100644 .veto/skills/impeccable/scripts/live-server.mjs create mode 100644 .veto/skills/impeccable/scripts/live-status.mjs create mode 100644 .veto/skills/impeccable/scripts/live-target.mjs create mode 100644 .veto/skills/impeccable/scripts/live-wrap.mjs create mode 100644 .veto/skills/impeccable/scripts/live.mjs create mode 100644 .veto/skills/impeccable/scripts/live/accept-css.mjs create mode 100644 .veto/skills/impeccable/scripts/live/accept-verify.mjs create mode 100644 .veto/skills/impeccable/scripts/live/browser-script-parts.mjs create mode 100644 .veto/skills/impeccable/scripts/live/completion.mjs create mode 100644 .veto/skills/impeccable/scripts/live/event-validation.mjs create mode 100644 .veto/skills/impeccable/scripts/live/frameworks/astro.mjs create mode 100644 .veto/skills/impeccable/scripts/live/frameworks/detect-utils.mjs create mode 100644 .veto/skills/impeccable/scripts/live/frameworks/index.mjs create mode 100644 .veto/skills/impeccable/scripts/live/frameworks/journal.mjs create mode 100644 .veto/skills/impeccable/scripts/live/frameworks/nextjs.mjs create mode 100644 .veto/skills/impeccable/scripts/live/frameworks/nuxt.mjs create mode 100644 .veto/skills/impeccable/scripts/live/frameworks/script-src.mjs create mode 100644 .veto/skills/impeccable/scripts/live/frameworks/static-html.mjs create mode 100644 .veto/skills/impeccable/scripts/live/frameworks/sveltekit.mjs create mode 100644 .veto/skills/impeccable/scripts/live/frameworks/tag-strategy.mjs create mode 100644 .veto/skills/impeccable/scripts/live/frameworks/tanstack-start.mjs create mode 100644 .veto/skills/impeccable/scripts/live/frameworks/vite-generic.mjs create mode 100644 .veto/skills/impeccable/scripts/live/generation-preflight.mjs create mode 100644 .veto/skills/impeccable/scripts/live/insert-ui.mjs create mode 100644 .veto/skills/impeccable/scripts/live/instructions.mjs create mode 100644 .veto/skills/impeccable/scripts/live/manual-apply.mjs create mode 100644 .veto/skills/impeccable/scripts/live/manual-edit-routes.mjs create mode 100644 .veto/skills/impeccable/scripts/live/manual-edits-buffer.mjs create mode 100644 .veto/skills/impeccable/scripts/live/poll-lanes.mjs create mode 100644 .veto/skills/impeccable/scripts/live/project-ignores.mjs create mode 100644 .veto/skills/impeccable/scripts/live/roots.mjs create mode 100644 .veto/skills/impeccable/scripts/live/session-store.mjs create mode 100644 .veto/skills/impeccable/scripts/live/source-lock.mjs create mode 100644 .veto/skills/impeccable/scripts/live/source-search.mjs create mode 100644 .veto/skills/impeccable/scripts/live/svelte-ast.mjs create mode 100644 .veto/skills/impeccable/scripts/live/svelte-component.mjs create mode 100644 .veto/skills/impeccable/scripts/live/sveltekit-adapter.mjs create mode 100644 .veto/skills/impeccable/scripts/live/tanstack-adapter.mjs create mode 100644 .veto/skills/impeccable/scripts/live/ui-surfaces.mjs create mode 100644 .veto/skills/impeccable/scripts/live/vocabulary.mjs create mode 100644 .veto/skills/impeccable/scripts/modern-screenshot.umd.js create mode 100644 .veto/skills/impeccable/scripts/palette.mjs create mode 100644 .veto/skills/impeccable/scripts/pin.mjs create mode 100644 .veto/skills/impeccable/scripts/serve-question.mjs create mode 100644 .veto/skills/impeccable/scripts/surface-brief.mjs diff --git a/.agent/skills/impeccable/SKILL.md b/.agent/skills/impeccable/SKILL.md index f5da1671f..733586a93 100644 --- a/.agent/skills/impeccable/SKILL.md +++ b/.agent/skills/impeccable/SKILL.md @@ -1,14 +1,14 @@ --- name: impeccable description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks. -version: 4.1.2 +version: 4.1.3 license: Apache 2.0 allowed-tools: - Bash(npx impeccable *) - Bash(node .agent/skills/impeccable/scripts/*) --- -This skill gives you the tools and permission to create design that earns to be called out-of-distribution craft: Whereas before, your design work would have been safe, timid and measured, you now approach every design task as a award-winning design director with impeccable understanding for what makes exceptional design work: production-grade code, peak creativity, a clear POV, deep understanding of the needs of the client and users, and exceptional craft. +This skill gives you the tools and permission to create design that earns to be called out-of-distribution craft: Whereas before, your design work would have been safe, timid and measured, you now approach every design task as an award-winning design director with impeccable understanding for what makes exceptional design work: production-grade code, peak creativity, a clear POV, deep understanding of the needs of the client and users, and exceptional craft. Core principles: - Go all out. No hedging, no shortcuts. The deliverable must be complete (except assets the user must provide). @@ -18,7 +18,7 @@ Core principles: ## Setup 1. Run `node /scripts/context.mjs` once per session, where `` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .agent/skills/impeccable/scripts/...` command in this skill and its references, and `.agent/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target `. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it. -2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing. +2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures. 3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work. ## How to design diff --git a/.agent/skills/impeccable/reference/critique.md b/.agent/skills/impeccable/reference/critique.md index 187a8680c..1c4ae5b5c 100644 --- a/.agent/skills/impeccable/reference/critique.md +++ b/.agent/skills/impeccable/reference/critique.md @@ -1,6 +1,6 @@ ### Purpose -Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive/backlog for future commands. +Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive of that run. ### Hard Invariants @@ -84,7 +84,7 @@ After Assessment B returns usable CLI findings, reuse them. Do not rerun `detect Synthesize both assessments into a single report. Do NOT simply concatenate. Weave the findings together, noting where the LLM review and detector agree, where the detector caught issues the LLM missed, and where detector findings are false positives. -The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is only an archive/backlog for later commands. +The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is an archive of that run. Structure your feedback as a design director would: @@ -197,7 +197,7 @@ Skip this step if the Setup slug was null (vague or root-level target). IMPECCABLE_CRITIQUE_META='{"target":"","total_score":,"max_score":,"na_heuristics":"","p0_count":,"p1_count":}' \ node .agent/skills/impeccable/scripts/critique-storage.mjs write "" ``` - `max_score` is the applicable maximum from the heuristic table (40 when every heuristic applied), so a later run can tell a renormalized total from a full one. The helper prints the absolute path it wrote. + `max_score` is the applicable maximum from the heuristic table (40 when every heuristic applied), so a later run can tell a renormalized total from a full one. For a local file target, the helper also records an exact content fingerprint so polish can distinguish the assessed bytes from later edits without relying on Git state or timestamps. The helper prints the absolute path it wrote. Leave that file on disk. Polish closes it; this run does not. 3. **Delete the temp body file** after the write attempt completes, whether the write succeeded or failed. If deletion fails, mention `temp-file cleanup failed: ` briefly in the final output, but do not block the critique. diff --git a/.agent/skills/impeccable/reference/degraded/asset-producer.md b/.agent/skills/impeccable/reference/degraded/asset-producer.md index 652d8d999..78b570a64 100644 --- a/.agent/skills/impeccable/reference/degraded/asset-producer.md +++ b/.agent/skills/impeccable/reference/degraded/asset-producer.md @@ -15,74 +15,23 @@ When the parent hands you a decision card packet instead of an approved mock, th ## Input Contract -Expect: +Expect the measured spec (`.impeccable/build/spec.json`, written by `comp-spec.mjs` from the approved comp), the approved comp path, and the skill scripts path. Optionally: a subset of region ids to produce, extra prompt notes per region, and format or transparency needs. Everything else you need is in the spec: each raster region's id, kind (plate, image, texture), pixel box, sampled palette, aspect, note, and the plate path it must land on. -- Approved mock path or screenshot reference. -- Crop paths or a contact sheet with crop ids. -- Output directory. -- Required dimensions, format, transparency needs, and avoid list. -- Notes on what should remain semantic HTML/CSS/SVG instead of raster. +If there is no spec, stop and return one line asking the parent to run `comp-spec.mjs` first. You do not inventory the comp yourself; the spec is the inventory, and a second inventory disagrees with the first. -If the source mock is attached but has no filesystem path, use it for visual planning; ask for a path only before cropping or writing assets. +## The job -Defaults unless contradicted: +Every region with `medium: raster` in the spec ships as a plate at its `plate` path. A plate is the region regenerated at asset resolution from the comp crop as reference: same subject, same composition, same palette, same lighting and material, with the UI text and page chrome removed, at 1.5x the comp region's pixel size or more. The page draws text, controls, radius, shadow, and layout in code; the plate carries what code cannot draw. Crops from the comp are references, never shipping pixels: a comp is reference grade and a shipped crop is how a beautiful comp becomes a blurry site. -- `.webp` for opaque photos, backgrounds, and textures. -- `.png` for transparent cutouts, seals, tickets, and illustrations. -- Target production size, or at least 2x display size when dimensions are known. Never default to the small size of a full-page mock crop. -- Remove UI text, navigation, buttons, labels, and body copy. -- Keep physical marks only when the parent says they are part of the asset. -- Remove letterboxing, empty padding, baked card corners, borders, shadows, caption bands, and layout background unless the parent says those pixels are intrinsic. -- Keep the final assets directory clean: only files the build will consume. Source crops, reference crops, masks, and contact sheets go in a sibling `_sources`, `sources`, or review folder. +Per region, in the spec's order: -Ask blockers once, globally. Missing source path/crops or output directory blocks production. Exact dimensions, compression targets, retina variants, and format preferences do not; choose defaults and report them. +1. `node .agent/skills/impeccable/scripts/comp-spec.mjs --crop ` writes the reference crop under `.impeccable/build/crops/`. +2. Produce the plate. With the API fallback: `node .agent/skills/impeccable/scripts/generate-image.mjs --plate --quality high` does the whole step (crop as reference, the spec's plate prompt, output size chosen from the region's aspect, the file written to its plate path, prompt embedded, and the plate scored against the crop). With a harness-native image tool: use the crop as the input image and `node .agent/skills/impeccable/scripts/comp-spec.mjs --plate-prompt ` as the prompt, write the result to the plate path, then run `node .agent/skills/impeccable/scripts/embed-prompt.mjs --prompt ""`. +3. Read the score line. `PLATE-SCORE` under 50%, or a `PLATE-WARN`, means the plate does not read as the region: open the plate beside the crop, name what drifted (subject, framing, palette, style), tighten the prompt with that, and regenerate once. Two misses on one region: keep the better plate, mark it `needs_parent_review`, and say why in one line. +4. Transparent cutouts (a figure or object on the page ground): generate on a flat chroma color absent from the subject and key it to alpha before writing the PNG; never ship the keyed background. -## Workflow - -1. Inventory the full approved mock or every assigned crop. -2. Put each visual role in exactly one bucket: - - `produce`: needs generation, image editing, cleanup, cutout work, or a clean plate before it can ship. - - `direct`: ships after format conversion, compression, or renaming because the parent supplied a real standalone source: a project file, stock, or prior production art. A crop from the approved mock is never `direct`, whatever its apparent size. - - `semantic`: build in HTML/CSS/SVG/canvas, no raster output. -3. Crops from the mock are binding visual references, never shipping pixels: a full-page mock's effective resolution is reference grade, and a shipped crop, however close it looks, is how a beautiful comp becomes a blurry site. Every mock-derived asset goes through `produce` as a clean regeneration. -4. Give the parent an execution order for the `produce` bucket. -5. For produced assets, choose the least inventive strategy: image-to-image clean plate, faithful regeneration from crop reference, transparent cutout, texture/pattern reconstruction, stock/project source, or a semantic HTML/CSS/SVG recommendation when raster is wrong. -6. Use the harness's native image tool by default when generation or editing is needed; otherwise use the skill's generate-image.mjs. - -7. Remove baked-in UI text, navigation, buttons, body copy, and mock chrome unless the text is part of the asset. -8. Think through the final DOM/CSS representation before generating. If CSS will own radius, clipping, shadows, borders, perspective, responsive cropping, captions, or card frames, do not bake those into the bitmap. -9. Save outputs non-destructively in the requested project directory, and leave the intent with the file: after every generation, run `node .agent/skills/impeccable/scripts/embed-prompt.mjs --prompt ""` so the prompt lives inside the image itself. The build thread composes what you made and needs to know what it is looking at, and the embedding survives copies where sidecars get lost. -10. Compare each output against its source crop, opening every image by its workspace-relative path; sandboxed viewers reject absolute paths. If a review/QA tool is available, run it before the final manifest, then retry each major/fatal finding once before finalizing. - -Use `texture/pattern extraction` only when the source region is already clean enough to sample as texture. If UI, cards, labels, headings, body copy, or footer chrome must be removed first, classify it as crop-derived cleanup or clean-plate work. - -Use `semantic` for dashboards, charts, controls, screenshots of whole UI sections, data widgets, card chrome, app frames, icon toolbars, logos, wordmarks, and anything the final implementation can render crisply in HTML/CSS/SVG/canvas. Ship a screenshot raster only when the parent explicitly says the screenshot itself is the final asset. - -Semantic does not mean ignored. For every semantic role, write a concrete implementation handoff for the parent craft agent: the DOM/component layers, CSS-owned visual treatment, SVG/canvas/icon-library pieces, responsive behavior, and which nearby produced raster assets it composes with. For logos and icons, prefer inline SVG/vector or icon-library implementation unless the parent provides a production logo raster. - -## Prompt Pattern - -Use this shape for image-to-image work: - -```text -Use the provided crop as the approved visual reference. -Recreate the same asset as a clean reusable production image at the target component aspect ratio and at least 2x display resolution. -Preserve silhouette, object/scene perspective, camera angle, palette, lighting, material, texture, and visual role. -Remove baked-in UI copy, navigation, buttons, labels, body text, watermarks, and mock chrome unless explicitly part of the asset. -Remove letterboxing, padding, card borders, rounded clipping, CSS shadows, perspective transforms, caption bands, and layout backgrounds that the implementation should create in code. -Do not add new objects. Do not change the concept. Do not redesign the composition. -``` - -For transparent cutouts: use true alpha when the tool supports it; otherwise generate on a flat chroma-key color that cannot appear in the subject and post-process that color to alpha before shipping the PNG/WebP. Never ship the keyed background as the final asset. +Do not redesign. Do not add objects, restyle, or reinterpret; the comp was approved as it is. Do not touch the page code, the spec, or the comp. Do not produce anything the spec does not list; a region the parent forgot goes back as a one-line note, not a plate. ## Output Contract -Return a complete manifest, grouped by `produce`, `direct`, and `semantic`. For each asset include: `id`, `source_crop`, `output_path` when applicable, `strategy`, `prompt_used` when applicable, `dimensions`, `format`, `transparency`, `deviations`, and `qa_status`. - -For each semantic row include `id`, `implementation`, `notes`, and `qa_status`. The `implementation` is a concrete build handoff, not a note that no asset was produced: name the likely HTML/CSS/SVG/canvas/icon/component pieces and the visual responsibilities code owns. - -`qa_status` is `accepted`, `needs_parent_review`, or `blocked`. `accepted` only after visual comparison passes. `needs_parent_review` for cut-off subjects, unwanted borders or rounded-card chrome, letterboxing, baked semantic text, low-resolution output, perspective that should have been CSS, missing transparency, or drift from the crop. `blocked` when inputs, permissions, image capability, or asset source quality prevent a credible result. - -End with `execution_order`, `blockers`, and `assumptions` sections. Keep blockers global and minimal; per-asset rows carry only asset-specific risks or decisions. - -Do not modify implementation code. Do not edit the approved mock. Do not produce final page copy. The parent craft agent owns implementation and final mock fidelity. \ No newline at end of file +Return one line per raster region: ` % `. Then `blockers` (missing spec, missing comp, no image capability, exhausted key) and `assumptions`, each global and minimal. Nothing else: no summary, no praise, no implementation advice. The parent runs `build-phase.mjs advance` to verify the plates against the same spec; your line and its line must agree. \ No newline at end of file diff --git a/.agent/skills/impeccable/reference/degraded/finish-reviewer.md b/.agent/skills/impeccable/reference/degraded/finish-reviewer.md index e2a4fd130..c055569ae 100644 --- a/.agent/skills/impeccable/reference/degraded/finish-reviewer.md +++ b/.agent/skills/impeccable/reference/degraded/finish-reviewer.md @@ -11,16 +11,16 @@ A hard turn ceiling ends the run without warning; a run that ends before its con ## Input Contract -Expect: the original request; the confirmed user answers; the artifact path(s); the screenshots the parent captured, in `.impeccable/review/` (web: `desktop.png` and `mobile.png`; native: device-class names such as `phone.png` and `tablet.png`, suffixed per OS on adaptive). A screenshot path the calling brief names is authoritative when the file exists; `.impeccable/review/` is where to look when the brief names none or a named path is missing, never a filename you invent. Also expect: the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); the PRODUCT.md path; existing hook or detector findings; the chosen world's QUALITY BAR card paths; on a comp-led build the approved comp path (a code-led build has none; it passes the chosen decision comp as a separate critique-reference input, labeled as such, and nothing here that binds "the approved comp" binds it); and the skill's `reference/craft-floor.md` path. On a native (`ios` / `android` / `adaptive`) build the packet adds the platform reference path(s) (`reference/ios.md` / `reference/android.md`) and a line saying no detector ran: read the platform reference alongside the craft floor, judge every check in the platform's own conventions, treat the screenshots as device captures, and know your floor check is the build's only slop gate. When the harness can view images, open the screenshots, the comp, and the card first, and inventory the comp's salient elements in your own words before reading the direction contract or any builder-authored summary: a review anchored on the contract inherits whatever the builder's abstraction dropped. +Expect: the original request; the confirmed user answers; the artifact path(s); the screenshots the parent captured, in `.impeccable/review/` (web: `desktop.png` and `mobile.png`; native: device-class names such as `phone.png` and `tablet.png`, suffixed per OS on adaptive). A screenshot path the calling brief names is authoritative when the file exists; `.impeccable/review/` is where to look when the brief names none or a named path is missing, never a filename you invent. Also expect: the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); the PRODUCT.md path; existing hook or detector findings; the chosen world's QUALITY BAR card paths; on a comp-led build the approved comp path (a code-led build has none; it passes the chosen decision comp as a separate critique-reference input, labeled as such, and nothing here that binds "the approved comp" binds it); on a comp-led build the build state (`.impeccable/build/state.json`), the measured spec (`.impeccable/build/spec.json`), and the diff directories `.impeccable/review/diff/hero/` and `.impeccable/review/diff/final/` (each holds `side-by-side.png`, `heatmap.png`, `regions/.png` paired crops, and `report.json` with per-region scores and verdicts from `comp-diff.mjs`); and the skill's `reference/craft-floor.md` path. On a native (`ios` / `android` / `adaptive`) build the packet adds the platform reference path(s) (`reference/ios.md` / `reference/android.md`) and a line saying no detector ran: read the platform reference alongside the craft floor, judge every check in the platform's own conventions, treat the screenshots as device captures, and know your floor check is the build's only slop gate. When the harness can view images, open the screenshots, the comp, and the card first, and inventory the comp's salient elements in your own words before reading the direction contract or any builder-authored summary: a review anchored on the contract inherits whatever the builder's abstraction dropped. ## Checks, in order 0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round. -1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/review/hero-repro.png` exists: the hero reproduction checkpoint's capture at the comp's own dimensions; its absence means the reproduction phase ran unproven, a material finding. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. -2. **Fidelity.** Against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement. +1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. +2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement. 3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition. 4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport. -5. **Truth.** Demonstration data authored and labeled synthetic; no invented commercial claims; unanswered claims present as marked placeholders, not omissions. Every image-native region of the approved comp shipped as a real asset, not a gradient standing in for one, and every produced asset visibly present in the screenshots; an asset applied at near-zero opacity or buried behind other paint is a compliance token, not a shipped material. +5. **Truth.** Demonstration data authored and labeled synthetic; no invented commercial claims; unanswered claims present as marked placeholders, not omissions. Every raster region of the spec shipped as its plate (the spec names the file; the page references it; the region's diff row is not `missing`), not a gradient, an inline SVG, or a many-vertex `clip-path` standing in for it, and every produced asset visibly present in the screenshots; an asset applied at near-zero opacity or buried behind a wash is a compliance token, not a shipped material, and the detector's `buried-raster` and `organic-clip-path` findings in the packet are material fixes. 6. **Floor.** Read the craft floor's Refuse list and hold the screenshots against it: kickers and eyebrows, hard offset shadows outside a neobrutalist world, glyph icons, system display faces, gradient text, side stripes, and the rest. A banned element is a material fix even when it matches nothing in the comp: the builder loaded the same ban before writing it, and fidelity to a comp cannot authorize what the floor refuses. The parent's hook findings cover this mechanically where hooks run; this check exists because hookless harnesses reach you with none, and the last two live sessions shipped five kickers past a reviewer that never looked. Do not run a second detector pass; mechanical findings belong to the parent's hooks. diff --git a/.agent/skills/impeccable/reference/hooks.md b/.agent/skills/impeccable/reference/hooks.md index 188215495..2b7d95725 100644 --- a/.agent/skills/impeccable/reference/hooks.md +++ b/.agent/skills/impeccable/reference/hooks.md @@ -32,7 +32,7 @@ The first argument is the action. Defaults to `status`. | `ignore-value [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. | | `ignore-value --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/config.local.json`. | | `ignore-value "*" --file [--file ...]` | Turn one rule off in matching files only, leaving it active everywhere else. Repeat `--file`, or use `--file=` / `--files=`. A bare `"*"` with no `--file` is refused: use `ignore-rule ` if you really mean project-wide. | -| `reset` | Delete the project config, dedup cache, and Cursor pending queue. | +| `reset` | Delete the project config, dedup cache, and Cursor pending queue, and remove the hook's entries from every provider manifest `on` installs, the committed Copilot file included (a team-shared `settings.json` that `on` never writes is never touched). | ## Flow diff --git a/.agent/skills/impeccable/reference/new-work.md b/.agent/skills/impeccable/reference/new-work.md index 8647c52c7..96ff5c031 100644 --- a/.agent/skills/impeccable/reference/new-work.md +++ b/.agent/skills/impeccable/reference/new-work.md @@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u 4. Run `node .agent/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode ` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen. 5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLE’S PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register ` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel. -The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .agent/skills/impeccable/scripts/serve-question.mjs --start --payload ` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from --reroll ` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key --payload `, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. +The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .agent/skills/impeccable/scripts/serve-question.mjs --start --payload ` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from --reroll ` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key --payload `, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images. @@ -68,16 +68,20 @@ Calibration: AI-generated interfaces cluster around a few looks regardless of su ## 5. Record the decision -Before code, state the chosen direction as a contract in the artifact's opening comment, five short blocks, 150 words at most, in a form that survives the production build: an HTML comment in the emitted markup, never only a templating-frontmatter comment, placed as the first child of the document's body in the root layout, never inside a slotted or child component (some compilers, Astro among them, strip a slot's leading comment while keeping deeper ones). After the first production build, grep the built output for the seed key; a contract the build erased is a contract nobody can audit. THESIS: the one idea this surface owns and the category-default arrangement it refuses. OWN-WORLD: the palette and component language, specific enough to be recognizable with all content removed. STORY: what the visitor understands, believes, and does. FIRST VIEWPORT: the exact composition, what is where and at what scale, and where the primary action sits. FORM: the chosen form, its position on your ordered list, and the seed key the script printed. Close with one more line, FINISH: the run's exit condition, verbatim "unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance". The comment tops the artifact you re-open on every edit, the one reminder that survives a long build: a page that looks complete with the FINISH line undischarged is not done, it is abandoned at the finish line. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract. +Before code, record the chosen direction as a development-only contract under `## Direction contract` in the relevant surface brief. A direction contract is durable route or artifact strategy, so create or update the brief even when no other surface strategy needs persistence. Keep the contract to six short blocks and 150 words at most. THESIS: the one idea this surface owns and the category-default arrangement it refuses. OWN-WORLD: the palette and component language, specific enough to be recognizable with all content removed. STORY: what the visitor understands, believes, and does. FIRST VIEWPORT: the exact composition, what is where and at what scale, and where the primary action sits. FORM: the chosen form, its position on your ordered list, and the seed key the script printed. Close with one more line, FINISH: the run's exit condition, verbatim "unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance". The surface brief is the reminder later agents reload across edits and sessions: a page that looks complete with the FINISH line undischarged is not done, it is abandoned at the finish line. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract. + +Never copy the direction contract into implementation source or any browser-delivered artifact. This includes HTML or framework comments, hidden DOM, `