From 9b0b63c04f76b376fb11fdc61648c7bd3d85f6a4 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Sun, 14 Jun 2026 21:51:14 +0900 Subject: [PATCH] Prepare CLI 3.0.0, skill 3.6.0, extension 1.2.0 --- .../detector/engines/regex/detect-text.mjs | 18 +- .../skills/impeccable/scripts/live-browser.js | 162 +++++++++++++++++- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .claude/skills/impeccable/SKILL.md | 2 +- .../detector/engines/regex/detect-text.mjs | 18 +- .../skills/impeccable/scripts/live-browser.js | 162 +++++++++++++++++- .cursor/skills/impeccable/SKILL.md | 2 +- .../detector/engines/regex/detect-text.mjs | 18 +- .../skills/impeccable/scripts/live-browser.js | 162 +++++++++++++++++- .gemini/skills/impeccable/SKILL.md | 2 +- .../detector/engines/regex/detect-text.mjs | 18 +- .../skills/impeccable/scripts/live-browser.js | 162 +++++++++++++++++- .github/skills/impeccable/SKILL.md | 2 +- .../detector/engines/regex/detect-text.mjs | 18 +- .../skills/impeccable/scripts/live-browser.js | 162 +++++++++++++++++- .impeccable/config.json | 1 + .kiro/skills/impeccable/SKILL.md | 2 +- .../detector/engines/regex/detect-text.mjs | 18 +- .../skills/impeccable/scripts/live-browser.js | 162 +++++++++++++++++- .opencode/skills/impeccable/SKILL.md | 2 +- .../detector/engines/regex/detect-text.mjs | 18 +- .../skills/impeccable/scripts/live-browser.js | 162 +++++++++++++++++- .pi/skills/impeccable/SKILL.md | 2 +- .../detector/engines/regex/detect-text.mjs | 18 +- .pi/skills/impeccable/scripts/live-browser.js | 162 +++++++++++++++++- .qoder/skills/impeccable/SKILL.md | 2 +- .../detector/engines/regex/detect-text.mjs | 18 +- .../skills/impeccable/scripts/live-browser.js | 162 +++++++++++++++++- .rovodev/skills/impeccable/SKILL.md | 2 +- .../detector/engines/regex/detect-text.mjs | 18 +- .../skills/impeccable/scripts/live-browser.js | 162 +++++++++++++++++- .trae-cn/skills/impeccable/SKILL.md | 2 +- .../detector/engines/regex/detect-text.mjs | 18 +- .../skills/impeccable/scripts/live-browser.js | 162 +++++++++++++++++- .trae/skills/impeccable/SKILL.md | 2 +- .../detector/engines/regex/detect-text.mjs | 18 +- .../skills/impeccable/scripts/live-browser.js | 162 +++++++++++++++++- cli/engine/engines/regex/detect-text.mjs | 18 +- package.json | 2 +- plugin/.claude-plugin/plugin.json | 2 +- plugin/skills/impeccable/SKILL.md | 2 +- .../detector/engines/regex/detect-text.mjs | 18 +- .../skills/impeccable/scripts/live-browser.js | 162 +++++++++++++++++- site/pages/changelog.astro | 32 +++- site/styles/sub-pages.css | 4 +- skill/scripts/live-browser.js | 162 +++++++++++++++++- tests/detect-antipatterns-fixtures.test.mjs | 11 ++ tests/detect-antipatterns.test.js | 26 +++ .../numbered-section-markers.html | 84 +++++++++ 50 files changed, 2591 insertions(+), 119 deletions(-) create mode 100644 tests/fixtures/antipatterns/numbered-section-markers.html 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 4c0777c93..a72f93e27 100644 --- a/.agents/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.agents/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -23,6 +23,18 @@ function stripHtmlToText(html) { .replace(/\s+/g, ' '); } +const PAGE_ANALYZER_EXTS = new Set(['.html', '.htm', '.astro', '.vue', '.svelte']); + +function extFromFilePath(filePath) { + return filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : ''; +} + +function shouldRunPageAnalyzers(content, filePath) { + if (!isFullPage(content)) return false; + const ext = extFromFilePath(filePath); + return !ext || PAGE_ANALYZER_EXTS.has(ext); +} + function isNeutralBorderColor(str) { const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i); if (!m) return false; @@ -422,7 +434,7 @@ const TEXT_CONTENT_ANALYZER_IDS = [ function runTextContentAnalyzers(content, filePath, options = {}) { const profile = options?.profile; - if (!isFullPage(content)) return []; + if (!shouldRunPageAnalyzers(content, filePath)) return []; // The 4 text-content analyzers are at indices 3-6 in REGEX_ANALYZERS. const findings = []; for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { @@ -442,7 +454,7 @@ function detectText(content, filePath, options = {}) { const profile = options?.profile; const findings = []; const lines = content.split('\n'); - const ext = filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : ''; + const ext = extFromFilePath(filePath); // Run regex matchers on the full file content (catches Tailwind classes, inline styles) // Enable block context for CSS files where related properties span multiple lines @@ -498,7 +510,7 @@ function detectText(content, filePath, options = {}) { } // Page-level analyzers only run on full pages - if (isFullPage(content)) { + if (shouldRunPageAnalyzers(content, filePath)) { const analyzerIds = [ 'single-font', 'flat-type-hierarchy', diff --git a/.agents/skills/impeccable/scripts/live-browser.js b/.agents/skills/impeccable/scripts/live-browser.js index 539cd07d9..221fed9c6 100644 --- a/.agents/skills/impeccable/scripts/live-browser.js +++ b/.agents/skills/impeccable/scripts/live-browser.js @@ -287,7 +287,7 @@ } function shouldShowHighlightTagTooltip() { - // Configure/edit carry the tag in the bar selection pill — keep only the outline. + // Configure/edit carry the tag in the bar selection pill, so keep only the outline. return state !== 'CONFIGURING' && state !== 'EDITING'; } @@ -1148,7 +1148,7 @@ syncPageChatFocus('update-bar-content'); } - // Configure row — the floating bar surface IS the input; modifier pills sit left of the field. + // Configure row: the floating bar surface IS the input; modifier pills sit left of the field. const CONFIGURE_BAR_H = '36px'; // Compact selection pill + 7px inset balances vertical centering in the 36px bar. @@ -1519,7 +1519,7 @@ function buildConfigureCountControl({ controlsLocked, onClick }) { const count = el('button', configureInlineControlStyle({ - fontFamily: MONO, fontWeight: '600', letterSpacing: '-0.02em', + fontFamily: MONO, fontWeight: '600', letterSpacing: '0', })); count.textContent = '\u00D7' + selectedCount; count.disabled = controlsLocked; @@ -5065,6 +5065,157 @@ } } + async function loadSvelteComponentVariantSource(manifest, variantNum) { + const dir = String(manifest?.componentDir || '').replace(/^\/+/, ''); + if (!dir || !variantNum) return ''; + const sourcePath = dir + '/v' + variantNum + '.svelte'; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(sourcePath); + try { + const res = await fetch(url); + if (!res.ok) return ''; + return await res.text(); + } catch { + return ''; + } + } + + function extractSvelteComponentStyle(source) { + const match = String(source || '').match(/]*>([\s\S]*?)<\/style\s*>/i); + return match ? match[1].trim() : ''; + } + + async function applySvelteComponentVariantStyle(variantNum) { + if (!svelteComponentSession || !variantNum) return; + const { manifest, sessionId } = svelteComponentSession; + const source = await loadSvelteComponentVariantSource(manifest, variantNum); + const css = extractSvelteComponentStyle(source); + removeSvelteComponentVariantStyle(svelteComponentSession); + if (!css) return; + const scopedCss = scopeCssToSveltePreview(css, sessionId); + if (!scopedCss) return; + const style = document.createElement('style'); + style.dataset.impeccableSvelteComponentStyle = sessionId; + style.dataset.impeccableVariant = String(variantNum); + style.textContent = scopedCss; + document.head.appendChild(style); + svelteComponentSession.styleEl = style; + } + + function removeSvelteComponentVariantStyle(session = svelteComponentSession) { + const style = session?.styleEl; + if (style?.parentNode) style.parentNode.removeChild(style); + if (session) session.styleEl = null; + } + + function scopeCssToSveltePreview(css, sessionId) { + const prefix = '[data-impeccable-variants="' + String(sessionId).replace(/"/g, '\\"') + '"] '; + return scopeCssBlock(String(css || ''), prefix).trim(); + } + + function scopeCssBlock(css, prefix) { + let out = ''; + let i = 0; + while (i < css.length) { + const open = css.indexOf('{', i); + if (open === -1) { + out += css.slice(i); + break; + } + const semi = css.indexOf(';', i); + if (semi !== -1 && semi < open) { + out += css.slice(i, semi + 1); + i = semi + 1; + continue; + } + const prelude = css.slice(i, open).trim(); + const close = findMatchingCssBrace(css, open); + if (close === -1) { + out += css.slice(i); + break; + } + const body = css.slice(open + 1, close); + if (shouldScopeNestedCssAtRule(prelude)) { + out += prelude + ' {\n' + scopeCssBlock(body, prefix) + '\n}'; + } else if (prelude.startsWith('@')) { + out += prelude + ' {' + body + '}'; + } else { + out += prefixCssSelectors(prelude, prefix) + ' {' + body + '}'; + } + i = close + 1; + } + return out; + } + + function shouldScopeNestedCssAtRule(prelude) { + return /^@(media|supports|container|layer)\b/i.test(prelude || ''); + } + + function findMatchingCssBrace(css, openIndex) { + let depth = 0; + let quote = ''; + for (let i = openIndex; i < css.length; i++) { + const ch = css[i]; + const prev = css[i - 1]; + if (quote) { + if (ch === quote && prev !== '\\') quote = ''; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + } else if (ch === '{') { + depth++; + } else if (ch === '}') { + depth--; + if (depth === 0) return i; + } + } + return -1; + } + + function prefixCssSelectors(prelude, prefix) { + return splitCssSelectorList(prelude) + .map((selector) => { + const s = unwrapSvelteGlobalSelector(selector.trim()); + if (!s) return ''; + if (s.startsWith(prefix.trim())) return s; + if (s.startsWith(':host')) return s.replace(/^:host\b/, prefix.trim()); + return prefix + s; + }) + .filter(Boolean) + .join(', '); + } + + function splitCssSelectorList(selectorList) { + const selectors = []; + let start = 0; + let depth = 0; + let quote = ''; + for (let i = 0; i < selectorList.length; i++) { + const ch = selectorList[i]; + const prev = selectorList[i - 1]; + if (quote) { + if (ch === quote && prev !== '\\') quote = ''; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + } else if (ch === '(' || ch === '[') { + depth++; + } else if ((ch === ')' || ch === ']') && depth > 0) { + depth--; + } else if (ch === ',' && depth === 0) { + selectors.push(selectorList.slice(start, i)); + start = i + 1; + } + } + selectors.push(selectorList.slice(start)); + return selectors; + } + + function unwrapSvelteGlobalSelector(selector) { + return selector.replace(/:global\(([^()]*)\)/g, '$1'); + } + function buildSveltePropValuesFromLiveElement(liveEl, manifest) { const contract = manifest?.propContract || []; const values = {}; @@ -5101,6 +5252,7 @@ }); svelteComponentSession.mountedVariant = variantNum; svelteComponentSession.runtime = runtime; + await applySvelteComponentVariantStyle(variantNum); if (state === 'CYCLING') syncCyclingControls(); const nextAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); if (nextAnchor) { @@ -5134,6 +5286,7 @@ function teardownSvelteComponentSession(restoreOriginal) { if (!svelteComponentSession) return; const { wrapperEl, detachedOriginal, runtime, mountedInstance } = svelteComponentSession; + removeSvelteComponentVariantStyle(svelteComponentSession); if (mountedInstance && runtime?.unmount) { try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } } @@ -5173,6 +5326,7 @@ if (mountedInstance && runtime?.unmount) { try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } } + removeSvelteComponentVariantStyle(svelteComponentSession); wrapperEl.parentElement.replaceChild(committed, wrapperEl); svelteComponentSession = null; svelteRuntimePromise = null; @@ -8843,7 +8997,7 @@ void main() { cursor: 'pointer', flexShrink: '0', width: PAGE_CHAT_COLLAPSED_W, - transition: 'width 0.18s ease, border-color 0.15s ease', + transition: 'border-color 0.15s ease', }); pageChatEl.id = PREFIX + '-page-chat'; pageChatEl.dataset.expanded = 'false'; diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index fa47ae172..fa9e47420 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,7 +12,7 @@ { "name": "impeccable", "description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.", - "version": "3.5.0", + "version": "3.6.0", "author": { "name": "Paul Bakaus", "email": "paul@paulbakaus.com" diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 20c00a1ee..b30e62ce3 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "impeccable", "description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.", - "version": "3.5.0", + "version": "3.6.0", "author": { "name": "Paul Bakaus", "email": "paul@paulbakaus.com" diff --git a/.claude/skills/impeccable/SKILL.md b/.claude/skills/impeccable/SKILL.md index 73a27c46e..71252e52e 100644 --- a/.claude/skills/impeccable/SKILL.md +++ b/.claude/skills/impeccable/SKILL.md @@ -1,7 +1,7 @@ --- 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: 3.5.0 +version: 3.6.0 user-invocable: true argument-hint: "[craft|shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]" license: Apache 2.0 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 4c0777c93..a72f93e27 100644 --- a/.claude/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.claude/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -23,6 +23,18 @@ function stripHtmlToText(html) { .replace(/\s+/g, ' '); } +const PAGE_ANALYZER_EXTS = new Set(['.html', '.htm', '.astro', '.vue', '.svelte']); + +function extFromFilePath(filePath) { + return filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : ''; +} + +function shouldRunPageAnalyzers(content, filePath) { + if (!isFullPage(content)) return false; + const ext = extFromFilePath(filePath); + return !ext || PAGE_ANALYZER_EXTS.has(ext); +} + function isNeutralBorderColor(str) { const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i); if (!m) return false; @@ -422,7 +434,7 @@ const TEXT_CONTENT_ANALYZER_IDS = [ function runTextContentAnalyzers(content, filePath, options = {}) { const profile = options?.profile; - if (!isFullPage(content)) return []; + if (!shouldRunPageAnalyzers(content, filePath)) return []; // The 4 text-content analyzers are at indices 3-6 in REGEX_ANALYZERS. const findings = []; for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { @@ -442,7 +454,7 @@ function detectText(content, filePath, options = {}) { const profile = options?.profile; const findings = []; const lines = content.split('\n'); - const ext = filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : ''; + const ext = extFromFilePath(filePath); // Run regex matchers on the full file content (catches Tailwind classes, inline styles) // Enable block context for CSS files where related properties span multiple lines @@ -498,7 +510,7 @@ function detectText(content, filePath, options = {}) { } // Page-level analyzers only run on full pages - if (isFullPage(content)) { + if (shouldRunPageAnalyzers(content, filePath)) { const analyzerIds = [ 'single-font', 'flat-type-hierarchy', diff --git a/.claude/skills/impeccable/scripts/live-browser.js b/.claude/skills/impeccable/scripts/live-browser.js index 539cd07d9..221fed9c6 100644 --- a/.claude/skills/impeccable/scripts/live-browser.js +++ b/.claude/skills/impeccable/scripts/live-browser.js @@ -287,7 +287,7 @@ } function shouldShowHighlightTagTooltip() { - // Configure/edit carry the tag in the bar selection pill — keep only the outline. + // Configure/edit carry the tag in the bar selection pill, so keep only the outline. return state !== 'CONFIGURING' && state !== 'EDITING'; } @@ -1148,7 +1148,7 @@ syncPageChatFocus('update-bar-content'); } - // Configure row — the floating bar surface IS the input; modifier pills sit left of the field. + // Configure row: the floating bar surface IS the input; modifier pills sit left of the field. const CONFIGURE_BAR_H = '36px'; // Compact selection pill + 7px inset balances vertical centering in the 36px bar. @@ -1519,7 +1519,7 @@ function buildConfigureCountControl({ controlsLocked, onClick }) { const count = el('button', configureInlineControlStyle({ - fontFamily: MONO, fontWeight: '600', letterSpacing: '-0.02em', + fontFamily: MONO, fontWeight: '600', letterSpacing: '0', })); count.textContent = '\u00D7' + selectedCount; count.disabled = controlsLocked; @@ -5065,6 +5065,157 @@ } } + async function loadSvelteComponentVariantSource(manifest, variantNum) { + const dir = String(manifest?.componentDir || '').replace(/^\/+/, ''); + if (!dir || !variantNum) return ''; + const sourcePath = dir + '/v' + variantNum + '.svelte'; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(sourcePath); + try { + const res = await fetch(url); + if (!res.ok) return ''; + return await res.text(); + } catch { + return ''; + } + } + + function extractSvelteComponentStyle(source) { + const match = String(source || '').match(/]*>([\s\S]*?)<\/style\s*>/i); + return match ? match[1].trim() : ''; + } + + async function applySvelteComponentVariantStyle(variantNum) { + if (!svelteComponentSession || !variantNum) return; + const { manifest, sessionId } = svelteComponentSession; + const source = await loadSvelteComponentVariantSource(manifest, variantNum); + const css = extractSvelteComponentStyle(source); + removeSvelteComponentVariantStyle(svelteComponentSession); + if (!css) return; + const scopedCss = scopeCssToSveltePreview(css, sessionId); + if (!scopedCss) return; + const style = document.createElement('style'); + style.dataset.impeccableSvelteComponentStyle = sessionId; + style.dataset.impeccableVariant = String(variantNum); + style.textContent = scopedCss; + document.head.appendChild(style); + svelteComponentSession.styleEl = style; + } + + function removeSvelteComponentVariantStyle(session = svelteComponentSession) { + const style = session?.styleEl; + if (style?.parentNode) style.parentNode.removeChild(style); + if (session) session.styleEl = null; + } + + function scopeCssToSveltePreview(css, sessionId) { + const prefix = '[data-impeccable-variants="' + String(sessionId).replace(/"/g, '\\"') + '"] '; + return scopeCssBlock(String(css || ''), prefix).trim(); + } + + function scopeCssBlock(css, prefix) { + let out = ''; + let i = 0; + while (i < css.length) { + const open = css.indexOf('{', i); + if (open === -1) { + out += css.slice(i); + break; + } + const semi = css.indexOf(';', i); + if (semi !== -1 && semi < open) { + out += css.slice(i, semi + 1); + i = semi + 1; + continue; + } + const prelude = css.slice(i, open).trim(); + const close = findMatchingCssBrace(css, open); + if (close === -1) { + out += css.slice(i); + break; + } + const body = css.slice(open + 1, close); + if (shouldScopeNestedCssAtRule(prelude)) { + out += prelude + ' {\n' + scopeCssBlock(body, prefix) + '\n}'; + } else if (prelude.startsWith('@')) { + out += prelude + ' {' + body + '}'; + } else { + out += prefixCssSelectors(prelude, prefix) + ' {' + body + '}'; + } + i = close + 1; + } + return out; + } + + function shouldScopeNestedCssAtRule(prelude) { + return /^@(media|supports|container|layer)\b/i.test(prelude || ''); + } + + function findMatchingCssBrace(css, openIndex) { + let depth = 0; + let quote = ''; + for (let i = openIndex; i < css.length; i++) { + const ch = css[i]; + const prev = css[i - 1]; + if (quote) { + if (ch === quote && prev !== '\\') quote = ''; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + } else if (ch === '{') { + depth++; + } else if (ch === '}') { + depth--; + if (depth === 0) return i; + } + } + return -1; + } + + function prefixCssSelectors(prelude, prefix) { + return splitCssSelectorList(prelude) + .map((selector) => { + const s = unwrapSvelteGlobalSelector(selector.trim()); + if (!s) return ''; + if (s.startsWith(prefix.trim())) return s; + if (s.startsWith(':host')) return s.replace(/^:host\b/, prefix.trim()); + return prefix + s; + }) + .filter(Boolean) + .join(', '); + } + + function splitCssSelectorList(selectorList) { + const selectors = []; + let start = 0; + let depth = 0; + let quote = ''; + for (let i = 0; i < selectorList.length; i++) { + const ch = selectorList[i]; + const prev = selectorList[i - 1]; + if (quote) { + if (ch === quote && prev !== '\\') quote = ''; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + } else if (ch === '(' || ch === '[') { + depth++; + } else if ((ch === ')' || ch === ']') && depth > 0) { + depth--; + } else if (ch === ',' && depth === 0) { + selectors.push(selectorList.slice(start, i)); + start = i + 1; + } + } + selectors.push(selectorList.slice(start)); + return selectors; + } + + function unwrapSvelteGlobalSelector(selector) { + return selector.replace(/:global\(([^()]*)\)/g, '$1'); + } + function buildSveltePropValuesFromLiveElement(liveEl, manifest) { const contract = manifest?.propContract || []; const values = {}; @@ -5101,6 +5252,7 @@ }); svelteComponentSession.mountedVariant = variantNum; svelteComponentSession.runtime = runtime; + await applySvelteComponentVariantStyle(variantNum); if (state === 'CYCLING') syncCyclingControls(); const nextAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); if (nextAnchor) { @@ -5134,6 +5286,7 @@ function teardownSvelteComponentSession(restoreOriginal) { if (!svelteComponentSession) return; const { wrapperEl, detachedOriginal, runtime, mountedInstance } = svelteComponentSession; + removeSvelteComponentVariantStyle(svelteComponentSession); if (mountedInstance && runtime?.unmount) { try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } } @@ -5173,6 +5326,7 @@ if (mountedInstance && runtime?.unmount) { try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } } + removeSvelteComponentVariantStyle(svelteComponentSession); wrapperEl.parentElement.replaceChild(committed, wrapperEl); svelteComponentSession = null; svelteRuntimePromise = null; @@ -8843,7 +8997,7 @@ void main() { cursor: 'pointer', flexShrink: '0', width: PAGE_CHAT_COLLAPSED_W, - transition: 'width 0.18s ease, border-color 0.15s ease', + transition: 'border-color 0.15s ease', }); pageChatEl.id = PREFIX + '-page-chat'; pageChatEl.dataset.expanded = 'false'; diff --git a/.cursor/skills/impeccable/SKILL.md b/.cursor/skills/impeccable/SKILL.md index 3ec0bb2ae..c3914a465 100644 --- a/.cursor/skills/impeccable/SKILL.md +++ b/.cursor/skills/impeccable/SKILL.md @@ -1,7 +1,7 @@ --- 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: 3.5.0 +version: 3.6.0 license: Apache 2.0 --- 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 4c0777c93..a72f93e27 100644 --- a/.cursor/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.cursor/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -23,6 +23,18 @@ function stripHtmlToText(html) { .replace(/\s+/g, ' '); } +const PAGE_ANALYZER_EXTS = new Set(['.html', '.htm', '.astro', '.vue', '.svelte']); + +function extFromFilePath(filePath) { + return filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : ''; +} + +function shouldRunPageAnalyzers(content, filePath) { + if (!isFullPage(content)) return false; + const ext = extFromFilePath(filePath); + return !ext || PAGE_ANALYZER_EXTS.has(ext); +} + function isNeutralBorderColor(str) { const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i); if (!m) return false; @@ -422,7 +434,7 @@ const TEXT_CONTENT_ANALYZER_IDS = [ function runTextContentAnalyzers(content, filePath, options = {}) { const profile = options?.profile; - if (!isFullPage(content)) return []; + if (!shouldRunPageAnalyzers(content, filePath)) return []; // The 4 text-content analyzers are at indices 3-6 in REGEX_ANALYZERS. const findings = []; for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { @@ -442,7 +454,7 @@ function detectText(content, filePath, options = {}) { const profile = options?.profile; const findings = []; const lines = content.split('\n'); - const ext = filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : ''; + const ext = extFromFilePath(filePath); // Run regex matchers on the full file content (catches Tailwind classes, inline styles) // Enable block context for CSS files where related properties span multiple lines @@ -498,7 +510,7 @@ function detectText(content, filePath, options = {}) { } // Page-level analyzers only run on full pages - if (isFullPage(content)) { + if (shouldRunPageAnalyzers(content, filePath)) { const analyzerIds = [ 'single-font', 'flat-type-hierarchy', diff --git a/.cursor/skills/impeccable/scripts/live-browser.js b/.cursor/skills/impeccable/scripts/live-browser.js index 539cd07d9..221fed9c6 100644 --- a/.cursor/skills/impeccable/scripts/live-browser.js +++ b/.cursor/skills/impeccable/scripts/live-browser.js @@ -287,7 +287,7 @@ } function shouldShowHighlightTagTooltip() { - // Configure/edit carry the tag in the bar selection pill — keep only the outline. + // Configure/edit carry the tag in the bar selection pill, so keep only the outline. return state !== 'CONFIGURING' && state !== 'EDITING'; } @@ -1148,7 +1148,7 @@ syncPageChatFocus('update-bar-content'); } - // Configure row — the floating bar surface IS the input; modifier pills sit left of the field. + // Configure row: the floating bar surface IS the input; modifier pills sit left of the field. const CONFIGURE_BAR_H = '36px'; // Compact selection pill + 7px inset balances vertical centering in the 36px bar. @@ -1519,7 +1519,7 @@ function buildConfigureCountControl({ controlsLocked, onClick }) { const count = el('button', configureInlineControlStyle({ - fontFamily: MONO, fontWeight: '600', letterSpacing: '-0.02em', + fontFamily: MONO, fontWeight: '600', letterSpacing: '0', })); count.textContent = '\u00D7' + selectedCount; count.disabled = controlsLocked; @@ -5065,6 +5065,157 @@ } } + async function loadSvelteComponentVariantSource(manifest, variantNum) { + const dir = String(manifest?.componentDir || '').replace(/^\/+/, ''); + if (!dir || !variantNum) return ''; + const sourcePath = dir + '/v' + variantNum + '.svelte'; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(sourcePath); + try { + const res = await fetch(url); + if (!res.ok) return ''; + return await res.text(); + } catch { + return ''; + } + } + + function extractSvelteComponentStyle(source) { + const match = String(source || '').match(/]*>([\s\S]*?)<\/style\s*>/i); + return match ? match[1].trim() : ''; + } + + async function applySvelteComponentVariantStyle(variantNum) { + if (!svelteComponentSession || !variantNum) return; + const { manifest, sessionId } = svelteComponentSession; + const source = await loadSvelteComponentVariantSource(manifest, variantNum); + const css = extractSvelteComponentStyle(source); + removeSvelteComponentVariantStyle(svelteComponentSession); + if (!css) return; + const scopedCss = scopeCssToSveltePreview(css, sessionId); + if (!scopedCss) return; + const style = document.createElement('style'); + style.dataset.impeccableSvelteComponentStyle = sessionId; + style.dataset.impeccableVariant = String(variantNum); + style.textContent = scopedCss; + document.head.appendChild(style); + svelteComponentSession.styleEl = style; + } + + function removeSvelteComponentVariantStyle(session = svelteComponentSession) { + const style = session?.styleEl; + if (style?.parentNode) style.parentNode.removeChild(style); + if (session) session.styleEl = null; + } + + function scopeCssToSveltePreview(css, sessionId) { + const prefix = '[data-impeccable-variants="' + String(sessionId).replace(/"/g, '\\"') + '"] '; + return scopeCssBlock(String(css || ''), prefix).trim(); + } + + function scopeCssBlock(css, prefix) { + let out = ''; + let i = 0; + while (i < css.length) { + const open = css.indexOf('{', i); + if (open === -1) { + out += css.slice(i); + break; + } + const semi = css.indexOf(';', i); + if (semi !== -1 && semi < open) { + out += css.slice(i, semi + 1); + i = semi + 1; + continue; + } + const prelude = css.slice(i, open).trim(); + const close = findMatchingCssBrace(css, open); + if (close === -1) { + out += css.slice(i); + break; + } + const body = css.slice(open + 1, close); + if (shouldScopeNestedCssAtRule(prelude)) { + out += prelude + ' {\n' + scopeCssBlock(body, prefix) + '\n}'; + } else if (prelude.startsWith('@')) { + out += prelude + ' {' + body + '}'; + } else { + out += prefixCssSelectors(prelude, prefix) + ' {' + body + '}'; + } + i = close + 1; + } + return out; + } + + function shouldScopeNestedCssAtRule(prelude) { + return /^@(media|supports|container|layer)\b/i.test(prelude || ''); + } + + function findMatchingCssBrace(css, openIndex) { + let depth = 0; + let quote = ''; + for (let i = openIndex; i < css.length; i++) { + const ch = css[i]; + const prev = css[i - 1]; + if (quote) { + if (ch === quote && prev !== '\\') quote = ''; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + } else if (ch === '{') { + depth++; + } else if (ch === '}') { + depth--; + if (depth === 0) return i; + } + } + return -1; + } + + function prefixCssSelectors(prelude, prefix) { + return splitCssSelectorList(prelude) + .map((selector) => { + const s = unwrapSvelteGlobalSelector(selector.trim()); + if (!s) return ''; + if (s.startsWith(prefix.trim())) return s; + if (s.startsWith(':host')) return s.replace(/^:host\b/, prefix.trim()); + return prefix + s; + }) + .filter(Boolean) + .join(', '); + } + + function splitCssSelectorList(selectorList) { + const selectors = []; + let start = 0; + let depth = 0; + let quote = ''; + for (let i = 0; i < selectorList.length; i++) { + const ch = selectorList[i]; + const prev = selectorList[i - 1]; + if (quote) { + if (ch === quote && prev !== '\\') quote = ''; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + } else if (ch === '(' || ch === '[') { + depth++; + } else if ((ch === ')' || ch === ']') && depth > 0) { + depth--; + } else if (ch === ',' && depth === 0) { + selectors.push(selectorList.slice(start, i)); + start = i + 1; + } + } + selectors.push(selectorList.slice(start)); + return selectors; + } + + function unwrapSvelteGlobalSelector(selector) { + return selector.replace(/:global\(([^()]*)\)/g, '$1'); + } + function buildSveltePropValuesFromLiveElement(liveEl, manifest) { const contract = manifest?.propContract || []; const values = {}; @@ -5101,6 +5252,7 @@ }); svelteComponentSession.mountedVariant = variantNum; svelteComponentSession.runtime = runtime; + await applySvelteComponentVariantStyle(variantNum); if (state === 'CYCLING') syncCyclingControls(); const nextAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); if (nextAnchor) { @@ -5134,6 +5286,7 @@ function teardownSvelteComponentSession(restoreOriginal) { if (!svelteComponentSession) return; const { wrapperEl, detachedOriginal, runtime, mountedInstance } = svelteComponentSession; + removeSvelteComponentVariantStyle(svelteComponentSession); if (mountedInstance && runtime?.unmount) { try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } } @@ -5173,6 +5326,7 @@ if (mountedInstance && runtime?.unmount) { try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } } + removeSvelteComponentVariantStyle(svelteComponentSession); wrapperEl.parentElement.replaceChild(committed, wrapperEl); svelteComponentSession = null; svelteRuntimePromise = null; @@ -8843,7 +8997,7 @@ void main() { cursor: 'pointer', flexShrink: '0', width: PAGE_CHAT_COLLAPSED_W, - transition: 'width 0.18s ease, border-color 0.15s ease', + transition: 'border-color 0.15s ease', }); pageChatEl.id = PREFIX + '-page-chat'; pageChatEl.dataset.expanded = 'false'; diff --git a/.gemini/skills/impeccable/SKILL.md b/.gemini/skills/impeccable/SKILL.md index 50d3db7b2..2fa46bf77 100644 --- a/.gemini/skills/impeccable/SKILL.md +++ b/.gemini/skills/impeccable/SKILL.md @@ -1,7 +1,7 @@ --- 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: 3.5.0 +version: 3.6.0 --- Designs and iterates production-grade frontend interfaces. Real working code, committed design choices, exceptional craft. 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 4c0777c93..a72f93e27 100644 --- a/.gemini/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.gemini/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -23,6 +23,18 @@ function stripHtmlToText(html) { .replace(/\s+/g, ' '); } +const PAGE_ANALYZER_EXTS = new Set(['.html', '.htm', '.astro', '.vue', '.svelte']); + +function extFromFilePath(filePath) { + return filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : ''; +} + +function shouldRunPageAnalyzers(content, filePath) { + if (!isFullPage(content)) return false; + const ext = extFromFilePath(filePath); + return !ext || PAGE_ANALYZER_EXTS.has(ext); +} + function isNeutralBorderColor(str) { const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i); if (!m) return false; @@ -422,7 +434,7 @@ const TEXT_CONTENT_ANALYZER_IDS = [ function runTextContentAnalyzers(content, filePath, options = {}) { const profile = options?.profile; - if (!isFullPage(content)) return []; + if (!shouldRunPageAnalyzers(content, filePath)) return []; // The 4 text-content analyzers are at indices 3-6 in REGEX_ANALYZERS. const findings = []; for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { @@ -442,7 +454,7 @@ function detectText(content, filePath, options = {}) { const profile = options?.profile; const findings = []; const lines = content.split('\n'); - const ext = filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : ''; + const ext = extFromFilePath(filePath); // Run regex matchers on the full file content (catches Tailwind classes, inline styles) // Enable block context for CSS files where related properties span multiple lines @@ -498,7 +510,7 @@ function detectText(content, filePath, options = {}) { } // Page-level analyzers only run on full pages - if (isFullPage(content)) { + if (shouldRunPageAnalyzers(content, filePath)) { const analyzerIds = [ 'single-font', 'flat-type-hierarchy', diff --git a/.gemini/skills/impeccable/scripts/live-browser.js b/.gemini/skills/impeccable/scripts/live-browser.js index 539cd07d9..221fed9c6 100644 --- a/.gemini/skills/impeccable/scripts/live-browser.js +++ b/.gemini/skills/impeccable/scripts/live-browser.js @@ -287,7 +287,7 @@ } function shouldShowHighlightTagTooltip() { - // Configure/edit carry the tag in the bar selection pill — keep only the outline. + // Configure/edit carry the tag in the bar selection pill, so keep only the outline. return state !== 'CONFIGURING' && state !== 'EDITING'; } @@ -1148,7 +1148,7 @@ syncPageChatFocus('update-bar-content'); } - // Configure row — the floating bar surface IS the input; modifier pills sit left of the field. + // Configure row: the floating bar surface IS the input; modifier pills sit left of the field. const CONFIGURE_BAR_H = '36px'; // Compact selection pill + 7px inset balances vertical centering in the 36px bar. @@ -1519,7 +1519,7 @@ function buildConfigureCountControl({ controlsLocked, onClick }) { const count = el('button', configureInlineControlStyle({ - fontFamily: MONO, fontWeight: '600', letterSpacing: '-0.02em', + fontFamily: MONO, fontWeight: '600', letterSpacing: '0', })); count.textContent = '\u00D7' + selectedCount; count.disabled = controlsLocked; @@ -5065,6 +5065,157 @@ } } + async function loadSvelteComponentVariantSource(manifest, variantNum) { + const dir = String(manifest?.componentDir || '').replace(/^\/+/, ''); + if (!dir || !variantNum) return ''; + const sourcePath = dir + '/v' + variantNum + '.svelte'; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(sourcePath); + try { + const res = await fetch(url); + if (!res.ok) return ''; + return await res.text(); + } catch { + return ''; + } + } + + function extractSvelteComponentStyle(source) { + const match = String(source || '').match(/]*>([\s\S]*?)<\/style\s*>/i); + return match ? match[1].trim() : ''; + } + + async function applySvelteComponentVariantStyle(variantNum) { + if (!svelteComponentSession || !variantNum) return; + const { manifest, sessionId } = svelteComponentSession; + const source = await loadSvelteComponentVariantSource(manifest, variantNum); + const css = extractSvelteComponentStyle(source); + removeSvelteComponentVariantStyle(svelteComponentSession); + if (!css) return; + const scopedCss = scopeCssToSveltePreview(css, sessionId); + if (!scopedCss) return; + const style = document.createElement('style'); + style.dataset.impeccableSvelteComponentStyle = sessionId; + style.dataset.impeccableVariant = String(variantNum); + style.textContent = scopedCss; + document.head.appendChild(style); + svelteComponentSession.styleEl = style; + } + + function removeSvelteComponentVariantStyle(session = svelteComponentSession) { + const style = session?.styleEl; + if (style?.parentNode) style.parentNode.removeChild(style); + if (session) session.styleEl = null; + } + + function scopeCssToSveltePreview(css, sessionId) { + const prefix = '[data-impeccable-variants="' + String(sessionId).replace(/"/g, '\\"') + '"] '; + return scopeCssBlock(String(css || ''), prefix).trim(); + } + + function scopeCssBlock(css, prefix) { + let out = ''; + let i = 0; + while (i < css.length) { + const open = css.indexOf('{', i); + if (open === -1) { + out += css.slice(i); + break; + } + const semi = css.indexOf(';', i); + if (semi !== -1 && semi < open) { + out += css.slice(i, semi + 1); + i = semi + 1; + continue; + } + const prelude = css.slice(i, open).trim(); + const close = findMatchingCssBrace(css, open); + if (close === -1) { + out += css.slice(i); + break; + } + const body = css.slice(open + 1, close); + if (shouldScopeNestedCssAtRule(prelude)) { + out += prelude + ' {\n' + scopeCssBlock(body, prefix) + '\n}'; + } else if (prelude.startsWith('@')) { + out += prelude + ' {' + body + '}'; + } else { + out += prefixCssSelectors(prelude, prefix) + ' {' + body + '}'; + } + i = close + 1; + } + return out; + } + + function shouldScopeNestedCssAtRule(prelude) { + return /^@(media|supports|container|layer)\b/i.test(prelude || ''); + } + + function findMatchingCssBrace(css, openIndex) { + let depth = 0; + let quote = ''; + for (let i = openIndex; i < css.length; i++) { + const ch = css[i]; + const prev = css[i - 1]; + if (quote) { + if (ch === quote && prev !== '\\') quote = ''; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + } else if (ch === '{') { + depth++; + } else if (ch === '}') { + depth--; + if (depth === 0) return i; + } + } + return -1; + } + + function prefixCssSelectors(prelude, prefix) { + return splitCssSelectorList(prelude) + .map((selector) => { + const s = unwrapSvelteGlobalSelector(selector.trim()); + if (!s) return ''; + if (s.startsWith(prefix.trim())) return s; + if (s.startsWith(':host')) return s.replace(/^:host\b/, prefix.trim()); + return prefix + s; + }) + .filter(Boolean) + .join(', '); + } + + function splitCssSelectorList(selectorList) { + const selectors = []; + let start = 0; + let depth = 0; + let quote = ''; + for (let i = 0; i < selectorList.length; i++) { + const ch = selectorList[i]; + const prev = selectorList[i - 1]; + if (quote) { + if (ch === quote && prev !== '\\') quote = ''; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + } else if (ch === '(' || ch === '[') { + depth++; + } else if ((ch === ')' || ch === ']') && depth > 0) { + depth--; + } else if (ch === ',' && depth === 0) { + selectors.push(selectorList.slice(start, i)); + start = i + 1; + } + } + selectors.push(selectorList.slice(start)); + return selectors; + } + + function unwrapSvelteGlobalSelector(selector) { + return selector.replace(/:global\(([^()]*)\)/g, '$1'); + } + function buildSveltePropValuesFromLiveElement(liveEl, manifest) { const contract = manifest?.propContract || []; const values = {}; @@ -5101,6 +5252,7 @@ }); svelteComponentSession.mountedVariant = variantNum; svelteComponentSession.runtime = runtime; + await applySvelteComponentVariantStyle(variantNum); if (state === 'CYCLING') syncCyclingControls(); const nextAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); if (nextAnchor) { @@ -5134,6 +5286,7 @@ function teardownSvelteComponentSession(restoreOriginal) { if (!svelteComponentSession) return; const { wrapperEl, detachedOriginal, runtime, mountedInstance } = svelteComponentSession; + removeSvelteComponentVariantStyle(svelteComponentSession); if (mountedInstance && runtime?.unmount) { try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } } @@ -5173,6 +5326,7 @@ if (mountedInstance && runtime?.unmount) { try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } } + removeSvelteComponentVariantStyle(svelteComponentSession); wrapperEl.parentElement.replaceChild(committed, wrapperEl); svelteComponentSession = null; svelteRuntimePromise = null; @@ -8843,7 +8997,7 @@ void main() { cursor: 'pointer', flexShrink: '0', width: PAGE_CHAT_COLLAPSED_W, - transition: 'width 0.18s ease, border-color 0.15s ease', + transition: 'border-color 0.15s ease', }); pageChatEl.id = PREFIX + '-page-chat'; pageChatEl.dataset.expanded = 'false'; diff --git a/.github/skills/impeccable/SKILL.md b/.github/skills/impeccable/SKILL.md index 25fc8b4de..899ea7f00 100644 --- a/.github/skills/impeccable/SKILL.md +++ b/.github/skills/impeccable/SKILL.md @@ -1,7 +1,7 @@ --- 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: 3.5.0 +version: 3.6.0 user-invocable: true argument-hint: "[craft|shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]" license: Apache 2.0 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 4c0777c93..a72f93e27 100644 --- a/.github/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.github/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -23,6 +23,18 @@ function stripHtmlToText(html) { .replace(/\s+/g, ' '); } +const PAGE_ANALYZER_EXTS = new Set(['.html', '.htm', '.astro', '.vue', '.svelte']); + +function extFromFilePath(filePath) { + return filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : ''; +} + +function shouldRunPageAnalyzers(content, filePath) { + if (!isFullPage(content)) return false; + const ext = extFromFilePath(filePath); + return !ext || PAGE_ANALYZER_EXTS.has(ext); +} + function isNeutralBorderColor(str) { const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i); if (!m) return false; @@ -422,7 +434,7 @@ const TEXT_CONTENT_ANALYZER_IDS = [ function runTextContentAnalyzers(content, filePath, options = {}) { const profile = options?.profile; - if (!isFullPage(content)) return []; + if (!shouldRunPageAnalyzers(content, filePath)) return []; // The 4 text-content analyzers are at indices 3-6 in REGEX_ANALYZERS. const findings = []; for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { @@ -442,7 +454,7 @@ function detectText(content, filePath, options = {}) { const profile = options?.profile; const findings = []; const lines = content.split('\n'); - const ext = filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : ''; + const ext = extFromFilePath(filePath); // Run regex matchers on the full file content (catches Tailwind classes, inline styles) // Enable block context for CSS files where related properties span multiple lines @@ -498,7 +510,7 @@ function detectText(content, filePath, options = {}) { } // Page-level analyzers only run on full pages - if (isFullPage(content)) { + if (shouldRunPageAnalyzers(content, filePath)) { const analyzerIds = [ 'single-font', 'flat-type-hierarchy', diff --git a/.github/skills/impeccable/scripts/live-browser.js b/.github/skills/impeccable/scripts/live-browser.js index 539cd07d9..221fed9c6 100644 --- a/.github/skills/impeccable/scripts/live-browser.js +++ b/.github/skills/impeccable/scripts/live-browser.js @@ -287,7 +287,7 @@ } function shouldShowHighlightTagTooltip() { - // Configure/edit carry the tag in the bar selection pill — keep only the outline. + // Configure/edit carry the tag in the bar selection pill, so keep only the outline. return state !== 'CONFIGURING' && state !== 'EDITING'; } @@ -1148,7 +1148,7 @@ syncPageChatFocus('update-bar-content'); } - // Configure row — the floating bar surface IS the input; modifier pills sit left of the field. + // Configure row: the floating bar surface IS the input; modifier pills sit left of the field. const CONFIGURE_BAR_H = '36px'; // Compact selection pill + 7px inset balances vertical centering in the 36px bar. @@ -1519,7 +1519,7 @@ function buildConfigureCountControl({ controlsLocked, onClick }) { const count = el('button', configureInlineControlStyle({ - fontFamily: MONO, fontWeight: '600', letterSpacing: '-0.02em', + fontFamily: MONO, fontWeight: '600', letterSpacing: '0', })); count.textContent = '\u00D7' + selectedCount; count.disabled = controlsLocked; @@ -5065,6 +5065,157 @@ } } + async function loadSvelteComponentVariantSource(manifest, variantNum) { + const dir = String(manifest?.componentDir || '').replace(/^\/+/, ''); + if (!dir || !variantNum) return ''; + const sourcePath = dir + '/v' + variantNum + '.svelte'; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(sourcePath); + try { + const res = await fetch(url); + if (!res.ok) return ''; + return await res.text(); + } catch { + return ''; + } + } + + function extractSvelteComponentStyle(source) { + const match = String(source || '').match(/]*>([\s\S]*?)<\/style\s*>/i); + return match ? match[1].trim() : ''; + } + + async function applySvelteComponentVariantStyle(variantNum) { + if (!svelteComponentSession || !variantNum) return; + const { manifest, sessionId } = svelteComponentSession; + const source = await loadSvelteComponentVariantSource(manifest, variantNum); + const css = extractSvelteComponentStyle(source); + removeSvelteComponentVariantStyle(svelteComponentSession); + if (!css) return; + const scopedCss = scopeCssToSveltePreview(css, sessionId); + if (!scopedCss) return; + const style = document.createElement('style'); + style.dataset.impeccableSvelteComponentStyle = sessionId; + style.dataset.impeccableVariant = String(variantNum); + style.textContent = scopedCss; + document.head.appendChild(style); + svelteComponentSession.styleEl = style; + } + + function removeSvelteComponentVariantStyle(session = svelteComponentSession) { + const style = session?.styleEl; + if (style?.parentNode) style.parentNode.removeChild(style); + if (session) session.styleEl = null; + } + + function scopeCssToSveltePreview(css, sessionId) { + const prefix = '[data-impeccable-variants="' + String(sessionId).replace(/"/g, '\\"') + '"] '; + return scopeCssBlock(String(css || ''), prefix).trim(); + } + + function scopeCssBlock(css, prefix) { + let out = ''; + let i = 0; + while (i < css.length) { + const open = css.indexOf('{', i); + if (open === -1) { + out += css.slice(i); + break; + } + const semi = css.indexOf(';', i); + if (semi !== -1 && semi < open) { + out += css.slice(i, semi + 1); + i = semi + 1; + continue; + } + const prelude = css.slice(i, open).trim(); + const close = findMatchingCssBrace(css, open); + if (close === -1) { + out += css.slice(i); + break; + } + const body = css.slice(open + 1, close); + if (shouldScopeNestedCssAtRule(prelude)) { + out += prelude + ' {\n' + scopeCssBlock(body, prefix) + '\n}'; + } else if (prelude.startsWith('@')) { + out += prelude + ' {' + body + '}'; + } else { + out += prefixCssSelectors(prelude, prefix) + ' {' + body + '}'; + } + i = close + 1; + } + return out; + } + + function shouldScopeNestedCssAtRule(prelude) { + return /^@(media|supports|container|layer)\b/i.test(prelude || ''); + } + + function findMatchingCssBrace(css, openIndex) { + let depth = 0; + let quote = ''; + for (let i = openIndex; i < css.length; i++) { + const ch = css[i]; + const prev = css[i - 1]; + if (quote) { + if (ch === quote && prev !== '\\') quote = ''; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + } else if (ch === '{') { + depth++; + } else if (ch === '}') { + depth--; + if (depth === 0) return i; + } + } + return -1; + } + + function prefixCssSelectors(prelude, prefix) { + return splitCssSelectorList(prelude) + .map((selector) => { + const s = unwrapSvelteGlobalSelector(selector.trim()); + if (!s) return ''; + if (s.startsWith(prefix.trim())) return s; + if (s.startsWith(':host')) return s.replace(/^:host\b/, prefix.trim()); + return prefix + s; + }) + .filter(Boolean) + .join(', '); + } + + function splitCssSelectorList(selectorList) { + const selectors = []; + let start = 0; + let depth = 0; + let quote = ''; + for (let i = 0; i < selectorList.length; i++) { + const ch = selectorList[i]; + const prev = selectorList[i - 1]; + if (quote) { + if (ch === quote && prev !== '\\') quote = ''; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + } else if (ch === '(' || ch === '[') { + depth++; + } else if ((ch === ')' || ch === ']') && depth > 0) { + depth--; + } else if (ch === ',' && depth === 0) { + selectors.push(selectorList.slice(start, i)); + start = i + 1; + } + } + selectors.push(selectorList.slice(start)); + return selectors; + } + + function unwrapSvelteGlobalSelector(selector) { + return selector.replace(/:global\(([^()]*)\)/g, '$1'); + } + function buildSveltePropValuesFromLiveElement(liveEl, manifest) { const contract = manifest?.propContract || []; const values = {}; @@ -5101,6 +5252,7 @@ }); svelteComponentSession.mountedVariant = variantNum; svelteComponentSession.runtime = runtime; + await applySvelteComponentVariantStyle(variantNum); if (state === 'CYCLING') syncCyclingControls(); const nextAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); if (nextAnchor) { @@ -5134,6 +5286,7 @@ function teardownSvelteComponentSession(restoreOriginal) { if (!svelteComponentSession) return; const { wrapperEl, detachedOriginal, runtime, mountedInstance } = svelteComponentSession; + removeSvelteComponentVariantStyle(svelteComponentSession); if (mountedInstance && runtime?.unmount) { try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } } @@ -5173,6 +5326,7 @@ if (mountedInstance && runtime?.unmount) { try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } } + removeSvelteComponentVariantStyle(svelteComponentSession); wrapperEl.parentElement.replaceChild(committed, wrapperEl); svelteComponentSession = null; svelteRuntimePromise = null; @@ -8843,7 +8997,7 @@ void main() { cursor: 'pointer', flexShrink: '0', width: PAGE_CHAT_COLLAPSED_W, - transition: 'width 0.18s ease, border-color 0.15s ease', + transition: 'border-color 0.15s ease', }); pageChatEl.id = PREFIX + '-page-chat'; pageChatEl.dataset.expanded = 'false'; diff --git a/.impeccable/config.json b/.impeccable/config.json index 04079b9f4..09c079955 100644 --- a/.impeccable/config.json +++ b/.impeccable/config.json @@ -4,6 +4,7 @@ "ignoreRules": [], "ignoreFiles": [ "tests/fixtures/**", + "tests/detect-antipatterns.test.js", "site/pages/slop/**" ], "ignoreValues": [], diff --git a/.kiro/skills/impeccable/SKILL.md b/.kiro/skills/impeccable/SKILL.md index 23fcd684a..a737ffd16 100644 --- a/.kiro/skills/impeccable/SKILL.md +++ b/.kiro/skills/impeccable/SKILL.md @@ -1,7 +1,7 @@ --- 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: 3.5.0 +version: 3.6.0 license: Apache 2.0 --- 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 4c0777c93..a72f93e27 100644 --- a/.kiro/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.kiro/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -23,6 +23,18 @@ function stripHtmlToText(html) { .replace(/\s+/g, ' '); } +const PAGE_ANALYZER_EXTS = new Set(['.html', '.htm', '.astro', '.vue', '.svelte']); + +function extFromFilePath(filePath) { + return filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : ''; +} + +function shouldRunPageAnalyzers(content, filePath) { + if (!isFullPage(content)) return false; + const ext = extFromFilePath(filePath); + return !ext || PAGE_ANALYZER_EXTS.has(ext); +} + function isNeutralBorderColor(str) { const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i); if (!m) return false; @@ -422,7 +434,7 @@ const TEXT_CONTENT_ANALYZER_IDS = [ function runTextContentAnalyzers(content, filePath, options = {}) { const profile = options?.profile; - if (!isFullPage(content)) return []; + if (!shouldRunPageAnalyzers(content, filePath)) return []; // The 4 text-content analyzers are at indices 3-6 in REGEX_ANALYZERS. const findings = []; for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { @@ -442,7 +454,7 @@ function detectText(content, filePath, options = {}) { const profile = options?.profile; const findings = []; const lines = content.split('\n'); - const ext = filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : ''; + const ext = extFromFilePath(filePath); // Run regex matchers on the full file content (catches Tailwind classes, inline styles) // Enable block context for CSS files where related properties span multiple lines @@ -498,7 +510,7 @@ function detectText(content, filePath, options = {}) { } // Page-level analyzers only run on full pages - if (isFullPage(content)) { + if (shouldRunPageAnalyzers(content, filePath)) { const analyzerIds = [ 'single-font', 'flat-type-hierarchy', diff --git a/.kiro/skills/impeccable/scripts/live-browser.js b/.kiro/skills/impeccable/scripts/live-browser.js index 539cd07d9..221fed9c6 100644 --- a/.kiro/skills/impeccable/scripts/live-browser.js +++ b/.kiro/skills/impeccable/scripts/live-browser.js @@ -287,7 +287,7 @@ } function shouldShowHighlightTagTooltip() { - // Configure/edit carry the tag in the bar selection pill — keep only the outline. + // Configure/edit carry the tag in the bar selection pill, so keep only the outline. return state !== 'CONFIGURING' && state !== 'EDITING'; } @@ -1148,7 +1148,7 @@ syncPageChatFocus('update-bar-content'); } - // Configure row — the floating bar surface IS the input; modifier pills sit left of the field. + // Configure row: the floating bar surface IS the input; modifier pills sit left of the field. const CONFIGURE_BAR_H = '36px'; // Compact selection pill + 7px inset balances vertical centering in the 36px bar. @@ -1519,7 +1519,7 @@ function buildConfigureCountControl({ controlsLocked, onClick }) { const count = el('button', configureInlineControlStyle({ - fontFamily: MONO, fontWeight: '600', letterSpacing: '-0.02em', + fontFamily: MONO, fontWeight: '600', letterSpacing: '0', })); count.textContent = '\u00D7' + selectedCount; count.disabled = controlsLocked; @@ -5065,6 +5065,157 @@ } } + async function loadSvelteComponentVariantSource(manifest, variantNum) { + const dir = String(manifest?.componentDir || '').replace(/^\/+/, ''); + if (!dir || !variantNum) return ''; + const sourcePath = dir + '/v' + variantNum + '.svelte'; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(sourcePath); + try { + const res = await fetch(url); + if (!res.ok) return ''; + return await res.text(); + } catch { + return ''; + } + } + + function extractSvelteComponentStyle(source) { + const match = String(source || '').match(/]*>([\s\S]*?)<\/style\s*>/i); + return match ? match[1].trim() : ''; + } + + async function applySvelteComponentVariantStyle(variantNum) { + if (!svelteComponentSession || !variantNum) return; + const { manifest, sessionId } = svelteComponentSession; + const source = await loadSvelteComponentVariantSource(manifest, variantNum); + const css = extractSvelteComponentStyle(source); + removeSvelteComponentVariantStyle(svelteComponentSession); + if (!css) return; + const scopedCss = scopeCssToSveltePreview(css, sessionId); + if (!scopedCss) return; + const style = document.createElement('style'); + style.dataset.impeccableSvelteComponentStyle = sessionId; + style.dataset.impeccableVariant = String(variantNum); + style.textContent = scopedCss; + document.head.appendChild(style); + svelteComponentSession.styleEl = style; + } + + function removeSvelteComponentVariantStyle(session = svelteComponentSession) { + const style = session?.styleEl; + if (style?.parentNode) style.parentNode.removeChild(style); + if (session) session.styleEl = null; + } + + function scopeCssToSveltePreview(css, sessionId) { + const prefix = '[data-impeccable-variants="' + String(sessionId).replace(/"/g, '\\"') + '"] '; + return scopeCssBlock(String(css || ''), prefix).trim(); + } + + function scopeCssBlock(css, prefix) { + let out = ''; + let i = 0; + while (i < css.length) { + const open = css.indexOf('{', i); + if (open === -1) { + out += css.slice(i); + break; + } + const semi = css.indexOf(';', i); + if (semi !== -1 && semi < open) { + out += css.slice(i, semi + 1); + i = semi + 1; + continue; + } + const prelude = css.slice(i, open).trim(); + const close = findMatchingCssBrace(css, open); + if (close === -1) { + out += css.slice(i); + break; + } + const body = css.slice(open + 1, close); + if (shouldScopeNestedCssAtRule(prelude)) { + out += prelude + ' {\n' + scopeCssBlock(body, prefix) + '\n}'; + } else if (prelude.startsWith('@')) { + out += prelude + ' {' + body + '}'; + } else { + out += prefixCssSelectors(prelude, prefix) + ' {' + body + '}'; + } + i = close + 1; + } + return out; + } + + function shouldScopeNestedCssAtRule(prelude) { + return /^@(media|supports|container|layer)\b/i.test(prelude || ''); + } + + function findMatchingCssBrace(css, openIndex) { + let depth = 0; + let quote = ''; + for (let i = openIndex; i < css.length; i++) { + const ch = css[i]; + const prev = css[i - 1]; + if (quote) { + if (ch === quote && prev !== '\\') quote = ''; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + } else if (ch === '{') { + depth++; + } else if (ch === '}') { + depth--; + if (depth === 0) return i; + } + } + return -1; + } + + function prefixCssSelectors(prelude, prefix) { + return splitCssSelectorList(prelude) + .map((selector) => { + const s = unwrapSvelteGlobalSelector(selector.trim()); + if (!s) return ''; + if (s.startsWith(prefix.trim())) return s; + if (s.startsWith(':host')) return s.replace(/^:host\b/, prefix.trim()); + return prefix + s; + }) + .filter(Boolean) + .join(', '); + } + + function splitCssSelectorList(selectorList) { + const selectors = []; + let start = 0; + let depth = 0; + let quote = ''; + for (let i = 0; i < selectorList.length; i++) { + const ch = selectorList[i]; + const prev = selectorList[i - 1]; + if (quote) { + if (ch === quote && prev !== '\\') quote = ''; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + } else if (ch === '(' || ch === '[') { + depth++; + } else if ((ch === ')' || ch === ']') && depth > 0) { + depth--; + } else if (ch === ',' && depth === 0) { + selectors.push(selectorList.slice(start, i)); + start = i + 1; + } + } + selectors.push(selectorList.slice(start)); + return selectors; + } + + function unwrapSvelteGlobalSelector(selector) { + return selector.replace(/:global\(([^()]*)\)/g, '$1'); + } + function buildSveltePropValuesFromLiveElement(liveEl, manifest) { const contract = manifest?.propContract || []; const values = {}; @@ -5101,6 +5252,7 @@ }); svelteComponentSession.mountedVariant = variantNum; svelteComponentSession.runtime = runtime; + await applySvelteComponentVariantStyle(variantNum); if (state === 'CYCLING') syncCyclingControls(); const nextAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); if (nextAnchor) { @@ -5134,6 +5286,7 @@ function teardownSvelteComponentSession(restoreOriginal) { if (!svelteComponentSession) return; const { wrapperEl, detachedOriginal, runtime, mountedInstance } = svelteComponentSession; + removeSvelteComponentVariantStyle(svelteComponentSession); if (mountedInstance && runtime?.unmount) { try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } } @@ -5173,6 +5326,7 @@ if (mountedInstance && runtime?.unmount) { try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } } + removeSvelteComponentVariantStyle(svelteComponentSession); wrapperEl.parentElement.replaceChild(committed, wrapperEl); svelteComponentSession = null; svelteRuntimePromise = null; @@ -8843,7 +8997,7 @@ void main() { cursor: 'pointer', flexShrink: '0', width: PAGE_CHAT_COLLAPSED_W, - transition: 'width 0.18s ease, border-color 0.15s ease', + transition: 'border-color 0.15s ease', }); pageChatEl.id = PREFIX + '-page-chat'; pageChatEl.dataset.expanded = 'false'; diff --git a/.opencode/skills/impeccable/SKILL.md b/.opencode/skills/impeccable/SKILL.md index 7dfae0809..60ad163a1 100644 --- a/.opencode/skills/impeccable/SKILL.md +++ b/.opencode/skills/impeccable/SKILL.md @@ -1,7 +1,7 @@ --- 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: 3.5.0 +version: 3.6.0 user-invocable: true argument-hint: "[craft|shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]" license: Apache 2.0 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 4c0777c93..a72f93e27 100644 --- a/.opencode/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.opencode/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -23,6 +23,18 @@ function stripHtmlToText(html) { .replace(/\s+/g, ' '); } +const PAGE_ANALYZER_EXTS = new Set(['.html', '.htm', '.astro', '.vue', '.svelte']); + +function extFromFilePath(filePath) { + return filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : ''; +} + +function shouldRunPageAnalyzers(content, filePath) { + if (!isFullPage(content)) return false; + const ext = extFromFilePath(filePath); + return !ext || PAGE_ANALYZER_EXTS.has(ext); +} + function isNeutralBorderColor(str) { const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i); if (!m) return false; @@ -422,7 +434,7 @@ const TEXT_CONTENT_ANALYZER_IDS = [ function runTextContentAnalyzers(content, filePath, options = {}) { const profile = options?.profile; - if (!isFullPage(content)) return []; + if (!shouldRunPageAnalyzers(content, filePath)) return []; // The 4 text-content analyzers are at indices 3-6 in REGEX_ANALYZERS. const findings = []; for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { @@ -442,7 +454,7 @@ function detectText(content, filePath, options = {}) { const profile = options?.profile; const findings = []; const lines = content.split('\n'); - const ext = filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : ''; + const ext = extFromFilePath(filePath); // Run regex matchers on the full file content (catches Tailwind classes, inline styles) // Enable block context for CSS files where related properties span multiple lines @@ -498,7 +510,7 @@ function detectText(content, filePath, options = {}) { } // Page-level analyzers only run on full pages - if (isFullPage(content)) { + if (shouldRunPageAnalyzers(content, filePath)) { const analyzerIds = [ 'single-font', 'flat-type-hierarchy', diff --git a/.opencode/skills/impeccable/scripts/live-browser.js b/.opencode/skills/impeccable/scripts/live-browser.js index 539cd07d9..221fed9c6 100644 --- a/.opencode/skills/impeccable/scripts/live-browser.js +++ b/.opencode/skills/impeccable/scripts/live-browser.js @@ -287,7 +287,7 @@ } function shouldShowHighlightTagTooltip() { - // Configure/edit carry the tag in the bar selection pill — keep only the outline. + // Configure/edit carry the tag in the bar selection pill, so keep only the outline. return state !== 'CONFIGURING' && state !== 'EDITING'; } @@ -1148,7 +1148,7 @@ syncPageChatFocus('update-bar-content'); } - // Configure row — the floating bar surface IS the input; modifier pills sit left of the field. + // Configure row: the floating bar surface IS the input; modifier pills sit left of the field. const CONFIGURE_BAR_H = '36px'; // Compact selection pill + 7px inset balances vertical centering in the 36px bar. @@ -1519,7 +1519,7 @@ function buildConfigureCountControl({ controlsLocked, onClick }) { const count = el('button', configureInlineControlStyle({ - fontFamily: MONO, fontWeight: '600', letterSpacing: '-0.02em', + fontFamily: MONO, fontWeight: '600', letterSpacing: '0', })); count.textContent = '\u00D7' + selectedCount; count.disabled = controlsLocked; @@ -5065,6 +5065,157 @@ } } + async function loadSvelteComponentVariantSource(manifest, variantNum) { + const dir = String(manifest?.componentDir || '').replace(/^\/+/, ''); + if (!dir || !variantNum) return ''; + const sourcePath = dir + '/v' + variantNum + '.svelte'; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(sourcePath); + try { + const res = await fetch(url); + if (!res.ok) return ''; + return await res.text(); + } catch { + return ''; + } + } + + function extractSvelteComponentStyle(source) { + const match = String(source || '').match(/]*>([\s\S]*?)<\/style\s*>/i); + return match ? match[1].trim() : ''; + } + + async function applySvelteComponentVariantStyle(variantNum) { + if (!svelteComponentSession || !variantNum) return; + const { manifest, sessionId } = svelteComponentSession; + const source = await loadSvelteComponentVariantSource(manifest, variantNum); + const css = extractSvelteComponentStyle(source); + removeSvelteComponentVariantStyle(svelteComponentSession); + if (!css) return; + const scopedCss = scopeCssToSveltePreview(css, sessionId); + if (!scopedCss) return; + const style = document.createElement('style'); + style.dataset.impeccableSvelteComponentStyle = sessionId; + style.dataset.impeccableVariant = String(variantNum); + style.textContent = scopedCss; + document.head.appendChild(style); + svelteComponentSession.styleEl = style; + } + + function removeSvelteComponentVariantStyle(session = svelteComponentSession) { + const style = session?.styleEl; + if (style?.parentNode) style.parentNode.removeChild(style); + if (session) session.styleEl = null; + } + + function scopeCssToSveltePreview(css, sessionId) { + const prefix = '[data-impeccable-variants="' + String(sessionId).replace(/"/g, '\\"') + '"] '; + return scopeCssBlock(String(css || ''), prefix).trim(); + } + + function scopeCssBlock(css, prefix) { + let out = ''; + let i = 0; + while (i < css.length) { + const open = css.indexOf('{', i); + if (open === -1) { + out += css.slice(i); + break; + } + const semi = css.indexOf(';', i); + if (semi !== -1 && semi < open) { + out += css.slice(i, semi + 1); + i = semi + 1; + continue; + } + const prelude = css.slice(i, open).trim(); + const close = findMatchingCssBrace(css, open); + if (close === -1) { + out += css.slice(i); + break; + } + const body = css.slice(open + 1, close); + if (shouldScopeNestedCssAtRule(prelude)) { + out += prelude + ' {\n' + scopeCssBlock(body, prefix) + '\n}'; + } else if (prelude.startsWith('@')) { + out += prelude + ' {' + body + '}'; + } else { + out += prefixCssSelectors(prelude, prefix) + ' {' + body + '}'; + } + i = close + 1; + } + return out; + } + + function shouldScopeNestedCssAtRule(prelude) { + return /^@(media|supports|container|layer)\b/i.test(prelude || ''); + } + + function findMatchingCssBrace(css, openIndex) { + let depth = 0; + let quote = ''; + for (let i = openIndex; i < css.length; i++) { + const ch = css[i]; + const prev = css[i - 1]; + if (quote) { + if (ch === quote && prev !== '\\') quote = ''; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + } else if (ch === '{') { + depth++; + } else if (ch === '}') { + depth--; + if (depth === 0) return i; + } + } + return -1; + } + + function prefixCssSelectors(prelude, prefix) { + return splitCssSelectorList(prelude) + .map((selector) => { + const s = unwrapSvelteGlobalSelector(selector.trim()); + if (!s) return ''; + if (s.startsWith(prefix.trim())) return s; + if (s.startsWith(':host')) return s.replace(/^:host\b/, prefix.trim()); + return prefix + s; + }) + .filter(Boolean) + .join(', '); + } + + function splitCssSelectorList(selectorList) { + const selectors = []; + let start = 0; + let depth = 0; + let quote = ''; + for (let i = 0; i < selectorList.length; i++) { + const ch = selectorList[i]; + const prev = selectorList[i - 1]; + if (quote) { + if (ch === quote && prev !== '\\') quote = ''; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + } else if (ch === '(' || ch === '[') { + depth++; + } else if ((ch === ')' || ch === ']') && depth > 0) { + depth--; + } else if (ch === ',' && depth === 0) { + selectors.push(selectorList.slice(start, i)); + start = i + 1; + } + } + selectors.push(selectorList.slice(start)); + return selectors; + } + + function unwrapSvelteGlobalSelector(selector) { + return selector.replace(/:global\(([^()]*)\)/g, '$1'); + } + function buildSveltePropValuesFromLiveElement(liveEl, manifest) { const contract = manifest?.propContract || []; const values = {}; @@ -5101,6 +5252,7 @@ }); svelteComponentSession.mountedVariant = variantNum; svelteComponentSession.runtime = runtime; + await applySvelteComponentVariantStyle(variantNum); if (state === 'CYCLING') syncCyclingControls(); const nextAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); if (nextAnchor) { @@ -5134,6 +5286,7 @@ function teardownSvelteComponentSession(restoreOriginal) { if (!svelteComponentSession) return; const { wrapperEl, detachedOriginal, runtime, mountedInstance } = svelteComponentSession; + removeSvelteComponentVariantStyle(svelteComponentSession); if (mountedInstance && runtime?.unmount) { try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } } @@ -5173,6 +5326,7 @@ if (mountedInstance && runtime?.unmount) { try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } } + removeSvelteComponentVariantStyle(svelteComponentSession); wrapperEl.parentElement.replaceChild(committed, wrapperEl); svelteComponentSession = null; svelteRuntimePromise = null; @@ -8843,7 +8997,7 @@ void main() { cursor: 'pointer', flexShrink: '0', width: PAGE_CHAT_COLLAPSED_W, - transition: 'width 0.18s ease, border-color 0.15s ease', + transition: 'border-color 0.15s ease', }); pageChatEl.id = PREFIX + '-page-chat'; pageChatEl.dataset.expanded = 'false'; diff --git a/.pi/skills/impeccable/SKILL.md b/.pi/skills/impeccable/SKILL.md index 3dbec85cb..6cd49ca4f 100644 --- a/.pi/skills/impeccable/SKILL.md +++ b/.pi/skills/impeccable/SKILL.md @@ -1,7 +1,7 @@ --- 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: 3.5.0 +version: 3.6.0 license: Apache 2.0 allowed-tools: - Bash(npx impeccable *) 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 4c0777c93..a72f93e27 100644 --- a/.pi/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.pi/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -23,6 +23,18 @@ function stripHtmlToText(html) { .replace(/\s+/g, ' '); } +const PAGE_ANALYZER_EXTS = new Set(['.html', '.htm', '.astro', '.vue', '.svelte']); + +function extFromFilePath(filePath) { + return filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : ''; +} + +function shouldRunPageAnalyzers(content, filePath) { + if (!isFullPage(content)) return false; + const ext = extFromFilePath(filePath); + return !ext || PAGE_ANALYZER_EXTS.has(ext); +} + function isNeutralBorderColor(str) { const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i); if (!m) return false; @@ -422,7 +434,7 @@ const TEXT_CONTENT_ANALYZER_IDS = [ function runTextContentAnalyzers(content, filePath, options = {}) { const profile = options?.profile; - if (!isFullPage(content)) return []; + if (!shouldRunPageAnalyzers(content, filePath)) return []; // The 4 text-content analyzers are at indices 3-6 in REGEX_ANALYZERS. const findings = []; for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { @@ -442,7 +454,7 @@ function detectText(content, filePath, options = {}) { const profile = options?.profile; const findings = []; const lines = content.split('\n'); - const ext = filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : ''; + const ext = extFromFilePath(filePath); // Run regex matchers on the full file content (catches Tailwind classes, inline styles) // Enable block context for CSS files where related properties span multiple lines @@ -498,7 +510,7 @@ function detectText(content, filePath, options = {}) { } // Page-level analyzers only run on full pages - if (isFullPage(content)) { + if (shouldRunPageAnalyzers(content, filePath)) { const analyzerIds = [ 'single-font', 'flat-type-hierarchy', diff --git a/.pi/skills/impeccable/scripts/live-browser.js b/.pi/skills/impeccable/scripts/live-browser.js index 539cd07d9..221fed9c6 100644 --- a/.pi/skills/impeccable/scripts/live-browser.js +++ b/.pi/skills/impeccable/scripts/live-browser.js @@ -287,7 +287,7 @@ } function shouldShowHighlightTagTooltip() { - // Configure/edit carry the tag in the bar selection pill — keep only the outline. + // Configure/edit carry the tag in the bar selection pill, so keep only the outline. return state !== 'CONFIGURING' && state !== 'EDITING'; } @@ -1148,7 +1148,7 @@ syncPageChatFocus('update-bar-content'); } - // Configure row — the floating bar surface IS the input; modifier pills sit left of the field. + // Configure row: the floating bar surface IS the input; modifier pills sit left of the field. const CONFIGURE_BAR_H = '36px'; // Compact selection pill + 7px inset balances vertical centering in the 36px bar. @@ -1519,7 +1519,7 @@ function buildConfigureCountControl({ controlsLocked, onClick }) { const count = el('button', configureInlineControlStyle({ - fontFamily: MONO, fontWeight: '600', letterSpacing: '-0.02em', + fontFamily: MONO, fontWeight: '600', letterSpacing: '0', })); count.textContent = '\u00D7' + selectedCount; count.disabled = controlsLocked; @@ -5065,6 +5065,157 @@ } } + async function loadSvelteComponentVariantSource(manifest, variantNum) { + const dir = String(manifest?.componentDir || '').replace(/^\/+/, ''); + if (!dir || !variantNum) return ''; + const sourcePath = dir + '/v' + variantNum + '.svelte'; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(sourcePath); + try { + const res = await fetch(url); + if (!res.ok) return ''; + return await res.text(); + } catch { + return ''; + } + } + + function extractSvelteComponentStyle(source) { + const match = String(source || '').match(/]*>([\s\S]*?)<\/style\s*>/i); + return match ? match[1].trim() : ''; + } + + async function applySvelteComponentVariantStyle(variantNum) { + if (!svelteComponentSession || !variantNum) return; + const { manifest, sessionId } = svelteComponentSession; + const source = await loadSvelteComponentVariantSource(manifest, variantNum); + const css = extractSvelteComponentStyle(source); + removeSvelteComponentVariantStyle(svelteComponentSession); + if (!css) return; + const scopedCss = scopeCssToSveltePreview(css, sessionId); + if (!scopedCss) return; + const style = document.createElement('style'); + style.dataset.impeccableSvelteComponentStyle = sessionId; + style.dataset.impeccableVariant = String(variantNum); + style.textContent = scopedCss; + document.head.appendChild(style); + svelteComponentSession.styleEl = style; + } + + function removeSvelteComponentVariantStyle(session = svelteComponentSession) { + const style = session?.styleEl; + if (style?.parentNode) style.parentNode.removeChild(style); + if (session) session.styleEl = null; + } + + function scopeCssToSveltePreview(css, sessionId) { + const prefix = '[data-impeccable-variants="' + String(sessionId).replace(/"/g, '\\"') + '"] '; + return scopeCssBlock(String(css || ''), prefix).trim(); + } + + function scopeCssBlock(css, prefix) { + let out = ''; + let i = 0; + while (i < css.length) { + const open = css.indexOf('{', i); + if (open === -1) { + out += css.slice(i); + break; + } + const semi = css.indexOf(';', i); + if (semi !== -1 && semi < open) { + out += css.slice(i, semi + 1); + i = semi + 1; + continue; + } + const prelude = css.slice(i, open).trim(); + const close = findMatchingCssBrace(css, open); + if (close === -1) { + out += css.slice(i); + break; + } + const body = css.slice(open + 1, close); + if (shouldScopeNestedCssAtRule(prelude)) { + out += prelude + ' {\n' + scopeCssBlock(body, prefix) + '\n}'; + } else if (prelude.startsWith('@')) { + out += prelude + ' {' + body + '}'; + } else { + out += prefixCssSelectors(prelude, prefix) + ' {' + body + '}'; + } + i = close + 1; + } + return out; + } + + function shouldScopeNestedCssAtRule(prelude) { + return /^@(media|supports|container|layer)\b/i.test(prelude || ''); + } + + function findMatchingCssBrace(css, openIndex) { + let depth = 0; + let quote = ''; + for (let i = openIndex; i < css.length; i++) { + const ch = css[i]; + const prev = css[i - 1]; + if (quote) { + if (ch === quote && prev !== '\\') quote = ''; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + } else if (ch === '{') { + depth++; + } else if (ch === '}') { + depth--; + if (depth === 0) return i; + } + } + return -1; + } + + function prefixCssSelectors(prelude, prefix) { + return splitCssSelectorList(prelude) + .map((selector) => { + const s = unwrapSvelteGlobalSelector(selector.trim()); + if (!s) return ''; + if (s.startsWith(prefix.trim())) return s; + if (s.startsWith(':host')) return s.replace(/^:host\b/, prefix.trim()); + return prefix + s; + }) + .filter(Boolean) + .join(', '); + } + + function splitCssSelectorList(selectorList) { + const selectors = []; + let start = 0; + let depth = 0; + let quote = ''; + for (let i = 0; i < selectorList.length; i++) { + const ch = selectorList[i]; + const prev = selectorList[i - 1]; + if (quote) { + if (ch === quote && prev !== '\\') quote = ''; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + } else if (ch === '(' || ch === '[') { + depth++; + } else if ((ch === ')' || ch === ']') && depth > 0) { + depth--; + } else if (ch === ',' && depth === 0) { + selectors.push(selectorList.slice(start, i)); + start = i + 1; + } + } + selectors.push(selectorList.slice(start)); + return selectors; + } + + function unwrapSvelteGlobalSelector(selector) { + return selector.replace(/:global\(([^()]*)\)/g, '$1'); + } + function buildSveltePropValuesFromLiveElement(liveEl, manifest) { const contract = manifest?.propContract || []; const values = {}; @@ -5101,6 +5252,7 @@ }); svelteComponentSession.mountedVariant = variantNum; svelteComponentSession.runtime = runtime; + await applySvelteComponentVariantStyle(variantNum); if (state === 'CYCLING') syncCyclingControls(); const nextAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); if (nextAnchor) { @@ -5134,6 +5286,7 @@ function teardownSvelteComponentSession(restoreOriginal) { if (!svelteComponentSession) return; const { wrapperEl, detachedOriginal, runtime, mountedInstance } = svelteComponentSession; + removeSvelteComponentVariantStyle(svelteComponentSession); if (mountedInstance && runtime?.unmount) { try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } } @@ -5173,6 +5326,7 @@ if (mountedInstance && runtime?.unmount) { try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } } + removeSvelteComponentVariantStyle(svelteComponentSession); wrapperEl.parentElement.replaceChild(committed, wrapperEl); svelteComponentSession = null; svelteRuntimePromise = null; @@ -8843,7 +8997,7 @@ void main() { cursor: 'pointer', flexShrink: '0', width: PAGE_CHAT_COLLAPSED_W, - transition: 'width 0.18s ease, border-color 0.15s ease', + transition: 'border-color 0.15s ease', }); pageChatEl.id = PREFIX + '-page-chat'; pageChatEl.dataset.expanded = 'false'; diff --git a/.qoder/skills/impeccable/SKILL.md b/.qoder/skills/impeccable/SKILL.md index d8ccbe204..b540d1a55 100644 --- a/.qoder/skills/impeccable/SKILL.md +++ b/.qoder/skills/impeccable/SKILL.md @@ -1,7 +1,7 @@ --- 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: 3.5.0 +version: 3.6.0 user-invocable: true argument-hint: "[craft|shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]" license: Apache 2.0 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 4c0777c93..a72f93e27 100644 --- a/.qoder/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.qoder/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -23,6 +23,18 @@ function stripHtmlToText(html) { .replace(/\s+/g, ' '); } +const PAGE_ANALYZER_EXTS = new Set(['.html', '.htm', '.astro', '.vue', '.svelte']); + +function extFromFilePath(filePath) { + return filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : ''; +} + +function shouldRunPageAnalyzers(content, filePath) { + if (!isFullPage(content)) return false; + const ext = extFromFilePath(filePath); + return !ext || PAGE_ANALYZER_EXTS.has(ext); +} + function isNeutralBorderColor(str) { const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i); if (!m) return false; @@ -422,7 +434,7 @@ const TEXT_CONTENT_ANALYZER_IDS = [ function runTextContentAnalyzers(content, filePath, options = {}) { const profile = options?.profile; - if (!isFullPage(content)) return []; + if (!shouldRunPageAnalyzers(content, filePath)) return []; // The 4 text-content analyzers are at indices 3-6 in REGEX_ANALYZERS. const findings = []; for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { @@ -442,7 +454,7 @@ function detectText(content, filePath, options = {}) { const profile = options?.profile; const findings = []; const lines = content.split('\n'); - const ext = filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : ''; + const ext = extFromFilePath(filePath); // Run regex matchers on the full file content (catches Tailwind classes, inline styles) // Enable block context for CSS files where related properties span multiple lines @@ -498,7 +510,7 @@ function detectText(content, filePath, options = {}) { } // Page-level analyzers only run on full pages - if (isFullPage(content)) { + if (shouldRunPageAnalyzers(content, filePath)) { const analyzerIds = [ 'single-font', 'flat-type-hierarchy', diff --git a/.qoder/skills/impeccable/scripts/live-browser.js b/.qoder/skills/impeccable/scripts/live-browser.js index 539cd07d9..221fed9c6 100644 --- a/.qoder/skills/impeccable/scripts/live-browser.js +++ b/.qoder/skills/impeccable/scripts/live-browser.js @@ -287,7 +287,7 @@ } function shouldShowHighlightTagTooltip() { - // Configure/edit carry the tag in the bar selection pill — keep only the outline. + // Configure/edit carry the tag in the bar selection pill, so keep only the outline. return state !== 'CONFIGURING' && state !== 'EDITING'; } @@ -1148,7 +1148,7 @@ syncPageChatFocus('update-bar-content'); } - // Configure row — the floating bar surface IS the input; modifier pills sit left of the field. + // Configure row: the floating bar surface IS the input; modifier pills sit left of the field. const CONFIGURE_BAR_H = '36px'; // Compact selection pill + 7px inset balances vertical centering in the 36px bar. @@ -1519,7 +1519,7 @@ function buildConfigureCountControl({ controlsLocked, onClick }) { const count = el('button', configureInlineControlStyle({ - fontFamily: MONO, fontWeight: '600', letterSpacing: '-0.02em', + fontFamily: MONO, fontWeight: '600', letterSpacing: '0', })); count.textContent = '\u00D7' + selectedCount; count.disabled = controlsLocked; @@ -5065,6 +5065,157 @@ } } + async function loadSvelteComponentVariantSource(manifest, variantNum) { + const dir = String(manifest?.componentDir || '').replace(/^\/+/, ''); + if (!dir || !variantNum) return ''; + const sourcePath = dir + '/v' + variantNum + '.svelte'; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(sourcePath); + try { + const res = await fetch(url); + if (!res.ok) return ''; + return await res.text(); + } catch { + return ''; + } + } + + function extractSvelteComponentStyle(source) { + const match = String(source || '').match(/]*>([\s\S]*?)<\/style\s*>/i); + return match ? match[1].trim() : ''; + } + + async function applySvelteComponentVariantStyle(variantNum) { + if (!svelteComponentSession || !variantNum) return; + const { manifest, sessionId } = svelteComponentSession; + const source = await loadSvelteComponentVariantSource(manifest, variantNum); + const css = extractSvelteComponentStyle(source); + removeSvelteComponentVariantStyle(svelteComponentSession); + if (!css) return; + const scopedCss = scopeCssToSveltePreview(css, sessionId); + if (!scopedCss) return; + const style = document.createElement('style'); + style.dataset.impeccableSvelteComponentStyle = sessionId; + style.dataset.impeccableVariant = String(variantNum); + style.textContent = scopedCss; + document.head.appendChild(style); + svelteComponentSession.styleEl = style; + } + + function removeSvelteComponentVariantStyle(session = svelteComponentSession) { + const style = session?.styleEl; + if (style?.parentNode) style.parentNode.removeChild(style); + if (session) session.styleEl = null; + } + + function scopeCssToSveltePreview(css, sessionId) { + const prefix = '[data-impeccable-variants="' + String(sessionId).replace(/"/g, '\\"') + '"] '; + return scopeCssBlock(String(css || ''), prefix).trim(); + } + + function scopeCssBlock(css, prefix) { + let out = ''; + let i = 0; + while (i < css.length) { + const open = css.indexOf('{', i); + if (open === -1) { + out += css.slice(i); + break; + } + const semi = css.indexOf(';', i); + if (semi !== -1 && semi < open) { + out += css.slice(i, semi + 1); + i = semi + 1; + continue; + } + const prelude = css.slice(i, open).trim(); + const close = findMatchingCssBrace(css, open); + if (close === -1) { + out += css.slice(i); + break; + } + const body = css.slice(open + 1, close); + if (shouldScopeNestedCssAtRule(prelude)) { + out += prelude + ' {\n' + scopeCssBlock(body, prefix) + '\n}'; + } else if (prelude.startsWith('@')) { + out += prelude + ' {' + body + '}'; + } else { + out += prefixCssSelectors(prelude, prefix) + ' {' + body + '}'; + } + i = close + 1; + } + return out; + } + + function shouldScopeNestedCssAtRule(prelude) { + return /^@(media|supports|container|layer)\b/i.test(prelude || ''); + } + + function findMatchingCssBrace(css, openIndex) { + let depth = 0; + let quote = ''; + for (let i = openIndex; i < css.length; i++) { + const ch = css[i]; + const prev = css[i - 1]; + if (quote) { + if (ch === quote && prev !== '\\') quote = ''; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + } else if (ch === '{') { + depth++; + } else if (ch === '}') { + depth--; + if (depth === 0) return i; + } + } + return -1; + } + + function prefixCssSelectors(prelude, prefix) { + return splitCssSelectorList(prelude) + .map((selector) => { + const s = unwrapSvelteGlobalSelector(selector.trim()); + if (!s) return ''; + if (s.startsWith(prefix.trim())) return s; + if (s.startsWith(':host')) return s.replace(/^:host\b/, prefix.trim()); + return prefix + s; + }) + .filter(Boolean) + .join(', '); + } + + function splitCssSelectorList(selectorList) { + const selectors = []; + let start = 0; + let depth = 0; + let quote = ''; + for (let i = 0; i < selectorList.length; i++) { + const ch = selectorList[i]; + const prev = selectorList[i - 1]; + if (quote) { + if (ch === quote && prev !== '\\') quote = ''; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + } else if (ch === '(' || ch === '[') { + depth++; + } else if ((ch === ')' || ch === ']') && depth > 0) { + depth--; + } else if (ch === ',' && depth === 0) { + selectors.push(selectorList.slice(start, i)); + start = i + 1; + } + } + selectors.push(selectorList.slice(start)); + return selectors; + } + + function unwrapSvelteGlobalSelector(selector) { + return selector.replace(/:global\(([^()]*)\)/g, '$1'); + } + function buildSveltePropValuesFromLiveElement(liveEl, manifest) { const contract = manifest?.propContract || []; const values = {}; @@ -5101,6 +5252,7 @@ }); svelteComponentSession.mountedVariant = variantNum; svelteComponentSession.runtime = runtime; + await applySvelteComponentVariantStyle(variantNum); if (state === 'CYCLING') syncCyclingControls(); const nextAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); if (nextAnchor) { @@ -5134,6 +5286,7 @@ function teardownSvelteComponentSession(restoreOriginal) { if (!svelteComponentSession) return; const { wrapperEl, detachedOriginal, runtime, mountedInstance } = svelteComponentSession; + removeSvelteComponentVariantStyle(svelteComponentSession); if (mountedInstance && runtime?.unmount) { try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } } @@ -5173,6 +5326,7 @@ if (mountedInstance && runtime?.unmount) { try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } } + removeSvelteComponentVariantStyle(svelteComponentSession); wrapperEl.parentElement.replaceChild(committed, wrapperEl); svelteComponentSession = null; svelteRuntimePromise = null; @@ -8843,7 +8997,7 @@ void main() { cursor: 'pointer', flexShrink: '0', width: PAGE_CHAT_COLLAPSED_W, - transition: 'width 0.18s ease, border-color 0.15s ease', + transition: 'border-color 0.15s ease', }); pageChatEl.id = PREFIX + '-page-chat'; pageChatEl.dataset.expanded = 'false'; diff --git a/.rovodev/skills/impeccable/SKILL.md b/.rovodev/skills/impeccable/SKILL.md index d36116d63..fef050ee7 100644 --- a/.rovodev/skills/impeccable/SKILL.md +++ b/.rovodev/skills/impeccable/SKILL.md @@ -1,7 +1,7 @@ --- 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: 3.5.0 +version: 3.6.0 user-invocable: true argument-hint: "[craft|shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]" license: Apache 2.0 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 4c0777c93..a72f93e27 100644 --- a/.rovodev/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.rovodev/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -23,6 +23,18 @@ function stripHtmlToText(html) { .replace(/\s+/g, ' '); } +const PAGE_ANALYZER_EXTS = new Set(['.html', '.htm', '.astro', '.vue', '.svelte']); + +function extFromFilePath(filePath) { + return filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : ''; +} + +function shouldRunPageAnalyzers(content, filePath) { + if (!isFullPage(content)) return false; + const ext = extFromFilePath(filePath); + return !ext || PAGE_ANALYZER_EXTS.has(ext); +} + function isNeutralBorderColor(str) { const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i); if (!m) return false; @@ -422,7 +434,7 @@ const TEXT_CONTENT_ANALYZER_IDS = [ function runTextContentAnalyzers(content, filePath, options = {}) { const profile = options?.profile; - if (!isFullPage(content)) return []; + if (!shouldRunPageAnalyzers(content, filePath)) return []; // The 4 text-content analyzers are at indices 3-6 in REGEX_ANALYZERS. const findings = []; for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { @@ -442,7 +454,7 @@ function detectText(content, filePath, options = {}) { const profile = options?.profile; const findings = []; const lines = content.split('\n'); - const ext = filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : ''; + const ext = extFromFilePath(filePath); // Run regex matchers on the full file content (catches Tailwind classes, inline styles) // Enable block context for CSS files where related properties span multiple lines @@ -498,7 +510,7 @@ function detectText(content, filePath, options = {}) { } // Page-level analyzers only run on full pages - if (isFullPage(content)) { + if (shouldRunPageAnalyzers(content, filePath)) { const analyzerIds = [ 'single-font', 'flat-type-hierarchy', diff --git a/.rovodev/skills/impeccable/scripts/live-browser.js b/.rovodev/skills/impeccable/scripts/live-browser.js index 539cd07d9..221fed9c6 100644 --- a/.rovodev/skills/impeccable/scripts/live-browser.js +++ b/.rovodev/skills/impeccable/scripts/live-browser.js @@ -287,7 +287,7 @@ } function shouldShowHighlightTagTooltip() { - // Configure/edit carry the tag in the bar selection pill — keep only the outline. + // Configure/edit carry the tag in the bar selection pill, so keep only the outline. return state !== 'CONFIGURING' && state !== 'EDITING'; } @@ -1148,7 +1148,7 @@ syncPageChatFocus('update-bar-content'); } - // Configure row — the floating bar surface IS the input; modifier pills sit left of the field. + // Configure row: the floating bar surface IS the input; modifier pills sit left of the field. const CONFIGURE_BAR_H = '36px'; // Compact selection pill + 7px inset balances vertical centering in the 36px bar. @@ -1519,7 +1519,7 @@ function buildConfigureCountControl({ controlsLocked, onClick }) { const count = el('button', configureInlineControlStyle({ - fontFamily: MONO, fontWeight: '600', letterSpacing: '-0.02em', + fontFamily: MONO, fontWeight: '600', letterSpacing: '0', })); count.textContent = '\u00D7' + selectedCount; count.disabled = controlsLocked; @@ -5065,6 +5065,157 @@ } } + async function loadSvelteComponentVariantSource(manifest, variantNum) { + const dir = String(manifest?.componentDir || '').replace(/^\/+/, ''); + if (!dir || !variantNum) return ''; + const sourcePath = dir + '/v' + variantNum + '.svelte'; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(sourcePath); + try { + const res = await fetch(url); + if (!res.ok) return ''; + return await res.text(); + } catch { + return ''; + } + } + + function extractSvelteComponentStyle(source) { + const match = String(source || '').match(/]*>([\s\S]*?)<\/style\s*>/i); + return match ? match[1].trim() : ''; + } + + async function applySvelteComponentVariantStyle(variantNum) { + if (!svelteComponentSession || !variantNum) return; + const { manifest, sessionId } = svelteComponentSession; + const source = await loadSvelteComponentVariantSource(manifest, variantNum); + const css = extractSvelteComponentStyle(source); + removeSvelteComponentVariantStyle(svelteComponentSession); + if (!css) return; + const scopedCss = scopeCssToSveltePreview(css, sessionId); + if (!scopedCss) return; + const style = document.createElement('style'); + style.dataset.impeccableSvelteComponentStyle = sessionId; + style.dataset.impeccableVariant = String(variantNum); + style.textContent = scopedCss; + document.head.appendChild(style); + svelteComponentSession.styleEl = style; + } + + function removeSvelteComponentVariantStyle(session = svelteComponentSession) { + const style = session?.styleEl; + if (style?.parentNode) style.parentNode.removeChild(style); + if (session) session.styleEl = null; + } + + function scopeCssToSveltePreview(css, sessionId) { + const prefix = '[data-impeccable-variants="' + String(sessionId).replace(/"/g, '\\"') + '"] '; + return scopeCssBlock(String(css || ''), prefix).trim(); + } + + function scopeCssBlock(css, prefix) { + let out = ''; + let i = 0; + while (i < css.length) { + const open = css.indexOf('{', i); + if (open === -1) { + out += css.slice(i); + break; + } + const semi = css.indexOf(';', i); + if (semi !== -1 && semi < open) { + out += css.slice(i, semi + 1); + i = semi + 1; + continue; + } + const prelude = css.slice(i, open).trim(); + const close = findMatchingCssBrace(css, open); + if (close === -1) { + out += css.slice(i); + break; + } + const body = css.slice(open + 1, close); + if (shouldScopeNestedCssAtRule(prelude)) { + out += prelude + ' {\n' + scopeCssBlock(body, prefix) + '\n}'; + } else if (prelude.startsWith('@')) { + out += prelude + ' {' + body + '}'; + } else { + out += prefixCssSelectors(prelude, prefix) + ' {' + body + '}'; + } + i = close + 1; + } + return out; + } + + function shouldScopeNestedCssAtRule(prelude) { + return /^@(media|supports|container|layer)\b/i.test(prelude || ''); + } + + function findMatchingCssBrace(css, openIndex) { + let depth = 0; + let quote = ''; + for (let i = openIndex; i < css.length; i++) { + const ch = css[i]; + const prev = css[i - 1]; + if (quote) { + if (ch === quote && prev !== '\\') quote = ''; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + } else if (ch === '{') { + depth++; + } else if (ch === '}') { + depth--; + if (depth === 0) return i; + } + } + return -1; + } + + function prefixCssSelectors(prelude, prefix) { + return splitCssSelectorList(prelude) + .map((selector) => { + const s = unwrapSvelteGlobalSelector(selector.trim()); + if (!s) return ''; + if (s.startsWith(prefix.trim())) return s; + if (s.startsWith(':host')) return s.replace(/^:host\b/, prefix.trim()); + return prefix + s; + }) + .filter(Boolean) + .join(', '); + } + + function splitCssSelectorList(selectorList) { + const selectors = []; + let start = 0; + let depth = 0; + let quote = ''; + for (let i = 0; i < selectorList.length; i++) { + const ch = selectorList[i]; + const prev = selectorList[i - 1]; + if (quote) { + if (ch === quote && prev !== '\\') quote = ''; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + } else if (ch === '(' || ch === '[') { + depth++; + } else if ((ch === ')' || ch === ']') && depth > 0) { + depth--; + } else if (ch === ',' && depth === 0) { + selectors.push(selectorList.slice(start, i)); + start = i + 1; + } + } + selectors.push(selectorList.slice(start)); + return selectors; + } + + function unwrapSvelteGlobalSelector(selector) { + return selector.replace(/:global\(([^()]*)\)/g, '$1'); + } + function buildSveltePropValuesFromLiveElement(liveEl, manifest) { const contract = manifest?.propContract || []; const values = {}; @@ -5101,6 +5252,7 @@ }); svelteComponentSession.mountedVariant = variantNum; svelteComponentSession.runtime = runtime; + await applySvelteComponentVariantStyle(variantNum); if (state === 'CYCLING') syncCyclingControls(); const nextAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); if (nextAnchor) { @@ -5134,6 +5286,7 @@ function teardownSvelteComponentSession(restoreOriginal) { if (!svelteComponentSession) return; const { wrapperEl, detachedOriginal, runtime, mountedInstance } = svelteComponentSession; + removeSvelteComponentVariantStyle(svelteComponentSession); if (mountedInstance && runtime?.unmount) { try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } } @@ -5173,6 +5326,7 @@ if (mountedInstance && runtime?.unmount) { try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } } + removeSvelteComponentVariantStyle(svelteComponentSession); wrapperEl.parentElement.replaceChild(committed, wrapperEl); svelteComponentSession = null; svelteRuntimePromise = null; @@ -8843,7 +8997,7 @@ void main() { cursor: 'pointer', flexShrink: '0', width: PAGE_CHAT_COLLAPSED_W, - transition: 'width 0.18s ease, border-color 0.15s ease', + transition: 'border-color 0.15s ease', }); pageChatEl.id = PREFIX + '-page-chat'; pageChatEl.dataset.expanded = 'false'; diff --git a/.trae-cn/skills/impeccable/SKILL.md b/.trae-cn/skills/impeccable/SKILL.md index 187455c1b..de7197258 100644 --- a/.trae-cn/skills/impeccable/SKILL.md +++ b/.trae-cn/skills/impeccable/SKILL.md @@ -1,7 +1,7 @@ --- 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: 3.5.0 +version: 3.6.0 user-invocable: true argument-hint: "[craft|shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]" license: Apache 2.0 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 4c0777c93..a72f93e27 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 @@ -23,6 +23,18 @@ function stripHtmlToText(html) { .replace(/\s+/g, ' '); } +const PAGE_ANALYZER_EXTS = new Set(['.html', '.htm', '.astro', '.vue', '.svelte']); + +function extFromFilePath(filePath) { + return filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : ''; +} + +function shouldRunPageAnalyzers(content, filePath) { + if (!isFullPage(content)) return false; + const ext = extFromFilePath(filePath); + return !ext || PAGE_ANALYZER_EXTS.has(ext); +} + function isNeutralBorderColor(str) { const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i); if (!m) return false; @@ -422,7 +434,7 @@ const TEXT_CONTENT_ANALYZER_IDS = [ function runTextContentAnalyzers(content, filePath, options = {}) { const profile = options?.profile; - if (!isFullPage(content)) return []; + if (!shouldRunPageAnalyzers(content, filePath)) return []; // The 4 text-content analyzers are at indices 3-6 in REGEX_ANALYZERS. const findings = []; for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { @@ -442,7 +454,7 @@ function detectText(content, filePath, options = {}) { const profile = options?.profile; const findings = []; const lines = content.split('\n'); - const ext = filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : ''; + const ext = extFromFilePath(filePath); // Run regex matchers on the full file content (catches Tailwind classes, inline styles) // Enable block context for CSS files where related properties span multiple lines @@ -498,7 +510,7 @@ function detectText(content, filePath, options = {}) { } // Page-level analyzers only run on full pages - if (isFullPage(content)) { + if (shouldRunPageAnalyzers(content, filePath)) { const analyzerIds = [ 'single-font', 'flat-type-hierarchy', diff --git a/.trae-cn/skills/impeccable/scripts/live-browser.js b/.trae-cn/skills/impeccable/scripts/live-browser.js index 539cd07d9..221fed9c6 100644 --- a/.trae-cn/skills/impeccable/scripts/live-browser.js +++ b/.trae-cn/skills/impeccable/scripts/live-browser.js @@ -287,7 +287,7 @@ } function shouldShowHighlightTagTooltip() { - // Configure/edit carry the tag in the bar selection pill — keep only the outline. + // Configure/edit carry the tag in the bar selection pill, so keep only the outline. return state !== 'CONFIGURING' && state !== 'EDITING'; } @@ -1148,7 +1148,7 @@ syncPageChatFocus('update-bar-content'); } - // Configure row — the floating bar surface IS the input; modifier pills sit left of the field. + // Configure row: the floating bar surface IS the input; modifier pills sit left of the field. const CONFIGURE_BAR_H = '36px'; // Compact selection pill + 7px inset balances vertical centering in the 36px bar. @@ -1519,7 +1519,7 @@ function buildConfigureCountControl({ controlsLocked, onClick }) { const count = el('button', configureInlineControlStyle({ - fontFamily: MONO, fontWeight: '600', letterSpacing: '-0.02em', + fontFamily: MONO, fontWeight: '600', letterSpacing: '0', })); count.textContent = '\u00D7' + selectedCount; count.disabled = controlsLocked; @@ -5065,6 +5065,157 @@ } } + async function loadSvelteComponentVariantSource(manifest, variantNum) { + const dir = String(manifest?.componentDir || '').replace(/^\/+/, ''); + if (!dir || !variantNum) return ''; + const sourcePath = dir + '/v' + variantNum + '.svelte'; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(sourcePath); + try { + const res = await fetch(url); + if (!res.ok) return ''; + return await res.text(); + } catch { + return ''; + } + } + + function extractSvelteComponentStyle(source) { + const match = String(source || '').match(/]*>([\s\S]*?)<\/style\s*>/i); + return match ? match[1].trim() : ''; + } + + async function applySvelteComponentVariantStyle(variantNum) { + if (!svelteComponentSession || !variantNum) return; + const { manifest, sessionId } = svelteComponentSession; + const source = await loadSvelteComponentVariantSource(manifest, variantNum); + const css = extractSvelteComponentStyle(source); + removeSvelteComponentVariantStyle(svelteComponentSession); + if (!css) return; + const scopedCss = scopeCssToSveltePreview(css, sessionId); + if (!scopedCss) return; + const style = document.createElement('style'); + style.dataset.impeccableSvelteComponentStyle = sessionId; + style.dataset.impeccableVariant = String(variantNum); + style.textContent = scopedCss; + document.head.appendChild(style); + svelteComponentSession.styleEl = style; + } + + function removeSvelteComponentVariantStyle(session = svelteComponentSession) { + const style = session?.styleEl; + if (style?.parentNode) style.parentNode.removeChild(style); + if (session) session.styleEl = null; + } + + function scopeCssToSveltePreview(css, sessionId) { + const prefix = '[data-impeccable-variants="' + String(sessionId).replace(/"/g, '\\"') + '"] '; + return scopeCssBlock(String(css || ''), prefix).trim(); + } + + function scopeCssBlock(css, prefix) { + let out = ''; + let i = 0; + while (i < css.length) { + const open = css.indexOf('{', i); + if (open === -1) { + out += css.slice(i); + break; + } + const semi = css.indexOf(';', i); + if (semi !== -1 && semi < open) { + out += css.slice(i, semi + 1); + i = semi + 1; + continue; + } + const prelude = css.slice(i, open).trim(); + const close = findMatchingCssBrace(css, open); + if (close === -1) { + out += css.slice(i); + break; + } + const body = css.slice(open + 1, close); + if (shouldScopeNestedCssAtRule(prelude)) { + out += prelude + ' {\n' + scopeCssBlock(body, prefix) + '\n}'; + } else if (prelude.startsWith('@')) { + out += prelude + ' {' + body + '}'; + } else { + out += prefixCssSelectors(prelude, prefix) + ' {' + body + '}'; + } + i = close + 1; + } + return out; + } + + function shouldScopeNestedCssAtRule(prelude) { + return /^@(media|supports|container|layer)\b/i.test(prelude || ''); + } + + function findMatchingCssBrace(css, openIndex) { + let depth = 0; + let quote = ''; + for (let i = openIndex; i < css.length; i++) { + const ch = css[i]; + const prev = css[i - 1]; + if (quote) { + if (ch === quote && prev !== '\\') quote = ''; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + } else if (ch === '{') { + depth++; + } else if (ch === '}') { + depth--; + if (depth === 0) return i; + } + } + return -1; + } + + function prefixCssSelectors(prelude, prefix) { + return splitCssSelectorList(prelude) + .map((selector) => { + const s = unwrapSvelteGlobalSelector(selector.trim()); + if (!s) return ''; + if (s.startsWith(prefix.trim())) return s; + if (s.startsWith(':host')) return s.replace(/^:host\b/, prefix.trim()); + return prefix + s; + }) + .filter(Boolean) + .join(', '); + } + + function splitCssSelectorList(selectorList) { + const selectors = []; + let start = 0; + let depth = 0; + let quote = ''; + for (let i = 0; i < selectorList.length; i++) { + const ch = selectorList[i]; + const prev = selectorList[i - 1]; + if (quote) { + if (ch === quote && prev !== '\\') quote = ''; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + } else if (ch === '(' || ch === '[') { + depth++; + } else if ((ch === ')' || ch === ']') && depth > 0) { + depth--; + } else if (ch === ',' && depth === 0) { + selectors.push(selectorList.slice(start, i)); + start = i + 1; + } + } + selectors.push(selectorList.slice(start)); + return selectors; + } + + function unwrapSvelteGlobalSelector(selector) { + return selector.replace(/:global\(([^()]*)\)/g, '$1'); + } + function buildSveltePropValuesFromLiveElement(liveEl, manifest) { const contract = manifest?.propContract || []; const values = {}; @@ -5101,6 +5252,7 @@ }); svelteComponentSession.mountedVariant = variantNum; svelteComponentSession.runtime = runtime; + await applySvelteComponentVariantStyle(variantNum); if (state === 'CYCLING') syncCyclingControls(); const nextAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); if (nextAnchor) { @@ -5134,6 +5286,7 @@ function teardownSvelteComponentSession(restoreOriginal) { if (!svelteComponentSession) return; const { wrapperEl, detachedOriginal, runtime, mountedInstance } = svelteComponentSession; + removeSvelteComponentVariantStyle(svelteComponentSession); if (mountedInstance && runtime?.unmount) { try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } } @@ -5173,6 +5326,7 @@ if (mountedInstance && runtime?.unmount) { try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } } + removeSvelteComponentVariantStyle(svelteComponentSession); wrapperEl.parentElement.replaceChild(committed, wrapperEl); svelteComponentSession = null; svelteRuntimePromise = null; @@ -8843,7 +8997,7 @@ void main() { cursor: 'pointer', flexShrink: '0', width: PAGE_CHAT_COLLAPSED_W, - transition: 'width 0.18s ease, border-color 0.15s ease', + transition: 'border-color 0.15s ease', }); pageChatEl.id = PREFIX + '-page-chat'; pageChatEl.dataset.expanded = 'false'; diff --git a/.trae/skills/impeccable/SKILL.md b/.trae/skills/impeccable/SKILL.md index d36c0e8a8..e5fbeab15 100644 --- a/.trae/skills/impeccable/SKILL.md +++ b/.trae/skills/impeccable/SKILL.md @@ -1,7 +1,7 @@ --- 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: 3.5.0 +version: 3.6.0 user-invocable: true argument-hint: "[craft|shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]" license: Apache 2.0 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 4c0777c93..a72f93e27 100644 --- a/.trae/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.trae/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -23,6 +23,18 @@ function stripHtmlToText(html) { .replace(/\s+/g, ' '); } +const PAGE_ANALYZER_EXTS = new Set(['.html', '.htm', '.astro', '.vue', '.svelte']); + +function extFromFilePath(filePath) { + return filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : ''; +} + +function shouldRunPageAnalyzers(content, filePath) { + if (!isFullPage(content)) return false; + const ext = extFromFilePath(filePath); + return !ext || PAGE_ANALYZER_EXTS.has(ext); +} + function isNeutralBorderColor(str) { const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i); if (!m) return false; @@ -422,7 +434,7 @@ const TEXT_CONTENT_ANALYZER_IDS = [ function runTextContentAnalyzers(content, filePath, options = {}) { const profile = options?.profile; - if (!isFullPage(content)) return []; + if (!shouldRunPageAnalyzers(content, filePath)) return []; // The 4 text-content analyzers are at indices 3-6 in REGEX_ANALYZERS. const findings = []; for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { @@ -442,7 +454,7 @@ function detectText(content, filePath, options = {}) { const profile = options?.profile; const findings = []; const lines = content.split('\n'); - const ext = filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : ''; + const ext = extFromFilePath(filePath); // Run regex matchers on the full file content (catches Tailwind classes, inline styles) // Enable block context for CSS files where related properties span multiple lines @@ -498,7 +510,7 @@ function detectText(content, filePath, options = {}) { } // Page-level analyzers only run on full pages - if (isFullPage(content)) { + if (shouldRunPageAnalyzers(content, filePath)) { const analyzerIds = [ 'single-font', 'flat-type-hierarchy', diff --git a/.trae/skills/impeccable/scripts/live-browser.js b/.trae/skills/impeccable/scripts/live-browser.js index 539cd07d9..221fed9c6 100644 --- a/.trae/skills/impeccable/scripts/live-browser.js +++ b/.trae/skills/impeccable/scripts/live-browser.js @@ -287,7 +287,7 @@ } function shouldShowHighlightTagTooltip() { - // Configure/edit carry the tag in the bar selection pill — keep only the outline. + // Configure/edit carry the tag in the bar selection pill, so keep only the outline. return state !== 'CONFIGURING' && state !== 'EDITING'; } @@ -1148,7 +1148,7 @@ syncPageChatFocus('update-bar-content'); } - // Configure row — the floating bar surface IS the input; modifier pills sit left of the field. + // Configure row: the floating bar surface IS the input; modifier pills sit left of the field. const CONFIGURE_BAR_H = '36px'; // Compact selection pill + 7px inset balances vertical centering in the 36px bar. @@ -1519,7 +1519,7 @@ function buildConfigureCountControl({ controlsLocked, onClick }) { const count = el('button', configureInlineControlStyle({ - fontFamily: MONO, fontWeight: '600', letterSpacing: '-0.02em', + fontFamily: MONO, fontWeight: '600', letterSpacing: '0', })); count.textContent = '\u00D7' + selectedCount; count.disabled = controlsLocked; @@ -5065,6 +5065,157 @@ } } + async function loadSvelteComponentVariantSource(manifest, variantNum) { + const dir = String(manifest?.componentDir || '').replace(/^\/+/, ''); + if (!dir || !variantNum) return ''; + const sourcePath = dir + '/v' + variantNum + '.svelte'; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(sourcePath); + try { + const res = await fetch(url); + if (!res.ok) return ''; + return await res.text(); + } catch { + return ''; + } + } + + function extractSvelteComponentStyle(source) { + const match = String(source || '').match(/]*>([\s\S]*?)<\/style\s*>/i); + return match ? match[1].trim() : ''; + } + + async function applySvelteComponentVariantStyle(variantNum) { + if (!svelteComponentSession || !variantNum) return; + const { manifest, sessionId } = svelteComponentSession; + const source = await loadSvelteComponentVariantSource(manifest, variantNum); + const css = extractSvelteComponentStyle(source); + removeSvelteComponentVariantStyle(svelteComponentSession); + if (!css) return; + const scopedCss = scopeCssToSveltePreview(css, sessionId); + if (!scopedCss) return; + const style = document.createElement('style'); + style.dataset.impeccableSvelteComponentStyle = sessionId; + style.dataset.impeccableVariant = String(variantNum); + style.textContent = scopedCss; + document.head.appendChild(style); + svelteComponentSession.styleEl = style; + } + + function removeSvelteComponentVariantStyle(session = svelteComponentSession) { + const style = session?.styleEl; + if (style?.parentNode) style.parentNode.removeChild(style); + if (session) session.styleEl = null; + } + + function scopeCssToSveltePreview(css, sessionId) { + const prefix = '[data-impeccable-variants="' + String(sessionId).replace(/"/g, '\\"') + '"] '; + return scopeCssBlock(String(css || ''), prefix).trim(); + } + + function scopeCssBlock(css, prefix) { + let out = ''; + let i = 0; + while (i < css.length) { + const open = css.indexOf('{', i); + if (open === -1) { + out += css.slice(i); + break; + } + const semi = css.indexOf(';', i); + if (semi !== -1 && semi < open) { + out += css.slice(i, semi + 1); + i = semi + 1; + continue; + } + const prelude = css.slice(i, open).trim(); + const close = findMatchingCssBrace(css, open); + if (close === -1) { + out += css.slice(i); + break; + } + const body = css.slice(open + 1, close); + if (shouldScopeNestedCssAtRule(prelude)) { + out += prelude + ' {\n' + scopeCssBlock(body, prefix) + '\n}'; + } else if (prelude.startsWith('@')) { + out += prelude + ' {' + body + '}'; + } else { + out += prefixCssSelectors(prelude, prefix) + ' {' + body + '}'; + } + i = close + 1; + } + return out; + } + + function shouldScopeNestedCssAtRule(prelude) { + return /^@(media|supports|container|layer)\b/i.test(prelude || ''); + } + + function findMatchingCssBrace(css, openIndex) { + let depth = 0; + let quote = ''; + for (let i = openIndex; i < css.length; i++) { + const ch = css[i]; + const prev = css[i - 1]; + if (quote) { + if (ch === quote && prev !== '\\') quote = ''; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + } else if (ch === '{') { + depth++; + } else if (ch === '}') { + depth--; + if (depth === 0) return i; + } + } + return -1; + } + + function prefixCssSelectors(prelude, prefix) { + return splitCssSelectorList(prelude) + .map((selector) => { + const s = unwrapSvelteGlobalSelector(selector.trim()); + if (!s) return ''; + if (s.startsWith(prefix.trim())) return s; + if (s.startsWith(':host')) return s.replace(/^:host\b/, prefix.trim()); + return prefix + s; + }) + .filter(Boolean) + .join(', '); + } + + function splitCssSelectorList(selectorList) { + const selectors = []; + let start = 0; + let depth = 0; + let quote = ''; + for (let i = 0; i < selectorList.length; i++) { + const ch = selectorList[i]; + const prev = selectorList[i - 1]; + if (quote) { + if (ch === quote && prev !== '\\') quote = ''; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + } else if (ch === '(' || ch === '[') { + depth++; + } else if ((ch === ')' || ch === ']') && depth > 0) { + depth--; + } else if (ch === ',' && depth === 0) { + selectors.push(selectorList.slice(start, i)); + start = i + 1; + } + } + selectors.push(selectorList.slice(start)); + return selectors; + } + + function unwrapSvelteGlobalSelector(selector) { + return selector.replace(/:global\(([^()]*)\)/g, '$1'); + } + function buildSveltePropValuesFromLiveElement(liveEl, manifest) { const contract = manifest?.propContract || []; const values = {}; @@ -5101,6 +5252,7 @@ }); svelteComponentSession.mountedVariant = variantNum; svelteComponentSession.runtime = runtime; + await applySvelteComponentVariantStyle(variantNum); if (state === 'CYCLING') syncCyclingControls(); const nextAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); if (nextAnchor) { @@ -5134,6 +5286,7 @@ function teardownSvelteComponentSession(restoreOriginal) { if (!svelteComponentSession) return; const { wrapperEl, detachedOriginal, runtime, mountedInstance } = svelteComponentSession; + removeSvelteComponentVariantStyle(svelteComponentSession); if (mountedInstance && runtime?.unmount) { try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } } @@ -5173,6 +5326,7 @@ if (mountedInstance && runtime?.unmount) { try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } } + removeSvelteComponentVariantStyle(svelteComponentSession); wrapperEl.parentElement.replaceChild(committed, wrapperEl); svelteComponentSession = null; svelteRuntimePromise = null; @@ -8843,7 +8997,7 @@ void main() { cursor: 'pointer', flexShrink: '0', width: PAGE_CHAT_COLLAPSED_W, - transition: 'width 0.18s ease, border-color 0.15s ease', + transition: 'border-color 0.15s ease', }); pageChatEl.id = PREFIX + '-page-chat'; pageChatEl.dataset.expanded = 'false'; diff --git a/cli/engine/engines/regex/detect-text.mjs b/cli/engine/engines/regex/detect-text.mjs index 4c0777c93..a72f93e27 100644 --- a/cli/engine/engines/regex/detect-text.mjs +++ b/cli/engine/engines/regex/detect-text.mjs @@ -23,6 +23,18 @@ function stripHtmlToText(html) { .replace(/\s+/g, ' '); } +const PAGE_ANALYZER_EXTS = new Set(['.html', '.htm', '.astro', '.vue', '.svelte']); + +function extFromFilePath(filePath) { + return filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : ''; +} + +function shouldRunPageAnalyzers(content, filePath) { + if (!isFullPage(content)) return false; + const ext = extFromFilePath(filePath); + return !ext || PAGE_ANALYZER_EXTS.has(ext); +} + function isNeutralBorderColor(str) { const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i); if (!m) return false; @@ -422,7 +434,7 @@ const TEXT_CONTENT_ANALYZER_IDS = [ function runTextContentAnalyzers(content, filePath, options = {}) { const profile = options?.profile; - if (!isFullPage(content)) return []; + if (!shouldRunPageAnalyzers(content, filePath)) return []; // The 4 text-content analyzers are at indices 3-6 in REGEX_ANALYZERS. const findings = []; for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { @@ -442,7 +454,7 @@ function detectText(content, filePath, options = {}) { const profile = options?.profile; const findings = []; const lines = content.split('\n'); - const ext = filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : ''; + const ext = extFromFilePath(filePath); // Run regex matchers on the full file content (catches Tailwind classes, inline styles) // Enable block context for CSS files where related properties span multiple lines @@ -498,7 +510,7 @@ function detectText(content, filePath, options = {}) { } // Page-level analyzers only run on full pages - if (isFullPage(content)) { + if (shouldRunPageAnalyzers(content, filePath)) { const analyzerIds = [ 'single-font', 'flat-type-hierarchy', diff --git a/package.json b/package.json index 4484adc2f..bfe0083d7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impeccable", - "version": "2.3.2", + "version": "3.0.0", "author": "Paul Bakaus", "description": "Design skills, commands, and anti-pattern detection for AI coding agents", "keywords": [ diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 705c75741..aa9f42934 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "impeccable", "description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.", - "version": "3.5.0", + "version": "3.6.0", "author": { "name": "Paul Bakaus", "email": "paul@paulbakaus.com" diff --git a/plugin/skills/impeccable/SKILL.md b/plugin/skills/impeccable/SKILL.md index 73a27c46e..71252e52e 100644 --- a/plugin/skills/impeccable/SKILL.md +++ b/plugin/skills/impeccable/SKILL.md @@ -1,7 +1,7 @@ --- 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: 3.5.0 +version: 3.6.0 user-invocable: true argument-hint: "[craft|shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]" license: Apache 2.0 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 4c0777c93..a72f93e27 100644 --- a/plugin/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/plugin/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -23,6 +23,18 @@ function stripHtmlToText(html) { .replace(/\s+/g, ' '); } +const PAGE_ANALYZER_EXTS = new Set(['.html', '.htm', '.astro', '.vue', '.svelte']); + +function extFromFilePath(filePath) { + return filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : ''; +} + +function shouldRunPageAnalyzers(content, filePath) { + if (!isFullPage(content)) return false; + const ext = extFromFilePath(filePath); + return !ext || PAGE_ANALYZER_EXTS.has(ext); +} + function isNeutralBorderColor(str) { const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i); if (!m) return false; @@ -422,7 +434,7 @@ const TEXT_CONTENT_ANALYZER_IDS = [ function runTextContentAnalyzers(content, filePath, options = {}) { const profile = options?.profile; - if (!isFullPage(content)) return []; + if (!shouldRunPageAnalyzers(content, filePath)) return []; // The 4 text-content analyzers are at indices 3-6 in REGEX_ANALYZERS. const findings = []; for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { @@ -442,7 +454,7 @@ function detectText(content, filePath, options = {}) { const profile = options?.profile; const findings = []; const lines = content.split('\n'); - const ext = filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : ''; + const ext = extFromFilePath(filePath); // Run regex matchers on the full file content (catches Tailwind classes, inline styles) // Enable block context for CSS files where related properties span multiple lines @@ -498,7 +510,7 @@ function detectText(content, filePath, options = {}) { } // Page-level analyzers only run on full pages - if (isFullPage(content)) { + if (shouldRunPageAnalyzers(content, filePath)) { const analyzerIds = [ 'single-font', 'flat-type-hierarchy', diff --git a/plugin/skills/impeccable/scripts/live-browser.js b/plugin/skills/impeccable/scripts/live-browser.js index 539cd07d9..221fed9c6 100644 --- a/plugin/skills/impeccable/scripts/live-browser.js +++ b/plugin/skills/impeccable/scripts/live-browser.js @@ -287,7 +287,7 @@ } function shouldShowHighlightTagTooltip() { - // Configure/edit carry the tag in the bar selection pill — keep only the outline. + // Configure/edit carry the tag in the bar selection pill, so keep only the outline. return state !== 'CONFIGURING' && state !== 'EDITING'; } @@ -1148,7 +1148,7 @@ syncPageChatFocus('update-bar-content'); } - // Configure row — the floating bar surface IS the input; modifier pills sit left of the field. + // Configure row: the floating bar surface IS the input; modifier pills sit left of the field. const CONFIGURE_BAR_H = '36px'; // Compact selection pill + 7px inset balances vertical centering in the 36px bar. @@ -1519,7 +1519,7 @@ function buildConfigureCountControl({ controlsLocked, onClick }) { const count = el('button', configureInlineControlStyle({ - fontFamily: MONO, fontWeight: '600', letterSpacing: '-0.02em', + fontFamily: MONO, fontWeight: '600', letterSpacing: '0', })); count.textContent = '\u00D7' + selectedCount; count.disabled = controlsLocked; @@ -5065,6 +5065,157 @@ } } + async function loadSvelteComponentVariantSource(manifest, variantNum) { + const dir = String(manifest?.componentDir || '').replace(/^\/+/, ''); + if (!dir || !variantNum) return ''; + const sourcePath = dir + '/v' + variantNum + '.svelte'; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(sourcePath); + try { + const res = await fetch(url); + if (!res.ok) return ''; + return await res.text(); + } catch { + return ''; + } + } + + function extractSvelteComponentStyle(source) { + const match = String(source || '').match(/]*>([\s\S]*?)<\/style\s*>/i); + return match ? match[1].trim() : ''; + } + + async function applySvelteComponentVariantStyle(variantNum) { + if (!svelteComponentSession || !variantNum) return; + const { manifest, sessionId } = svelteComponentSession; + const source = await loadSvelteComponentVariantSource(manifest, variantNum); + const css = extractSvelteComponentStyle(source); + removeSvelteComponentVariantStyle(svelteComponentSession); + if (!css) return; + const scopedCss = scopeCssToSveltePreview(css, sessionId); + if (!scopedCss) return; + const style = document.createElement('style'); + style.dataset.impeccableSvelteComponentStyle = sessionId; + style.dataset.impeccableVariant = String(variantNum); + style.textContent = scopedCss; + document.head.appendChild(style); + svelteComponentSession.styleEl = style; + } + + function removeSvelteComponentVariantStyle(session = svelteComponentSession) { + const style = session?.styleEl; + if (style?.parentNode) style.parentNode.removeChild(style); + if (session) session.styleEl = null; + } + + function scopeCssToSveltePreview(css, sessionId) { + const prefix = '[data-impeccable-variants="' + String(sessionId).replace(/"/g, '\\"') + '"] '; + return scopeCssBlock(String(css || ''), prefix).trim(); + } + + function scopeCssBlock(css, prefix) { + let out = ''; + let i = 0; + while (i < css.length) { + const open = css.indexOf('{', i); + if (open === -1) { + out += css.slice(i); + break; + } + const semi = css.indexOf(';', i); + if (semi !== -1 && semi < open) { + out += css.slice(i, semi + 1); + i = semi + 1; + continue; + } + const prelude = css.slice(i, open).trim(); + const close = findMatchingCssBrace(css, open); + if (close === -1) { + out += css.slice(i); + break; + } + const body = css.slice(open + 1, close); + if (shouldScopeNestedCssAtRule(prelude)) { + out += prelude + ' {\n' + scopeCssBlock(body, prefix) + '\n}'; + } else if (prelude.startsWith('@')) { + out += prelude + ' {' + body + '}'; + } else { + out += prefixCssSelectors(prelude, prefix) + ' {' + body + '}'; + } + i = close + 1; + } + return out; + } + + function shouldScopeNestedCssAtRule(prelude) { + return /^@(media|supports|container|layer)\b/i.test(prelude || ''); + } + + function findMatchingCssBrace(css, openIndex) { + let depth = 0; + let quote = ''; + for (let i = openIndex; i < css.length; i++) { + const ch = css[i]; + const prev = css[i - 1]; + if (quote) { + if (ch === quote && prev !== '\\') quote = ''; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + } else if (ch === '{') { + depth++; + } else if (ch === '}') { + depth--; + if (depth === 0) return i; + } + } + return -1; + } + + function prefixCssSelectors(prelude, prefix) { + return splitCssSelectorList(prelude) + .map((selector) => { + const s = unwrapSvelteGlobalSelector(selector.trim()); + if (!s) return ''; + if (s.startsWith(prefix.trim())) return s; + if (s.startsWith(':host')) return s.replace(/^:host\b/, prefix.trim()); + return prefix + s; + }) + .filter(Boolean) + .join(', '); + } + + function splitCssSelectorList(selectorList) { + const selectors = []; + let start = 0; + let depth = 0; + let quote = ''; + for (let i = 0; i < selectorList.length; i++) { + const ch = selectorList[i]; + const prev = selectorList[i - 1]; + if (quote) { + if (ch === quote && prev !== '\\') quote = ''; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + } else if (ch === '(' || ch === '[') { + depth++; + } else if ((ch === ')' || ch === ']') && depth > 0) { + depth--; + } else if (ch === ',' && depth === 0) { + selectors.push(selectorList.slice(start, i)); + start = i + 1; + } + } + selectors.push(selectorList.slice(start)); + return selectors; + } + + function unwrapSvelteGlobalSelector(selector) { + return selector.replace(/:global\(([^()]*)\)/g, '$1'); + } + function buildSveltePropValuesFromLiveElement(liveEl, manifest) { const contract = manifest?.propContract || []; const values = {}; @@ -5101,6 +5252,7 @@ }); svelteComponentSession.mountedVariant = variantNum; svelteComponentSession.runtime = runtime; + await applySvelteComponentVariantStyle(variantNum); if (state === 'CYCLING') syncCyclingControls(); const nextAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); if (nextAnchor) { @@ -5134,6 +5286,7 @@ function teardownSvelteComponentSession(restoreOriginal) { if (!svelteComponentSession) return; const { wrapperEl, detachedOriginal, runtime, mountedInstance } = svelteComponentSession; + removeSvelteComponentVariantStyle(svelteComponentSession); if (mountedInstance && runtime?.unmount) { try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } } @@ -5173,6 +5326,7 @@ if (mountedInstance && runtime?.unmount) { try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } } + removeSvelteComponentVariantStyle(svelteComponentSession); wrapperEl.parentElement.replaceChild(committed, wrapperEl); svelteComponentSession = null; svelteRuntimePromise = null; @@ -8843,7 +8997,7 @@ void main() { cursor: 'pointer', flexShrink: '0', width: PAGE_CHAT_COLLAPSED_W, - transition: 'width 0.18s ease, border-color 0.15s ease', + transition: 'border-color 0.15s ease', }); pageChatEl.id = PREFIX + '-page-chat'; pageChatEl.dataset.expanded = 'false'; diff --git a/site/pages/changelog.astro b/site/pages/changelog.astro index 0bc885d9a..91ca46c63 100644 --- a/site/pages/changelog.astro +++ b/site/pages/changelog.astro @@ -23,8 +23,20 @@ import '../styles/changelog-faq-kinpaku.css'; -
-
v3.5.0May 28, 2026Current
+
+
v3.6.0June 14, 2026Current
+

Project design hooks, deeper Live Mode support for Svelte and manual edits, and a broad detector accuracy pass across the skill, CLI, and extension.

+
    +
  • Project design hooks. /impeccable hooks installs and repairs a project-local detector hook for Claude, Codex, and Cursor. Claude and Codex get post-edit reminders, Cursor can block proposed writes before they land, and /impeccable hooks on now handles manifest setup and consent instead of leaving users to wire files by hand.
  • +
  • Hook findings are actionable, not noisy. Hook runs track clean, pending, and fresh findings, cache duplicate reports, audit their own activity, and offer narrow ignore flows through ignore-value, ignore-file, and ignore-rule. Shared config lives in .impeccable/config.json, with local consent and overrides in .impeccable/config.local.json.
  • +
  • Svelte-native Live Mode. Svelte and SvelteKit variants now preview as temporary framework components with params stored in params.json, then accept back into the selected source component. That keeps stateful pages closer to their real shape and avoids the HMR resets caused by string-injected previews.
  • +
  • Manual and browser Live Mode got sturdier. Manual text edits have dedicated evidence, apply, and discard routes; Live Mode preserves insertion anchors and mapped-list accept cleanup; and the browser payload is split into DOM helpers, UI primitives, vocabulary, and manual-apply modules instead of one giant script.
  • +
  • Detector accuracy improved across the bundled skill. Hidden and unrendered elements are skipped in browser rules, sr-only and visually hidden text no longer trips text-overflow, repeated kicker false positives are reduced in card and list contexts, oversized H1 detection now requires viewport dominance, clipped overflow distinguishes decorative viewports from escaping content, OKLCH alpha parses correctly, Sass files count as CSS-like detector inputs, transparent borders or shadows no longer trigger the GPT thin-border rule, and page-level numbered-marker checks no longer treat JS, TS, JSX, TSX, or CSS implementation literals as visible page copy.
  • +
+
+ +
+
v3.5.0May 28, 2026

The biggest release yet. Per-provider rules that lift GPT-5.5 and Codex most, a skill that adapts to new versus existing projects, and Live Mode in Beta.

@@ -71,6 +83,17 @@ import '../styles/changelog-faq-kinpaku.css';
+
+
CLI v3.0.0June 14, 2026
+
    +
  • Breaking: Node 24 minimum. The CLI now declares "node": ">=24". Upgrade Node before installing or running this version.
  • +
  • Hook-aware installs and updates. impeccable skills install and impeccable skills update can prompt once for hook consent, persist the local answer, install or repair .claude/settings.local.json, .codex/hooks.json, and .cursor/hooks.json, and still honor --no-hooks for teams that want skills without editor hooks.
  • +
  • Local and submodule workflows are cleaner. skills link --source=.impeccable supports repo-local development, symlink-safe updates avoid clobbering linked installs, provider aliases include Codex and Rovo Dev names, local bundle overrides are explicit, and ZIP extraction is safer on Windows.
  • +
  • The detector got a real accuracy pass. The CLI detector now skips hidden browser elements, handles sr-only text overflow, reduces repeated kicker false positives, tightens oversized H1 and clipped-overflow heuristics, understands OKLCH alpha and Sass inputs, avoids transparent-border false positives in the GPT thin-border rule, and keeps page-level numbered-marker analysis out of JS, TS, JSX, TSX, and CSS source literals.
  • +
  • Release and CI plumbing is stricter. Build commands are split between source validation and release-output sync, scripts/run-tests.mjs owns named test suites, and bun run smoke:hooks verifies provider hook manifests across the generated bundles.
  • +
+
+
CLI v2.3.2May 29, 2026
    @@ -105,9 +128,12 @@ import '../styles/changelog-faq-kinpaku.css';
-
Extension v1.2.0June 1, 2026
+
Extension v1.2.0June 14, 2026
  • Firefox build. The same detector, popup, DevTools panel, and per-rule toggles now ship as a Firefox add-on. bun run build:extension emits a Gecko-compatible package next to the Chrome one, with the background worker declared as an event page and a data-collection declaration that states what the extension already does: the scan runs in the page, and nothing leaves your machine.
  • +
  • Firefox DevTools paths are fixed. DevTools panel and sidebar URLs are root-relative in the Firefox manifest, so packaged builds can open their extension pages reliably.
  • +
  • Detector results match the latest engine. The overlay picks up the same false-positive fixes as the CLI: hidden-element skips, sr-only text-overflow handling, tighter repeated kicker, oversized H1, clipped-overflow, OKLCH alpha, Sass-adjacent CSS parsing, and transparent-border handling.
  • +
  • Scan responses are easier to correlate. Extension scan messages echo scan IDs back to the caller, and the store metadata and icon set were refreshed for the current 41-rule detector.
diff --git a/site/styles/sub-pages.css b/site/styles/sub-pages.css index 331638834..164f2b3ec 100644 --- a/site/styles/sub-pages.css +++ b/site/styles/sub-pages.css @@ -2370,8 +2370,8 @@ main#main { .prose blockquote { margin: 1.5em 0; - padding: 0 0 0 var(--spacing-md); - border-left: 3px solid var(--color-mist); + padding: var(--spacing-sm) 0; + border-block: 1px solid var(--color-mist); color: var(--color-ash); font-style: italic; } diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index 539cd07d9..221fed9c6 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -287,7 +287,7 @@ } function shouldShowHighlightTagTooltip() { - // Configure/edit carry the tag in the bar selection pill — keep only the outline. + // Configure/edit carry the tag in the bar selection pill, so keep only the outline. return state !== 'CONFIGURING' && state !== 'EDITING'; } @@ -1148,7 +1148,7 @@ syncPageChatFocus('update-bar-content'); } - // Configure row — the floating bar surface IS the input; modifier pills sit left of the field. + // Configure row: the floating bar surface IS the input; modifier pills sit left of the field. const CONFIGURE_BAR_H = '36px'; // Compact selection pill + 7px inset balances vertical centering in the 36px bar. @@ -1519,7 +1519,7 @@ function buildConfigureCountControl({ controlsLocked, onClick }) { const count = el('button', configureInlineControlStyle({ - fontFamily: MONO, fontWeight: '600', letterSpacing: '-0.02em', + fontFamily: MONO, fontWeight: '600', letterSpacing: '0', })); count.textContent = '\u00D7' + selectedCount; count.disabled = controlsLocked; @@ -5065,6 +5065,157 @@ } } + async function loadSvelteComponentVariantSource(manifest, variantNum) { + const dir = String(manifest?.componentDir || '').replace(/^\/+/, ''); + if (!dir || !variantNum) return ''; + const sourcePath = dir + '/v' + variantNum + '.svelte'; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(sourcePath); + try { + const res = await fetch(url); + if (!res.ok) return ''; + return await res.text(); + } catch { + return ''; + } + } + + function extractSvelteComponentStyle(source) { + const match = String(source || '').match(/]*>([\s\S]*?)<\/style\s*>/i); + return match ? match[1].trim() : ''; + } + + async function applySvelteComponentVariantStyle(variantNum) { + if (!svelteComponentSession || !variantNum) return; + const { manifest, sessionId } = svelteComponentSession; + const source = await loadSvelteComponentVariantSource(manifest, variantNum); + const css = extractSvelteComponentStyle(source); + removeSvelteComponentVariantStyle(svelteComponentSession); + if (!css) return; + const scopedCss = scopeCssToSveltePreview(css, sessionId); + if (!scopedCss) return; + const style = document.createElement('style'); + style.dataset.impeccableSvelteComponentStyle = sessionId; + style.dataset.impeccableVariant = String(variantNum); + style.textContent = scopedCss; + document.head.appendChild(style); + svelteComponentSession.styleEl = style; + } + + function removeSvelteComponentVariantStyle(session = svelteComponentSession) { + const style = session?.styleEl; + if (style?.parentNode) style.parentNode.removeChild(style); + if (session) session.styleEl = null; + } + + function scopeCssToSveltePreview(css, sessionId) { + const prefix = '[data-impeccable-variants="' + String(sessionId).replace(/"/g, '\\"') + '"] '; + return scopeCssBlock(String(css || ''), prefix).trim(); + } + + function scopeCssBlock(css, prefix) { + let out = ''; + let i = 0; + while (i < css.length) { + const open = css.indexOf('{', i); + if (open === -1) { + out += css.slice(i); + break; + } + const semi = css.indexOf(';', i); + if (semi !== -1 && semi < open) { + out += css.slice(i, semi + 1); + i = semi + 1; + continue; + } + const prelude = css.slice(i, open).trim(); + const close = findMatchingCssBrace(css, open); + if (close === -1) { + out += css.slice(i); + break; + } + const body = css.slice(open + 1, close); + if (shouldScopeNestedCssAtRule(prelude)) { + out += prelude + ' {\n' + scopeCssBlock(body, prefix) + '\n}'; + } else if (prelude.startsWith('@')) { + out += prelude + ' {' + body + '}'; + } else { + out += prefixCssSelectors(prelude, prefix) + ' {' + body + '}'; + } + i = close + 1; + } + return out; + } + + function shouldScopeNestedCssAtRule(prelude) { + return /^@(media|supports|container|layer)\b/i.test(prelude || ''); + } + + function findMatchingCssBrace(css, openIndex) { + let depth = 0; + let quote = ''; + for (let i = openIndex; i < css.length; i++) { + const ch = css[i]; + const prev = css[i - 1]; + if (quote) { + if (ch === quote && prev !== '\\') quote = ''; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + } else if (ch === '{') { + depth++; + } else if (ch === '}') { + depth--; + if (depth === 0) return i; + } + } + return -1; + } + + function prefixCssSelectors(prelude, prefix) { + return splitCssSelectorList(prelude) + .map((selector) => { + const s = unwrapSvelteGlobalSelector(selector.trim()); + if (!s) return ''; + if (s.startsWith(prefix.trim())) return s; + if (s.startsWith(':host')) return s.replace(/^:host\b/, prefix.trim()); + return prefix + s; + }) + .filter(Boolean) + .join(', '); + } + + function splitCssSelectorList(selectorList) { + const selectors = []; + let start = 0; + let depth = 0; + let quote = ''; + for (let i = 0; i < selectorList.length; i++) { + const ch = selectorList[i]; + const prev = selectorList[i - 1]; + if (quote) { + if (ch === quote && prev !== '\\') quote = ''; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + } else if (ch === '(' || ch === '[') { + depth++; + } else if ((ch === ')' || ch === ']') && depth > 0) { + depth--; + } else if (ch === ',' && depth === 0) { + selectors.push(selectorList.slice(start, i)); + start = i + 1; + } + } + selectors.push(selectorList.slice(start)); + return selectors; + } + + function unwrapSvelteGlobalSelector(selector) { + return selector.replace(/:global\(([^()]*)\)/g, '$1'); + } + function buildSveltePropValuesFromLiveElement(liveEl, manifest) { const contract = manifest?.propContract || []; const values = {}; @@ -5101,6 +5252,7 @@ }); svelteComponentSession.mountedVariant = variantNum; svelteComponentSession.runtime = runtime; + await applySvelteComponentVariantStyle(variantNum); if (state === 'CYCLING') syncCyclingControls(); const nextAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); if (nextAnchor) { @@ -5134,6 +5286,7 @@ function teardownSvelteComponentSession(restoreOriginal) { if (!svelteComponentSession) return; const { wrapperEl, detachedOriginal, runtime, mountedInstance } = svelteComponentSession; + removeSvelteComponentVariantStyle(svelteComponentSession); if (mountedInstance && runtime?.unmount) { try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } } @@ -5173,6 +5326,7 @@ if (mountedInstance && runtime?.unmount) { try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } } + removeSvelteComponentVariantStyle(svelteComponentSession); wrapperEl.parentElement.replaceChild(committed, wrapperEl); svelteComponentSession = null; svelteRuntimePromise = null; @@ -8843,7 +8997,7 @@ void main() { cursor: 'pointer', flexShrink: '0', width: PAGE_CHAT_COLLAPSED_W, - transition: 'width 0.18s ease, border-color 0.15s ease', + transition: 'border-color 0.15s ease', }); pageChatEl.id = PREFIX + '-page-chat'; pageChatEl.dataset.expanded = 'false'; diff --git a/tests/detect-antipatterns-fixtures.test.mjs b/tests/detect-antipatterns-fixtures.test.mjs index 5f2cd28a3..5eb6fe0cc 100644 --- a/tests/detect-antipatterns-fixtures.test.mjs +++ b/tests/detect-antipatterns-fixtures.test.mjs @@ -242,6 +242,17 @@ describe('detectHtml — static HTML/CSS fixtures', () => { const f = await detectHtml(path.join(FIXTURES, 'typography-should-pass.html')); assert.equal(f.length, 0); }); + + it('numbered-section-markers: visible sequence flags while script/style/svg internals pass', async () => { + const f = await detectHtml(path.join(FIXTURES, 'numbered-section-markers.html')); + const numbered = f.filter(r => r.antipattern === 'numbered-section-markers'); + assert.equal( + numbered.length, + 1, + `expected one visible numbered-marker finding, got: ${numbered.map(r => r.snippet).join('; ')}` + ); + assert.match(numbered[0].snippet, /01, 02, 03/); + }); }); describe('detectHtml — icon-tile-stack', () => { diff --git a/tests/detect-antipatterns.test.js b/tests/detect-antipatterns.test.js index b3b1b6bdb..199779717 100644 --- a/tests/detect-antipatterns.test.js +++ b/tests/detect-antipatterns.test.js @@ -247,6 +247,32 @@ describe('partials skip page-level checks', () => { }); }); +describe('detectText — numbered section markers', () => { + test('flags visible full-page numbered section labels', () => { + const page = '' + + '
01

Strategy

' + + '
02

Prototype

' + + '
03

Launch

' + + ''; + const f = detectText(page, 'test.html'); + expect(f.some(r => r.antipattern === 'numbered-section-markers')).toBe(true); + }); + + test('does not run page-level numbered marker analysis on JS source with embedded HTML strings', () => { + const source = ` + const shell = 'Preview'; + const palette = 'oklch(86% 0.07 84 / 0.08)'; + const shadow = '0 0 0 1px oklch(0% 0 0 / 0.04), 0 4px 16px oklch(0% 0 0 / 0.05), 0 1px 3px oklch(0% 0 0 / 0.06)'; + const size = '11.5px'; + const eye = ''; + const shader = 'float band = bandAt(uv.y - y, 0.05, 0.32);'; + const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; + `; + const f = detectText(source, 'live-browser.js'); + expect(f.filter(r => r.antipattern === 'numbered-section-markers')).toHaveLength(0); + }); +}); + // --------------------------------------------------------------------------- // Layout anti-patterns // --------------------------------------------------------------------------- diff --git a/tests/fixtures/antipatterns/numbered-section-markers.html b/tests/fixtures/antipatterns/numbered-section-markers.html new file mode 100644 index 000000000..bdcecb834 --- /dev/null +++ b/tests/fixtures/antipatterns/numbered-section-markers.html @@ -0,0 +1,84 @@ + + + + + Numbered section marker fixture + + + +
+
+

Should flag

+
+ 01 +

Strategy

+
+
+ 02 +

Prototype

+
+
+ 03 +

Launch

+
+
+ 04 +

Measure

+
+
+ +
+

Should pass

+
+

Readable date

+

Updated June 14, 2026.

+
+
+

Statistic cards

+

Revenue grew 18%, margin reached 24%, and churn fell to 3%.

+
+
+

CSS generated content

+

Generated numbers in CSS should not count as body copy.

+
+
+

Inline SVG coordinates

+ +
+
+

Script constants

+ +

Implementation constants in script blocks are invisible.

+
+
+
+ +