mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-13 14:46:35 +03:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff94918ddc | ||
|
|
894b95b988 | ||
|
|
f4f16e3802 |
@@ -1490,7 +1490,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
|
||||
return isNeutralAuthoredColor(m[1]);
|
||||
}
|
||||
|
||||
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
|
||||
|
||||
function scanJs(text, start, onChar) {
|
||||
let stringQuote = '';
|
||||
let inTemplate = false;
|
||||
let paren = 0;
|
||||
let brace = 0;
|
||||
const interpBrace = [];
|
||||
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const prev = text[i - 1];
|
||||
const next = text[i + 1];
|
||||
|
||||
if (stringQuote) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === stringQuote) stringQuote = '';
|
||||
continue;
|
||||
}
|
||||
if (inTemplate && interpBrace.length === 0) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === '$' && next === '{') {
|
||||
brace++;
|
||||
interpBrace.push(brace);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === '`') { inTemplate = false; continue; }
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"') { stringQuote = char; continue; }
|
||||
if (char === '`') { inTemplate = true; continue; }
|
||||
if (char === '(') { paren++; continue; }
|
||||
if (char === ')') { paren--; continue; }
|
||||
if (char === '{') { brace++; continue; }
|
||||
if (char === '}') {
|
||||
brace--;
|
||||
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
|
||||
continue;
|
||||
}
|
||||
if (onChar(char, i, prev, next, { paren, brace })) return;
|
||||
}
|
||||
}
|
||||
|
||||
function containingMarkupTag(line, index) {
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
const tagStart = line.indexOf('<', i);
|
||||
if (tagStart === -1) break;
|
||||
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
|
||||
i = tagStart + 1;
|
||||
continue;
|
||||
}
|
||||
let tagEnd = -1;
|
||||
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
|
||||
if (char === '>' && depth.brace === 0) {
|
||||
tagEnd = j;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (tagEnd === -1) break;
|
||||
if (index >= tagStart && index <= tagEnd) {
|
||||
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
|
||||
}
|
||||
i = tagEnd + 1;
|
||||
}
|
||||
return { text: line, start: 0 };
|
||||
}
|
||||
|
||||
function findTernarySplit(text) {
|
||||
let qPos = -1;
|
||||
let qParen = 0;
|
||||
let qBrace = 0;
|
||||
let nested = 0;
|
||||
let colonPos = -1;
|
||||
let split = null;
|
||||
|
||||
const isQuestion = (char, prev, next) =>
|
||||
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
|
||||
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
|
||||
|
||||
scanJs(text, 0, (char, i, prev, next, depth) => {
|
||||
if (colonPos === -1) {
|
||||
if (qPos === -1 && isQuestion(char, prev, next)) {
|
||||
qPos = i;
|
||||
qParen = depth.paren;
|
||||
qBrace = depth.brace;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
|
||||
nested++;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
|
||||
if (nested) nested--;
|
||||
else colonPos = i;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (char === ',' && sameDepth(depth)) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1, i),
|
||||
suffix: text.slice(i),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!split && qPos !== -1 && colonPos !== -1) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1),
|
||||
suffix: '',
|
||||
};
|
||||
}
|
||||
return split;
|
||||
}
|
||||
|
||||
function exclusiveClassScopes(text) {
|
||||
const split = findTernarySplit(text);
|
||||
if (!split) return [text];
|
||||
return [
|
||||
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
|
||||
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
|
||||
];
|
||||
}
|
||||
|
||||
function grayOnColorScopes(line, index) {
|
||||
return exclusiveClassScopes(containingMarkupTag(line, index).text);
|
||||
}
|
||||
|
||||
function grayOnColorPairs(line, grayClass, index) {
|
||||
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
|
||||
}
|
||||
|
||||
const REGEX_MATCHERS = [
|
||||
// --- Side-tab ---
|
||||
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
|
||||
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
|
||||
fmt: () => 'bg-clip-text + bg-gradient' },
|
||||
// --- Tailwind gray on colored bg ---
|
||||
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
|
||||
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
|
||||
fmt: (m, line) => {
|
||||
const bg = grayOnColorPairs(line, m[0], m.index)
|
||||
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
|
||||
.find(Boolean);
|
||||
return `${m[0]} on ${bg?.[0] || '?'}`;
|
||||
} },
|
||||
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
||||
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
|
||||
// --- Tailwind AI palette ---
|
||||
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
|
||||
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
|
||||
|
||||
@@ -217,7 +217,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -2060,7 +2060,7 @@
|
||||
if (anchor) return anchor;
|
||||
}
|
||||
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (wrapper) {
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0 && visibleVariant > 0) {
|
||||
@@ -2131,14 +2131,14 @@
|
||||
|
||||
function isInsertGeneratingSession() {
|
||||
if (state !== 'GENERATING' || !currentSessionId) return false;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
|
||||
}
|
||||
|
||||
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
|
||||
function ensureInsertPlaceholder() {
|
||||
if (!isInsertGeneratingSession()) return placeholderElement;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0) return placeholderElement;
|
||||
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
|
||||
@@ -3156,7 +3156,7 @@
|
||||
|| svelteComponentSession.wrapperEl
|
||||
|| null;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
@@ -4900,7 +4900,7 @@
|
||||
return Object.values(svelteComponentSession.paramsByVariant || {})
|
||||
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
|
||||
.reduce((total, variant) => total + parseVariantParams(variant).length, 0);
|
||||
@@ -5004,7 +5004,7 @@
|
||||
scheduleCyclingBarSync(sessionId, num);
|
||||
return true;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
updateVariantStateStylesheet(sessionId, num);
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
@@ -5820,7 +5820,6 @@
|
||||
return;
|
||||
}
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
completeParameterGenerationIfReady();
|
||||
@@ -6217,71 +6216,6 @@
|
||||
return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
|
||||
}
|
||||
|
||||
function sourceHasSessionWrapper(text, sessionId) {
|
||||
const src = String(text || '');
|
||||
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|
||||
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|
||||
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
|
||||
* wrapper deleted from source look identical in the DOM, and only the second
|
||||
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
|
||||
* injecting raw JSX; reading the file as plain text and matching the session
|
||||
* marker honors that, because no DOM is ever built from what comes back.
|
||||
* Marker present means the component is simply not mounted right now (a
|
||||
* closed modal, another route) and the variant observer keeps waiting.
|
||||
* Marker absent after the same retry budget the HTML path uses means the
|
||||
* file was edited out from under the session, which no reload, HMR push, or
|
||||
* server restart can repair, so the session self-discards and hands the
|
||||
* surface back to the picker.
|
||||
*/
|
||||
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
|
||||
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
|
||||
const retryLater = () => {
|
||||
setTimeout(() => {
|
||||
if (!stillActive()) return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
};
|
||||
// Discarding is durable (the session moves to the discarded phase and the
|
||||
// picker replaces it), so it needs evidence that the wrapper is gone: a
|
||||
// read that answers without the marker, or a 404 (the file itself was
|
||||
// renamed or deleted). Either kind retries on the shared budget first.
|
||||
// A read that fails for any other reason (the server briefly away, a
|
||||
// transient fetch error) says nothing about the wrapper; after the budget
|
||||
// the session is kept, the user told, and the next event retries.
|
||||
const onNoWrapper = (reason) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
discardOrphanedSession(reason);
|
||||
};
|
||||
const onUnreadable = (detail) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
|
||||
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
|
||||
};
|
||||
fetch(url)
|
||||
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
|
||||
.then(text => {
|
||||
if (!stillActive()) return;
|
||||
if (sourceHasSessionWrapper(text, sessionId)) return;
|
||||
onNoWrapper('variant wrapper missing from source');
|
||||
})
|
||||
.catch(err => {
|
||||
const detail = err && err.message ? err.message : 'fetch failed';
|
||||
if (/source read failed: 404$/.test(detail)) {
|
||||
onNoWrapper('source file missing (404) while checking for the variant wrapper');
|
||||
return;
|
||||
}
|
||||
onUnreadable(detail);
|
||||
});
|
||||
}
|
||||
|
||||
function completeSourceInjection(wrapper, sessionId, opts) {
|
||||
recoveryWaitingForAnchor = false;
|
||||
if (pendingVariantAnchorRetryObserver) {
|
||||
@@ -6362,7 +6296,7 @@
|
||||
}
|
||||
rememberSessionFileMeta({ file: filePath });
|
||||
if (isJsxSourceFile(filePath)) {
|
||||
const liveWrapper = findVariantsWrapper(sessionId);
|
||||
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
|
||||
return;
|
||||
@@ -6392,7 +6326,14 @@
|
||||
return;
|
||||
}
|
||||
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
||||
probeJsxWrapperForOrphan(filePath, sessionId, opts);
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
|
||||
setTimeout(() => {
|
||||
if (sessionId !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -6434,7 +6375,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const existingWrapper = findVariantsWrapper(sessionId);
|
||||
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (existingWrapper) {
|
||||
const wrapper = srcWrapper.cloneNode(true);
|
||||
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
|
||||
@@ -6591,7 +6532,7 @@
|
||||
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
||||
return;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const visEl = pickVariantContent(wrapper, visibleVariant);
|
||||
if (visEl) selectedElement = visEl;
|
||||
@@ -6601,7 +6542,7 @@
|
||||
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
||||
return svelteComponentSession.mountedVariant;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
for (const variant of variants) {
|
||||
@@ -6716,17 +6657,8 @@
|
||||
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
|
||||
* one wrapper per item, so the hide, the release, and the existence checks
|
||||
* all have to speak about the same set.
|
||||
*/
|
||||
function discardedWrappers(sessionId) {
|
||||
if (!sessionId) return [];
|
||||
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
|
||||
}
|
||||
|
||||
function releaseDiscardedStaticWrapper(wrapper) {
|
||||
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
if (!wrapper) return;
|
||||
const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
|
||||
const content = orig?.firstElementChild;
|
||||
@@ -6737,18 +6669,6 @@
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the discard hide on every wrapper it covered. Releasing only the
|
||||
* first match left the other mapped items sitting at display:none with
|
||||
* their original content never restored, on exactly the static and
|
||||
* missed-HMR flows this fallback exists for.
|
||||
*/
|
||||
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
|
||||
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
|
||||
}
|
||||
|
||||
function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
|
||||
if (!sessionId || !document.body) return;
|
||||
if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
|
||||
@@ -6929,42 +6849,6 @@
|
||||
// MutationObserver for progressive variant reveal
|
||||
//
|
||||
|
||||
// A session id can have more than one wrapper in the DOM: the target may sit
|
||||
// inside a `.map()` callback (the wrapper renders once per item), or the
|
||||
// agent may have relocated the wrapper out of the shared primitive live-wrap
|
||||
// scaffolded into. A plain first match can then pin an empty scaffold while
|
||||
// the real variants sit in a later wrapper, which strands the session at
|
||||
// 0/N and leaves the bar, the params panel, and accept all reading the
|
||||
// wrong element. Prefer a wrapper that actually holds variants. With zero
|
||||
// or one match this is exactly the querySelector it replaces.
|
||||
//
|
||||
// Every lookup of the ACTIVE session's wrapper goes through here. The
|
||||
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
|
||||
// existence checks, selector strings for stylesheets and observers (which
|
||||
// want to cover every match), `querySelectorAll` sweeps, and the parsed
|
||||
// source document, which is not this document.
|
||||
function pickPopulatedVariantsWrapper(selector) {
|
||||
const matches = document.querySelectorAll(selector);
|
||||
if (matches.length < 2) return matches[0] || null;
|
||||
for (const candidate of matches) {
|
||||
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
/** The wrapper holding `sessionId`'s variants, or null without an id. */
|
||||
function findVariantsWrapper(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
|
||||
}
|
||||
|
||||
/** Any live variant wrapper, for the resume paths that have no id yet. */
|
||||
function findAnyVariantsWrapper() {
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
|
||||
}
|
||||
|
||||
function startVariantObserver(sessionId) {
|
||||
let updating = false; // re-entrancy guard
|
||||
|
||||
@@ -6994,7 +6878,7 @@
|
||||
}
|
||||
if (!dominated) return;
|
||||
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
@@ -7203,7 +7087,6 @@
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
if (state === 'GENERATING') {
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
disableInlineEdit();
|
||||
refreshParamsPanel();
|
||||
@@ -7264,7 +7147,6 @@
|
||||
pendingAcceptedSession = null;
|
||||
awaitingAcceptResult = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup. Try Accept again.', 5000);
|
||||
break;
|
||||
@@ -8367,15 +8249,6 @@ void main() {
|
||||
// matches the original off-white risograph paper.
|
||||
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
|
||||
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
|
||||
// showShaderOverlay is async: it appends its canvas, then awaits
|
||||
// createImageBitmap and the GL setup before it publishes shaderState. A
|
||||
// teardown that landed inside that window found shaderState still null,
|
||||
// returned, and then watched the construction publish itself over a session
|
||||
// that had already left GENERATING, with no teardown left to run. That is
|
||||
// the generating loader frozen over a page that already cycles (issue #719).
|
||||
// Every teardown bumps this epoch; a construction abandons its own canvas as
|
||||
// soon as it sees the epoch move.
|
||||
let shaderEpoch = 0;
|
||||
|
||||
// The element's effective background tone, used as the uniform halftone
|
||||
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground
|
||||
@@ -8522,28 +8395,14 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop a shader node no shaderState owns (an abandoned construction). */
|
||||
function removeStrayShaderNode() {
|
||||
const stray = uiGetById(PREFIX + '-shader');
|
||||
if (stray) stray.remove();
|
||||
}
|
||||
|
||||
function hideShaderOverlay() {
|
||||
// Bump first, unconditionally: this is what tells an in-flight
|
||||
// showShaderOverlay to abandon itself rather than publish over a session
|
||||
// that has already moved on.
|
||||
shaderEpoch += 1;
|
||||
if (!shaderState) {
|
||||
removeStrayShaderNode();
|
||||
return;
|
||||
}
|
||||
if (!shaderState) return;
|
||||
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
|
||||
if (shaderState.canvas) shaderState.canvas.remove();
|
||||
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
|
||||
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
shaderState = null;
|
||||
removeStrayShaderNode();
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
@@ -8568,16 +8427,6 @@ void main() {
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
// hideShaderOverlay just bumped the epoch, so this run owns it until the
|
||||
// next teardown. Every step past an await re-checks before it publishes.
|
||||
const epoch = shaderEpoch;
|
||||
const abandoned = (node, gl) => {
|
||||
if (epoch === shaderEpoch) return false;
|
||||
node.remove();
|
||||
const lose = gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
return true;
|
||||
};
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = PREFIX + '-shader';
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
@@ -8600,7 +8449,6 @@ void main() {
|
||||
if (!gl) {
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
if (abandoned(canvas, null)) return;
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
@@ -8640,22 +8488,16 @@ void main() {
|
||||
}
|
||||
|
||||
// Upload the screenshot as a texture
|
||||
if (abandoned(canvas, gl)) return;
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
if (abandoned(canvas, gl)) return;
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
if (abandoned(canvas, gl)) {
|
||||
if (bitmap.close) bitmap.close();
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
@@ -8674,7 +8516,6 @@ void main() {
|
||||
const paperRgb = paper || resolvePaperRgb(el);
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if (abandoned(canvas, gl)) return;
|
||||
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
|
||||
function frame() {
|
||||
if (!shaderState) return;
|
||||
@@ -8711,7 +8552,7 @@ void main() {
|
||||
clientSentAt: Date.now(),
|
||||
};
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
const acceptWrapper = findVariantsWrapper(currentSessionId);
|
||||
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
@@ -8754,7 +8595,6 @@ void main() {
|
||||
.catch(() => {
|
||||
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
@@ -8806,7 +8646,7 @@ void main() {
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
@@ -8933,7 +8773,7 @@ void main() {
|
||||
}
|
||||
|
||||
function commitAcceptedVariantToDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!accepted || !accepted.firstElementChild) return false;
|
||||
@@ -9161,7 +9001,7 @@ void main() {
|
||||
}
|
||||
|
||||
function restoreFromActiveSessions(activeSessions, reason) {
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
|
||||
if (svelteComponentSession?.sessionId === currentSessionId) return false;
|
||||
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
|
||||
@@ -9274,13 +9114,10 @@ void main() {
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
// replaced the wrapper by then (keeps static-server / no-HMR flows alive).
|
||||
// Every match, not the first: a target inside a `.map()` renders one
|
||||
// wrapper per item, and hiding only one leaves the rest of the
|
||||
// discarded variants on screen.
|
||||
const discardWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (discardWrappers.length > 0) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (wrapper) {
|
||||
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
||||
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
|
||||
else wrapper.style.display = 'none';
|
||||
}
|
||||
setTimeout(function() {
|
||||
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
||||
@@ -9288,19 +9125,16 @@ void main() {
|
||||
removeDiscardStateStylesheet();
|
||||
return;
|
||||
}
|
||||
const lateWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (lateWrappers.length === 0) {
|
||||
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (!lateWrapper) {
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
// Duplicates all render from one source element, so HMR ownership is
|
||||
// uniform across them; the first is a fair witness for the set.
|
||||
const lateWrapper = lateWrappers[0];
|
||||
if (recoverySuperseded) {
|
||||
if (hasFrameworkHmrOwnership(lateWrapper)) {
|
||||
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
} else {
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -9309,20 +9143,18 @@ void main() {
|
||||
// the final source rewrite, reload once after a grace window so the
|
||||
// discarded source becomes authoritative without a reconciler race.
|
||||
setTimeout(function() {
|
||||
const staleWrappers = discardedWrappers(cleanupSessionId);
|
||||
const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
||||
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
// A reload restores every wrapper's original at once, so there is
|
||||
// nothing per-wrapper to do here.
|
||||
if (staleWrappers.length > 0) location.reload();
|
||||
if (staleWrapper) location.reload();
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}, 2000);
|
||||
}
|
||||
hideBar(instantChrome);
|
||||
@@ -9510,13 +9342,8 @@ void main() {
|
||||
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
||||
}
|
||||
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
|
||||
// Which path resumed matters in the journal: an init resume is a fresh
|
||||
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
|
||||
// used to log the same `browser_resumed`, which made issue #719 take a
|
||||
// DOM reconstruction to diagnose.
|
||||
const resumeReason = opts.reason || 'browser_resumed';
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
|
||||
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
|
||||
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
|
||||
@@ -9615,38 +9442,16 @@ void main() {
|
||||
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// A resume can BE the arrival, not just a re-entry after one. The server's
|
||||
// generation preflight runs live-wrap with --defer-source-write, so the
|
||||
// wrapper and every variant reach the DOM in one HMR batch, and the
|
||||
// deferred-wrapper scout (constructed at init) runs before the variant
|
||||
// MutationObserver (constructed at Go) on that batch. Finish the same
|
||||
// transition the observer would have finished. Without hideShaderOverlay
|
||||
// the generating shader stays frozen over the target and the session looks
|
||||
// stuck at GENERATING while the bar already cycles (issue #719).
|
||||
if (state === 'CYCLING') {
|
||||
recoveryWaitingForAnchor = false;
|
||||
hideShaderOverlay();
|
||||
if (isInsert) finalizeInsertSession();
|
||||
disableInlineEdit();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
||||
sendCheckpoint('variants_progress');
|
||||
} else {
|
||||
queueCheckpoint(resumeReason);
|
||||
// Only variants_progress and variants_ready count as publication
|
||||
// progress. When the resume is the arrival, the observer never gets to
|
||||
// report it (this function disconnects and re-creates it below, which
|
||||
// drops the records it had already queued for this same batch), so
|
||||
// without this the server never learns the variants were published.
|
||||
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
sendCheckpoint('variants_ready');
|
||||
}
|
||||
queueCheckpoint('browser_resumed');
|
||||
}
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -12968,7 +12773,7 @@ void main() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
|
||||
if (!wrapper) return;
|
||||
scout.disconnect();
|
||||
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
|
||||
if (resumeSession(deferredResumeRevision)) {
|
||||
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
[alias]
|
||||
xtask = "run --quiet --package xtask --"
|
||||
@@ -1490,7 +1490,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
|
||||
return isNeutralAuthoredColor(m[1]);
|
||||
}
|
||||
|
||||
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
|
||||
|
||||
function scanJs(text, start, onChar) {
|
||||
let stringQuote = '';
|
||||
let inTemplate = false;
|
||||
let paren = 0;
|
||||
let brace = 0;
|
||||
const interpBrace = [];
|
||||
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const prev = text[i - 1];
|
||||
const next = text[i + 1];
|
||||
|
||||
if (stringQuote) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === stringQuote) stringQuote = '';
|
||||
continue;
|
||||
}
|
||||
if (inTemplate && interpBrace.length === 0) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === '$' && next === '{') {
|
||||
brace++;
|
||||
interpBrace.push(brace);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === '`') { inTemplate = false; continue; }
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"') { stringQuote = char; continue; }
|
||||
if (char === '`') { inTemplate = true; continue; }
|
||||
if (char === '(') { paren++; continue; }
|
||||
if (char === ')') { paren--; continue; }
|
||||
if (char === '{') { brace++; continue; }
|
||||
if (char === '}') {
|
||||
brace--;
|
||||
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
|
||||
continue;
|
||||
}
|
||||
if (onChar(char, i, prev, next, { paren, brace })) return;
|
||||
}
|
||||
}
|
||||
|
||||
function containingMarkupTag(line, index) {
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
const tagStart = line.indexOf('<', i);
|
||||
if (tagStart === -1) break;
|
||||
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
|
||||
i = tagStart + 1;
|
||||
continue;
|
||||
}
|
||||
let tagEnd = -1;
|
||||
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
|
||||
if (char === '>' && depth.brace === 0) {
|
||||
tagEnd = j;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (tagEnd === -1) break;
|
||||
if (index >= tagStart && index <= tagEnd) {
|
||||
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
|
||||
}
|
||||
i = tagEnd + 1;
|
||||
}
|
||||
return { text: line, start: 0 };
|
||||
}
|
||||
|
||||
function findTernarySplit(text) {
|
||||
let qPos = -1;
|
||||
let qParen = 0;
|
||||
let qBrace = 0;
|
||||
let nested = 0;
|
||||
let colonPos = -1;
|
||||
let split = null;
|
||||
|
||||
const isQuestion = (char, prev, next) =>
|
||||
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
|
||||
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
|
||||
|
||||
scanJs(text, 0, (char, i, prev, next, depth) => {
|
||||
if (colonPos === -1) {
|
||||
if (qPos === -1 && isQuestion(char, prev, next)) {
|
||||
qPos = i;
|
||||
qParen = depth.paren;
|
||||
qBrace = depth.brace;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
|
||||
nested++;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
|
||||
if (nested) nested--;
|
||||
else colonPos = i;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (char === ',' && sameDepth(depth)) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1, i),
|
||||
suffix: text.slice(i),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!split && qPos !== -1 && colonPos !== -1) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1),
|
||||
suffix: '',
|
||||
};
|
||||
}
|
||||
return split;
|
||||
}
|
||||
|
||||
function exclusiveClassScopes(text) {
|
||||
const split = findTernarySplit(text);
|
||||
if (!split) return [text];
|
||||
return [
|
||||
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
|
||||
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
|
||||
];
|
||||
}
|
||||
|
||||
function grayOnColorScopes(line, index) {
|
||||
return exclusiveClassScopes(containingMarkupTag(line, index).text);
|
||||
}
|
||||
|
||||
function grayOnColorPairs(line, grayClass, index) {
|
||||
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
|
||||
}
|
||||
|
||||
const REGEX_MATCHERS = [
|
||||
// --- Side-tab ---
|
||||
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
|
||||
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
|
||||
fmt: () => 'bg-clip-text + bg-gradient' },
|
||||
// --- Tailwind gray on colored bg ---
|
||||
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
|
||||
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
|
||||
fmt: (m, line) => {
|
||||
const bg = grayOnColorPairs(line, m[0], m.index)
|
||||
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
|
||||
.find(Boolean);
|
||||
return `${m[0]} on ${bg?.[0] || '?'}`;
|
||||
} },
|
||||
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
||||
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
|
||||
// --- Tailwind AI palette ---
|
||||
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
|
||||
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
|
||||
|
||||
@@ -217,7 +217,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -2060,7 +2060,7 @@
|
||||
if (anchor) return anchor;
|
||||
}
|
||||
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (wrapper) {
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0 && visibleVariant > 0) {
|
||||
@@ -2131,14 +2131,14 @@
|
||||
|
||||
function isInsertGeneratingSession() {
|
||||
if (state !== 'GENERATING' || !currentSessionId) return false;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
|
||||
}
|
||||
|
||||
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
|
||||
function ensureInsertPlaceholder() {
|
||||
if (!isInsertGeneratingSession()) return placeholderElement;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0) return placeholderElement;
|
||||
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
|
||||
@@ -3156,7 +3156,7 @@
|
||||
|| svelteComponentSession.wrapperEl
|
||||
|| null;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
@@ -4900,7 +4900,7 @@
|
||||
return Object.values(svelteComponentSession.paramsByVariant || {})
|
||||
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
|
||||
.reduce((total, variant) => total + parseVariantParams(variant).length, 0);
|
||||
@@ -5004,7 +5004,7 @@
|
||||
scheduleCyclingBarSync(sessionId, num);
|
||||
return true;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
updateVariantStateStylesheet(sessionId, num);
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
@@ -5820,7 +5820,6 @@
|
||||
return;
|
||||
}
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
completeParameterGenerationIfReady();
|
||||
@@ -6217,71 +6216,6 @@
|
||||
return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
|
||||
}
|
||||
|
||||
function sourceHasSessionWrapper(text, sessionId) {
|
||||
const src = String(text || '');
|
||||
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|
||||
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|
||||
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
|
||||
* wrapper deleted from source look identical in the DOM, and only the second
|
||||
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
|
||||
* injecting raw JSX; reading the file as plain text and matching the session
|
||||
* marker honors that, because no DOM is ever built from what comes back.
|
||||
* Marker present means the component is simply not mounted right now (a
|
||||
* closed modal, another route) and the variant observer keeps waiting.
|
||||
* Marker absent after the same retry budget the HTML path uses means the
|
||||
* file was edited out from under the session, which no reload, HMR push, or
|
||||
* server restart can repair, so the session self-discards and hands the
|
||||
* surface back to the picker.
|
||||
*/
|
||||
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
|
||||
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
|
||||
const retryLater = () => {
|
||||
setTimeout(() => {
|
||||
if (!stillActive()) return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
};
|
||||
// Discarding is durable (the session moves to the discarded phase and the
|
||||
// picker replaces it), so it needs evidence that the wrapper is gone: a
|
||||
// read that answers without the marker, or a 404 (the file itself was
|
||||
// renamed or deleted). Either kind retries on the shared budget first.
|
||||
// A read that fails for any other reason (the server briefly away, a
|
||||
// transient fetch error) says nothing about the wrapper; after the budget
|
||||
// the session is kept, the user told, and the next event retries.
|
||||
const onNoWrapper = (reason) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
discardOrphanedSession(reason);
|
||||
};
|
||||
const onUnreadable = (detail) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
|
||||
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
|
||||
};
|
||||
fetch(url)
|
||||
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
|
||||
.then(text => {
|
||||
if (!stillActive()) return;
|
||||
if (sourceHasSessionWrapper(text, sessionId)) return;
|
||||
onNoWrapper('variant wrapper missing from source');
|
||||
})
|
||||
.catch(err => {
|
||||
const detail = err && err.message ? err.message : 'fetch failed';
|
||||
if (/source read failed: 404$/.test(detail)) {
|
||||
onNoWrapper('source file missing (404) while checking for the variant wrapper');
|
||||
return;
|
||||
}
|
||||
onUnreadable(detail);
|
||||
});
|
||||
}
|
||||
|
||||
function completeSourceInjection(wrapper, sessionId, opts) {
|
||||
recoveryWaitingForAnchor = false;
|
||||
if (pendingVariantAnchorRetryObserver) {
|
||||
@@ -6362,7 +6296,7 @@
|
||||
}
|
||||
rememberSessionFileMeta({ file: filePath });
|
||||
if (isJsxSourceFile(filePath)) {
|
||||
const liveWrapper = findVariantsWrapper(sessionId);
|
||||
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
|
||||
return;
|
||||
@@ -6392,7 +6326,14 @@
|
||||
return;
|
||||
}
|
||||
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
||||
probeJsxWrapperForOrphan(filePath, sessionId, opts);
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
|
||||
setTimeout(() => {
|
||||
if (sessionId !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -6434,7 +6375,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const existingWrapper = findVariantsWrapper(sessionId);
|
||||
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (existingWrapper) {
|
||||
const wrapper = srcWrapper.cloneNode(true);
|
||||
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
|
||||
@@ -6591,7 +6532,7 @@
|
||||
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
||||
return;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const visEl = pickVariantContent(wrapper, visibleVariant);
|
||||
if (visEl) selectedElement = visEl;
|
||||
@@ -6601,7 +6542,7 @@
|
||||
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
||||
return svelteComponentSession.mountedVariant;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
for (const variant of variants) {
|
||||
@@ -6716,17 +6657,8 @@
|
||||
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
|
||||
* one wrapper per item, so the hide, the release, and the existence checks
|
||||
* all have to speak about the same set.
|
||||
*/
|
||||
function discardedWrappers(sessionId) {
|
||||
if (!sessionId) return [];
|
||||
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
|
||||
}
|
||||
|
||||
function releaseDiscardedStaticWrapper(wrapper) {
|
||||
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
if (!wrapper) return;
|
||||
const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
|
||||
const content = orig?.firstElementChild;
|
||||
@@ -6737,18 +6669,6 @@
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the discard hide on every wrapper it covered. Releasing only the
|
||||
* first match left the other mapped items sitting at display:none with
|
||||
* their original content never restored, on exactly the static and
|
||||
* missed-HMR flows this fallback exists for.
|
||||
*/
|
||||
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
|
||||
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
|
||||
}
|
||||
|
||||
function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
|
||||
if (!sessionId || !document.body) return;
|
||||
if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
|
||||
@@ -6929,42 +6849,6 @@
|
||||
// MutationObserver for progressive variant reveal
|
||||
//
|
||||
|
||||
// A session id can have more than one wrapper in the DOM: the target may sit
|
||||
// inside a `.map()` callback (the wrapper renders once per item), or the
|
||||
// agent may have relocated the wrapper out of the shared primitive live-wrap
|
||||
// scaffolded into. A plain first match can then pin an empty scaffold while
|
||||
// the real variants sit in a later wrapper, which strands the session at
|
||||
// 0/N and leaves the bar, the params panel, and accept all reading the
|
||||
// wrong element. Prefer a wrapper that actually holds variants. With zero
|
||||
// or one match this is exactly the querySelector it replaces.
|
||||
//
|
||||
// Every lookup of the ACTIVE session's wrapper goes through here. The
|
||||
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
|
||||
// existence checks, selector strings for stylesheets and observers (which
|
||||
// want to cover every match), `querySelectorAll` sweeps, and the parsed
|
||||
// source document, which is not this document.
|
||||
function pickPopulatedVariantsWrapper(selector) {
|
||||
const matches = document.querySelectorAll(selector);
|
||||
if (matches.length < 2) return matches[0] || null;
|
||||
for (const candidate of matches) {
|
||||
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
/** The wrapper holding `sessionId`'s variants, or null without an id. */
|
||||
function findVariantsWrapper(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
|
||||
}
|
||||
|
||||
/** Any live variant wrapper, for the resume paths that have no id yet. */
|
||||
function findAnyVariantsWrapper() {
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
|
||||
}
|
||||
|
||||
function startVariantObserver(sessionId) {
|
||||
let updating = false; // re-entrancy guard
|
||||
|
||||
@@ -6994,7 +6878,7 @@
|
||||
}
|
||||
if (!dominated) return;
|
||||
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
@@ -7203,7 +7087,6 @@
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
if (state === 'GENERATING') {
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
disableInlineEdit();
|
||||
refreshParamsPanel();
|
||||
@@ -7264,7 +7147,6 @@
|
||||
pendingAcceptedSession = null;
|
||||
awaitingAcceptResult = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup. Try Accept again.', 5000);
|
||||
break;
|
||||
@@ -8367,15 +8249,6 @@ void main() {
|
||||
// matches the original off-white risograph paper.
|
||||
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
|
||||
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
|
||||
// showShaderOverlay is async: it appends its canvas, then awaits
|
||||
// createImageBitmap and the GL setup before it publishes shaderState. A
|
||||
// teardown that landed inside that window found shaderState still null,
|
||||
// returned, and then watched the construction publish itself over a session
|
||||
// that had already left GENERATING, with no teardown left to run. That is
|
||||
// the generating loader frozen over a page that already cycles (issue #719).
|
||||
// Every teardown bumps this epoch; a construction abandons its own canvas as
|
||||
// soon as it sees the epoch move.
|
||||
let shaderEpoch = 0;
|
||||
|
||||
// The element's effective background tone, used as the uniform halftone
|
||||
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground
|
||||
@@ -8522,28 +8395,14 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop a shader node no shaderState owns (an abandoned construction). */
|
||||
function removeStrayShaderNode() {
|
||||
const stray = uiGetById(PREFIX + '-shader');
|
||||
if (stray) stray.remove();
|
||||
}
|
||||
|
||||
function hideShaderOverlay() {
|
||||
// Bump first, unconditionally: this is what tells an in-flight
|
||||
// showShaderOverlay to abandon itself rather than publish over a session
|
||||
// that has already moved on.
|
||||
shaderEpoch += 1;
|
||||
if (!shaderState) {
|
||||
removeStrayShaderNode();
|
||||
return;
|
||||
}
|
||||
if (!shaderState) return;
|
||||
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
|
||||
if (shaderState.canvas) shaderState.canvas.remove();
|
||||
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
|
||||
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
shaderState = null;
|
||||
removeStrayShaderNode();
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
@@ -8568,16 +8427,6 @@ void main() {
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
// hideShaderOverlay just bumped the epoch, so this run owns it until the
|
||||
// next teardown. Every step past an await re-checks before it publishes.
|
||||
const epoch = shaderEpoch;
|
||||
const abandoned = (node, gl) => {
|
||||
if (epoch === shaderEpoch) return false;
|
||||
node.remove();
|
||||
const lose = gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
return true;
|
||||
};
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = PREFIX + '-shader';
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
@@ -8600,7 +8449,6 @@ void main() {
|
||||
if (!gl) {
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
if (abandoned(canvas, null)) return;
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
@@ -8640,22 +8488,16 @@ void main() {
|
||||
}
|
||||
|
||||
// Upload the screenshot as a texture
|
||||
if (abandoned(canvas, gl)) return;
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
if (abandoned(canvas, gl)) return;
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
if (abandoned(canvas, gl)) {
|
||||
if (bitmap.close) bitmap.close();
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
@@ -8674,7 +8516,6 @@ void main() {
|
||||
const paperRgb = paper || resolvePaperRgb(el);
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if (abandoned(canvas, gl)) return;
|
||||
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
|
||||
function frame() {
|
||||
if (!shaderState) return;
|
||||
@@ -8711,7 +8552,7 @@ void main() {
|
||||
clientSentAt: Date.now(),
|
||||
};
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
const acceptWrapper = findVariantsWrapper(currentSessionId);
|
||||
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
@@ -8754,7 +8595,6 @@ void main() {
|
||||
.catch(() => {
|
||||
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
@@ -8806,7 +8646,7 @@ void main() {
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
@@ -8933,7 +8773,7 @@ void main() {
|
||||
}
|
||||
|
||||
function commitAcceptedVariantToDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!accepted || !accepted.firstElementChild) return false;
|
||||
@@ -9161,7 +9001,7 @@ void main() {
|
||||
}
|
||||
|
||||
function restoreFromActiveSessions(activeSessions, reason) {
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
|
||||
if (svelteComponentSession?.sessionId === currentSessionId) return false;
|
||||
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
|
||||
@@ -9274,13 +9114,10 @@ void main() {
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
// replaced the wrapper by then (keeps static-server / no-HMR flows alive).
|
||||
// Every match, not the first: a target inside a `.map()` renders one
|
||||
// wrapper per item, and hiding only one leaves the rest of the
|
||||
// discarded variants on screen.
|
||||
const discardWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (discardWrappers.length > 0) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (wrapper) {
|
||||
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
||||
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
|
||||
else wrapper.style.display = 'none';
|
||||
}
|
||||
setTimeout(function() {
|
||||
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
||||
@@ -9288,19 +9125,16 @@ void main() {
|
||||
removeDiscardStateStylesheet();
|
||||
return;
|
||||
}
|
||||
const lateWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (lateWrappers.length === 0) {
|
||||
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (!lateWrapper) {
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
// Duplicates all render from one source element, so HMR ownership is
|
||||
// uniform across them; the first is a fair witness for the set.
|
||||
const lateWrapper = lateWrappers[0];
|
||||
if (recoverySuperseded) {
|
||||
if (hasFrameworkHmrOwnership(lateWrapper)) {
|
||||
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
} else {
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -9309,20 +9143,18 @@ void main() {
|
||||
// the final source rewrite, reload once after a grace window so the
|
||||
// discarded source becomes authoritative without a reconciler race.
|
||||
setTimeout(function() {
|
||||
const staleWrappers = discardedWrappers(cleanupSessionId);
|
||||
const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
||||
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
// A reload restores every wrapper's original at once, so there is
|
||||
// nothing per-wrapper to do here.
|
||||
if (staleWrappers.length > 0) location.reload();
|
||||
if (staleWrapper) location.reload();
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}, 2000);
|
||||
}
|
||||
hideBar(instantChrome);
|
||||
@@ -9510,13 +9342,8 @@ void main() {
|
||||
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
||||
}
|
||||
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
|
||||
// Which path resumed matters in the journal: an init resume is a fresh
|
||||
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
|
||||
// used to log the same `browser_resumed`, which made issue #719 take a
|
||||
// DOM reconstruction to diagnose.
|
||||
const resumeReason = opts.reason || 'browser_resumed';
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
|
||||
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
|
||||
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
|
||||
@@ -9615,38 +9442,16 @@ void main() {
|
||||
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// A resume can BE the arrival, not just a re-entry after one. The server's
|
||||
// generation preflight runs live-wrap with --defer-source-write, so the
|
||||
// wrapper and every variant reach the DOM in one HMR batch, and the
|
||||
// deferred-wrapper scout (constructed at init) runs before the variant
|
||||
// MutationObserver (constructed at Go) on that batch. Finish the same
|
||||
// transition the observer would have finished. Without hideShaderOverlay
|
||||
// the generating shader stays frozen over the target and the session looks
|
||||
// stuck at GENERATING while the bar already cycles (issue #719).
|
||||
if (state === 'CYCLING') {
|
||||
recoveryWaitingForAnchor = false;
|
||||
hideShaderOverlay();
|
||||
if (isInsert) finalizeInsertSession();
|
||||
disableInlineEdit();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
||||
sendCheckpoint('variants_progress');
|
||||
} else {
|
||||
queueCheckpoint(resumeReason);
|
||||
// Only variants_progress and variants_ready count as publication
|
||||
// progress. When the resume is the arrival, the observer never gets to
|
||||
// report it (this function disconnects and re-creates it below, which
|
||||
// drops the records it had already queued for this same batch), so
|
||||
// without this the server never learns the variants were published.
|
||||
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
sendCheckpoint('variants_ready');
|
||||
}
|
||||
queueCheckpoint('browser_resumed');
|
||||
}
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -12968,7 +12773,7 @@ void main() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
|
||||
if (!wrapper) return;
|
||||
scout.disconnect();
|
||||
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
|
||||
if (resumeSession(deferredResumeRevision)) {
|
||||
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1490,7 +1490,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
|
||||
return isNeutralAuthoredColor(m[1]);
|
||||
}
|
||||
|
||||
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
|
||||
|
||||
function scanJs(text, start, onChar) {
|
||||
let stringQuote = '';
|
||||
let inTemplate = false;
|
||||
let paren = 0;
|
||||
let brace = 0;
|
||||
const interpBrace = [];
|
||||
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const prev = text[i - 1];
|
||||
const next = text[i + 1];
|
||||
|
||||
if (stringQuote) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === stringQuote) stringQuote = '';
|
||||
continue;
|
||||
}
|
||||
if (inTemplate && interpBrace.length === 0) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === '$' && next === '{') {
|
||||
brace++;
|
||||
interpBrace.push(brace);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === '`') { inTemplate = false; continue; }
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"') { stringQuote = char; continue; }
|
||||
if (char === '`') { inTemplate = true; continue; }
|
||||
if (char === '(') { paren++; continue; }
|
||||
if (char === ')') { paren--; continue; }
|
||||
if (char === '{') { brace++; continue; }
|
||||
if (char === '}') {
|
||||
brace--;
|
||||
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
|
||||
continue;
|
||||
}
|
||||
if (onChar(char, i, prev, next, { paren, brace })) return;
|
||||
}
|
||||
}
|
||||
|
||||
function containingMarkupTag(line, index) {
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
const tagStart = line.indexOf('<', i);
|
||||
if (tagStart === -1) break;
|
||||
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
|
||||
i = tagStart + 1;
|
||||
continue;
|
||||
}
|
||||
let tagEnd = -1;
|
||||
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
|
||||
if (char === '>' && depth.brace === 0) {
|
||||
tagEnd = j;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (tagEnd === -1) break;
|
||||
if (index >= tagStart && index <= tagEnd) {
|
||||
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
|
||||
}
|
||||
i = tagEnd + 1;
|
||||
}
|
||||
return { text: line, start: 0 };
|
||||
}
|
||||
|
||||
function findTernarySplit(text) {
|
||||
let qPos = -1;
|
||||
let qParen = 0;
|
||||
let qBrace = 0;
|
||||
let nested = 0;
|
||||
let colonPos = -1;
|
||||
let split = null;
|
||||
|
||||
const isQuestion = (char, prev, next) =>
|
||||
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
|
||||
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
|
||||
|
||||
scanJs(text, 0, (char, i, prev, next, depth) => {
|
||||
if (colonPos === -1) {
|
||||
if (qPos === -1 && isQuestion(char, prev, next)) {
|
||||
qPos = i;
|
||||
qParen = depth.paren;
|
||||
qBrace = depth.brace;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
|
||||
nested++;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
|
||||
if (nested) nested--;
|
||||
else colonPos = i;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (char === ',' && sameDepth(depth)) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1, i),
|
||||
suffix: text.slice(i),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!split && qPos !== -1 && colonPos !== -1) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1),
|
||||
suffix: '',
|
||||
};
|
||||
}
|
||||
return split;
|
||||
}
|
||||
|
||||
function exclusiveClassScopes(text) {
|
||||
const split = findTernarySplit(text);
|
||||
if (!split) return [text];
|
||||
return [
|
||||
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
|
||||
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
|
||||
];
|
||||
}
|
||||
|
||||
function grayOnColorScopes(line, index) {
|
||||
return exclusiveClassScopes(containingMarkupTag(line, index).text);
|
||||
}
|
||||
|
||||
function grayOnColorPairs(line, grayClass, index) {
|
||||
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
|
||||
}
|
||||
|
||||
const REGEX_MATCHERS = [
|
||||
// --- Side-tab ---
|
||||
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
|
||||
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
|
||||
fmt: () => 'bg-clip-text + bg-gradient' },
|
||||
// --- Tailwind gray on colored bg ---
|
||||
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
|
||||
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
|
||||
fmt: (m, line) => {
|
||||
const bg = grayOnColorPairs(line, m[0], m.index)
|
||||
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
|
||||
.find(Boolean);
|
||||
return `${m[0]} on ${bg?.[0] || '?'}`;
|
||||
} },
|
||||
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
||||
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
|
||||
// --- Tailwind AI palette ---
|
||||
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
|
||||
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
|
||||
|
||||
@@ -217,7 +217,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -2060,7 +2060,7 @@
|
||||
if (anchor) return anchor;
|
||||
}
|
||||
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (wrapper) {
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0 && visibleVariant > 0) {
|
||||
@@ -2131,14 +2131,14 @@
|
||||
|
||||
function isInsertGeneratingSession() {
|
||||
if (state !== 'GENERATING' || !currentSessionId) return false;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
|
||||
}
|
||||
|
||||
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
|
||||
function ensureInsertPlaceholder() {
|
||||
if (!isInsertGeneratingSession()) return placeholderElement;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0) return placeholderElement;
|
||||
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
|
||||
@@ -3156,7 +3156,7 @@
|
||||
|| svelteComponentSession.wrapperEl
|
||||
|| null;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
@@ -4900,7 +4900,7 @@
|
||||
return Object.values(svelteComponentSession.paramsByVariant || {})
|
||||
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
|
||||
.reduce((total, variant) => total + parseVariantParams(variant).length, 0);
|
||||
@@ -5004,7 +5004,7 @@
|
||||
scheduleCyclingBarSync(sessionId, num);
|
||||
return true;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
updateVariantStateStylesheet(sessionId, num);
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
@@ -5820,7 +5820,6 @@
|
||||
return;
|
||||
}
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
completeParameterGenerationIfReady();
|
||||
@@ -6217,71 +6216,6 @@
|
||||
return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
|
||||
}
|
||||
|
||||
function sourceHasSessionWrapper(text, sessionId) {
|
||||
const src = String(text || '');
|
||||
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|
||||
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|
||||
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
|
||||
* wrapper deleted from source look identical in the DOM, and only the second
|
||||
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
|
||||
* injecting raw JSX; reading the file as plain text and matching the session
|
||||
* marker honors that, because no DOM is ever built from what comes back.
|
||||
* Marker present means the component is simply not mounted right now (a
|
||||
* closed modal, another route) and the variant observer keeps waiting.
|
||||
* Marker absent after the same retry budget the HTML path uses means the
|
||||
* file was edited out from under the session, which no reload, HMR push, or
|
||||
* server restart can repair, so the session self-discards and hands the
|
||||
* surface back to the picker.
|
||||
*/
|
||||
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
|
||||
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
|
||||
const retryLater = () => {
|
||||
setTimeout(() => {
|
||||
if (!stillActive()) return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
};
|
||||
// Discarding is durable (the session moves to the discarded phase and the
|
||||
// picker replaces it), so it needs evidence that the wrapper is gone: a
|
||||
// read that answers without the marker, or a 404 (the file itself was
|
||||
// renamed or deleted). Either kind retries on the shared budget first.
|
||||
// A read that fails for any other reason (the server briefly away, a
|
||||
// transient fetch error) says nothing about the wrapper; after the budget
|
||||
// the session is kept, the user told, and the next event retries.
|
||||
const onNoWrapper = (reason) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
discardOrphanedSession(reason);
|
||||
};
|
||||
const onUnreadable = (detail) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
|
||||
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
|
||||
};
|
||||
fetch(url)
|
||||
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
|
||||
.then(text => {
|
||||
if (!stillActive()) return;
|
||||
if (sourceHasSessionWrapper(text, sessionId)) return;
|
||||
onNoWrapper('variant wrapper missing from source');
|
||||
})
|
||||
.catch(err => {
|
||||
const detail = err && err.message ? err.message : 'fetch failed';
|
||||
if (/source read failed: 404$/.test(detail)) {
|
||||
onNoWrapper('source file missing (404) while checking for the variant wrapper');
|
||||
return;
|
||||
}
|
||||
onUnreadable(detail);
|
||||
});
|
||||
}
|
||||
|
||||
function completeSourceInjection(wrapper, sessionId, opts) {
|
||||
recoveryWaitingForAnchor = false;
|
||||
if (pendingVariantAnchorRetryObserver) {
|
||||
@@ -6362,7 +6296,7 @@
|
||||
}
|
||||
rememberSessionFileMeta({ file: filePath });
|
||||
if (isJsxSourceFile(filePath)) {
|
||||
const liveWrapper = findVariantsWrapper(sessionId);
|
||||
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
|
||||
return;
|
||||
@@ -6392,7 +6326,14 @@
|
||||
return;
|
||||
}
|
||||
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
||||
probeJsxWrapperForOrphan(filePath, sessionId, opts);
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
|
||||
setTimeout(() => {
|
||||
if (sessionId !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -6434,7 +6375,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const existingWrapper = findVariantsWrapper(sessionId);
|
||||
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (existingWrapper) {
|
||||
const wrapper = srcWrapper.cloneNode(true);
|
||||
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
|
||||
@@ -6591,7 +6532,7 @@
|
||||
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
||||
return;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const visEl = pickVariantContent(wrapper, visibleVariant);
|
||||
if (visEl) selectedElement = visEl;
|
||||
@@ -6601,7 +6542,7 @@
|
||||
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
||||
return svelteComponentSession.mountedVariant;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
for (const variant of variants) {
|
||||
@@ -6716,17 +6657,8 @@
|
||||
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
|
||||
* one wrapper per item, so the hide, the release, and the existence checks
|
||||
* all have to speak about the same set.
|
||||
*/
|
||||
function discardedWrappers(sessionId) {
|
||||
if (!sessionId) return [];
|
||||
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
|
||||
}
|
||||
|
||||
function releaseDiscardedStaticWrapper(wrapper) {
|
||||
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
if (!wrapper) return;
|
||||
const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
|
||||
const content = orig?.firstElementChild;
|
||||
@@ -6737,18 +6669,6 @@
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the discard hide on every wrapper it covered. Releasing only the
|
||||
* first match left the other mapped items sitting at display:none with
|
||||
* their original content never restored, on exactly the static and
|
||||
* missed-HMR flows this fallback exists for.
|
||||
*/
|
||||
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
|
||||
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
|
||||
}
|
||||
|
||||
function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
|
||||
if (!sessionId || !document.body) return;
|
||||
if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
|
||||
@@ -6929,42 +6849,6 @@
|
||||
// MutationObserver for progressive variant reveal
|
||||
//
|
||||
|
||||
// A session id can have more than one wrapper in the DOM: the target may sit
|
||||
// inside a `.map()` callback (the wrapper renders once per item), or the
|
||||
// agent may have relocated the wrapper out of the shared primitive live-wrap
|
||||
// scaffolded into. A plain first match can then pin an empty scaffold while
|
||||
// the real variants sit in a later wrapper, which strands the session at
|
||||
// 0/N and leaves the bar, the params panel, and accept all reading the
|
||||
// wrong element. Prefer a wrapper that actually holds variants. With zero
|
||||
// or one match this is exactly the querySelector it replaces.
|
||||
//
|
||||
// Every lookup of the ACTIVE session's wrapper goes through here. The
|
||||
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
|
||||
// existence checks, selector strings for stylesheets and observers (which
|
||||
// want to cover every match), `querySelectorAll` sweeps, and the parsed
|
||||
// source document, which is not this document.
|
||||
function pickPopulatedVariantsWrapper(selector) {
|
||||
const matches = document.querySelectorAll(selector);
|
||||
if (matches.length < 2) return matches[0] || null;
|
||||
for (const candidate of matches) {
|
||||
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
/** The wrapper holding `sessionId`'s variants, or null without an id. */
|
||||
function findVariantsWrapper(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
|
||||
}
|
||||
|
||||
/** Any live variant wrapper, for the resume paths that have no id yet. */
|
||||
function findAnyVariantsWrapper() {
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
|
||||
}
|
||||
|
||||
function startVariantObserver(sessionId) {
|
||||
let updating = false; // re-entrancy guard
|
||||
|
||||
@@ -6994,7 +6878,7 @@
|
||||
}
|
||||
if (!dominated) return;
|
||||
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
@@ -7203,7 +7087,6 @@
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
if (state === 'GENERATING') {
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
disableInlineEdit();
|
||||
refreshParamsPanel();
|
||||
@@ -7264,7 +7147,6 @@
|
||||
pendingAcceptedSession = null;
|
||||
awaitingAcceptResult = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup. Try Accept again.', 5000);
|
||||
break;
|
||||
@@ -8367,15 +8249,6 @@ void main() {
|
||||
// matches the original off-white risograph paper.
|
||||
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
|
||||
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
|
||||
// showShaderOverlay is async: it appends its canvas, then awaits
|
||||
// createImageBitmap and the GL setup before it publishes shaderState. A
|
||||
// teardown that landed inside that window found shaderState still null,
|
||||
// returned, and then watched the construction publish itself over a session
|
||||
// that had already left GENERATING, with no teardown left to run. That is
|
||||
// the generating loader frozen over a page that already cycles (issue #719).
|
||||
// Every teardown bumps this epoch; a construction abandons its own canvas as
|
||||
// soon as it sees the epoch move.
|
||||
let shaderEpoch = 0;
|
||||
|
||||
// The element's effective background tone, used as the uniform halftone
|
||||
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground
|
||||
@@ -8522,28 +8395,14 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop a shader node no shaderState owns (an abandoned construction). */
|
||||
function removeStrayShaderNode() {
|
||||
const stray = uiGetById(PREFIX + '-shader');
|
||||
if (stray) stray.remove();
|
||||
}
|
||||
|
||||
function hideShaderOverlay() {
|
||||
// Bump first, unconditionally: this is what tells an in-flight
|
||||
// showShaderOverlay to abandon itself rather than publish over a session
|
||||
// that has already moved on.
|
||||
shaderEpoch += 1;
|
||||
if (!shaderState) {
|
||||
removeStrayShaderNode();
|
||||
return;
|
||||
}
|
||||
if (!shaderState) return;
|
||||
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
|
||||
if (shaderState.canvas) shaderState.canvas.remove();
|
||||
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
|
||||
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
shaderState = null;
|
||||
removeStrayShaderNode();
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
@@ -8568,16 +8427,6 @@ void main() {
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
// hideShaderOverlay just bumped the epoch, so this run owns it until the
|
||||
// next teardown. Every step past an await re-checks before it publishes.
|
||||
const epoch = shaderEpoch;
|
||||
const abandoned = (node, gl) => {
|
||||
if (epoch === shaderEpoch) return false;
|
||||
node.remove();
|
||||
const lose = gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
return true;
|
||||
};
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = PREFIX + '-shader';
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
@@ -8600,7 +8449,6 @@ void main() {
|
||||
if (!gl) {
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
if (abandoned(canvas, null)) return;
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
@@ -8640,22 +8488,16 @@ void main() {
|
||||
}
|
||||
|
||||
// Upload the screenshot as a texture
|
||||
if (abandoned(canvas, gl)) return;
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
if (abandoned(canvas, gl)) return;
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
if (abandoned(canvas, gl)) {
|
||||
if (bitmap.close) bitmap.close();
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
@@ -8674,7 +8516,6 @@ void main() {
|
||||
const paperRgb = paper || resolvePaperRgb(el);
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if (abandoned(canvas, gl)) return;
|
||||
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
|
||||
function frame() {
|
||||
if (!shaderState) return;
|
||||
@@ -8711,7 +8552,7 @@ void main() {
|
||||
clientSentAt: Date.now(),
|
||||
};
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
const acceptWrapper = findVariantsWrapper(currentSessionId);
|
||||
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
@@ -8754,7 +8595,6 @@ void main() {
|
||||
.catch(() => {
|
||||
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
@@ -8806,7 +8646,7 @@ void main() {
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
@@ -8933,7 +8773,7 @@ void main() {
|
||||
}
|
||||
|
||||
function commitAcceptedVariantToDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!accepted || !accepted.firstElementChild) return false;
|
||||
@@ -9161,7 +9001,7 @@ void main() {
|
||||
}
|
||||
|
||||
function restoreFromActiveSessions(activeSessions, reason) {
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
|
||||
if (svelteComponentSession?.sessionId === currentSessionId) return false;
|
||||
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
|
||||
@@ -9274,13 +9114,10 @@ void main() {
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
// replaced the wrapper by then (keeps static-server / no-HMR flows alive).
|
||||
// Every match, not the first: a target inside a `.map()` renders one
|
||||
// wrapper per item, and hiding only one leaves the rest of the
|
||||
// discarded variants on screen.
|
||||
const discardWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (discardWrappers.length > 0) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (wrapper) {
|
||||
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
||||
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
|
||||
else wrapper.style.display = 'none';
|
||||
}
|
||||
setTimeout(function() {
|
||||
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
||||
@@ -9288,19 +9125,16 @@ void main() {
|
||||
removeDiscardStateStylesheet();
|
||||
return;
|
||||
}
|
||||
const lateWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (lateWrappers.length === 0) {
|
||||
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (!lateWrapper) {
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
// Duplicates all render from one source element, so HMR ownership is
|
||||
// uniform across them; the first is a fair witness for the set.
|
||||
const lateWrapper = lateWrappers[0];
|
||||
if (recoverySuperseded) {
|
||||
if (hasFrameworkHmrOwnership(lateWrapper)) {
|
||||
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
} else {
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -9309,20 +9143,18 @@ void main() {
|
||||
// the final source rewrite, reload once after a grace window so the
|
||||
// discarded source becomes authoritative without a reconciler race.
|
||||
setTimeout(function() {
|
||||
const staleWrappers = discardedWrappers(cleanupSessionId);
|
||||
const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
||||
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
// A reload restores every wrapper's original at once, so there is
|
||||
// nothing per-wrapper to do here.
|
||||
if (staleWrappers.length > 0) location.reload();
|
||||
if (staleWrapper) location.reload();
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}, 2000);
|
||||
}
|
||||
hideBar(instantChrome);
|
||||
@@ -9510,13 +9342,8 @@ void main() {
|
||||
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
||||
}
|
||||
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
|
||||
// Which path resumed matters in the journal: an init resume is a fresh
|
||||
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
|
||||
// used to log the same `browser_resumed`, which made issue #719 take a
|
||||
// DOM reconstruction to diagnose.
|
||||
const resumeReason = opts.reason || 'browser_resumed';
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
|
||||
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
|
||||
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
|
||||
@@ -9615,38 +9442,16 @@ void main() {
|
||||
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// A resume can BE the arrival, not just a re-entry after one. The server's
|
||||
// generation preflight runs live-wrap with --defer-source-write, so the
|
||||
// wrapper and every variant reach the DOM in one HMR batch, and the
|
||||
// deferred-wrapper scout (constructed at init) runs before the variant
|
||||
// MutationObserver (constructed at Go) on that batch. Finish the same
|
||||
// transition the observer would have finished. Without hideShaderOverlay
|
||||
// the generating shader stays frozen over the target and the session looks
|
||||
// stuck at GENERATING while the bar already cycles (issue #719).
|
||||
if (state === 'CYCLING') {
|
||||
recoveryWaitingForAnchor = false;
|
||||
hideShaderOverlay();
|
||||
if (isInsert) finalizeInsertSession();
|
||||
disableInlineEdit();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
||||
sendCheckpoint('variants_progress');
|
||||
} else {
|
||||
queueCheckpoint(resumeReason);
|
||||
// Only variants_progress and variants_ready count as publication
|
||||
// progress. When the resume is the arrival, the observer never gets to
|
||||
// report it (this function disconnects and re-creates it below, which
|
||||
// drops the records it had already queued for this same batch), so
|
||||
// without this the server never learns the variants were published.
|
||||
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
sendCheckpoint('variants_ready');
|
||||
}
|
||||
queueCheckpoint('browser_resumed');
|
||||
}
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -12968,7 +12773,7 @@ void main() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
|
||||
if (!wrapper) return;
|
||||
scout.disconnect();
|
||||
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
|
||||
if (resumeSession(deferredResumeRevision)) {
|
||||
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1490,7 +1490,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
|
||||
return isNeutralAuthoredColor(m[1]);
|
||||
}
|
||||
|
||||
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
|
||||
|
||||
function scanJs(text, start, onChar) {
|
||||
let stringQuote = '';
|
||||
let inTemplate = false;
|
||||
let paren = 0;
|
||||
let brace = 0;
|
||||
const interpBrace = [];
|
||||
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const prev = text[i - 1];
|
||||
const next = text[i + 1];
|
||||
|
||||
if (stringQuote) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === stringQuote) stringQuote = '';
|
||||
continue;
|
||||
}
|
||||
if (inTemplate && interpBrace.length === 0) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === '$' && next === '{') {
|
||||
brace++;
|
||||
interpBrace.push(brace);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === '`') { inTemplate = false; continue; }
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"') { stringQuote = char; continue; }
|
||||
if (char === '`') { inTemplate = true; continue; }
|
||||
if (char === '(') { paren++; continue; }
|
||||
if (char === ')') { paren--; continue; }
|
||||
if (char === '{') { brace++; continue; }
|
||||
if (char === '}') {
|
||||
brace--;
|
||||
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
|
||||
continue;
|
||||
}
|
||||
if (onChar(char, i, prev, next, { paren, brace })) return;
|
||||
}
|
||||
}
|
||||
|
||||
function containingMarkupTag(line, index) {
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
const tagStart = line.indexOf('<', i);
|
||||
if (tagStart === -1) break;
|
||||
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
|
||||
i = tagStart + 1;
|
||||
continue;
|
||||
}
|
||||
let tagEnd = -1;
|
||||
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
|
||||
if (char === '>' && depth.brace === 0) {
|
||||
tagEnd = j;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (tagEnd === -1) break;
|
||||
if (index >= tagStart && index <= tagEnd) {
|
||||
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
|
||||
}
|
||||
i = tagEnd + 1;
|
||||
}
|
||||
return { text: line, start: 0 };
|
||||
}
|
||||
|
||||
function findTernarySplit(text) {
|
||||
let qPos = -1;
|
||||
let qParen = 0;
|
||||
let qBrace = 0;
|
||||
let nested = 0;
|
||||
let colonPos = -1;
|
||||
let split = null;
|
||||
|
||||
const isQuestion = (char, prev, next) =>
|
||||
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
|
||||
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
|
||||
|
||||
scanJs(text, 0, (char, i, prev, next, depth) => {
|
||||
if (colonPos === -1) {
|
||||
if (qPos === -1 && isQuestion(char, prev, next)) {
|
||||
qPos = i;
|
||||
qParen = depth.paren;
|
||||
qBrace = depth.brace;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
|
||||
nested++;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
|
||||
if (nested) nested--;
|
||||
else colonPos = i;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (char === ',' && sameDepth(depth)) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1, i),
|
||||
suffix: text.slice(i),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!split && qPos !== -1 && colonPos !== -1) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1),
|
||||
suffix: '',
|
||||
};
|
||||
}
|
||||
return split;
|
||||
}
|
||||
|
||||
function exclusiveClassScopes(text) {
|
||||
const split = findTernarySplit(text);
|
||||
if (!split) return [text];
|
||||
return [
|
||||
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
|
||||
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
|
||||
];
|
||||
}
|
||||
|
||||
function grayOnColorScopes(line, index) {
|
||||
return exclusiveClassScopes(containingMarkupTag(line, index).text);
|
||||
}
|
||||
|
||||
function grayOnColorPairs(line, grayClass, index) {
|
||||
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
|
||||
}
|
||||
|
||||
const REGEX_MATCHERS = [
|
||||
// --- Side-tab ---
|
||||
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
|
||||
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
|
||||
fmt: () => 'bg-clip-text + bg-gradient' },
|
||||
// --- Tailwind gray on colored bg ---
|
||||
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
|
||||
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
|
||||
fmt: (m, line) => {
|
||||
const bg = grayOnColorPairs(line, m[0], m.index)
|
||||
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
|
||||
.find(Boolean);
|
||||
return `${m[0]} on ${bg?.[0] || '?'}`;
|
||||
} },
|
||||
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
||||
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
|
||||
// --- Tailwind AI palette ---
|
||||
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
|
||||
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
|
||||
|
||||
@@ -217,7 +217,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -2060,7 +2060,7 @@
|
||||
if (anchor) return anchor;
|
||||
}
|
||||
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (wrapper) {
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0 && visibleVariant > 0) {
|
||||
@@ -2131,14 +2131,14 @@
|
||||
|
||||
function isInsertGeneratingSession() {
|
||||
if (state !== 'GENERATING' || !currentSessionId) return false;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
|
||||
}
|
||||
|
||||
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
|
||||
function ensureInsertPlaceholder() {
|
||||
if (!isInsertGeneratingSession()) return placeholderElement;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0) return placeholderElement;
|
||||
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
|
||||
@@ -3156,7 +3156,7 @@
|
||||
|| svelteComponentSession.wrapperEl
|
||||
|| null;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
@@ -4900,7 +4900,7 @@
|
||||
return Object.values(svelteComponentSession.paramsByVariant || {})
|
||||
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
|
||||
.reduce((total, variant) => total + parseVariantParams(variant).length, 0);
|
||||
@@ -5004,7 +5004,7 @@
|
||||
scheduleCyclingBarSync(sessionId, num);
|
||||
return true;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
updateVariantStateStylesheet(sessionId, num);
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
@@ -5820,7 +5820,6 @@
|
||||
return;
|
||||
}
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
completeParameterGenerationIfReady();
|
||||
@@ -6217,71 +6216,6 @@
|
||||
return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
|
||||
}
|
||||
|
||||
function sourceHasSessionWrapper(text, sessionId) {
|
||||
const src = String(text || '');
|
||||
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|
||||
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|
||||
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
|
||||
* wrapper deleted from source look identical in the DOM, and only the second
|
||||
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
|
||||
* injecting raw JSX; reading the file as plain text and matching the session
|
||||
* marker honors that, because no DOM is ever built from what comes back.
|
||||
* Marker present means the component is simply not mounted right now (a
|
||||
* closed modal, another route) and the variant observer keeps waiting.
|
||||
* Marker absent after the same retry budget the HTML path uses means the
|
||||
* file was edited out from under the session, which no reload, HMR push, or
|
||||
* server restart can repair, so the session self-discards and hands the
|
||||
* surface back to the picker.
|
||||
*/
|
||||
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
|
||||
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
|
||||
const retryLater = () => {
|
||||
setTimeout(() => {
|
||||
if (!stillActive()) return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
};
|
||||
// Discarding is durable (the session moves to the discarded phase and the
|
||||
// picker replaces it), so it needs evidence that the wrapper is gone: a
|
||||
// read that answers without the marker, or a 404 (the file itself was
|
||||
// renamed or deleted). Either kind retries on the shared budget first.
|
||||
// A read that fails for any other reason (the server briefly away, a
|
||||
// transient fetch error) says nothing about the wrapper; after the budget
|
||||
// the session is kept, the user told, and the next event retries.
|
||||
const onNoWrapper = (reason) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
discardOrphanedSession(reason);
|
||||
};
|
||||
const onUnreadable = (detail) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
|
||||
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
|
||||
};
|
||||
fetch(url)
|
||||
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
|
||||
.then(text => {
|
||||
if (!stillActive()) return;
|
||||
if (sourceHasSessionWrapper(text, sessionId)) return;
|
||||
onNoWrapper('variant wrapper missing from source');
|
||||
})
|
||||
.catch(err => {
|
||||
const detail = err && err.message ? err.message : 'fetch failed';
|
||||
if (/source read failed: 404$/.test(detail)) {
|
||||
onNoWrapper('source file missing (404) while checking for the variant wrapper');
|
||||
return;
|
||||
}
|
||||
onUnreadable(detail);
|
||||
});
|
||||
}
|
||||
|
||||
function completeSourceInjection(wrapper, sessionId, opts) {
|
||||
recoveryWaitingForAnchor = false;
|
||||
if (pendingVariantAnchorRetryObserver) {
|
||||
@@ -6362,7 +6296,7 @@
|
||||
}
|
||||
rememberSessionFileMeta({ file: filePath });
|
||||
if (isJsxSourceFile(filePath)) {
|
||||
const liveWrapper = findVariantsWrapper(sessionId);
|
||||
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
|
||||
return;
|
||||
@@ -6392,7 +6326,14 @@
|
||||
return;
|
||||
}
|
||||
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
||||
probeJsxWrapperForOrphan(filePath, sessionId, opts);
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
|
||||
setTimeout(() => {
|
||||
if (sessionId !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -6434,7 +6375,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const existingWrapper = findVariantsWrapper(sessionId);
|
||||
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (existingWrapper) {
|
||||
const wrapper = srcWrapper.cloneNode(true);
|
||||
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
|
||||
@@ -6591,7 +6532,7 @@
|
||||
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
||||
return;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const visEl = pickVariantContent(wrapper, visibleVariant);
|
||||
if (visEl) selectedElement = visEl;
|
||||
@@ -6601,7 +6542,7 @@
|
||||
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
||||
return svelteComponentSession.mountedVariant;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
for (const variant of variants) {
|
||||
@@ -6716,17 +6657,8 @@
|
||||
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
|
||||
* one wrapper per item, so the hide, the release, and the existence checks
|
||||
* all have to speak about the same set.
|
||||
*/
|
||||
function discardedWrappers(sessionId) {
|
||||
if (!sessionId) return [];
|
||||
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
|
||||
}
|
||||
|
||||
function releaseDiscardedStaticWrapper(wrapper) {
|
||||
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
if (!wrapper) return;
|
||||
const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
|
||||
const content = orig?.firstElementChild;
|
||||
@@ -6737,18 +6669,6 @@
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the discard hide on every wrapper it covered. Releasing only the
|
||||
* first match left the other mapped items sitting at display:none with
|
||||
* their original content never restored, on exactly the static and
|
||||
* missed-HMR flows this fallback exists for.
|
||||
*/
|
||||
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
|
||||
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
|
||||
}
|
||||
|
||||
function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
|
||||
if (!sessionId || !document.body) return;
|
||||
if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
|
||||
@@ -6929,42 +6849,6 @@
|
||||
// MutationObserver for progressive variant reveal
|
||||
//
|
||||
|
||||
// A session id can have more than one wrapper in the DOM: the target may sit
|
||||
// inside a `.map()` callback (the wrapper renders once per item), or the
|
||||
// agent may have relocated the wrapper out of the shared primitive live-wrap
|
||||
// scaffolded into. A plain first match can then pin an empty scaffold while
|
||||
// the real variants sit in a later wrapper, which strands the session at
|
||||
// 0/N and leaves the bar, the params panel, and accept all reading the
|
||||
// wrong element. Prefer a wrapper that actually holds variants. With zero
|
||||
// or one match this is exactly the querySelector it replaces.
|
||||
//
|
||||
// Every lookup of the ACTIVE session's wrapper goes through here. The
|
||||
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
|
||||
// existence checks, selector strings for stylesheets and observers (which
|
||||
// want to cover every match), `querySelectorAll` sweeps, and the parsed
|
||||
// source document, which is not this document.
|
||||
function pickPopulatedVariantsWrapper(selector) {
|
||||
const matches = document.querySelectorAll(selector);
|
||||
if (matches.length < 2) return matches[0] || null;
|
||||
for (const candidate of matches) {
|
||||
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
/** The wrapper holding `sessionId`'s variants, or null without an id. */
|
||||
function findVariantsWrapper(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
|
||||
}
|
||||
|
||||
/** Any live variant wrapper, for the resume paths that have no id yet. */
|
||||
function findAnyVariantsWrapper() {
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
|
||||
}
|
||||
|
||||
function startVariantObserver(sessionId) {
|
||||
let updating = false; // re-entrancy guard
|
||||
|
||||
@@ -6994,7 +6878,7 @@
|
||||
}
|
||||
if (!dominated) return;
|
||||
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
@@ -7203,7 +7087,6 @@
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
if (state === 'GENERATING') {
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
disableInlineEdit();
|
||||
refreshParamsPanel();
|
||||
@@ -7264,7 +7147,6 @@
|
||||
pendingAcceptedSession = null;
|
||||
awaitingAcceptResult = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup. Try Accept again.', 5000);
|
||||
break;
|
||||
@@ -8367,15 +8249,6 @@ void main() {
|
||||
// matches the original off-white risograph paper.
|
||||
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
|
||||
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
|
||||
// showShaderOverlay is async: it appends its canvas, then awaits
|
||||
// createImageBitmap and the GL setup before it publishes shaderState. A
|
||||
// teardown that landed inside that window found shaderState still null,
|
||||
// returned, and then watched the construction publish itself over a session
|
||||
// that had already left GENERATING, with no teardown left to run. That is
|
||||
// the generating loader frozen over a page that already cycles (issue #719).
|
||||
// Every teardown bumps this epoch; a construction abandons its own canvas as
|
||||
// soon as it sees the epoch move.
|
||||
let shaderEpoch = 0;
|
||||
|
||||
// The element's effective background tone, used as the uniform halftone
|
||||
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground
|
||||
@@ -8522,28 +8395,14 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop a shader node no shaderState owns (an abandoned construction). */
|
||||
function removeStrayShaderNode() {
|
||||
const stray = uiGetById(PREFIX + '-shader');
|
||||
if (stray) stray.remove();
|
||||
}
|
||||
|
||||
function hideShaderOverlay() {
|
||||
// Bump first, unconditionally: this is what tells an in-flight
|
||||
// showShaderOverlay to abandon itself rather than publish over a session
|
||||
// that has already moved on.
|
||||
shaderEpoch += 1;
|
||||
if (!shaderState) {
|
||||
removeStrayShaderNode();
|
||||
return;
|
||||
}
|
||||
if (!shaderState) return;
|
||||
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
|
||||
if (shaderState.canvas) shaderState.canvas.remove();
|
||||
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
|
||||
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
shaderState = null;
|
||||
removeStrayShaderNode();
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
@@ -8568,16 +8427,6 @@ void main() {
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
// hideShaderOverlay just bumped the epoch, so this run owns it until the
|
||||
// next teardown. Every step past an await re-checks before it publishes.
|
||||
const epoch = shaderEpoch;
|
||||
const abandoned = (node, gl) => {
|
||||
if (epoch === shaderEpoch) return false;
|
||||
node.remove();
|
||||
const lose = gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
return true;
|
||||
};
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = PREFIX + '-shader';
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
@@ -8600,7 +8449,6 @@ void main() {
|
||||
if (!gl) {
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
if (abandoned(canvas, null)) return;
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
@@ -8640,22 +8488,16 @@ void main() {
|
||||
}
|
||||
|
||||
// Upload the screenshot as a texture
|
||||
if (abandoned(canvas, gl)) return;
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
if (abandoned(canvas, gl)) return;
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
if (abandoned(canvas, gl)) {
|
||||
if (bitmap.close) bitmap.close();
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
@@ -8674,7 +8516,6 @@ void main() {
|
||||
const paperRgb = paper || resolvePaperRgb(el);
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if (abandoned(canvas, gl)) return;
|
||||
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
|
||||
function frame() {
|
||||
if (!shaderState) return;
|
||||
@@ -8711,7 +8552,7 @@ void main() {
|
||||
clientSentAt: Date.now(),
|
||||
};
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
const acceptWrapper = findVariantsWrapper(currentSessionId);
|
||||
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
@@ -8754,7 +8595,6 @@ void main() {
|
||||
.catch(() => {
|
||||
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
@@ -8806,7 +8646,7 @@ void main() {
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
@@ -8933,7 +8773,7 @@ void main() {
|
||||
}
|
||||
|
||||
function commitAcceptedVariantToDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!accepted || !accepted.firstElementChild) return false;
|
||||
@@ -9161,7 +9001,7 @@ void main() {
|
||||
}
|
||||
|
||||
function restoreFromActiveSessions(activeSessions, reason) {
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
|
||||
if (svelteComponentSession?.sessionId === currentSessionId) return false;
|
||||
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
|
||||
@@ -9274,13 +9114,10 @@ void main() {
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
// replaced the wrapper by then (keeps static-server / no-HMR flows alive).
|
||||
// Every match, not the first: a target inside a `.map()` renders one
|
||||
// wrapper per item, and hiding only one leaves the rest of the
|
||||
// discarded variants on screen.
|
||||
const discardWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (discardWrappers.length > 0) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (wrapper) {
|
||||
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
||||
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
|
||||
else wrapper.style.display = 'none';
|
||||
}
|
||||
setTimeout(function() {
|
||||
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
||||
@@ -9288,19 +9125,16 @@ void main() {
|
||||
removeDiscardStateStylesheet();
|
||||
return;
|
||||
}
|
||||
const lateWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (lateWrappers.length === 0) {
|
||||
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (!lateWrapper) {
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
// Duplicates all render from one source element, so HMR ownership is
|
||||
// uniform across them; the first is a fair witness for the set.
|
||||
const lateWrapper = lateWrappers[0];
|
||||
if (recoverySuperseded) {
|
||||
if (hasFrameworkHmrOwnership(lateWrapper)) {
|
||||
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
} else {
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -9309,20 +9143,18 @@ void main() {
|
||||
// the final source rewrite, reload once after a grace window so the
|
||||
// discarded source becomes authoritative without a reconciler race.
|
||||
setTimeout(function() {
|
||||
const staleWrappers = discardedWrappers(cleanupSessionId);
|
||||
const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
||||
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
// A reload restores every wrapper's original at once, so there is
|
||||
// nothing per-wrapper to do here.
|
||||
if (staleWrappers.length > 0) location.reload();
|
||||
if (staleWrapper) location.reload();
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}, 2000);
|
||||
}
|
||||
hideBar(instantChrome);
|
||||
@@ -9510,13 +9342,8 @@ void main() {
|
||||
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
||||
}
|
||||
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
|
||||
// Which path resumed matters in the journal: an init resume is a fresh
|
||||
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
|
||||
// used to log the same `browser_resumed`, which made issue #719 take a
|
||||
// DOM reconstruction to diagnose.
|
||||
const resumeReason = opts.reason || 'browser_resumed';
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
|
||||
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
|
||||
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
|
||||
@@ -9615,38 +9442,16 @@ void main() {
|
||||
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// A resume can BE the arrival, not just a re-entry after one. The server's
|
||||
// generation preflight runs live-wrap with --defer-source-write, so the
|
||||
// wrapper and every variant reach the DOM in one HMR batch, and the
|
||||
// deferred-wrapper scout (constructed at init) runs before the variant
|
||||
// MutationObserver (constructed at Go) on that batch. Finish the same
|
||||
// transition the observer would have finished. Without hideShaderOverlay
|
||||
// the generating shader stays frozen over the target and the session looks
|
||||
// stuck at GENERATING while the bar already cycles (issue #719).
|
||||
if (state === 'CYCLING') {
|
||||
recoveryWaitingForAnchor = false;
|
||||
hideShaderOverlay();
|
||||
if (isInsert) finalizeInsertSession();
|
||||
disableInlineEdit();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
||||
sendCheckpoint('variants_progress');
|
||||
} else {
|
||||
queueCheckpoint(resumeReason);
|
||||
// Only variants_progress and variants_ready count as publication
|
||||
// progress. When the resume is the arrival, the observer never gets to
|
||||
// report it (this function disconnects and re-creates it below, which
|
||||
// drops the records it had already queued for this same batch), so
|
||||
// without this the server never learns the variants were published.
|
||||
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
sendCheckpoint('variants_ready');
|
||||
}
|
||||
queueCheckpoint('browser_resumed');
|
||||
}
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -12968,7 +12773,7 @@ void main() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
|
||||
if (!wrapper) return;
|
||||
scout.disconnect();
|
||||
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
|
||||
if (resumeSession(deferredResumeRevision)) {
|
||||
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
# The oracle replays goldens recorded from a POSIX checkout, and a finding's
|
||||
# snippet carries the fixture's own bytes, so these files have to arrive with
|
||||
# LF on every platform. `-text` disables end-of-line conversion outright, which
|
||||
# is also safe for any binary that lands under these trees.
|
||||
tests/fixtures/** -text
|
||||
tests/oracle/** -text
|
||||
@@ -1490,7 +1490,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
|
||||
return isNeutralAuthoredColor(m[1]);
|
||||
}
|
||||
|
||||
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
|
||||
|
||||
function scanJs(text, start, onChar) {
|
||||
let stringQuote = '';
|
||||
let inTemplate = false;
|
||||
let paren = 0;
|
||||
let brace = 0;
|
||||
const interpBrace = [];
|
||||
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const prev = text[i - 1];
|
||||
const next = text[i + 1];
|
||||
|
||||
if (stringQuote) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === stringQuote) stringQuote = '';
|
||||
continue;
|
||||
}
|
||||
if (inTemplate && interpBrace.length === 0) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === '$' && next === '{') {
|
||||
brace++;
|
||||
interpBrace.push(brace);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === '`') { inTemplate = false; continue; }
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"') { stringQuote = char; continue; }
|
||||
if (char === '`') { inTemplate = true; continue; }
|
||||
if (char === '(') { paren++; continue; }
|
||||
if (char === ')') { paren--; continue; }
|
||||
if (char === '{') { brace++; continue; }
|
||||
if (char === '}') {
|
||||
brace--;
|
||||
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
|
||||
continue;
|
||||
}
|
||||
if (onChar(char, i, prev, next, { paren, brace })) return;
|
||||
}
|
||||
}
|
||||
|
||||
function containingMarkupTag(line, index) {
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
const tagStart = line.indexOf('<', i);
|
||||
if (tagStart === -1) break;
|
||||
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
|
||||
i = tagStart + 1;
|
||||
continue;
|
||||
}
|
||||
let tagEnd = -1;
|
||||
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
|
||||
if (char === '>' && depth.brace === 0) {
|
||||
tagEnd = j;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (tagEnd === -1) break;
|
||||
if (index >= tagStart && index <= tagEnd) {
|
||||
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
|
||||
}
|
||||
i = tagEnd + 1;
|
||||
}
|
||||
return { text: line, start: 0 };
|
||||
}
|
||||
|
||||
function findTernarySplit(text) {
|
||||
let qPos = -1;
|
||||
let qParen = 0;
|
||||
let qBrace = 0;
|
||||
let nested = 0;
|
||||
let colonPos = -1;
|
||||
let split = null;
|
||||
|
||||
const isQuestion = (char, prev, next) =>
|
||||
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
|
||||
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
|
||||
|
||||
scanJs(text, 0, (char, i, prev, next, depth) => {
|
||||
if (colonPos === -1) {
|
||||
if (qPos === -1 && isQuestion(char, prev, next)) {
|
||||
qPos = i;
|
||||
qParen = depth.paren;
|
||||
qBrace = depth.brace;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
|
||||
nested++;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
|
||||
if (nested) nested--;
|
||||
else colonPos = i;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (char === ',' && sameDepth(depth)) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1, i),
|
||||
suffix: text.slice(i),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!split && qPos !== -1 && colonPos !== -1) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1),
|
||||
suffix: '',
|
||||
};
|
||||
}
|
||||
return split;
|
||||
}
|
||||
|
||||
function exclusiveClassScopes(text) {
|
||||
const split = findTernarySplit(text);
|
||||
if (!split) return [text];
|
||||
return [
|
||||
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
|
||||
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
|
||||
];
|
||||
}
|
||||
|
||||
function grayOnColorScopes(line, index) {
|
||||
return exclusiveClassScopes(containingMarkupTag(line, index).text);
|
||||
}
|
||||
|
||||
function grayOnColorPairs(line, grayClass, index) {
|
||||
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
|
||||
}
|
||||
|
||||
const REGEX_MATCHERS = [
|
||||
// --- Side-tab ---
|
||||
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
|
||||
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
|
||||
fmt: () => 'bg-clip-text + bg-gradient' },
|
||||
// --- Tailwind gray on colored bg ---
|
||||
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
|
||||
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
|
||||
fmt: (m, line) => {
|
||||
const bg = grayOnColorPairs(line, m[0], m.index)
|
||||
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
|
||||
.find(Boolean);
|
||||
return `${m[0]} on ${bg?.[0] || '?'}`;
|
||||
} },
|
||||
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
||||
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
|
||||
// --- Tailwind AI palette ---
|
||||
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
|
||||
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
|
||||
|
||||
@@ -217,7 +217,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -2060,7 +2060,7 @@
|
||||
if (anchor) return anchor;
|
||||
}
|
||||
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (wrapper) {
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0 && visibleVariant > 0) {
|
||||
@@ -2131,14 +2131,14 @@
|
||||
|
||||
function isInsertGeneratingSession() {
|
||||
if (state !== 'GENERATING' || !currentSessionId) return false;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
|
||||
}
|
||||
|
||||
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
|
||||
function ensureInsertPlaceholder() {
|
||||
if (!isInsertGeneratingSession()) return placeholderElement;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0) return placeholderElement;
|
||||
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
|
||||
@@ -3156,7 +3156,7 @@
|
||||
|| svelteComponentSession.wrapperEl
|
||||
|| null;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
@@ -4900,7 +4900,7 @@
|
||||
return Object.values(svelteComponentSession.paramsByVariant || {})
|
||||
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
|
||||
.reduce((total, variant) => total + parseVariantParams(variant).length, 0);
|
||||
@@ -5004,7 +5004,7 @@
|
||||
scheduleCyclingBarSync(sessionId, num);
|
||||
return true;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
updateVariantStateStylesheet(sessionId, num);
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
@@ -5820,7 +5820,6 @@
|
||||
return;
|
||||
}
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
completeParameterGenerationIfReady();
|
||||
@@ -6217,71 +6216,6 @@
|
||||
return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
|
||||
}
|
||||
|
||||
function sourceHasSessionWrapper(text, sessionId) {
|
||||
const src = String(text || '');
|
||||
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|
||||
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|
||||
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
|
||||
* wrapper deleted from source look identical in the DOM, and only the second
|
||||
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
|
||||
* injecting raw JSX; reading the file as plain text and matching the session
|
||||
* marker honors that, because no DOM is ever built from what comes back.
|
||||
* Marker present means the component is simply not mounted right now (a
|
||||
* closed modal, another route) and the variant observer keeps waiting.
|
||||
* Marker absent after the same retry budget the HTML path uses means the
|
||||
* file was edited out from under the session, which no reload, HMR push, or
|
||||
* server restart can repair, so the session self-discards and hands the
|
||||
* surface back to the picker.
|
||||
*/
|
||||
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
|
||||
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
|
||||
const retryLater = () => {
|
||||
setTimeout(() => {
|
||||
if (!stillActive()) return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
};
|
||||
// Discarding is durable (the session moves to the discarded phase and the
|
||||
// picker replaces it), so it needs evidence that the wrapper is gone: a
|
||||
// read that answers without the marker, or a 404 (the file itself was
|
||||
// renamed or deleted). Either kind retries on the shared budget first.
|
||||
// A read that fails for any other reason (the server briefly away, a
|
||||
// transient fetch error) says nothing about the wrapper; after the budget
|
||||
// the session is kept, the user told, and the next event retries.
|
||||
const onNoWrapper = (reason) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
discardOrphanedSession(reason);
|
||||
};
|
||||
const onUnreadable = (detail) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
|
||||
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
|
||||
};
|
||||
fetch(url)
|
||||
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
|
||||
.then(text => {
|
||||
if (!stillActive()) return;
|
||||
if (sourceHasSessionWrapper(text, sessionId)) return;
|
||||
onNoWrapper('variant wrapper missing from source');
|
||||
})
|
||||
.catch(err => {
|
||||
const detail = err && err.message ? err.message : 'fetch failed';
|
||||
if (/source read failed: 404$/.test(detail)) {
|
||||
onNoWrapper('source file missing (404) while checking for the variant wrapper');
|
||||
return;
|
||||
}
|
||||
onUnreadable(detail);
|
||||
});
|
||||
}
|
||||
|
||||
function completeSourceInjection(wrapper, sessionId, opts) {
|
||||
recoveryWaitingForAnchor = false;
|
||||
if (pendingVariantAnchorRetryObserver) {
|
||||
@@ -6362,7 +6296,7 @@
|
||||
}
|
||||
rememberSessionFileMeta({ file: filePath });
|
||||
if (isJsxSourceFile(filePath)) {
|
||||
const liveWrapper = findVariantsWrapper(sessionId);
|
||||
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
|
||||
return;
|
||||
@@ -6392,7 +6326,14 @@
|
||||
return;
|
||||
}
|
||||
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
||||
probeJsxWrapperForOrphan(filePath, sessionId, opts);
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
|
||||
setTimeout(() => {
|
||||
if (sessionId !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -6434,7 +6375,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const existingWrapper = findVariantsWrapper(sessionId);
|
||||
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (existingWrapper) {
|
||||
const wrapper = srcWrapper.cloneNode(true);
|
||||
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
|
||||
@@ -6591,7 +6532,7 @@
|
||||
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
||||
return;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const visEl = pickVariantContent(wrapper, visibleVariant);
|
||||
if (visEl) selectedElement = visEl;
|
||||
@@ -6601,7 +6542,7 @@
|
||||
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
||||
return svelteComponentSession.mountedVariant;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
for (const variant of variants) {
|
||||
@@ -6716,17 +6657,8 @@
|
||||
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
|
||||
* one wrapper per item, so the hide, the release, and the existence checks
|
||||
* all have to speak about the same set.
|
||||
*/
|
||||
function discardedWrappers(sessionId) {
|
||||
if (!sessionId) return [];
|
||||
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
|
||||
}
|
||||
|
||||
function releaseDiscardedStaticWrapper(wrapper) {
|
||||
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
if (!wrapper) return;
|
||||
const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
|
||||
const content = orig?.firstElementChild;
|
||||
@@ -6737,18 +6669,6 @@
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the discard hide on every wrapper it covered. Releasing only the
|
||||
* first match left the other mapped items sitting at display:none with
|
||||
* their original content never restored, on exactly the static and
|
||||
* missed-HMR flows this fallback exists for.
|
||||
*/
|
||||
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
|
||||
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
|
||||
}
|
||||
|
||||
function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
|
||||
if (!sessionId || !document.body) return;
|
||||
if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
|
||||
@@ -6929,42 +6849,6 @@
|
||||
// MutationObserver for progressive variant reveal
|
||||
//
|
||||
|
||||
// A session id can have more than one wrapper in the DOM: the target may sit
|
||||
// inside a `.map()` callback (the wrapper renders once per item), or the
|
||||
// agent may have relocated the wrapper out of the shared primitive live-wrap
|
||||
// scaffolded into. A plain first match can then pin an empty scaffold while
|
||||
// the real variants sit in a later wrapper, which strands the session at
|
||||
// 0/N and leaves the bar, the params panel, and accept all reading the
|
||||
// wrong element. Prefer a wrapper that actually holds variants. With zero
|
||||
// or one match this is exactly the querySelector it replaces.
|
||||
//
|
||||
// Every lookup of the ACTIVE session's wrapper goes through here. The
|
||||
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
|
||||
// existence checks, selector strings for stylesheets and observers (which
|
||||
// want to cover every match), `querySelectorAll` sweeps, and the parsed
|
||||
// source document, which is not this document.
|
||||
function pickPopulatedVariantsWrapper(selector) {
|
||||
const matches = document.querySelectorAll(selector);
|
||||
if (matches.length < 2) return matches[0] || null;
|
||||
for (const candidate of matches) {
|
||||
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
/** The wrapper holding `sessionId`'s variants, or null without an id. */
|
||||
function findVariantsWrapper(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
|
||||
}
|
||||
|
||||
/** Any live variant wrapper, for the resume paths that have no id yet. */
|
||||
function findAnyVariantsWrapper() {
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
|
||||
}
|
||||
|
||||
function startVariantObserver(sessionId) {
|
||||
let updating = false; // re-entrancy guard
|
||||
|
||||
@@ -6994,7 +6878,7 @@
|
||||
}
|
||||
if (!dominated) return;
|
||||
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
@@ -7203,7 +7087,6 @@
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
if (state === 'GENERATING') {
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
disableInlineEdit();
|
||||
refreshParamsPanel();
|
||||
@@ -7264,7 +7147,6 @@
|
||||
pendingAcceptedSession = null;
|
||||
awaitingAcceptResult = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup. Try Accept again.', 5000);
|
||||
break;
|
||||
@@ -8367,15 +8249,6 @@ void main() {
|
||||
// matches the original off-white risograph paper.
|
||||
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
|
||||
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
|
||||
// showShaderOverlay is async: it appends its canvas, then awaits
|
||||
// createImageBitmap and the GL setup before it publishes shaderState. A
|
||||
// teardown that landed inside that window found shaderState still null,
|
||||
// returned, and then watched the construction publish itself over a session
|
||||
// that had already left GENERATING, with no teardown left to run. That is
|
||||
// the generating loader frozen over a page that already cycles (issue #719).
|
||||
// Every teardown bumps this epoch; a construction abandons its own canvas as
|
||||
// soon as it sees the epoch move.
|
||||
let shaderEpoch = 0;
|
||||
|
||||
// The element's effective background tone, used as the uniform halftone
|
||||
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground
|
||||
@@ -8522,28 +8395,14 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop a shader node no shaderState owns (an abandoned construction). */
|
||||
function removeStrayShaderNode() {
|
||||
const stray = uiGetById(PREFIX + '-shader');
|
||||
if (stray) stray.remove();
|
||||
}
|
||||
|
||||
function hideShaderOverlay() {
|
||||
// Bump first, unconditionally: this is what tells an in-flight
|
||||
// showShaderOverlay to abandon itself rather than publish over a session
|
||||
// that has already moved on.
|
||||
shaderEpoch += 1;
|
||||
if (!shaderState) {
|
||||
removeStrayShaderNode();
|
||||
return;
|
||||
}
|
||||
if (!shaderState) return;
|
||||
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
|
||||
if (shaderState.canvas) shaderState.canvas.remove();
|
||||
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
|
||||
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
shaderState = null;
|
||||
removeStrayShaderNode();
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
@@ -8568,16 +8427,6 @@ void main() {
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
// hideShaderOverlay just bumped the epoch, so this run owns it until the
|
||||
// next teardown. Every step past an await re-checks before it publishes.
|
||||
const epoch = shaderEpoch;
|
||||
const abandoned = (node, gl) => {
|
||||
if (epoch === shaderEpoch) return false;
|
||||
node.remove();
|
||||
const lose = gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
return true;
|
||||
};
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = PREFIX + '-shader';
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
@@ -8600,7 +8449,6 @@ void main() {
|
||||
if (!gl) {
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
if (abandoned(canvas, null)) return;
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
@@ -8640,22 +8488,16 @@ void main() {
|
||||
}
|
||||
|
||||
// Upload the screenshot as a texture
|
||||
if (abandoned(canvas, gl)) return;
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
if (abandoned(canvas, gl)) return;
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
if (abandoned(canvas, gl)) {
|
||||
if (bitmap.close) bitmap.close();
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
@@ -8674,7 +8516,6 @@ void main() {
|
||||
const paperRgb = paper || resolvePaperRgb(el);
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if (abandoned(canvas, gl)) return;
|
||||
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
|
||||
function frame() {
|
||||
if (!shaderState) return;
|
||||
@@ -8711,7 +8552,7 @@ void main() {
|
||||
clientSentAt: Date.now(),
|
||||
};
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
const acceptWrapper = findVariantsWrapper(currentSessionId);
|
||||
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
@@ -8754,7 +8595,6 @@ void main() {
|
||||
.catch(() => {
|
||||
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
@@ -8806,7 +8646,7 @@ void main() {
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
@@ -8933,7 +8773,7 @@ void main() {
|
||||
}
|
||||
|
||||
function commitAcceptedVariantToDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!accepted || !accepted.firstElementChild) return false;
|
||||
@@ -9161,7 +9001,7 @@ void main() {
|
||||
}
|
||||
|
||||
function restoreFromActiveSessions(activeSessions, reason) {
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
|
||||
if (svelteComponentSession?.sessionId === currentSessionId) return false;
|
||||
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
|
||||
@@ -9274,13 +9114,10 @@ void main() {
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
// replaced the wrapper by then (keeps static-server / no-HMR flows alive).
|
||||
// Every match, not the first: a target inside a `.map()` renders one
|
||||
// wrapper per item, and hiding only one leaves the rest of the
|
||||
// discarded variants on screen.
|
||||
const discardWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (discardWrappers.length > 0) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (wrapper) {
|
||||
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
||||
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
|
||||
else wrapper.style.display = 'none';
|
||||
}
|
||||
setTimeout(function() {
|
||||
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
||||
@@ -9288,19 +9125,16 @@ void main() {
|
||||
removeDiscardStateStylesheet();
|
||||
return;
|
||||
}
|
||||
const lateWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (lateWrappers.length === 0) {
|
||||
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (!lateWrapper) {
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
// Duplicates all render from one source element, so HMR ownership is
|
||||
// uniform across them; the first is a fair witness for the set.
|
||||
const lateWrapper = lateWrappers[0];
|
||||
if (recoverySuperseded) {
|
||||
if (hasFrameworkHmrOwnership(lateWrapper)) {
|
||||
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
} else {
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -9309,20 +9143,18 @@ void main() {
|
||||
// the final source rewrite, reload once after a grace window so the
|
||||
// discarded source becomes authoritative without a reconciler race.
|
||||
setTimeout(function() {
|
||||
const staleWrappers = discardedWrappers(cleanupSessionId);
|
||||
const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
||||
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
// A reload restores every wrapper's original at once, so there is
|
||||
// nothing per-wrapper to do here.
|
||||
if (staleWrappers.length > 0) location.reload();
|
||||
if (staleWrapper) location.reload();
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}, 2000);
|
||||
}
|
||||
hideBar(instantChrome);
|
||||
@@ -9510,13 +9342,8 @@ void main() {
|
||||
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
||||
}
|
||||
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
|
||||
// Which path resumed matters in the journal: an init resume is a fresh
|
||||
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
|
||||
// used to log the same `browser_resumed`, which made issue #719 take a
|
||||
// DOM reconstruction to diagnose.
|
||||
const resumeReason = opts.reason || 'browser_resumed';
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
|
||||
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
|
||||
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
|
||||
@@ -9615,38 +9442,16 @@ void main() {
|
||||
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// A resume can BE the arrival, not just a re-entry after one. The server's
|
||||
// generation preflight runs live-wrap with --defer-source-write, so the
|
||||
// wrapper and every variant reach the DOM in one HMR batch, and the
|
||||
// deferred-wrapper scout (constructed at init) runs before the variant
|
||||
// MutationObserver (constructed at Go) on that batch. Finish the same
|
||||
// transition the observer would have finished. Without hideShaderOverlay
|
||||
// the generating shader stays frozen over the target and the session looks
|
||||
// stuck at GENERATING while the bar already cycles (issue #719).
|
||||
if (state === 'CYCLING') {
|
||||
recoveryWaitingForAnchor = false;
|
||||
hideShaderOverlay();
|
||||
if (isInsert) finalizeInsertSession();
|
||||
disableInlineEdit();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
||||
sendCheckpoint('variants_progress');
|
||||
} else {
|
||||
queueCheckpoint(resumeReason);
|
||||
// Only variants_progress and variants_ready count as publication
|
||||
// progress. When the resume is the arrival, the observer never gets to
|
||||
// report it (this function disconnects and re-creates it below, which
|
||||
// drops the records it had already queued for this same batch), so
|
||||
// without this the server never learns the variants were published.
|
||||
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
sendCheckpoint('variants_ready');
|
||||
}
|
||||
queueCheckpoint('browser_resumed');
|
||||
}
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -12968,7 +12773,7 @@ void main() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
|
||||
if (!wrapper) return;
|
||||
scout.disconnect();
|
||||
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
|
||||
if (resumeSession(deferredResumeRevision)) {
|
||||
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
||||
}
|
||||
});
|
||||
|
||||
+6
-185
@@ -23,7 +23,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
core: ${{ steps.plan.outputs.core }}
|
||||
rust: ${{ steps.plan.outputs.rust }}
|
||||
detector: ${{ steps.plan.outputs.detector }}
|
||||
live: ${{ steps.plan.outputs.live }}
|
||||
framework: ${{ steps.plan.outputs.framework }}
|
||||
@@ -93,22 +92,13 @@ jobs:
|
||||
if: needs.changes.outputs.framework == 'true'
|
||||
run: bun run test:framework
|
||||
|
||||
- name: Rebuild browser detector
|
||||
if: needs.changes.outputs.detector == 'true'
|
||||
run: bun run build:browser
|
||||
|
||||
- name: Build
|
||||
run: bun run build
|
||||
|
||||
# `bun run build:extension` runs `cargo xtask bundle`: the rule core
|
||||
# compiled to wasm plus the page JS in browser-bundle/.
|
||||
- name: Install the pinned toolchain
|
||||
if: needs.changes.outputs.detector == 'true'
|
||||
run: rustup show && rustup target add wasm32-unknown-unknown
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: needs.changes.outputs.detector == 'true'
|
||||
|
||||
- name: Install wasm-pack
|
||||
if: needs.changes.outputs.detector == 'true'
|
||||
run: cargo install wasm-pack --locked
|
||||
|
||||
- name: Build extension
|
||||
if: needs.changes.outputs.detector == 'true'
|
||||
run: bun run build:extension
|
||||
@@ -121,131 +111,18 @@ jobs:
|
||||
run: npx --yes web-ext@10 lint --source-dir dist/extension-firefox
|
||||
|
||||
- name: Verify generated tracked outputs
|
||||
# extension/detector/ is gitignored (built by `cargo xtask bundle`);
|
||||
# it stays listed so a stray tracked copy shows up here.
|
||||
run: git diff --exit-code -- .agents .claude .cursor .gemini .github/skills plugin extension/detector
|
||||
run: git diff --exit-code -- .agents .claude .cursor .gemini .github/skills plugin cli/engine/detect-antipatterns-browser.js extension/detector
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: impeccable-build-node-${{ matrix.node-version }}
|
||||
name: impeccable-dist-node-${{ matrix.node-version }}
|
||||
# Ship the packaged zips, not the unpacked Firefox staging tree.
|
||||
path: |
|
||||
dist/
|
||||
!dist/extension-firefox/
|
||||
retention-days: 7
|
||||
|
||||
# The Rust workspace: the engine binary, the rule core, and every crate
|
||||
# behind them. Everything builds from source with no downloads.
|
||||
rust:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.rust == 'true'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
# rust-toolchain.toml names the channel; `rustup show` installs it.
|
||||
# Never override the toolchain here.
|
||||
- name: Install the pinned toolchain
|
||||
run: rustup show
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Build
|
||||
run: cargo build --workspace --all-targets
|
||||
|
||||
- name: Test
|
||||
run: cargo test --workspace
|
||||
|
||||
# The engine ships a windows-x64 binary (release-engine.yml), so the
|
||||
# workspace has to build and pass its own tests there. Tests that need a
|
||||
# browser or the oracle skip when those are absent.
|
||||
rust-windows:
|
||||
runs-on: windows-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.rust == 'true'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
- name: Install the pinned toolchain
|
||||
run: rustup show
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo build --workspace --all-targets
|
||||
- run: cargo test --workspace --no-fail-fast
|
||||
|
||||
# Behavior gate: replays the tests/oracle/ goldens against a release build
|
||||
# of the engine from THIS checkout (so a PR is judged on its own source,
|
||||
# not on the last published binary). Without this job the oracle only ever
|
||||
# runs on developer laptops: tests/oracle.test.mjs skips cleanly when no
|
||||
# binary is present, so the default suite is silent about it on CI.
|
||||
oracle:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.oracle == 'true' || needs.changes.outputs.rust == 'true'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Install the pinned toolchain
|
||||
run: rustup show
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Build the engine from source
|
||||
run: cargo build --release -p impeccable
|
||||
|
||||
- name: Replay oracle goldens
|
||||
env:
|
||||
IMPECCABLE_BIN: ${{ github.workspace }}/target/release/impeccable
|
||||
run: node tests/oracle/run.mjs
|
||||
|
||||
# Release-order guard (triage decision D4). Verifies that the engine release for
|
||||
# the pinned ENGINE_VERSION is fully published — the five dist binaries + .sha256
|
||||
# AND the five @impeccable/cli-<os>-<arch> npm platform packages — before a skill
|
||||
# release/merge that depends on them. The launcher, npm shim, and
|
||||
# `impeccable install` all dead-end without those assets.
|
||||
#
|
||||
# continue-on-error is a release-time toggle: until the first engine release is
|
||||
# published, the assets cannot exist and this job would block
|
||||
# every PR. It emits a loud ::warning instead. Once v<ENGINE_VERSION> is live,
|
||||
# flip `continue-on-error` to false so a MIS-ORDERED release (skill/CLI ahead of
|
||||
# the engine) fails CI. release.mjs already hard-fails `release:skill`/`release:cli`.
|
||||
engine-release-ready:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Check engine release assets for pinned ENGINE_VERSION
|
||||
id: check
|
||||
continue-on-error: true
|
||||
run: node scripts/check-engine-release.mjs
|
||||
|
||||
- name: Annotate missing engine release
|
||||
if: steps.check.outcome != 'success'
|
||||
run: |
|
||||
echo "::warning title=Engine release not ready::The engine release for v$(cat ENGINE_VERSION) is not fully published (engine-v$(cat ENGINE_VERSION) release) and/or the @impeccable/cli-<os>-<arch> npm platform packages. Releasing the skill/CLI (or merging) now would dead-end the launcher, the npm shim, and impeccable install. Expected until the first engine release exists; after that, publish the engine + platform packages and flip this job's continue-on-error to false so a mis-ordered release fails CI."
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test-matrix
|
||||
@@ -280,16 +157,6 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
# The live verbs are the engine binary; build it from this checkout so the
|
||||
# suite tests the branch, not the last published release.
|
||||
- name: Install the pinned toolchain
|
||||
run: rustup show
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Build the engine
|
||||
run: cargo build --release -p impeccable
|
||||
|
||||
- name: Run remote CLI E2E smoke
|
||||
run: bun run test:cli-remote-e2e
|
||||
|
||||
@@ -345,16 +212,6 @@ jobs:
|
||||
- name: Install Playwright Chromium
|
||||
run: npx playwright install chromium
|
||||
|
||||
# The live verbs are the engine binary; build it from this checkout so the
|
||||
# suite tests the branch, not the last published release.
|
||||
- name: Install the pinned toolchain
|
||||
run: rustup show
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Build the engine
|
||||
run: cargo build --release -p impeccable
|
||||
|
||||
- name: Run live E2E tests
|
||||
run: bun run test:live-e2e
|
||||
env:
|
||||
@@ -431,16 +288,6 @@ jobs:
|
||||
- name: Install Playwright Chromium
|
||||
run: npx playwright install chromium
|
||||
|
||||
# The live verbs are the engine binary; build it from this checkout so the
|
||||
# suite tests the branch, not the last published release.
|
||||
- name: Install the pinned toolchain
|
||||
run: rustup show
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Build the engine
|
||||
run: cargo build --release -p impeccable
|
||||
|
||||
- name: Run live E2E tests
|
||||
run: bun run test:live-e2e
|
||||
env:
|
||||
@@ -513,19 +360,6 @@ jobs:
|
||||
if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }}
|
||||
run: npx playwright install chromium
|
||||
|
||||
# The live verbs are the engine binary; build it from this checkout so the
|
||||
# suite tests the branch, not the last published release.
|
||||
- name: Install the pinned toolchain
|
||||
if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }}
|
||||
run: rustup show
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }}
|
||||
|
||||
- name: Build the engine
|
||||
if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }}
|
||||
run: cargo build --release -p impeccable
|
||||
|
||||
- name: Run accept cleanup regression
|
||||
if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }}
|
||||
run: |
|
||||
@@ -590,19 +424,6 @@ jobs:
|
||||
if: ${{ env.DEEPSEEK_API_KEY != '' }}
|
||||
run: npx playwright install chromium
|
||||
|
||||
# The live verbs are the engine binary; build it from this checkout so the
|
||||
# suite tests the branch, not the last published release.
|
||||
- name: Install the pinned toolchain
|
||||
if: ${{ env.DEEPSEEK_API_KEY != '' }}
|
||||
run: rustup show
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: ${{ env.DEEPSEEK_API_KEY != '' }}
|
||||
|
||||
- name: Build the engine
|
||||
if: ${{ env.DEEPSEEK_API_KEY != '' }}
|
||||
run: cargo build --release -p impeccable
|
||||
|
||||
- name: Run Svelte adapter DeepSeek sweep
|
||||
if: ${{ env.DEEPSEEK_API_KEY != '' }}
|
||||
run: bun run test:live-svelte-adapter-deepseek
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
name: release-engine
|
||||
# Builds the engine binary for every supported target and publishes them, with
|
||||
# sha256 sidecars, as the GitHub Release `engine-v<X>` on this repo. That
|
||||
# release is what the launcher (skill/scripts/impeccable), the npm shim
|
||||
# (cli/bin/cli.js), `impeccable install`, and `bun run fetch:engine` download.
|
||||
#
|
||||
# Trigger: `bun run release:engine` (scripts/release.mjs) verifies
|
||||
# ENGINE_VERSION, the npm platform-package pins, and a clean tree, then
|
||||
# pushes the tag. Third-party actions are pinned to commit SHAs so a
|
||||
# moved tag cannot swap the code this workflow runs.
|
||||
on:
|
||||
push:
|
||||
tags: ['engine-v*']
|
||||
permissions:
|
||||
contents: write
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { os: macos-14, target: aarch64-apple-darwin, short: darwin-arm64 }
|
||||
# No Intel runner: GitHub retired macos-13. Apple's toolchain builds
|
||||
# x86_64 on an arm64 host natively once the target is installed.
|
||||
- { os: macos-14, target: x86_64-apple-darwin, short: darwin-x64 }
|
||||
- { os: ubuntu-latest, target: x86_64-unknown-linux-musl, short: linux-x64 }
|
||||
- { os: ubuntu-latest, target: aarch64-unknown-linux-musl, short: linux-arm64, cross: true }
|
||||
- { os: windows-latest, target: x86_64-pc-windows-msvc, short: windows-x64 }
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- name: Check the tag matches ENGINE_VERSION
|
||||
shell: bash
|
||||
run: |
|
||||
set -e
|
||||
want="engine-v$(tr -d '[:space:]' < ENGINE_VERSION)"
|
||||
[ "$GITHUB_REF_NAME" = "$want" ] || { echo "tag $GITHUB_REF_NAME != $want"; exit 1; }
|
||||
# rust-toolchain.toml names the channel; `rustup show` installs it.
|
||||
# Never override the toolchain here.
|
||||
- name: Install the pinned toolchain
|
||||
shell: bash
|
||||
run: rustup show && rustup target add ${{ matrix.target }}
|
||||
- if: matrix.os == 'ubuntu-latest'
|
||||
run: sudo apt-get update && sudo apt-get install -y musl-tools
|
||||
- if: matrix.cross
|
||||
run: cargo install cross --locked
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: ${{ matrix.cross && 'cross' || 'cargo' }} build --release -p impeccable --target ${{ matrix.target }}
|
||||
- name: Smoke the binary
|
||||
if: ${{ !matrix.cross }}
|
||||
shell: bash
|
||||
run: target/${{ matrix.target }}/release/impeccable${{ runner.os == 'Windows' && '.exe' || '' }} engine-probe
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: impeccable-${{ matrix.short }}
|
||||
path: target/${{ matrix.target }}/release/impeccable${{ runner.os == 'Windows' && '.exe' || '' }}
|
||||
if-no-files-found: error
|
||||
publish:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with: { path: artifacts }
|
||||
- name: Lay out release assets with checksums
|
||||
run: |
|
||||
set -e
|
||||
mkdir -p out
|
||||
for d in artifacts/impeccable-*; do
|
||||
short=$(basename "$d" | sed 's/^impeccable-//')
|
||||
f=$(ls "$d" | head -1)
|
||||
case "$short" in windows-*) dest="out/impeccable-$short.exe" ;; *) dest="out/impeccable-$short" ;; esac
|
||||
cp "$d/$f" "$dest"
|
||||
(cd out && sha256sum "$(basename "$dest")" > "$(basename "$dest").sha256")
|
||||
done
|
||||
ls -la out
|
||||
- name: Publish the GitHub Release
|
||||
env: { GH_TOKEN: "${{ github.token }}" }
|
||||
# No --clobber: a published asset is immutable. A re-run against an
|
||||
# existing release fails on the first existing asset instead of
|
||||
# silently replacing a binary and its sidecar hash.
|
||||
run: |
|
||||
set -e
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
gh release create "$tag" --repo "$GITHUB_REPOSITORY" --title "impeccable engine $tag" \
|
||||
--notes "Prebuilt impeccable engine binaries ($tag). The launcher, the npm shim and impeccable install download these on first run. Docs: https://impeccable.style" out/* || \
|
||||
gh release upload "$tag" out/* --repo "$GITHUB_REPOSITORY"
|
||||
-12
@@ -13,17 +13,10 @@ build/
|
||||
# can copy them into tmp git repos and assert is-generated behavior.
|
||||
!tests/framework-fixtures/**/dist/
|
||||
!tests/framework-fixtures/**/dist/**
|
||||
# Same for the oracle workspaces: live-html carries a dist/generated.html
|
||||
# that the generated-file cases point at.
|
||||
!tests/oracle/workspaces/**/dist/
|
||||
!tests/oracle/workspaces/**/dist/**
|
||||
|
||||
# Build artifacts
|
||||
*.log
|
||||
|
||||
# Cargo (the Rust workspace; Cargo.lock IS tracked, it pins the engine build)
|
||||
/target/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -90,11 +83,6 @@ src/lib/impeccable/__runtime.js
|
||||
# Extension build artifacts
|
||||
extension/detector/
|
||||
|
||||
# Engine binaries: fetched per platform (scripts/fetch-engine.mjs), never tracked.
|
||||
# The launcher next to them (skill/scripts/impeccable) is the tracked file.
|
||||
skill/scripts/bin/
|
||||
**/skills/impeccable/scripts/bin/
|
||||
|
||||
# Legacy design context (pre-v3.1, auto-migrated to PRODUCT.md by load-context.mjs)
|
||||
.impeccable.md
|
||||
# Note: PRODUCT.md and DESIGN.md are INTENTIONALLY tracked in this repo —
|
||||
|
||||
@@ -1490,7 +1490,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
|
||||
return isNeutralAuthoredColor(m[1]);
|
||||
}
|
||||
|
||||
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
|
||||
|
||||
function scanJs(text, start, onChar) {
|
||||
let stringQuote = '';
|
||||
let inTemplate = false;
|
||||
let paren = 0;
|
||||
let brace = 0;
|
||||
const interpBrace = [];
|
||||
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const prev = text[i - 1];
|
||||
const next = text[i + 1];
|
||||
|
||||
if (stringQuote) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === stringQuote) stringQuote = '';
|
||||
continue;
|
||||
}
|
||||
if (inTemplate && interpBrace.length === 0) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === '$' && next === '{') {
|
||||
brace++;
|
||||
interpBrace.push(brace);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === '`') { inTemplate = false; continue; }
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"') { stringQuote = char; continue; }
|
||||
if (char === '`') { inTemplate = true; continue; }
|
||||
if (char === '(') { paren++; continue; }
|
||||
if (char === ')') { paren--; continue; }
|
||||
if (char === '{') { brace++; continue; }
|
||||
if (char === '}') {
|
||||
brace--;
|
||||
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
|
||||
continue;
|
||||
}
|
||||
if (onChar(char, i, prev, next, { paren, brace })) return;
|
||||
}
|
||||
}
|
||||
|
||||
function containingMarkupTag(line, index) {
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
const tagStart = line.indexOf('<', i);
|
||||
if (tagStart === -1) break;
|
||||
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
|
||||
i = tagStart + 1;
|
||||
continue;
|
||||
}
|
||||
let tagEnd = -1;
|
||||
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
|
||||
if (char === '>' && depth.brace === 0) {
|
||||
tagEnd = j;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (tagEnd === -1) break;
|
||||
if (index >= tagStart && index <= tagEnd) {
|
||||
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
|
||||
}
|
||||
i = tagEnd + 1;
|
||||
}
|
||||
return { text: line, start: 0 };
|
||||
}
|
||||
|
||||
function findTernarySplit(text) {
|
||||
let qPos = -1;
|
||||
let qParen = 0;
|
||||
let qBrace = 0;
|
||||
let nested = 0;
|
||||
let colonPos = -1;
|
||||
let split = null;
|
||||
|
||||
const isQuestion = (char, prev, next) =>
|
||||
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
|
||||
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
|
||||
|
||||
scanJs(text, 0, (char, i, prev, next, depth) => {
|
||||
if (colonPos === -1) {
|
||||
if (qPos === -1 && isQuestion(char, prev, next)) {
|
||||
qPos = i;
|
||||
qParen = depth.paren;
|
||||
qBrace = depth.brace;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
|
||||
nested++;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
|
||||
if (nested) nested--;
|
||||
else colonPos = i;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (char === ',' && sameDepth(depth)) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1, i),
|
||||
suffix: text.slice(i),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!split && qPos !== -1 && colonPos !== -1) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1),
|
||||
suffix: '',
|
||||
};
|
||||
}
|
||||
return split;
|
||||
}
|
||||
|
||||
function exclusiveClassScopes(text) {
|
||||
const split = findTernarySplit(text);
|
||||
if (!split) return [text];
|
||||
return [
|
||||
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
|
||||
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
|
||||
];
|
||||
}
|
||||
|
||||
function grayOnColorScopes(line, index) {
|
||||
return exclusiveClassScopes(containingMarkupTag(line, index).text);
|
||||
}
|
||||
|
||||
function grayOnColorPairs(line, grayClass, index) {
|
||||
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
|
||||
}
|
||||
|
||||
const REGEX_MATCHERS = [
|
||||
// --- Side-tab ---
|
||||
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
|
||||
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
|
||||
fmt: () => 'bg-clip-text + bg-gradient' },
|
||||
// --- Tailwind gray on colored bg ---
|
||||
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
|
||||
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
|
||||
fmt: (m, line) => {
|
||||
const bg = grayOnColorPairs(line, m[0], m.index)
|
||||
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
|
||||
.find(Boolean);
|
||||
return `${m[0]} on ${bg?.[0] || '?'}`;
|
||||
} },
|
||||
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
||||
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
|
||||
// --- Tailwind AI palette ---
|
||||
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
|
||||
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
|
||||
|
||||
@@ -217,7 +217,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -2060,7 +2060,7 @@
|
||||
if (anchor) return anchor;
|
||||
}
|
||||
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (wrapper) {
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0 && visibleVariant > 0) {
|
||||
@@ -2131,14 +2131,14 @@
|
||||
|
||||
function isInsertGeneratingSession() {
|
||||
if (state !== 'GENERATING' || !currentSessionId) return false;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
|
||||
}
|
||||
|
||||
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
|
||||
function ensureInsertPlaceholder() {
|
||||
if (!isInsertGeneratingSession()) return placeholderElement;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0) return placeholderElement;
|
||||
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
|
||||
@@ -3156,7 +3156,7 @@
|
||||
|| svelteComponentSession.wrapperEl
|
||||
|| null;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
@@ -4900,7 +4900,7 @@
|
||||
return Object.values(svelteComponentSession.paramsByVariant || {})
|
||||
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
|
||||
.reduce((total, variant) => total + parseVariantParams(variant).length, 0);
|
||||
@@ -5004,7 +5004,7 @@
|
||||
scheduleCyclingBarSync(sessionId, num);
|
||||
return true;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
updateVariantStateStylesheet(sessionId, num);
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
@@ -5820,7 +5820,6 @@
|
||||
return;
|
||||
}
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
completeParameterGenerationIfReady();
|
||||
@@ -6217,71 +6216,6 @@
|
||||
return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
|
||||
}
|
||||
|
||||
function sourceHasSessionWrapper(text, sessionId) {
|
||||
const src = String(text || '');
|
||||
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|
||||
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|
||||
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
|
||||
* wrapper deleted from source look identical in the DOM, and only the second
|
||||
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
|
||||
* injecting raw JSX; reading the file as plain text and matching the session
|
||||
* marker honors that, because no DOM is ever built from what comes back.
|
||||
* Marker present means the component is simply not mounted right now (a
|
||||
* closed modal, another route) and the variant observer keeps waiting.
|
||||
* Marker absent after the same retry budget the HTML path uses means the
|
||||
* file was edited out from under the session, which no reload, HMR push, or
|
||||
* server restart can repair, so the session self-discards and hands the
|
||||
* surface back to the picker.
|
||||
*/
|
||||
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
|
||||
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
|
||||
const retryLater = () => {
|
||||
setTimeout(() => {
|
||||
if (!stillActive()) return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
};
|
||||
// Discarding is durable (the session moves to the discarded phase and the
|
||||
// picker replaces it), so it needs evidence that the wrapper is gone: a
|
||||
// read that answers without the marker, or a 404 (the file itself was
|
||||
// renamed or deleted). Either kind retries on the shared budget first.
|
||||
// A read that fails for any other reason (the server briefly away, a
|
||||
// transient fetch error) says nothing about the wrapper; after the budget
|
||||
// the session is kept, the user told, and the next event retries.
|
||||
const onNoWrapper = (reason) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
discardOrphanedSession(reason);
|
||||
};
|
||||
const onUnreadable = (detail) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
|
||||
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
|
||||
};
|
||||
fetch(url)
|
||||
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
|
||||
.then(text => {
|
||||
if (!stillActive()) return;
|
||||
if (sourceHasSessionWrapper(text, sessionId)) return;
|
||||
onNoWrapper('variant wrapper missing from source');
|
||||
})
|
||||
.catch(err => {
|
||||
const detail = err && err.message ? err.message : 'fetch failed';
|
||||
if (/source read failed: 404$/.test(detail)) {
|
||||
onNoWrapper('source file missing (404) while checking for the variant wrapper');
|
||||
return;
|
||||
}
|
||||
onUnreadable(detail);
|
||||
});
|
||||
}
|
||||
|
||||
function completeSourceInjection(wrapper, sessionId, opts) {
|
||||
recoveryWaitingForAnchor = false;
|
||||
if (pendingVariantAnchorRetryObserver) {
|
||||
@@ -6362,7 +6296,7 @@
|
||||
}
|
||||
rememberSessionFileMeta({ file: filePath });
|
||||
if (isJsxSourceFile(filePath)) {
|
||||
const liveWrapper = findVariantsWrapper(sessionId);
|
||||
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
|
||||
return;
|
||||
@@ -6392,7 +6326,14 @@
|
||||
return;
|
||||
}
|
||||
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
||||
probeJsxWrapperForOrphan(filePath, sessionId, opts);
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
|
||||
setTimeout(() => {
|
||||
if (sessionId !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -6434,7 +6375,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const existingWrapper = findVariantsWrapper(sessionId);
|
||||
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (existingWrapper) {
|
||||
const wrapper = srcWrapper.cloneNode(true);
|
||||
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
|
||||
@@ -6591,7 +6532,7 @@
|
||||
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
||||
return;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const visEl = pickVariantContent(wrapper, visibleVariant);
|
||||
if (visEl) selectedElement = visEl;
|
||||
@@ -6601,7 +6542,7 @@
|
||||
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
||||
return svelteComponentSession.mountedVariant;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
for (const variant of variants) {
|
||||
@@ -6716,17 +6657,8 @@
|
||||
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
|
||||
* one wrapper per item, so the hide, the release, and the existence checks
|
||||
* all have to speak about the same set.
|
||||
*/
|
||||
function discardedWrappers(sessionId) {
|
||||
if (!sessionId) return [];
|
||||
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
|
||||
}
|
||||
|
||||
function releaseDiscardedStaticWrapper(wrapper) {
|
||||
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
if (!wrapper) return;
|
||||
const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
|
||||
const content = orig?.firstElementChild;
|
||||
@@ -6737,18 +6669,6 @@
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the discard hide on every wrapper it covered. Releasing only the
|
||||
* first match left the other mapped items sitting at display:none with
|
||||
* their original content never restored, on exactly the static and
|
||||
* missed-HMR flows this fallback exists for.
|
||||
*/
|
||||
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
|
||||
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
|
||||
}
|
||||
|
||||
function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
|
||||
if (!sessionId || !document.body) return;
|
||||
if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
|
||||
@@ -6929,42 +6849,6 @@
|
||||
// MutationObserver for progressive variant reveal
|
||||
//
|
||||
|
||||
// A session id can have more than one wrapper in the DOM: the target may sit
|
||||
// inside a `.map()` callback (the wrapper renders once per item), or the
|
||||
// agent may have relocated the wrapper out of the shared primitive live-wrap
|
||||
// scaffolded into. A plain first match can then pin an empty scaffold while
|
||||
// the real variants sit in a later wrapper, which strands the session at
|
||||
// 0/N and leaves the bar, the params panel, and accept all reading the
|
||||
// wrong element. Prefer a wrapper that actually holds variants. With zero
|
||||
// or one match this is exactly the querySelector it replaces.
|
||||
//
|
||||
// Every lookup of the ACTIVE session's wrapper goes through here. The
|
||||
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
|
||||
// existence checks, selector strings for stylesheets and observers (which
|
||||
// want to cover every match), `querySelectorAll` sweeps, and the parsed
|
||||
// source document, which is not this document.
|
||||
function pickPopulatedVariantsWrapper(selector) {
|
||||
const matches = document.querySelectorAll(selector);
|
||||
if (matches.length < 2) return matches[0] || null;
|
||||
for (const candidate of matches) {
|
||||
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
/** The wrapper holding `sessionId`'s variants, or null without an id. */
|
||||
function findVariantsWrapper(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
|
||||
}
|
||||
|
||||
/** Any live variant wrapper, for the resume paths that have no id yet. */
|
||||
function findAnyVariantsWrapper() {
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
|
||||
}
|
||||
|
||||
function startVariantObserver(sessionId) {
|
||||
let updating = false; // re-entrancy guard
|
||||
|
||||
@@ -6994,7 +6878,7 @@
|
||||
}
|
||||
if (!dominated) return;
|
||||
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
@@ -7203,7 +7087,6 @@
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
if (state === 'GENERATING') {
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
disableInlineEdit();
|
||||
refreshParamsPanel();
|
||||
@@ -7264,7 +7147,6 @@
|
||||
pendingAcceptedSession = null;
|
||||
awaitingAcceptResult = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup. Try Accept again.', 5000);
|
||||
break;
|
||||
@@ -8367,15 +8249,6 @@ void main() {
|
||||
// matches the original off-white risograph paper.
|
||||
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
|
||||
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
|
||||
// showShaderOverlay is async: it appends its canvas, then awaits
|
||||
// createImageBitmap and the GL setup before it publishes shaderState. A
|
||||
// teardown that landed inside that window found shaderState still null,
|
||||
// returned, and then watched the construction publish itself over a session
|
||||
// that had already left GENERATING, with no teardown left to run. That is
|
||||
// the generating loader frozen over a page that already cycles (issue #719).
|
||||
// Every teardown bumps this epoch; a construction abandons its own canvas as
|
||||
// soon as it sees the epoch move.
|
||||
let shaderEpoch = 0;
|
||||
|
||||
// The element's effective background tone, used as the uniform halftone
|
||||
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground
|
||||
@@ -8522,28 +8395,14 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop a shader node no shaderState owns (an abandoned construction). */
|
||||
function removeStrayShaderNode() {
|
||||
const stray = uiGetById(PREFIX + '-shader');
|
||||
if (stray) stray.remove();
|
||||
}
|
||||
|
||||
function hideShaderOverlay() {
|
||||
// Bump first, unconditionally: this is what tells an in-flight
|
||||
// showShaderOverlay to abandon itself rather than publish over a session
|
||||
// that has already moved on.
|
||||
shaderEpoch += 1;
|
||||
if (!shaderState) {
|
||||
removeStrayShaderNode();
|
||||
return;
|
||||
}
|
||||
if (!shaderState) return;
|
||||
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
|
||||
if (shaderState.canvas) shaderState.canvas.remove();
|
||||
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
|
||||
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
shaderState = null;
|
||||
removeStrayShaderNode();
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
@@ -8568,16 +8427,6 @@ void main() {
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
// hideShaderOverlay just bumped the epoch, so this run owns it until the
|
||||
// next teardown. Every step past an await re-checks before it publishes.
|
||||
const epoch = shaderEpoch;
|
||||
const abandoned = (node, gl) => {
|
||||
if (epoch === shaderEpoch) return false;
|
||||
node.remove();
|
||||
const lose = gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
return true;
|
||||
};
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = PREFIX + '-shader';
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
@@ -8600,7 +8449,6 @@ void main() {
|
||||
if (!gl) {
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
if (abandoned(canvas, null)) return;
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
@@ -8640,22 +8488,16 @@ void main() {
|
||||
}
|
||||
|
||||
// Upload the screenshot as a texture
|
||||
if (abandoned(canvas, gl)) return;
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
if (abandoned(canvas, gl)) return;
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
if (abandoned(canvas, gl)) {
|
||||
if (bitmap.close) bitmap.close();
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
@@ -8674,7 +8516,6 @@ void main() {
|
||||
const paperRgb = paper || resolvePaperRgb(el);
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if (abandoned(canvas, gl)) return;
|
||||
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
|
||||
function frame() {
|
||||
if (!shaderState) return;
|
||||
@@ -8711,7 +8552,7 @@ void main() {
|
||||
clientSentAt: Date.now(),
|
||||
};
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
const acceptWrapper = findVariantsWrapper(currentSessionId);
|
||||
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
@@ -8754,7 +8595,6 @@ void main() {
|
||||
.catch(() => {
|
||||
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
@@ -8806,7 +8646,7 @@ void main() {
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
@@ -8933,7 +8773,7 @@ void main() {
|
||||
}
|
||||
|
||||
function commitAcceptedVariantToDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!accepted || !accepted.firstElementChild) return false;
|
||||
@@ -9161,7 +9001,7 @@ void main() {
|
||||
}
|
||||
|
||||
function restoreFromActiveSessions(activeSessions, reason) {
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
|
||||
if (svelteComponentSession?.sessionId === currentSessionId) return false;
|
||||
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
|
||||
@@ -9274,13 +9114,10 @@ void main() {
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
// replaced the wrapper by then (keeps static-server / no-HMR flows alive).
|
||||
// Every match, not the first: a target inside a `.map()` renders one
|
||||
// wrapper per item, and hiding only one leaves the rest of the
|
||||
// discarded variants on screen.
|
||||
const discardWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (discardWrappers.length > 0) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (wrapper) {
|
||||
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
||||
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
|
||||
else wrapper.style.display = 'none';
|
||||
}
|
||||
setTimeout(function() {
|
||||
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
||||
@@ -9288,19 +9125,16 @@ void main() {
|
||||
removeDiscardStateStylesheet();
|
||||
return;
|
||||
}
|
||||
const lateWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (lateWrappers.length === 0) {
|
||||
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (!lateWrapper) {
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
// Duplicates all render from one source element, so HMR ownership is
|
||||
// uniform across them; the first is a fair witness for the set.
|
||||
const lateWrapper = lateWrappers[0];
|
||||
if (recoverySuperseded) {
|
||||
if (hasFrameworkHmrOwnership(lateWrapper)) {
|
||||
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
} else {
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -9309,20 +9143,18 @@ void main() {
|
||||
// the final source rewrite, reload once after a grace window so the
|
||||
// discarded source becomes authoritative without a reconciler race.
|
||||
setTimeout(function() {
|
||||
const staleWrappers = discardedWrappers(cleanupSessionId);
|
||||
const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
||||
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
// A reload restores every wrapper's original at once, so there is
|
||||
// nothing per-wrapper to do here.
|
||||
if (staleWrappers.length > 0) location.reload();
|
||||
if (staleWrapper) location.reload();
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}, 2000);
|
||||
}
|
||||
hideBar(instantChrome);
|
||||
@@ -9510,13 +9342,8 @@ void main() {
|
||||
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
||||
}
|
||||
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
|
||||
// Which path resumed matters in the journal: an init resume is a fresh
|
||||
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
|
||||
// used to log the same `browser_resumed`, which made issue #719 take a
|
||||
// DOM reconstruction to diagnose.
|
||||
const resumeReason = opts.reason || 'browser_resumed';
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
|
||||
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
|
||||
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
|
||||
@@ -9615,38 +9442,16 @@ void main() {
|
||||
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// A resume can BE the arrival, not just a re-entry after one. The server's
|
||||
// generation preflight runs live-wrap with --defer-source-write, so the
|
||||
// wrapper and every variant reach the DOM in one HMR batch, and the
|
||||
// deferred-wrapper scout (constructed at init) runs before the variant
|
||||
// MutationObserver (constructed at Go) on that batch. Finish the same
|
||||
// transition the observer would have finished. Without hideShaderOverlay
|
||||
// the generating shader stays frozen over the target and the session looks
|
||||
// stuck at GENERATING while the bar already cycles (issue #719).
|
||||
if (state === 'CYCLING') {
|
||||
recoveryWaitingForAnchor = false;
|
||||
hideShaderOverlay();
|
||||
if (isInsert) finalizeInsertSession();
|
||||
disableInlineEdit();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
||||
sendCheckpoint('variants_progress');
|
||||
} else {
|
||||
queueCheckpoint(resumeReason);
|
||||
// Only variants_progress and variants_ready count as publication
|
||||
// progress. When the resume is the arrival, the observer never gets to
|
||||
// report it (this function disconnects and re-creates it below, which
|
||||
// drops the records it had already queued for this same batch), so
|
||||
// without this the server never learns the variants were published.
|
||||
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
sendCheckpoint('variants_ready');
|
||||
}
|
||||
queueCheckpoint('browser_resumed');
|
||||
}
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -12968,7 +12773,7 @@ void main() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
|
||||
if (!wrapper) return;
|
||||
scout.disconnect();
|
||||
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
|
||||
if (resumeSession(deferredResumeRevision)) {
|
||||
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1490,7 +1490,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
|
||||
return isNeutralAuthoredColor(m[1]);
|
||||
}
|
||||
|
||||
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
|
||||
|
||||
function scanJs(text, start, onChar) {
|
||||
let stringQuote = '';
|
||||
let inTemplate = false;
|
||||
let paren = 0;
|
||||
let brace = 0;
|
||||
const interpBrace = [];
|
||||
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const prev = text[i - 1];
|
||||
const next = text[i + 1];
|
||||
|
||||
if (stringQuote) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === stringQuote) stringQuote = '';
|
||||
continue;
|
||||
}
|
||||
if (inTemplate && interpBrace.length === 0) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === '$' && next === '{') {
|
||||
brace++;
|
||||
interpBrace.push(brace);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === '`') { inTemplate = false; continue; }
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"') { stringQuote = char; continue; }
|
||||
if (char === '`') { inTemplate = true; continue; }
|
||||
if (char === '(') { paren++; continue; }
|
||||
if (char === ')') { paren--; continue; }
|
||||
if (char === '{') { brace++; continue; }
|
||||
if (char === '}') {
|
||||
brace--;
|
||||
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
|
||||
continue;
|
||||
}
|
||||
if (onChar(char, i, prev, next, { paren, brace })) return;
|
||||
}
|
||||
}
|
||||
|
||||
function containingMarkupTag(line, index) {
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
const tagStart = line.indexOf('<', i);
|
||||
if (tagStart === -1) break;
|
||||
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
|
||||
i = tagStart + 1;
|
||||
continue;
|
||||
}
|
||||
let tagEnd = -1;
|
||||
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
|
||||
if (char === '>' && depth.brace === 0) {
|
||||
tagEnd = j;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (tagEnd === -1) break;
|
||||
if (index >= tagStart && index <= tagEnd) {
|
||||
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
|
||||
}
|
||||
i = tagEnd + 1;
|
||||
}
|
||||
return { text: line, start: 0 };
|
||||
}
|
||||
|
||||
function findTernarySplit(text) {
|
||||
let qPos = -1;
|
||||
let qParen = 0;
|
||||
let qBrace = 0;
|
||||
let nested = 0;
|
||||
let colonPos = -1;
|
||||
let split = null;
|
||||
|
||||
const isQuestion = (char, prev, next) =>
|
||||
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
|
||||
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
|
||||
|
||||
scanJs(text, 0, (char, i, prev, next, depth) => {
|
||||
if (colonPos === -1) {
|
||||
if (qPos === -1 && isQuestion(char, prev, next)) {
|
||||
qPos = i;
|
||||
qParen = depth.paren;
|
||||
qBrace = depth.brace;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
|
||||
nested++;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
|
||||
if (nested) nested--;
|
||||
else colonPos = i;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (char === ',' && sameDepth(depth)) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1, i),
|
||||
suffix: text.slice(i),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!split && qPos !== -1 && colonPos !== -1) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1),
|
||||
suffix: '',
|
||||
};
|
||||
}
|
||||
return split;
|
||||
}
|
||||
|
||||
function exclusiveClassScopes(text) {
|
||||
const split = findTernarySplit(text);
|
||||
if (!split) return [text];
|
||||
return [
|
||||
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
|
||||
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
|
||||
];
|
||||
}
|
||||
|
||||
function grayOnColorScopes(line, index) {
|
||||
return exclusiveClassScopes(containingMarkupTag(line, index).text);
|
||||
}
|
||||
|
||||
function grayOnColorPairs(line, grayClass, index) {
|
||||
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
|
||||
}
|
||||
|
||||
const REGEX_MATCHERS = [
|
||||
// --- Side-tab ---
|
||||
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
|
||||
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
|
||||
fmt: () => 'bg-clip-text + bg-gradient' },
|
||||
// --- Tailwind gray on colored bg ---
|
||||
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
|
||||
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
|
||||
fmt: (m, line) => {
|
||||
const bg = grayOnColorPairs(line, m[0], m.index)
|
||||
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
|
||||
.find(Boolean);
|
||||
return `${m[0]} on ${bg?.[0] || '?'}`;
|
||||
} },
|
||||
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
||||
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
|
||||
// --- Tailwind AI palette ---
|
||||
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
|
||||
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
|
||||
|
||||
@@ -217,7 +217,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -2060,7 +2060,7 @@
|
||||
if (anchor) return anchor;
|
||||
}
|
||||
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (wrapper) {
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0 && visibleVariant > 0) {
|
||||
@@ -2131,14 +2131,14 @@
|
||||
|
||||
function isInsertGeneratingSession() {
|
||||
if (state !== 'GENERATING' || !currentSessionId) return false;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
|
||||
}
|
||||
|
||||
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
|
||||
function ensureInsertPlaceholder() {
|
||||
if (!isInsertGeneratingSession()) return placeholderElement;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0) return placeholderElement;
|
||||
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
|
||||
@@ -3156,7 +3156,7 @@
|
||||
|| svelteComponentSession.wrapperEl
|
||||
|| null;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
@@ -4900,7 +4900,7 @@
|
||||
return Object.values(svelteComponentSession.paramsByVariant || {})
|
||||
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
|
||||
.reduce((total, variant) => total + parseVariantParams(variant).length, 0);
|
||||
@@ -5004,7 +5004,7 @@
|
||||
scheduleCyclingBarSync(sessionId, num);
|
||||
return true;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
updateVariantStateStylesheet(sessionId, num);
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
@@ -5820,7 +5820,6 @@
|
||||
return;
|
||||
}
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
completeParameterGenerationIfReady();
|
||||
@@ -6217,71 +6216,6 @@
|
||||
return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
|
||||
}
|
||||
|
||||
function sourceHasSessionWrapper(text, sessionId) {
|
||||
const src = String(text || '');
|
||||
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|
||||
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|
||||
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
|
||||
* wrapper deleted from source look identical in the DOM, and only the second
|
||||
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
|
||||
* injecting raw JSX; reading the file as plain text and matching the session
|
||||
* marker honors that, because no DOM is ever built from what comes back.
|
||||
* Marker present means the component is simply not mounted right now (a
|
||||
* closed modal, another route) and the variant observer keeps waiting.
|
||||
* Marker absent after the same retry budget the HTML path uses means the
|
||||
* file was edited out from under the session, which no reload, HMR push, or
|
||||
* server restart can repair, so the session self-discards and hands the
|
||||
* surface back to the picker.
|
||||
*/
|
||||
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
|
||||
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
|
||||
const retryLater = () => {
|
||||
setTimeout(() => {
|
||||
if (!stillActive()) return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
};
|
||||
// Discarding is durable (the session moves to the discarded phase and the
|
||||
// picker replaces it), so it needs evidence that the wrapper is gone: a
|
||||
// read that answers without the marker, or a 404 (the file itself was
|
||||
// renamed or deleted). Either kind retries on the shared budget first.
|
||||
// A read that fails for any other reason (the server briefly away, a
|
||||
// transient fetch error) says nothing about the wrapper; after the budget
|
||||
// the session is kept, the user told, and the next event retries.
|
||||
const onNoWrapper = (reason) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
discardOrphanedSession(reason);
|
||||
};
|
||||
const onUnreadable = (detail) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
|
||||
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
|
||||
};
|
||||
fetch(url)
|
||||
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
|
||||
.then(text => {
|
||||
if (!stillActive()) return;
|
||||
if (sourceHasSessionWrapper(text, sessionId)) return;
|
||||
onNoWrapper('variant wrapper missing from source');
|
||||
})
|
||||
.catch(err => {
|
||||
const detail = err && err.message ? err.message : 'fetch failed';
|
||||
if (/source read failed: 404$/.test(detail)) {
|
||||
onNoWrapper('source file missing (404) while checking for the variant wrapper');
|
||||
return;
|
||||
}
|
||||
onUnreadable(detail);
|
||||
});
|
||||
}
|
||||
|
||||
function completeSourceInjection(wrapper, sessionId, opts) {
|
||||
recoveryWaitingForAnchor = false;
|
||||
if (pendingVariantAnchorRetryObserver) {
|
||||
@@ -6362,7 +6296,7 @@
|
||||
}
|
||||
rememberSessionFileMeta({ file: filePath });
|
||||
if (isJsxSourceFile(filePath)) {
|
||||
const liveWrapper = findVariantsWrapper(sessionId);
|
||||
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
|
||||
return;
|
||||
@@ -6392,7 +6326,14 @@
|
||||
return;
|
||||
}
|
||||
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
||||
probeJsxWrapperForOrphan(filePath, sessionId, opts);
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
|
||||
setTimeout(() => {
|
||||
if (sessionId !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -6434,7 +6375,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const existingWrapper = findVariantsWrapper(sessionId);
|
||||
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (existingWrapper) {
|
||||
const wrapper = srcWrapper.cloneNode(true);
|
||||
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
|
||||
@@ -6591,7 +6532,7 @@
|
||||
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
||||
return;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const visEl = pickVariantContent(wrapper, visibleVariant);
|
||||
if (visEl) selectedElement = visEl;
|
||||
@@ -6601,7 +6542,7 @@
|
||||
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
||||
return svelteComponentSession.mountedVariant;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
for (const variant of variants) {
|
||||
@@ -6716,17 +6657,8 @@
|
||||
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
|
||||
* one wrapper per item, so the hide, the release, and the existence checks
|
||||
* all have to speak about the same set.
|
||||
*/
|
||||
function discardedWrappers(sessionId) {
|
||||
if (!sessionId) return [];
|
||||
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
|
||||
}
|
||||
|
||||
function releaseDiscardedStaticWrapper(wrapper) {
|
||||
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
if (!wrapper) return;
|
||||
const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
|
||||
const content = orig?.firstElementChild;
|
||||
@@ -6737,18 +6669,6 @@
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the discard hide on every wrapper it covered. Releasing only the
|
||||
* first match left the other mapped items sitting at display:none with
|
||||
* their original content never restored, on exactly the static and
|
||||
* missed-HMR flows this fallback exists for.
|
||||
*/
|
||||
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
|
||||
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
|
||||
}
|
||||
|
||||
function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
|
||||
if (!sessionId || !document.body) return;
|
||||
if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
|
||||
@@ -6929,42 +6849,6 @@
|
||||
// MutationObserver for progressive variant reveal
|
||||
//
|
||||
|
||||
// A session id can have more than one wrapper in the DOM: the target may sit
|
||||
// inside a `.map()` callback (the wrapper renders once per item), or the
|
||||
// agent may have relocated the wrapper out of the shared primitive live-wrap
|
||||
// scaffolded into. A plain first match can then pin an empty scaffold while
|
||||
// the real variants sit in a later wrapper, which strands the session at
|
||||
// 0/N and leaves the bar, the params panel, and accept all reading the
|
||||
// wrong element. Prefer a wrapper that actually holds variants. With zero
|
||||
// or one match this is exactly the querySelector it replaces.
|
||||
//
|
||||
// Every lookup of the ACTIVE session's wrapper goes through here. The
|
||||
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
|
||||
// existence checks, selector strings for stylesheets and observers (which
|
||||
// want to cover every match), `querySelectorAll` sweeps, and the parsed
|
||||
// source document, which is not this document.
|
||||
function pickPopulatedVariantsWrapper(selector) {
|
||||
const matches = document.querySelectorAll(selector);
|
||||
if (matches.length < 2) return matches[0] || null;
|
||||
for (const candidate of matches) {
|
||||
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
/** The wrapper holding `sessionId`'s variants, or null without an id. */
|
||||
function findVariantsWrapper(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
|
||||
}
|
||||
|
||||
/** Any live variant wrapper, for the resume paths that have no id yet. */
|
||||
function findAnyVariantsWrapper() {
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
|
||||
}
|
||||
|
||||
function startVariantObserver(sessionId) {
|
||||
let updating = false; // re-entrancy guard
|
||||
|
||||
@@ -6994,7 +6878,7 @@
|
||||
}
|
||||
if (!dominated) return;
|
||||
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
@@ -7203,7 +7087,6 @@
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
if (state === 'GENERATING') {
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
disableInlineEdit();
|
||||
refreshParamsPanel();
|
||||
@@ -7264,7 +7147,6 @@
|
||||
pendingAcceptedSession = null;
|
||||
awaitingAcceptResult = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup. Try Accept again.', 5000);
|
||||
break;
|
||||
@@ -8367,15 +8249,6 @@ void main() {
|
||||
// matches the original off-white risograph paper.
|
||||
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
|
||||
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
|
||||
// showShaderOverlay is async: it appends its canvas, then awaits
|
||||
// createImageBitmap and the GL setup before it publishes shaderState. A
|
||||
// teardown that landed inside that window found shaderState still null,
|
||||
// returned, and then watched the construction publish itself over a session
|
||||
// that had already left GENERATING, with no teardown left to run. That is
|
||||
// the generating loader frozen over a page that already cycles (issue #719).
|
||||
// Every teardown bumps this epoch; a construction abandons its own canvas as
|
||||
// soon as it sees the epoch move.
|
||||
let shaderEpoch = 0;
|
||||
|
||||
// The element's effective background tone, used as the uniform halftone
|
||||
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground
|
||||
@@ -8522,28 +8395,14 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop a shader node no shaderState owns (an abandoned construction). */
|
||||
function removeStrayShaderNode() {
|
||||
const stray = uiGetById(PREFIX + '-shader');
|
||||
if (stray) stray.remove();
|
||||
}
|
||||
|
||||
function hideShaderOverlay() {
|
||||
// Bump first, unconditionally: this is what tells an in-flight
|
||||
// showShaderOverlay to abandon itself rather than publish over a session
|
||||
// that has already moved on.
|
||||
shaderEpoch += 1;
|
||||
if (!shaderState) {
|
||||
removeStrayShaderNode();
|
||||
return;
|
||||
}
|
||||
if (!shaderState) return;
|
||||
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
|
||||
if (shaderState.canvas) shaderState.canvas.remove();
|
||||
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
|
||||
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
shaderState = null;
|
||||
removeStrayShaderNode();
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
@@ -8568,16 +8427,6 @@ void main() {
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
// hideShaderOverlay just bumped the epoch, so this run owns it until the
|
||||
// next teardown. Every step past an await re-checks before it publishes.
|
||||
const epoch = shaderEpoch;
|
||||
const abandoned = (node, gl) => {
|
||||
if (epoch === shaderEpoch) return false;
|
||||
node.remove();
|
||||
const lose = gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
return true;
|
||||
};
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = PREFIX + '-shader';
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
@@ -8600,7 +8449,6 @@ void main() {
|
||||
if (!gl) {
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
if (abandoned(canvas, null)) return;
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
@@ -8640,22 +8488,16 @@ void main() {
|
||||
}
|
||||
|
||||
// Upload the screenshot as a texture
|
||||
if (abandoned(canvas, gl)) return;
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
if (abandoned(canvas, gl)) return;
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
if (abandoned(canvas, gl)) {
|
||||
if (bitmap.close) bitmap.close();
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
@@ -8674,7 +8516,6 @@ void main() {
|
||||
const paperRgb = paper || resolvePaperRgb(el);
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if (abandoned(canvas, gl)) return;
|
||||
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
|
||||
function frame() {
|
||||
if (!shaderState) return;
|
||||
@@ -8711,7 +8552,7 @@ void main() {
|
||||
clientSentAt: Date.now(),
|
||||
};
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
const acceptWrapper = findVariantsWrapper(currentSessionId);
|
||||
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
@@ -8754,7 +8595,6 @@ void main() {
|
||||
.catch(() => {
|
||||
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
@@ -8806,7 +8646,7 @@ void main() {
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
@@ -8933,7 +8773,7 @@ void main() {
|
||||
}
|
||||
|
||||
function commitAcceptedVariantToDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!accepted || !accepted.firstElementChild) return false;
|
||||
@@ -9161,7 +9001,7 @@ void main() {
|
||||
}
|
||||
|
||||
function restoreFromActiveSessions(activeSessions, reason) {
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
|
||||
if (svelteComponentSession?.sessionId === currentSessionId) return false;
|
||||
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
|
||||
@@ -9274,13 +9114,10 @@ void main() {
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
// replaced the wrapper by then (keeps static-server / no-HMR flows alive).
|
||||
// Every match, not the first: a target inside a `.map()` renders one
|
||||
// wrapper per item, and hiding only one leaves the rest of the
|
||||
// discarded variants on screen.
|
||||
const discardWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (discardWrappers.length > 0) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (wrapper) {
|
||||
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
||||
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
|
||||
else wrapper.style.display = 'none';
|
||||
}
|
||||
setTimeout(function() {
|
||||
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
||||
@@ -9288,19 +9125,16 @@ void main() {
|
||||
removeDiscardStateStylesheet();
|
||||
return;
|
||||
}
|
||||
const lateWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (lateWrappers.length === 0) {
|
||||
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (!lateWrapper) {
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
// Duplicates all render from one source element, so HMR ownership is
|
||||
// uniform across them; the first is a fair witness for the set.
|
||||
const lateWrapper = lateWrappers[0];
|
||||
if (recoverySuperseded) {
|
||||
if (hasFrameworkHmrOwnership(lateWrapper)) {
|
||||
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
} else {
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -9309,20 +9143,18 @@ void main() {
|
||||
// the final source rewrite, reload once after a grace window so the
|
||||
// discarded source becomes authoritative without a reconciler race.
|
||||
setTimeout(function() {
|
||||
const staleWrappers = discardedWrappers(cleanupSessionId);
|
||||
const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
||||
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
// A reload restores every wrapper's original at once, so there is
|
||||
// nothing per-wrapper to do here.
|
||||
if (staleWrappers.length > 0) location.reload();
|
||||
if (staleWrapper) location.reload();
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}, 2000);
|
||||
}
|
||||
hideBar(instantChrome);
|
||||
@@ -9510,13 +9342,8 @@ void main() {
|
||||
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
||||
}
|
||||
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
|
||||
// Which path resumed matters in the journal: an init resume is a fresh
|
||||
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
|
||||
// used to log the same `browser_resumed`, which made issue #719 take a
|
||||
// DOM reconstruction to diagnose.
|
||||
const resumeReason = opts.reason || 'browser_resumed';
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
|
||||
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
|
||||
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
|
||||
@@ -9615,38 +9442,16 @@ void main() {
|
||||
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// A resume can BE the arrival, not just a re-entry after one. The server's
|
||||
// generation preflight runs live-wrap with --defer-source-write, so the
|
||||
// wrapper and every variant reach the DOM in one HMR batch, and the
|
||||
// deferred-wrapper scout (constructed at init) runs before the variant
|
||||
// MutationObserver (constructed at Go) on that batch. Finish the same
|
||||
// transition the observer would have finished. Without hideShaderOverlay
|
||||
// the generating shader stays frozen over the target and the session looks
|
||||
// stuck at GENERATING while the bar already cycles (issue #719).
|
||||
if (state === 'CYCLING') {
|
||||
recoveryWaitingForAnchor = false;
|
||||
hideShaderOverlay();
|
||||
if (isInsert) finalizeInsertSession();
|
||||
disableInlineEdit();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
||||
sendCheckpoint('variants_progress');
|
||||
} else {
|
||||
queueCheckpoint(resumeReason);
|
||||
// Only variants_progress and variants_ready count as publication
|
||||
// progress. When the resume is the arrival, the observer never gets to
|
||||
// report it (this function disconnects and re-creates it below, which
|
||||
// drops the records it had already queued for this same batch), so
|
||||
// without this the server never learns the variants were published.
|
||||
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
sendCheckpoint('variants_ready');
|
||||
}
|
||||
queueCheckpoint('browser_resumed');
|
||||
}
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -12968,7 +12773,7 @@ void main() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
|
||||
if (!wrapper) return;
|
||||
scout.disconnect();
|
||||
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
|
||||
if (resumeSession(deferredResumeRevision)) {
|
||||
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1490,7 +1490,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
|
||||
return isNeutralAuthoredColor(m[1]);
|
||||
}
|
||||
|
||||
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
|
||||
|
||||
function scanJs(text, start, onChar) {
|
||||
let stringQuote = '';
|
||||
let inTemplate = false;
|
||||
let paren = 0;
|
||||
let brace = 0;
|
||||
const interpBrace = [];
|
||||
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const prev = text[i - 1];
|
||||
const next = text[i + 1];
|
||||
|
||||
if (stringQuote) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === stringQuote) stringQuote = '';
|
||||
continue;
|
||||
}
|
||||
if (inTemplate && interpBrace.length === 0) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === '$' && next === '{') {
|
||||
brace++;
|
||||
interpBrace.push(brace);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === '`') { inTemplate = false; continue; }
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"') { stringQuote = char; continue; }
|
||||
if (char === '`') { inTemplate = true; continue; }
|
||||
if (char === '(') { paren++; continue; }
|
||||
if (char === ')') { paren--; continue; }
|
||||
if (char === '{') { brace++; continue; }
|
||||
if (char === '}') {
|
||||
brace--;
|
||||
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
|
||||
continue;
|
||||
}
|
||||
if (onChar(char, i, prev, next, { paren, brace })) return;
|
||||
}
|
||||
}
|
||||
|
||||
function containingMarkupTag(line, index) {
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
const tagStart = line.indexOf('<', i);
|
||||
if (tagStart === -1) break;
|
||||
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
|
||||
i = tagStart + 1;
|
||||
continue;
|
||||
}
|
||||
let tagEnd = -1;
|
||||
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
|
||||
if (char === '>' && depth.brace === 0) {
|
||||
tagEnd = j;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (tagEnd === -1) break;
|
||||
if (index >= tagStart && index <= tagEnd) {
|
||||
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
|
||||
}
|
||||
i = tagEnd + 1;
|
||||
}
|
||||
return { text: line, start: 0 };
|
||||
}
|
||||
|
||||
function findTernarySplit(text) {
|
||||
let qPos = -1;
|
||||
let qParen = 0;
|
||||
let qBrace = 0;
|
||||
let nested = 0;
|
||||
let colonPos = -1;
|
||||
let split = null;
|
||||
|
||||
const isQuestion = (char, prev, next) =>
|
||||
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
|
||||
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
|
||||
|
||||
scanJs(text, 0, (char, i, prev, next, depth) => {
|
||||
if (colonPos === -1) {
|
||||
if (qPos === -1 && isQuestion(char, prev, next)) {
|
||||
qPos = i;
|
||||
qParen = depth.paren;
|
||||
qBrace = depth.brace;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
|
||||
nested++;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
|
||||
if (nested) nested--;
|
||||
else colonPos = i;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (char === ',' && sameDepth(depth)) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1, i),
|
||||
suffix: text.slice(i),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!split && qPos !== -1 && colonPos !== -1) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1),
|
||||
suffix: '',
|
||||
};
|
||||
}
|
||||
return split;
|
||||
}
|
||||
|
||||
function exclusiveClassScopes(text) {
|
||||
const split = findTernarySplit(text);
|
||||
if (!split) return [text];
|
||||
return [
|
||||
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
|
||||
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
|
||||
];
|
||||
}
|
||||
|
||||
function grayOnColorScopes(line, index) {
|
||||
return exclusiveClassScopes(containingMarkupTag(line, index).text);
|
||||
}
|
||||
|
||||
function grayOnColorPairs(line, grayClass, index) {
|
||||
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
|
||||
}
|
||||
|
||||
const REGEX_MATCHERS = [
|
||||
// --- Side-tab ---
|
||||
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
|
||||
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
|
||||
fmt: () => 'bg-clip-text + bg-gradient' },
|
||||
// --- Tailwind gray on colored bg ---
|
||||
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
|
||||
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
|
||||
fmt: (m, line) => {
|
||||
const bg = grayOnColorPairs(line, m[0], m.index)
|
||||
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
|
||||
.find(Boolean);
|
||||
return `${m[0]} on ${bg?.[0] || '?'}`;
|
||||
} },
|
||||
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
||||
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
|
||||
// --- Tailwind AI palette ---
|
||||
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
|
||||
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
|
||||
|
||||
@@ -217,7 +217,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -2060,7 +2060,7 @@
|
||||
if (anchor) return anchor;
|
||||
}
|
||||
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (wrapper) {
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0 && visibleVariant > 0) {
|
||||
@@ -2131,14 +2131,14 @@
|
||||
|
||||
function isInsertGeneratingSession() {
|
||||
if (state !== 'GENERATING' || !currentSessionId) return false;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
|
||||
}
|
||||
|
||||
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
|
||||
function ensureInsertPlaceholder() {
|
||||
if (!isInsertGeneratingSession()) return placeholderElement;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0) return placeholderElement;
|
||||
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
|
||||
@@ -3156,7 +3156,7 @@
|
||||
|| svelteComponentSession.wrapperEl
|
||||
|| null;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
@@ -4900,7 +4900,7 @@
|
||||
return Object.values(svelteComponentSession.paramsByVariant || {})
|
||||
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
|
||||
.reduce((total, variant) => total + parseVariantParams(variant).length, 0);
|
||||
@@ -5004,7 +5004,7 @@
|
||||
scheduleCyclingBarSync(sessionId, num);
|
||||
return true;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
updateVariantStateStylesheet(sessionId, num);
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
@@ -5820,7 +5820,6 @@
|
||||
return;
|
||||
}
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
completeParameterGenerationIfReady();
|
||||
@@ -6217,71 +6216,6 @@
|
||||
return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
|
||||
}
|
||||
|
||||
function sourceHasSessionWrapper(text, sessionId) {
|
||||
const src = String(text || '');
|
||||
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|
||||
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|
||||
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
|
||||
* wrapper deleted from source look identical in the DOM, and only the second
|
||||
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
|
||||
* injecting raw JSX; reading the file as plain text and matching the session
|
||||
* marker honors that, because no DOM is ever built from what comes back.
|
||||
* Marker present means the component is simply not mounted right now (a
|
||||
* closed modal, another route) and the variant observer keeps waiting.
|
||||
* Marker absent after the same retry budget the HTML path uses means the
|
||||
* file was edited out from under the session, which no reload, HMR push, or
|
||||
* server restart can repair, so the session self-discards and hands the
|
||||
* surface back to the picker.
|
||||
*/
|
||||
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
|
||||
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
|
||||
const retryLater = () => {
|
||||
setTimeout(() => {
|
||||
if (!stillActive()) return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
};
|
||||
// Discarding is durable (the session moves to the discarded phase and the
|
||||
// picker replaces it), so it needs evidence that the wrapper is gone: a
|
||||
// read that answers without the marker, or a 404 (the file itself was
|
||||
// renamed or deleted). Either kind retries on the shared budget first.
|
||||
// A read that fails for any other reason (the server briefly away, a
|
||||
// transient fetch error) says nothing about the wrapper; after the budget
|
||||
// the session is kept, the user told, and the next event retries.
|
||||
const onNoWrapper = (reason) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
discardOrphanedSession(reason);
|
||||
};
|
||||
const onUnreadable = (detail) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
|
||||
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
|
||||
};
|
||||
fetch(url)
|
||||
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
|
||||
.then(text => {
|
||||
if (!stillActive()) return;
|
||||
if (sourceHasSessionWrapper(text, sessionId)) return;
|
||||
onNoWrapper('variant wrapper missing from source');
|
||||
})
|
||||
.catch(err => {
|
||||
const detail = err && err.message ? err.message : 'fetch failed';
|
||||
if (/source read failed: 404$/.test(detail)) {
|
||||
onNoWrapper('source file missing (404) while checking for the variant wrapper');
|
||||
return;
|
||||
}
|
||||
onUnreadable(detail);
|
||||
});
|
||||
}
|
||||
|
||||
function completeSourceInjection(wrapper, sessionId, opts) {
|
||||
recoveryWaitingForAnchor = false;
|
||||
if (pendingVariantAnchorRetryObserver) {
|
||||
@@ -6362,7 +6296,7 @@
|
||||
}
|
||||
rememberSessionFileMeta({ file: filePath });
|
||||
if (isJsxSourceFile(filePath)) {
|
||||
const liveWrapper = findVariantsWrapper(sessionId);
|
||||
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
|
||||
return;
|
||||
@@ -6392,7 +6326,14 @@
|
||||
return;
|
||||
}
|
||||
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
||||
probeJsxWrapperForOrphan(filePath, sessionId, opts);
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
|
||||
setTimeout(() => {
|
||||
if (sessionId !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -6434,7 +6375,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const existingWrapper = findVariantsWrapper(sessionId);
|
||||
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (existingWrapper) {
|
||||
const wrapper = srcWrapper.cloneNode(true);
|
||||
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
|
||||
@@ -6591,7 +6532,7 @@
|
||||
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
||||
return;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const visEl = pickVariantContent(wrapper, visibleVariant);
|
||||
if (visEl) selectedElement = visEl;
|
||||
@@ -6601,7 +6542,7 @@
|
||||
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
||||
return svelteComponentSession.mountedVariant;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
for (const variant of variants) {
|
||||
@@ -6716,17 +6657,8 @@
|
||||
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
|
||||
* one wrapper per item, so the hide, the release, and the existence checks
|
||||
* all have to speak about the same set.
|
||||
*/
|
||||
function discardedWrappers(sessionId) {
|
||||
if (!sessionId) return [];
|
||||
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
|
||||
}
|
||||
|
||||
function releaseDiscardedStaticWrapper(wrapper) {
|
||||
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
if (!wrapper) return;
|
||||
const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
|
||||
const content = orig?.firstElementChild;
|
||||
@@ -6737,18 +6669,6 @@
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the discard hide on every wrapper it covered. Releasing only the
|
||||
* first match left the other mapped items sitting at display:none with
|
||||
* their original content never restored, on exactly the static and
|
||||
* missed-HMR flows this fallback exists for.
|
||||
*/
|
||||
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
|
||||
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
|
||||
}
|
||||
|
||||
function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
|
||||
if (!sessionId || !document.body) return;
|
||||
if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
|
||||
@@ -6929,42 +6849,6 @@
|
||||
// MutationObserver for progressive variant reveal
|
||||
//
|
||||
|
||||
// A session id can have more than one wrapper in the DOM: the target may sit
|
||||
// inside a `.map()` callback (the wrapper renders once per item), or the
|
||||
// agent may have relocated the wrapper out of the shared primitive live-wrap
|
||||
// scaffolded into. A plain first match can then pin an empty scaffold while
|
||||
// the real variants sit in a later wrapper, which strands the session at
|
||||
// 0/N and leaves the bar, the params panel, and accept all reading the
|
||||
// wrong element. Prefer a wrapper that actually holds variants. With zero
|
||||
// or one match this is exactly the querySelector it replaces.
|
||||
//
|
||||
// Every lookup of the ACTIVE session's wrapper goes through here. The
|
||||
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
|
||||
// existence checks, selector strings for stylesheets and observers (which
|
||||
// want to cover every match), `querySelectorAll` sweeps, and the parsed
|
||||
// source document, which is not this document.
|
||||
function pickPopulatedVariantsWrapper(selector) {
|
||||
const matches = document.querySelectorAll(selector);
|
||||
if (matches.length < 2) return matches[0] || null;
|
||||
for (const candidate of matches) {
|
||||
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
/** The wrapper holding `sessionId`'s variants, or null without an id. */
|
||||
function findVariantsWrapper(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
|
||||
}
|
||||
|
||||
/** Any live variant wrapper, for the resume paths that have no id yet. */
|
||||
function findAnyVariantsWrapper() {
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
|
||||
}
|
||||
|
||||
function startVariantObserver(sessionId) {
|
||||
let updating = false; // re-entrancy guard
|
||||
|
||||
@@ -6994,7 +6878,7 @@
|
||||
}
|
||||
if (!dominated) return;
|
||||
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
@@ -7203,7 +7087,6 @@
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
if (state === 'GENERATING') {
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
disableInlineEdit();
|
||||
refreshParamsPanel();
|
||||
@@ -7264,7 +7147,6 @@
|
||||
pendingAcceptedSession = null;
|
||||
awaitingAcceptResult = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup. Try Accept again.', 5000);
|
||||
break;
|
||||
@@ -8367,15 +8249,6 @@ void main() {
|
||||
// matches the original off-white risograph paper.
|
||||
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
|
||||
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
|
||||
// showShaderOverlay is async: it appends its canvas, then awaits
|
||||
// createImageBitmap and the GL setup before it publishes shaderState. A
|
||||
// teardown that landed inside that window found shaderState still null,
|
||||
// returned, and then watched the construction publish itself over a session
|
||||
// that had already left GENERATING, with no teardown left to run. That is
|
||||
// the generating loader frozen over a page that already cycles (issue #719).
|
||||
// Every teardown bumps this epoch; a construction abandons its own canvas as
|
||||
// soon as it sees the epoch move.
|
||||
let shaderEpoch = 0;
|
||||
|
||||
// The element's effective background tone, used as the uniform halftone
|
||||
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground
|
||||
@@ -8522,28 +8395,14 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop a shader node no shaderState owns (an abandoned construction). */
|
||||
function removeStrayShaderNode() {
|
||||
const stray = uiGetById(PREFIX + '-shader');
|
||||
if (stray) stray.remove();
|
||||
}
|
||||
|
||||
function hideShaderOverlay() {
|
||||
// Bump first, unconditionally: this is what tells an in-flight
|
||||
// showShaderOverlay to abandon itself rather than publish over a session
|
||||
// that has already moved on.
|
||||
shaderEpoch += 1;
|
||||
if (!shaderState) {
|
||||
removeStrayShaderNode();
|
||||
return;
|
||||
}
|
||||
if (!shaderState) return;
|
||||
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
|
||||
if (shaderState.canvas) shaderState.canvas.remove();
|
||||
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
|
||||
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
shaderState = null;
|
||||
removeStrayShaderNode();
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
@@ -8568,16 +8427,6 @@ void main() {
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
// hideShaderOverlay just bumped the epoch, so this run owns it until the
|
||||
// next teardown. Every step past an await re-checks before it publishes.
|
||||
const epoch = shaderEpoch;
|
||||
const abandoned = (node, gl) => {
|
||||
if (epoch === shaderEpoch) return false;
|
||||
node.remove();
|
||||
const lose = gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
return true;
|
||||
};
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = PREFIX + '-shader';
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
@@ -8600,7 +8449,6 @@ void main() {
|
||||
if (!gl) {
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
if (abandoned(canvas, null)) return;
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
@@ -8640,22 +8488,16 @@ void main() {
|
||||
}
|
||||
|
||||
// Upload the screenshot as a texture
|
||||
if (abandoned(canvas, gl)) return;
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
if (abandoned(canvas, gl)) return;
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
if (abandoned(canvas, gl)) {
|
||||
if (bitmap.close) bitmap.close();
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
@@ -8674,7 +8516,6 @@ void main() {
|
||||
const paperRgb = paper || resolvePaperRgb(el);
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if (abandoned(canvas, gl)) return;
|
||||
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
|
||||
function frame() {
|
||||
if (!shaderState) return;
|
||||
@@ -8711,7 +8552,7 @@ void main() {
|
||||
clientSentAt: Date.now(),
|
||||
};
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
const acceptWrapper = findVariantsWrapper(currentSessionId);
|
||||
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
@@ -8754,7 +8595,6 @@ void main() {
|
||||
.catch(() => {
|
||||
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
@@ -8806,7 +8646,7 @@ void main() {
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
@@ -8933,7 +8773,7 @@ void main() {
|
||||
}
|
||||
|
||||
function commitAcceptedVariantToDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!accepted || !accepted.firstElementChild) return false;
|
||||
@@ -9161,7 +9001,7 @@ void main() {
|
||||
}
|
||||
|
||||
function restoreFromActiveSessions(activeSessions, reason) {
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
|
||||
if (svelteComponentSession?.sessionId === currentSessionId) return false;
|
||||
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
|
||||
@@ -9274,13 +9114,10 @@ void main() {
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
// replaced the wrapper by then (keeps static-server / no-HMR flows alive).
|
||||
// Every match, not the first: a target inside a `.map()` renders one
|
||||
// wrapper per item, and hiding only one leaves the rest of the
|
||||
// discarded variants on screen.
|
||||
const discardWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (discardWrappers.length > 0) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (wrapper) {
|
||||
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
||||
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
|
||||
else wrapper.style.display = 'none';
|
||||
}
|
||||
setTimeout(function() {
|
||||
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
||||
@@ -9288,19 +9125,16 @@ void main() {
|
||||
removeDiscardStateStylesheet();
|
||||
return;
|
||||
}
|
||||
const lateWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (lateWrappers.length === 0) {
|
||||
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (!lateWrapper) {
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
// Duplicates all render from one source element, so HMR ownership is
|
||||
// uniform across them; the first is a fair witness for the set.
|
||||
const lateWrapper = lateWrappers[0];
|
||||
if (recoverySuperseded) {
|
||||
if (hasFrameworkHmrOwnership(lateWrapper)) {
|
||||
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
} else {
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -9309,20 +9143,18 @@ void main() {
|
||||
// the final source rewrite, reload once after a grace window so the
|
||||
// discarded source becomes authoritative without a reconciler race.
|
||||
setTimeout(function() {
|
||||
const staleWrappers = discardedWrappers(cleanupSessionId);
|
||||
const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
||||
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
// A reload restores every wrapper's original at once, so there is
|
||||
// nothing per-wrapper to do here.
|
||||
if (staleWrappers.length > 0) location.reload();
|
||||
if (staleWrapper) location.reload();
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}, 2000);
|
||||
}
|
||||
hideBar(instantChrome);
|
||||
@@ -9510,13 +9342,8 @@ void main() {
|
||||
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
||||
}
|
||||
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
|
||||
// Which path resumed matters in the journal: an init resume is a fresh
|
||||
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
|
||||
// used to log the same `browser_resumed`, which made issue #719 take a
|
||||
// DOM reconstruction to diagnose.
|
||||
const resumeReason = opts.reason || 'browser_resumed';
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
|
||||
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
|
||||
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
|
||||
@@ -9615,38 +9442,16 @@ void main() {
|
||||
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// A resume can BE the arrival, not just a re-entry after one. The server's
|
||||
// generation preflight runs live-wrap with --defer-source-write, so the
|
||||
// wrapper and every variant reach the DOM in one HMR batch, and the
|
||||
// deferred-wrapper scout (constructed at init) runs before the variant
|
||||
// MutationObserver (constructed at Go) on that batch. Finish the same
|
||||
// transition the observer would have finished. Without hideShaderOverlay
|
||||
// the generating shader stays frozen over the target and the session looks
|
||||
// stuck at GENERATING while the bar already cycles (issue #719).
|
||||
if (state === 'CYCLING') {
|
||||
recoveryWaitingForAnchor = false;
|
||||
hideShaderOverlay();
|
||||
if (isInsert) finalizeInsertSession();
|
||||
disableInlineEdit();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
||||
sendCheckpoint('variants_progress');
|
||||
} else {
|
||||
queueCheckpoint(resumeReason);
|
||||
// Only variants_progress and variants_ready count as publication
|
||||
// progress. When the resume is the arrival, the observer never gets to
|
||||
// report it (this function disconnects and re-creates it below, which
|
||||
// drops the records it had already queued for this same batch), so
|
||||
// without this the server never learns the variants were published.
|
||||
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
sendCheckpoint('variants_ready');
|
||||
}
|
||||
queueCheckpoint('browser_resumed');
|
||||
}
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -12968,7 +12773,7 @@ void main() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
|
||||
if (!wrapper) return;
|
||||
scout.disconnect();
|
||||
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
|
||||
if (resumeSession(deferredResumeRevision)) {
|
||||
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1490,7 +1490,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
|
||||
return isNeutralAuthoredColor(m[1]);
|
||||
}
|
||||
|
||||
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
|
||||
|
||||
function scanJs(text, start, onChar) {
|
||||
let stringQuote = '';
|
||||
let inTemplate = false;
|
||||
let paren = 0;
|
||||
let brace = 0;
|
||||
const interpBrace = [];
|
||||
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const prev = text[i - 1];
|
||||
const next = text[i + 1];
|
||||
|
||||
if (stringQuote) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === stringQuote) stringQuote = '';
|
||||
continue;
|
||||
}
|
||||
if (inTemplate && interpBrace.length === 0) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === '$' && next === '{') {
|
||||
brace++;
|
||||
interpBrace.push(brace);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === '`') { inTemplate = false; continue; }
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"') { stringQuote = char; continue; }
|
||||
if (char === '`') { inTemplate = true; continue; }
|
||||
if (char === '(') { paren++; continue; }
|
||||
if (char === ')') { paren--; continue; }
|
||||
if (char === '{') { brace++; continue; }
|
||||
if (char === '}') {
|
||||
brace--;
|
||||
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
|
||||
continue;
|
||||
}
|
||||
if (onChar(char, i, prev, next, { paren, brace })) return;
|
||||
}
|
||||
}
|
||||
|
||||
function containingMarkupTag(line, index) {
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
const tagStart = line.indexOf('<', i);
|
||||
if (tagStart === -1) break;
|
||||
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
|
||||
i = tagStart + 1;
|
||||
continue;
|
||||
}
|
||||
let tagEnd = -1;
|
||||
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
|
||||
if (char === '>' && depth.brace === 0) {
|
||||
tagEnd = j;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (tagEnd === -1) break;
|
||||
if (index >= tagStart && index <= tagEnd) {
|
||||
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
|
||||
}
|
||||
i = tagEnd + 1;
|
||||
}
|
||||
return { text: line, start: 0 };
|
||||
}
|
||||
|
||||
function findTernarySplit(text) {
|
||||
let qPos = -1;
|
||||
let qParen = 0;
|
||||
let qBrace = 0;
|
||||
let nested = 0;
|
||||
let colonPos = -1;
|
||||
let split = null;
|
||||
|
||||
const isQuestion = (char, prev, next) =>
|
||||
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
|
||||
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
|
||||
|
||||
scanJs(text, 0, (char, i, prev, next, depth) => {
|
||||
if (colonPos === -1) {
|
||||
if (qPos === -1 && isQuestion(char, prev, next)) {
|
||||
qPos = i;
|
||||
qParen = depth.paren;
|
||||
qBrace = depth.brace;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
|
||||
nested++;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
|
||||
if (nested) nested--;
|
||||
else colonPos = i;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (char === ',' && sameDepth(depth)) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1, i),
|
||||
suffix: text.slice(i),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!split && qPos !== -1 && colonPos !== -1) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1),
|
||||
suffix: '',
|
||||
};
|
||||
}
|
||||
return split;
|
||||
}
|
||||
|
||||
function exclusiveClassScopes(text) {
|
||||
const split = findTernarySplit(text);
|
||||
if (!split) return [text];
|
||||
return [
|
||||
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
|
||||
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
|
||||
];
|
||||
}
|
||||
|
||||
function grayOnColorScopes(line, index) {
|
||||
return exclusiveClassScopes(containingMarkupTag(line, index).text);
|
||||
}
|
||||
|
||||
function grayOnColorPairs(line, grayClass, index) {
|
||||
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
|
||||
}
|
||||
|
||||
const REGEX_MATCHERS = [
|
||||
// --- Side-tab ---
|
||||
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
|
||||
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
|
||||
fmt: () => 'bg-clip-text + bg-gradient' },
|
||||
// --- Tailwind gray on colored bg ---
|
||||
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
|
||||
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
|
||||
fmt: (m, line) => {
|
||||
const bg = grayOnColorPairs(line, m[0], m.index)
|
||||
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
|
||||
.find(Boolean);
|
||||
return `${m[0]} on ${bg?.[0] || '?'}`;
|
||||
} },
|
||||
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
||||
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
|
||||
// --- Tailwind AI palette ---
|
||||
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
|
||||
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
|
||||
|
||||
@@ -217,7 +217,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -2060,7 +2060,7 @@
|
||||
if (anchor) return anchor;
|
||||
}
|
||||
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (wrapper) {
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0 && visibleVariant > 0) {
|
||||
@@ -2131,14 +2131,14 @@
|
||||
|
||||
function isInsertGeneratingSession() {
|
||||
if (state !== 'GENERATING' || !currentSessionId) return false;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
|
||||
}
|
||||
|
||||
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
|
||||
function ensureInsertPlaceholder() {
|
||||
if (!isInsertGeneratingSession()) return placeholderElement;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0) return placeholderElement;
|
||||
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
|
||||
@@ -3156,7 +3156,7 @@
|
||||
|| svelteComponentSession.wrapperEl
|
||||
|| null;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
@@ -4900,7 +4900,7 @@
|
||||
return Object.values(svelteComponentSession.paramsByVariant || {})
|
||||
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
|
||||
.reduce((total, variant) => total + parseVariantParams(variant).length, 0);
|
||||
@@ -5004,7 +5004,7 @@
|
||||
scheduleCyclingBarSync(sessionId, num);
|
||||
return true;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
updateVariantStateStylesheet(sessionId, num);
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
@@ -5820,7 +5820,6 @@
|
||||
return;
|
||||
}
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
completeParameterGenerationIfReady();
|
||||
@@ -6217,71 +6216,6 @@
|
||||
return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
|
||||
}
|
||||
|
||||
function sourceHasSessionWrapper(text, sessionId) {
|
||||
const src = String(text || '');
|
||||
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|
||||
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|
||||
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
|
||||
* wrapper deleted from source look identical in the DOM, and only the second
|
||||
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
|
||||
* injecting raw JSX; reading the file as plain text and matching the session
|
||||
* marker honors that, because no DOM is ever built from what comes back.
|
||||
* Marker present means the component is simply not mounted right now (a
|
||||
* closed modal, another route) and the variant observer keeps waiting.
|
||||
* Marker absent after the same retry budget the HTML path uses means the
|
||||
* file was edited out from under the session, which no reload, HMR push, or
|
||||
* server restart can repair, so the session self-discards and hands the
|
||||
* surface back to the picker.
|
||||
*/
|
||||
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
|
||||
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
|
||||
const retryLater = () => {
|
||||
setTimeout(() => {
|
||||
if (!stillActive()) return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
};
|
||||
// Discarding is durable (the session moves to the discarded phase and the
|
||||
// picker replaces it), so it needs evidence that the wrapper is gone: a
|
||||
// read that answers without the marker, or a 404 (the file itself was
|
||||
// renamed or deleted). Either kind retries on the shared budget first.
|
||||
// A read that fails for any other reason (the server briefly away, a
|
||||
// transient fetch error) says nothing about the wrapper; after the budget
|
||||
// the session is kept, the user told, and the next event retries.
|
||||
const onNoWrapper = (reason) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
discardOrphanedSession(reason);
|
||||
};
|
||||
const onUnreadable = (detail) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
|
||||
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
|
||||
};
|
||||
fetch(url)
|
||||
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
|
||||
.then(text => {
|
||||
if (!stillActive()) return;
|
||||
if (sourceHasSessionWrapper(text, sessionId)) return;
|
||||
onNoWrapper('variant wrapper missing from source');
|
||||
})
|
||||
.catch(err => {
|
||||
const detail = err && err.message ? err.message : 'fetch failed';
|
||||
if (/source read failed: 404$/.test(detail)) {
|
||||
onNoWrapper('source file missing (404) while checking for the variant wrapper');
|
||||
return;
|
||||
}
|
||||
onUnreadable(detail);
|
||||
});
|
||||
}
|
||||
|
||||
function completeSourceInjection(wrapper, sessionId, opts) {
|
||||
recoveryWaitingForAnchor = false;
|
||||
if (pendingVariantAnchorRetryObserver) {
|
||||
@@ -6362,7 +6296,7 @@
|
||||
}
|
||||
rememberSessionFileMeta({ file: filePath });
|
||||
if (isJsxSourceFile(filePath)) {
|
||||
const liveWrapper = findVariantsWrapper(sessionId);
|
||||
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
|
||||
return;
|
||||
@@ -6392,7 +6326,14 @@
|
||||
return;
|
||||
}
|
||||
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
||||
probeJsxWrapperForOrphan(filePath, sessionId, opts);
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
|
||||
setTimeout(() => {
|
||||
if (sessionId !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -6434,7 +6375,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const existingWrapper = findVariantsWrapper(sessionId);
|
||||
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (existingWrapper) {
|
||||
const wrapper = srcWrapper.cloneNode(true);
|
||||
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
|
||||
@@ -6591,7 +6532,7 @@
|
||||
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
||||
return;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const visEl = pickVariantContent(wrapper, visibleVariant);
|
||||
if (visEl) selectedElement = visEl;
|
||||
@@ -6601,7 +6542,7 @@
|
||||
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
||||
return svelteComponentSession.mountedVariant;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
for (const variant of variants) {
|
||||
@@ -6716,17 +6657,8 @@
|
||||
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
|
||||
* one wrapper per item, so the hide, the release, and the existence checks
|
||||
* all have to speak about the same set.
|
||||
*/
|
||||
function discardedWrappers(sessionId) {
|
||||
if (!sessionId) return [];
|
||||
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
|
||||
}
|
||||
|
||||
function releaseDiscardedStaticWrapper(wrapper) {
|
||||
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
if (!wrapper) return;
|
||||
const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
|
||||
const content = orig?.firstElementChild;
|
||||
@@ -6737,18 +6669,6 @@
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the discard hide on every wrapper it covered. Releasing only the
|
||||
* first match left the other mapped items sitting at display:none with
|
||||
* their original content never restored, on exactly the static and
|
||||
* missed-HMR flows this fallback exists for.
|
||||
*/
|
||||
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
|
||||
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
|
||||
}
|
||||
|
||||
function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
|
||||
if (!sessionId || !document.body) return;
|
||||
if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
|
||||
@@ -6929,42 +6849,6 @@
|
||||
// MutationObserver for progressive variant reveal
|
||||
//
|
||||
|
||||
// A session id can have more than one wrapper in the DOM: the target may sit
|
||||
// inside a `.map()` callback (the wrapper renders once per item), or the
|
||||
// agent may have relocated the wrapper out of the shared primitive live-wrap
|
||||
// scaffolded into. A plain first match can then pin an empty scaffold while
|
||||
// the real variants sit in a later wrapper, which strands the session at
|
||||
// 0/N and leaves the bar, the params panel, and accept all reading the
|
||||
// wrong element. Prefer a wrapper that actually holds variants. With zero
|
||||
// or one match this is exactly the querySelector it replaces.
|
||||
//
|
||||
// Every lookup of the ACTIVE session's wrapper goes through here. The
|
||||
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
|
||||
// existence checks, selector strings for stylesheets and observers (which
|
||||
// want to cover every match), `querySelectorAll` sweeps, and the parsed
|
||||
// source document, which is not this document.
|
||||
function pickPopulatedVariantsWrapper(selector) {
|
||||
const matches = document.querySelectorAll(selector);
|
||||
if (matches.length < 2) return matches[0] || null;
|
||||
for (const candidate of matches) {
|
||||
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
/** The wrapper holding `sessionId`'s variants, or null without an id. */
|
||||
function findVariantsWrapper(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
|
||||
}
|
||||
|
||||
/** Any live variant wrapper, for the resume paths that have no id yet. */
|
||||
function findAnyVariantsWrapper() {
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
|
||||
}
|
||||
|
||||
function startVariantObserver(sessionId) {
|
||||
let updating = false; // re-entrancy guard
|
||||
|
||||
@@ -6994,7 +6878,7 @@
|
||||
}
|
||||
if (!dominated) return;
|
||||
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
@@ -7203,7 +7087,6 @@
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
if (state === 'GENERATING') {
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
disableInlineEdit();
|
||||
refreshParamsPanel();
|
||||
@@ -7264,7 +7147,6 @@
|
||||
pendingAcceptedSession = null;
|
||||
awaitingAcceptResult = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup. Try Accept again.', 5000);
|
||||
break;
|
||||
@@ -8367,15 +8249,6 @@ void main() {
|
||||
// matches the original off-white risograph paper.
|
||||
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
|
||||
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
|
||||
// showShaderOverlay is async: it appends its canvas, then awaits
|
||||
// createImageBitmap and the GL setup before it publishes shaderState. A
|
||||
// teardown that landed inside that window found shaderState still null,
|
||||
// returned, and then watched the construction publish itself over a session
|
||||
// that had already left GENERATING, with no teardown left to run. That is
|
||||
// the generating loader frozen over a page that already cycles (issue #719).
|
||||
// Every teardown bumps this epoch; a construction abandons its own canvas as
|
||||
// soon as it sees the epoch move.
|
||||
let shaderEpoch = 0;
|
||||
|
||||
// The element's effective background tone, used as the uniform halftone
|
||||
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground
|
||||
@@ -8522,28 +8395,14 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop a shader node no shaderState owns (an abandoned construction). */
|
||||
function removeStrayShaderNode() {
|
||||
const stray = uiGetById(PREFIX + '-shader');
|
||||
if (stray) stray.remove();
|
||||
}
|
||||
|
||||
function hideShaderOverlay() {
|
||||
// Bump first, unconditionally: this is what tells an in-flight
|
||||
// showShaderOverlay to abandon itself rather than publish over a session
|
||||
// that has already moved on.
|
||||
shaderEpoch += 1;
|
||||
if (!shaderState) {
|
||||
removeStrayShaderNode();
|
||||
return;
|
||||
}
|
||||
if (!shaderState) return;
|
||||
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
|
||||
if (shaderState.canvas) shaderState.canvas.remove();
|
||||
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
|
||||
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
shaderState = null;
|
||||
removeStrayShaderNode();
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
@@ -8568,16 +8427,6 @@ void main() {
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
// hideShaderOverlay just bumped the epoch, so this run owns it until the
|
||||
// next teardown. Every step past an await re-checks before it publishes.
|
||||
const epoch = shaderEpoch;
|
||||
const abandoned = (node, gl) => {
|
||||
if (epoch === shaderEpoch) return false;
|
||||
node.remove();
|
||||
const lose = gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
return true;
|
||||
};
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = PREFIX + '-shader';
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
@@ -8600,7 +8449,6 @@ void main() {
|
||||
if (!gl) {
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
if (abandoned(canvas, null)) return;
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
@@ -8640,22 +8488,16 @@ void main() {
|
||||
}
|
||||
|
||||
// Upload the screenshot as a texture
|
||||
if (abandoned(canvas, gl)) return;
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
if (abandoned(canvas, gl)) return;
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
if (abandoned(canvas, gl)) {
|
||||
if (bitmap.close) bitmap.close();
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
@@ -8674,7 +8516,6 @@ void main() {
|
||||
const paperRgb = paper || resolvePaperRgb(el);
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if (abandoned(canvas, gl)) return;
|
||||
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
|
||||
function frame() {
|
||||
if (!shaderState) return;
|
||||
@@ -8711,7 +8552,7 @@ void main() {
|
||||
clientSentAt: Date.now(),
|
||||
};
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
const acceptWrapper = findVariantsWrapper(currentSessionId);
|
||||
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
@@ -8754,7 +8595,6 @@ void main() {
|
||||
.catch(() => {
|
||||
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
@@ -8806,7 +8646,7 @@ void main() {
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
@@ -8933,7 +8773,7 @@ void main() {
|
||||
}
|
||||
|
||||
function commitAcceptedVariantToDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!accepted || !accepted.firstElementChild) return false;
|
||||
@@ -9161,7 +9001,7 @@ void main() {
|
||||
}
|
||||
|
||||
function restoreFromActiveSessions(activeSessions, reason) {
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
|
||||
if (svelteComponentSession?.sessionId === currentSessionId) return false;
|
||||
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
|
||||
@@ -9274,13 +9114,10 @@ void main() {
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
// replaced the wrapper by then (keeps static-server / no-HMR flows alive).
|
||||
// Every match, not the first: a target inside a `.map()` renders one
|
||||
// wrapper per item, and hiding only one leaves the rest of the
|
||||
// discarded variants on screen.
|
||||
const discardWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (discardWrappers.length > 0) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (wrapper) {
|
||||
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
||||
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
|
||||
else wrapper.style.display = 'none';
|
||||
}
|
||||
setTimeout(function() {
|
||||
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
||||
@@ -9288,19 +9125,16 @@ void main() {
|
||||
removeDiscardStateStylesheet();
|
||||
return;
|
||||
}
|
||||
const lateWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (lateWrappers.length === 0) {
|
||||
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (!lateWrapper) {
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
// Duplicates all render from one source element, so HMR ownership is
|
||||
// uniform across them; the first is a fair witness for the set.
|
||||
const lateWrapper = lateWrappers[0];
|
||||
if (recoverySuperseded) {
|
||||
if (hasFrameworkHmrOwnership(lateWrapper)) {
|
||||
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
} else {
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -9309,20 +9143,18 @@ void main() {
|
||||
// the final source rewrite, reload once after a grace window so the
|
||||
// discarded source becomes authoritative without a reconciler race.
|
||||
setTimeout(function() {
|
||||
const staleWrappers = discardedWrappers(cleanupSessionId);
|
||||
const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
||||
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
// A reload restores every wrapper's original at once, so there is
|
||||
// nothing per-wrapper to do here.
|
||||
if (staleWrappers.length > 0) location.reload();
|
||||
if (staleWrapper) location.reload();
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}, 2000);
|
||||
}
|
||||
hideBar(instantChrome);
|
||||
@@ -9510,13 +9342,8 @@ void main() {
|
||||
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
||||
}
|
||||
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
|
||||
// Which path resumed matters in the journal: an init resume is a fresh
|
||||
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
|
||||
// used to log the same `browser_resumed`, which made issue #719 take a
|
||||
// DOM reconstruction to diagnose.
|
||||
const resumeReason = opts.reason || 'browser_resumed';
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
|
||||
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
|
||||
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
|
||||
@@ -9615,38 +9442,16 @@ void main() {
|
||||
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// A resume can BE the arrival, not just a re-entry after one. The server's
|
||||
// generation preflight runs live-wrap with --defer-source-write, so the
|
||||
// wrapper and every variant reach the DOM in one HMR batch, and the
|
||||
// deferred-wrapper scout (constructed at init) runs before the variant
|
||||
// MutationObserver (constructed at Go) on that batch. Finish the same
|
||||
// transition the observer would have finished. Without hideShaderOverlay
|
||||
// the generating shader stays frozen over the target and the session looks
|
||||
// stuck at GENERATING while the bar already cycles (issue #719).
|
||||
if (state === 'CYCLING') {
|
||||
recoveryWaitingForAnchor = false;
|
||||
hideShaderOverlay();
|
||||
if (isInsert) finalizeInsertSession();
|
||||
disableInlineEdit();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
||||
sendCheckpoint('variants_progress');
|
||||
} else {
|
||||
queueCheckpoint(resumeReason);
|
||||
// Only variants_progress and variants_ready count as publication
|
||||
// progress. When the resume is the arrival, the observer never gets to
|
||||
// report it (this function disconnects and re-creates it below, which
|
||||
// drops the records it had already queued for this same batch), so
|
||||
// without this the server never learns the variants were published.
|
||||
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
sendCheckpoint('variants_ready');
|
||||
}
|
||||
queueCheckpoint('browser_resumed');
|
||||
}
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -12968,7 +12773,7 @@ void main() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
|
||||
if (!wrapper) return;
|
||||
scout.disconnect();
|
||||
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
|
||||
if (resumeSession(deferredResumeRevision)) {
|
||||
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1490,7 +1490,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
|
||||
return isNeutralAuthoredColor(m[1]);
|
||||
}
|
||||
|
||||
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
|
||||
|
||||
function scanJs(text, start, onChar) {
|
||||
let stringQuote = '';
|
||||
let inTemplate = false;
|
||||
let paren = 0;
|
||||
let brace = 0;
|
||||
const interpBrace = [];
|
||||
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const prev = text[i - 1];
|
||||
const next = text[i + 1];
|
||||
|
||||
if (stringQuote) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === stringQuote) stringQuote = '';
|
||||
continue;
|
||||
}
|
||||
if (inTemplate && interpBrace.length === 0) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === '$' && next === '{') {
|
||||
brace++;
|
||||
interpBrace.push(brace);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === '`') { inTemplate = false; continue; }
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"') { stringQuote = char; continue; }
|
||||
if (char === '`') { inTemplate = true; continue; }
|
||||
if (char === '(') { paren++; continue; }
|
||||
if (char === ')') { paren--; continue; }
|
||||
if (char === '{') { brace++; continue; }
|
||||
if (char === '}') {
|
||||
brace--;
|
||||
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
|
||||
continue;
|
||||
}
|
||||
if (onChar(char, i, prev, next, { paren, brace })) return;
|
||||
}
|
||||
}
|
||||
|
||||
function containingMarkupTag(line, index) {
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
const tagStart = line.indexOf('<', i);
|
||||
if (tagStart === -1) break;
|
||||
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
|
||||
i = tagStart + 1;
|
||||
continue;
|
||||
}
|
||||
let tagEnd = -1;
|
||||
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
|
||||
if (char === '>' && depth.brace === 0) {
|
||||
tagEnd = j;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (tagEnd === -1) break;
|
||||
if (index >= tagStart && index <= tagEnd) {
|
||||
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
|
||||
}
|
||||
i = tagEnd + 1;
|
||||
}
|
||||
return { text: line, start: 0 };
|
||||
}
|
||||
|
||||
function findTernarySplit(text) {
|
||||
let qPos = -1;
|
||||
let qParen = 0;
|
||||
let qBrace = 0;
|
||||
let nested = 0;
|
||||
let colonPos = -1;
|
||||
let split = null;
|
||||
|
||||
const isQuestion = (char, prev, next) =>
|
||||
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
|
||||
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
|
||||
|
||||
scanJs(text, 0, (char, i, prev, next, depth) => {
|
||||
if (colonPos === -1) {
|
||||
if (qPos === -1 && isQuestion(char, prev, next)) {
|
||||
qPos = i;
|
||||
qParen = depth.paren;
|
||||
qBrace = depth.brace;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
|
||||
nested++;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
|
||||
if (nested) nested--;
|
||||
else colonPos = i;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (char === ',' && sameDepth(depth)) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1, i),
|
||||
suffix: text.slice(i),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!split && qPos !== -1 && colonPos !== -1) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1),
|
||||
suffix: '',
|
||||
};
|
||||
}
|
||||
return split;
|
||||
}
|
||||
|
||||
function exclusiveClassScopes(text) {
|
||||
const split = findTernarySplit(text);
|
||||
if (!split) return [text];
|
||||
return [
|
||||
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
|
||||
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
|
||||
];
|
||||
}
|
||||
|
||||
function grayOnColorScopes(line, index) {
|
||||
return exclusiveClassScopes(containingMarkupTag(line, index).text);
|
||||
}
|
||||
|
||||
function grayOnColorPairs(line, grayClass, index) {
|
||||
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
|
||||
}
|
||||
|
||||
const REGEX_MATCHERS = [
|
||||
// --- Side-tab ---
|
||||
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
|
||||
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
|
||||
fmt: () => 'bg-clip-text + bg-gradient' },
|
||||
// --- Tailwind gray on colored bg ---
|
||||
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
|
||||
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
|
||||
fmt: (m, line) => {
|
||||
const bg = grayOnColorPairs(line, m[0], m.index)
|
||||
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
|
||||
.find(Boolean);
|
||||
return `${m[0]} on ${bg?.[0] || '?'}`;
|
||||
} },
|
||||
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
||||
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
|
||||
// --- Tailwind AI palette ---
|
||||
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
|
||||
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
|
||||
|
||||
@@ -217,7 +217,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -2060,7 +2060,7 @@
|
||||
if (anchor) return anchor;
|
||||
}
|
||||
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (wrapper) {
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0 && visibleVariant > 0) {
|
||||
@@ -2131,14 +2131,14 @@
|
||||
|
||||
function isInsertGeneratingSession() {
|
||||
if (state !== 'GENERATING' || !currentSessionId) return false;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
|
||||
}
|
||||
|
||||
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
|
||||
function ensureInsertPlaceholder() {
|
||||
if (!isInsertGeneratingSession()) return placeholderElement;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0) return placeholderElement;
|
||||
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
|
||||
@@ -3156,7 +3156,7 @@
|
||||
|| svelteComponentSession.wrapperEl
|
||||
|| null;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
@@ -4900,7 +4900,7 @@
|
||||
return Object.values(svelteComponentSession.paramsByVariant || {})
|
||||
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
|
||||
.reduce((total, variant) => total + parseVariantParams(variant).length, 0);
|
||||
@@ -5004,7 +5004,7 @@
|
||||
scheduleCyclingBarSync(sessionId, num);
|
||||
return true;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
updateVariantStateStylesheet(sessionId, num);
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
@@ -5820,7 +5820,6 @@
|
||||
return;
|
||||
}
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
completeParameterGenerationIfReady();
|
||||
@@ -6217,71 +6216,6 @@
|
||||
return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
|
||||
}
|
||||
|
||||
function sourceHasSessionWrapper(text, sessionId) {
|
||||
const src = String(text || '');
|
||||
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|
||||
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|
||||
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
|
||||
* wrapper deleted from source look identical in the DOM, and only the second
|
||||
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
|
||||
* injecting raw JSX; reading the file as plain text and matching the session
|
||||
* marker honors that, because no DOM is ever built from what comes back.
|
||||
* Marker present means the component is simply not mounted right now (a
|
||||
* closed modal, another route) and the variant observer keeps waiting.
|
||||
* Marker absent after the same retry budget the HTML path uses means the
|
||||
* file was edited out from under the session, which no reload, HMR push, or
|
||||
* server restart can repair, so the session self-discards and hands the
|
||||
* surface back to the picker.
|
||||
*/
|
||||
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
|
||||
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
|
||||
const retryLater = () => {
|
||||
setTimeout(() => {
|
||||
if (!stillActive()) return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
};
|
||||
// Discarding is durable (the session moves to the discarded phase and the
|
||||
// picker replaces it), so it needs evidence that the wrapper is gone: a
|
||||
// read that answers without the marker, or a 404 (the file itself was
|
||||
// renamed or deleted). Either kind retries on the shared budget first.
|
||||
// A read that fails for any other reason (the server briefly away, a
|
||||
// transient fetch error) says nothing about the wrapper; after the budget
|
||||
// the session is kept, the user told, and the next event retries.
|
||||
const onNoWrapper = (reason) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
discardOrphanedSession(reason);
|
||||
};
|
||||
const onUnreadable = (detail) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
|
||||
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
|
||||
};
|
||||
fetch(url)
|
||||
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
|
||||
.then(text => {
|
||||
if (!stillActive()) return;
|
||||
if (sourceHasSessionWrapper(text, sessionId)) return;
|
||||
onNoWrapper('variant wrapper missing from source');
|
||||
})
|
||||
.catch(err => {
|
||||
const detail = err && err.message ? err.message : 'fetch failed';
|
||||
if (/source read failed: 404$/.test(detail)) {
|
||||
onNoWrapper('source file missing (404) while checking for the variant wrapper');
|
||||
return;
|
||||
}
|
||||
onUnreadable(detail);
|
||||
});
|
||||
}
|
||||
|
||||
function completeSourceInjection(wrapper, sessionId, opts) {
|
||||
recoveryWaitingForAnchor = false;
|
||||
if (pendingVariantAnchorRetryObserver) {
|
||||
@@ -6362,7 +6296,7 @@
|
||||
}
|
||||
rememberSessionFileMeta({ file: filePath });
|
||||
if (isJsxSourceFile(filePath)) {
|
||||
const liveWrapper = findVariantsWrapper(sessionId);
|
||||
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
|
||||
return;
|
||||
@@ -6392,7 +6326,14 @@
|
||||
return;
|
||||
}
|
||||
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
||||
probeJsxWrapperForOrphan(filePath, sessionId, opts);
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
|
||||
setTimeout(() => {
|
||||
if (sessionId !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -6434,7 +6375,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const existingWrapper = findVariantsWrapper(sessionId);
|
||||
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (existingWrapper) {
|
||||
const wrapper = srcWrapper.cloneNode(true);
|
||||
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
|
||||
@@ -6591,7 +6532,7 @@
|
||||
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
||||
return;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const visEl = pickVariantContent(wrapper, visibleVariant);
|
||||
if (visEl) selectedElement = visEl;
|
||||
@@ -6601,7 +6542,7 @@
|
||||
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
||||
return svelteComponentSession.mountedVariant;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
for (const variant of variants) {
|
||||
@@ -6716,17 +6657,8 @@
|
||||
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
|
||||
* one wrapper per item, so the hide, the release, and the existence checks
|
||||
* all have to speak about the same set.
|
||||
*/
|
||||
function discardedWrappers(sessionId) {
|
||||
if (!sessionId) return [];
|
||||
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
|
||||
}
|
||||
|
||||
function releaseDiscardedStaticWrapper(wrapper) {
|
||||
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
if (!wrapper) return;
|
||||
const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
|
||||
const content = orig?.firstElementChild;
|
||||
@@ -6737,18 +6669,6 @@
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the discard hide on every wrapper it covered. Releasing only the
|
||||
* first match left the other mapped items sitting at display:none with
|
||||
* their original content never restored, on exactly the static and
|
||||
* missed-HMR flows this fallback exists for.
|
||||
*/
|
||||
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
|
||||
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
|
||||
}
|
||||
|
||||
function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
|
||||
if (!sessionId || !document.body) return;
|
||||
if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
|
||||
@@ -6929,42 +6849,6 @@
|
||||
// MutationObserver for progressive variant reveal
|
||||
//
|
||||
|
||||
// A session id can have more than one wrapper in the DOM: the target may sit
|
||||
// inside a `.map()` callback (the wrapper renders once per item), or the
|
||||
// agent may have relocated the wrapper out of the shared primitive live-wrap
|
||||
// scaffolded into. A plain first match can then pin an empty scaffold while
|
||||
// the real variants sit in a later wrapper, which strands the session at
|
||||
// 0/N and leaves the bar, the params panel, and accept all reading the
|
||||
// wrong element. Prefer a wrapper that actually holds variants. With zero
|
||||
// or one match this is exactly the querySelector it replaces.
|
||||
//
|
||||
// Every lookup of the ACTIVE session's wrapper goes through here. The
|
||||
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
|
||||
// existence checks, selector strings for stylesheets and observers (which
|
||||
// want to cover every match), `querySelectorAll` sweeps, and the parsed
|
||||
// source document, which is not this document.
|
||||
function pickPopulatedVariantsWrapper(selector) {
|
||||
const matches = document.querySelectorAll(selector);
|
||||
if (matches.length < 2) return matches[0] || null;
|
||||
for (const candidate of matches) {
|
||||
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
/** The wrapper holding `sessionId`'s variants, or null without an id. */
|
||||
function findVariantsWrapper(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
|
||||
}
|
||||
|
||||
/** Any live variant wrapper, for the resume paths that have no id yet. */
|
||||
function findAnyVariantsWrapper() {
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
|
||||
}
|
||||
|
||||
function startVariantObserver(sessionId) {
|
||||
let updating = false; // re-entrancy guard
|
||||
|
||||
@@ -6994,7 +6878,7 @@
|
||||
}
|
||||
if (!dominated) return;
|
||||
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
@@ -7203,7 +7087,6 @@
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
if (state === 'GENERATING') {
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
disableInlineEdit();
|
||||
refreshParamsPanel();
|
||||
@@ -7264,7 +7147,6 @@
|
||||
pendingAcceptedSession = null;
|
||||
awaitingAcceptResult = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup. Try Accept again.', 5000);
|
||||
break;
|
||||
@@ -8367,15 +8249,6 @@ void main() {
|
||||
// matches the original off-white risograph paper.
|
||||
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
|
||||
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
|
||||
// showShaderOverlay is async: it appends its canvas, then awaits
|
||||
// createImageBitmap and the GL setup before it publishes shaderState. A
|
||||
// teardown that landed inside that window found shaderState still null,
|
||||
// returned, and then watched the construction publish itself over a session
|
||||
// that had already left GENERATING, with no teardown left to run. That is
|
||||
// the generating loader frozen over a page that already cycles (issue #719).
|
||||
// Every teardown bumps this epoch; a construction abandons its own canvas as
|
||||
// soon as it sees the epoch move.
|
||||
let shaderEpoch = 0;
|
||||
|
||||
// The element's effective background tone, used as the uniform halftone
|
||||
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground
|
||||
@@ -8522,28 +8395,14 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop a shader node no shaderState owns (an abandoned construction). */
|
||||
function removeStrayShaderNode() {
|
||||
const stray = uiGetById(PREFIX + '-shader');
|
||||
if (stray) stray.remove();
|
||||
}
|
||||
|
||||
function hideShaderOverlay() {
|
||||
// Bump first, unconditionally: this is what tells an in-flight
|
||||
// showShaderOverlay to abandon itself rather than publish over a session
|
||||
// that has already moved on.
|
||||
shaderEpoch += 1;
|
||||
if (!shaderState) {
|
||||
removeStrayShaderNode();
|
||||
return;
|
||||
}
|
||||
if (!shaderState) return;
|
||||
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
|
||||
if (shaderState.canvas) shaderState.canvas.remove();
|
||||
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
|
||||
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
shaderState = null;
|
||||
removeStrayShaderNode();
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
@@ -8568,16 +8427,6 @@ void main() {
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
// hideShaderOverlay just bumped the epoch, so this run owns it until the
|
||||
// next teardown. Every step past an await re-checks before it publishes.
|
||||
const epoch = shaderEpoch;
|
||||
const abandoned = (node, gl) => {
|
||||
if (epoch === shaderEpoch) return false;
|
||||
node.remove();
|
||||
const lose = gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
return true;
|
||||
};
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = PREFIX + '-shader';
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
@@ -8600,7 +8449,6 @@ void main() {
|
||||
if (!gl) {
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
if (abandoned(canvas, null)) return;
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
@@ -8640,22 +8488,16 @@ void main() {
|
||||
}
|
||||
|
||||
// Upload the screenshot as a texture
|
||||
if (abandoned(canvas, gl)) return;
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
if (abandoned(canvas, gl)) return;
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
if (abandoned(canvas, gl)) {
|
||||
if (bitmap.close) bitmap.close();
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
@@ -8674,7 +8516,6 @@ void main() {
|
||||
const paperRgb = paper || resolvePaperRgb(el);
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if (abandoned(canvas, gl)) return;
|
||||
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
|
||||
function frame() {
|
||||
if (!shaderState) return;
|
||||
@@ -8711,7 +8552,7 @@ void main() {
|
||||
clientSentAt: Date.now(),
|
||||
};
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
const acceptWrapper = findVariantsWrapper(currentSessionId);
|
||||
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
@@ -8754,7 +8595,6 @@ void main() {
|
||||
.catch(() => {
|
||||
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
@@ -8806,7 +8646,7 @@ void main() {
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
@@ -8933,7 +8773,7 @@ void main() {
|
||||
}
|
||||
|
||||
function commitAcceptedVariantToDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!accepted || !accepted.firstElementChild) return false;
|
||||
@@ -9161,7 +9001,7 @@ void main() {
|
||||
}
|
||||
|
||||
function restoreFromActiveSessions(activeSessions, reason) {
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
|
||||
if (svelteComponentSession?.sessionId === currentSessionId) return false;
|
||||
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
|
||||
@@ -9274,13 +9114,10 @@ void main() {
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
// replaced the wrapper by then (keeps static-server / no-HMR flows alive).
|
||||
// Every match, not the first: a target inside a `.map()` renders one
|
||||
// wrapper per item, and hiding only one leaves the rest of the
|
||||
// discarded variants on screen.
|
||||
const discardWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (discardWrappers.length > 0) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (wrapper) {
|
||||
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
||||
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
|
||||
else wrapper.style.display = 'none';
|
||||
}
|
||||
setTimeout(function() {
|
||||
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
||||
@@ -9288,19 +9125,16 @@ void main() {
|
||||
removeDiscardStateStylesheet();
|
||||
return;
|
||||
}
|
||||
const lateWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (lateWrappers.length === 0) {
|
||||
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (!lateWrapper) {
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
// Duplicates all render from one source element, so HMR ownership is
|
||||
// uniform across them; the first is a fair witness for the set.
|
||||
const lateWrapper = lateWrappers[0];
|
||||
if (recoverySuperseded) {
|
||||
if (hasFrameworkHmrOwnership(lateWrapper)) {
|
||||
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
} else {
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -9309,20 +9143,18 @@ void main() {
|
||||
// the final source rewrite, reload once after a grace window so the
|
||||
// discarded source becomes authoritative without a reconciler race.
|
||||
setTimeout(function() {
|
||||
const staleWrappers = discardedWrappers(cleanupSessionId);
|
||||
const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
||||
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
// A reload restores every wrapper's original at once, so there is
|
||||
// nothing per-wrapper to do here.
|
||||
if (staleWrappers.length > 0) location.reload();
|
||||
if (staleWrapper) location.reload();
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}, 2000);
|
||||
}
|
||||
hideBar(instantChrome);
|
||||
@@ -9510,13 +9342,8 @@ void main() {
|
||||
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
||||
}
|
||||
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
|
||||
// Which path resumed matters in the journal: an init resume is a fresh
|
||||
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
|
||||
// used to log the same `browser_resumed`, which made issue #719 take a
|
||||
// DOM reconstruction to diagnose.
|
||||
const resumeReason = opts.reason || 'browser_resumed';
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
|
||||
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
|
||||
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
|
||||
@@ -9615,38 +9442,16 @@ void main() {
|
||||
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// A resume can BE the arrival, not just a re-entry after one. The server's
|
||||
// generation preflight runs live-wrap with --defer-source-write, so the
|
||||
// wrapper and every variant reach the DOM in one HMR batch, and the
|
||||
// deferred-wrapper scout (constructed at init) runs before the variant
|
||||
// MutationObserver (constructed at Go) on that batch. Finish the same
|
||||
// transition the observer would have finished. Without hideShaderOverlay
|
||||
// the generating shader stays frozen over the target and the session looks
|
||||
// stuck at GENERATING while the bar already cycles (issue #719).
|
||||
if (state === 'CYCLING') {
|
||||
recoveryWaitingForAnchor = false;
|
||||
hideShaderOverlay();
|
||||
if (isInsert) finalizeInsertSession();
|
||||
disableInlineEdit();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
||||
sendCheckpoint('variants_progress');
|
||||
} else {
|
||||
queueCheckpoint(resumeReason);
|
||||
// Only variants_progress and variants_ready count as publication
|
||||
// progress. When the resume is the arrival, the observer never gets to
|
||||
// report it (this function disconnects and re-creates it below, which
|
||||
// drops the records it had already queued for this same batch), so
|
||||
// without this the server never learns the variants were published.
|
||||
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
sendCheckpoint('variants_ready');
|
||||
}
|
||||
queueCheckpoint('browser_resumed');
|
||||
}
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -12968,7 +12773,7 @@ void main() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
|
||||
if (!wrapper) return;
|
||||
scout.disconnect();
|
||||
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
|
||||
if (resumeSession(deferredResumeRevision)) {
|
||||
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1490,7 +1490,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
|
||||
return isNeutralAuthoredColor(m[1]);
|
||||
}
|
||||
|
||||
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
|
||||
|
||||
function scanJs(text, start, onChar) {
|
||||
let stringQuote = '';
|
||||
let inTemplate = false;
|
||||
let paren = 0;
|
||||
let brace = 0;
|
||||
const interpBrace = [];
|
||||
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const prev = text[i - 1];
|
||||
const next = text[i + 1];
|
||||
|
||||
if (stringQuote) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === stringQuote) stringQuote = '';
|
||||
continue;
|
||||
}
|
||||
if (inTemplate && interpBrace.length === 0) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === '$' && next === '{') {
|
||||
brace++;
|
||||
interpBrace.push(brace);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === '`') { inTemplate = false; continue; }
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"') { stringQuote = char; continue; }
|
||||
if (char === '`') { inTemplate = true; continue; }
|
||||
if (char === '(') { paren++; continue; }
|
||||
if (char === ')') { paren--; continue; }
|
||||
if (char === '{') { brace++; continue; }
|
||||
if (char === '}') {
|
||||
brace--;
|
||||
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
|
||||
continue;
|
||||
}
|
||||
if (onChar(char, i, prev, next, { paren, brace })) return;
|
||||
}
|
||||
}
|
||||
|
||||
function containingMarkupTag(line, index) {
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
const tagStart = line.indexOf('<', i);
|
||||
if (tagStart === -1) break;
|
||||
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
|
||||
i = tagStart + 1;
|
||||
continue;
|
||||
}
|
||||
let tagEnd = -1;
|
||||
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
|
||||
if (char === '>' && depth.brace === 0) {
|
||||
tagEnd = j;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (tagEnd === -1) break;
|
||||
if (index >= tagStart && index <= tagEnd) {
|
||||
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
|
||||
}
|
||||
i = tagEnd + 1;
|
||||
}
|
||||
return { text: line, start: 0 };
|
||||
}
|
||||
|
||||
function findTernarySplit(text) {
|
||||
let qPos = -1;
|
||||
let qParen = 0;
|
||||
let qBrace = 0;
|
||||
let nested = 0;
|
||||
let colonPos = -1;
|
||||
let split = null;
|
||||
|
||||
const isQuestion = (char, prev, next) =>
|
||||
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
|
||||
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
|
||||
|
||||
scanJs(text, 0, (char, i, prev, next, depth) => {
|
||||
if (colonPos === -1) {
|
||||
if (qPos === -1 && isQuestion(char, prev, next)) {
|
||||
qPos = i;
|
||||
qParen = depth.paren;
|
||||
qBrace = depth.brace;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
|
||||
nested++;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
|
||||
if (nested) nested--;
|
||||
else colonPos = i;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (char === ',' && sameDepth(depth)) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1, i),
|
||||
suffix: text.slice(i),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!split && qPos !== -1 && colonPos !== -1) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1),
|
||||
suffix: '',
|
||||
};
|
||||
}
|
||||
return split;
|
||||
}
|
||||
|
||||
function exclusiveClassScopes(text) {
|
||||
const split = findTernarySplit(text);
|
||||
if (!split) return [text];
|
||||
return [
|
||||
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
|
||||
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
|
||||
];
|
||||
}
|
||||
|
||||
function grayOnColorScopes(line, index) {
|
||||
return exclusiveClassScopes(containingMarkupTag(line, index).text);
|
||||
}
|
||||
|
||||
function grayOnColorPairs(line, grayClass, index) {
|
||||
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
|
||||
}
|
||||
|
||||
const REGEX_MATCHERS = [
|
||||
// --- Side-tab ---
|
||||
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
|
||||
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
|
||||
fmt: () => 'bg-clip-text + bg-gradient' },
|
||||
// --- Tailwind gray on colored bg ---
|
||||
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
|
||||
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
|
||||
fmt: (m, line) => {
|
||||
const bg = grayOnColorPairs(line, m[0], m.index)
|
||||
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
|
||||
.find(Boolean);
|
||||
return `${m[0]} on ${bg?.[0] || '?'}`;
|
||||
} },
|
||||
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
||||
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
|
||||
// --- Tailwind AI palette ---
|
||||
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
|
||||
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
|
||||
|
||||
@@ -217,7 +217,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -2060,7 +2060,7 @@
|
||||
if (anchor) return anchor;
|
||||
}
|
||||
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (wrapper) {
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0 && visibleVariant > 0) {
|
||||
@@ -2131,14 +2131,14 @@
|
||||
|
||||
function isInsertGeneratingSession() {
|
||||
if (state !== 'GENERATING' || !currentSessionId) return false;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
|
||||
}
|
||||
|
||||
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
|
||||
function ensureInsertPlaceholder() {
|
||||
if (!isInsertGeneratingSession()) return placeholderElement;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0) return placeholderElement;
|
||||
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
|
||||
@@ -3156,7 +3156,7 @@
|
||||
|| svelteComponentSession.wrapperEl
|
||||
|| null;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
@@ -4900,7 +4900,7 @@
|
||||
return Object.values(svelteComponentSession.paramsByVariant || {})
|
||||
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
|
||||
.reduce((total, variant) => total + parseVariantParams(variant).length, 0);
|
||||
@@ -5004,7 +5004,7 @@
|
||||
scheduleCyclingBarSync(sessionId, num);
|
||||
return true;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
updateVariantStateStylesheet(sessionId, num);
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
@@ -5820,7 +5820,6 @@
|
||||
return;
|
||||
}
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
completeParameterGenerationIfReady();
|
||||
@@ -6217,71 +6216,6 @@
|
||||
return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
|
||||
}
|
||||
|
||||
function sourceHasSessionWrapper(text, sessionId) {
|
||||
const src = String(text || '');
|
||||
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|
||||
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|
||||
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
|
||||
* wrapper deleted from source look identical in the DOM, and only the second
|
||||
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
|
||||
* injecting raw JSX; reading the file as plain text and matching the session
|
||||
* marker honors that, because no DOM is ever built from what comes back.
|
||||
* Marker present means the component is simply not mounted right now (a
|
||||
* closed modal, another route) and the variant observer keeps waiting.
|
||||
* Marker absent after the same retry budget the HTML path uses means the
|
||||
* file was edited out from under the session, which no reload, HMR push, or
|
||||
* server restart can repair, so the session self-discards and hands the
|
||||
* surface back to the picker.
|
||||
*/
|
||||
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
|
||||
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
|
||||
const retryLater = () => {
|
||||
setTimeout(() => {
|
||||
if (!stillActive()) return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
};
|
||||
// Discarding is durable (the session moves to the discarded phase and the
|
||||
// picker replaces it), so it needs evidence that the wrapper is gone: a
|
||||
// read that answers without the marker, or a 404 (the file itself was
|
||||
// renamed or deleted). Either kind retries on the shared budget first.
|
||||
// A read that fails for any other reason (the server briefly away, a
|
||||
// transient fetch error) says nothing about the wrapper; after the budget
|
||||
// the session is kept, the user told, and the next event retries.
|
||||
const onNoWrapper = (reason) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
discardOrphanedSession(reason);
|
||||
};
|
||||
const onUnreadable = (detail) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
|
||||
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
|
||||
};
|
||||
fetch(url)
|
||||
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
|
||||
.then(text => {
|
||||
if (!stillActive()) return;
|
||||
if (sourceHasSessionWrapper(text, sessionId)) return;
|
||||
onNoWrapper('variant wrapper missing from source');
|
||||
})
|
||||
.catch(err => {
|
||||
const detail = err && err.message ? err.message : 'fetch failed';
|
||||
if (/source read failed: 404$/.test(detail)) {
|
||||
onNoWrapper('source file missing (404) while checking for the variant wrapper');
|
||||
return;
|
||||
}
|
||||
onUnreadable(detail);
|
||||
});
|
||||
}
|
||||
|
||||
function completeSourceInjection(wrapper, sessionId, opts) {
|
||||
recoveryWaitingForAnchor = false;
|
||||
if (pendingVariantAnchorRetryObserver) {
|
||||
@@ -6362,7 +6296,7 @@
|
||||
}
|
||||
rememberSessionFileMeta({ file: filePath });
|
||||
if (isJsxSourceFile(filePath)) {
|
||||
const liveWrapper = findVariantsWrapper(sessionId);
|
||||
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
|
||||
return;
|
||||
@@ -6392,7 +6326,14 @@
|
||||
return;
|
||||
}
|
||||
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
||||
probeJsxWrapperForOrphan(filePath, sessionId, opts);
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
|
||||
setTimeout(() => {
|
||||
if (sessionId !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -6434,7 +6375,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const existingWrapper = findVariantsWrapper(sessionId);
|
||||
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (existingWrapper) {
|
||||
const wrapper = srcWrapper.cloneNode(true);
|
||||
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
|
||||
@@ -6591,7 +6532,7 @@
|
||||
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
||||
return;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const visEl = pickVariantContent(wrapper, visibleVariant);
|
||||
if (visEl) selectedElement = visEl;
|
||||
@@ -6601,7 +6542,7 @@
|
||||
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
||||
return svelteComponentSession.mountedVariant;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
for (const variant of variants) {
|
||||
@@ -6716,17 +6657,8 @@
|
||||
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
|
||||
* one wrapper per item, so the hide, the release, and the existence checks
|
||||
* all have to speak about the same set.
|
||||
*/
|
||||
function discardedWrappers(sessionId) {
|
||||
if (!sessionId) return [];
|
||||
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
|
||||
}
|
||||
|
||||
function releaseDiscardedStaticWrapper(wrapper) {
|
||||
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
if (!wrapper) return;
|
||||
const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
|
||||
const content = orig?.firstElementChild;
|
||||
@@ -6737,18 +6669,6 @@
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the discard hide on every wrapper it covered. Releasing only the
|
||||
* first match left the other mapped items sitting at display:none with
|
||||
* their original content never restored, on exactly the static and
|
||||
* missed-HMR flows this fallback exists for.
|
||||
*/
|
||||
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
|
||||
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
|
||||
}
|
||||
|
||||
function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
|
||||
if (!sessionId || !document.body) return;
|
||||
if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
|
||||
@@ -6929,42 +6849,6 @@
|
||||
// MutationObserver for progressive variant reveal
|
||||
//
|
||||
|
||||
// A session id can have more than one wrapper in the DOM: the target may sit
|
||||
// inside a `.map()` callback (the wrapper renders once per item), or the
|
||||
// agent may have relocated the wrapper out of the shared primitive live-wrap
|
||||
// scaffolded into. A plain first match can then pin an empty scaffold while
|
||||
// the real variants sit in a later wrapper, which strands the session at
|
||||
// 0/N and leaves the bar, the params panel, and accept all reading the
|
||||
// wrong element. Prefer a wrapper that actually holds variants. With zero
|
||||
// or one match this is exactly the querySelector it replaces.
|
||||
//
|
||||
// Every lookup of the ACTIVE session's wrapper goes through here. The
|
||||
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
|
||||
// existence checks, selector strings for stylesheets and observers (which
|
||||
// want to cover every match), `querySelectorAll` sweeps, and the parsed
|
||||
// source document, which is not this document.
|
||||
function pickPopulatedVariantsWrapper(selector) {
|
||||
const matches = document.querySelectorAll(selector);
|
||||
if (matches.length < 2) return matches[0] || null;
|
||||
for (const candidate of matches) {
|
||||
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
/** The wrapper holding `sessionId`'s variants, or null without an id. */
|
||||
function findVariantsWrapper(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
|
||||
}
|
||||
|
||||
/** Any live variant wrapper, for the resume paths that have no id yet. */
|
||||
function findAnyVariantsWrapper() {
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
|
||||
}
|
||||
|
||||
function startVariantObserver(sessionId) {
|
||||
let updating = false; // re-entrancy guard
|
||||
|
||||
@@ -6994,7 +6878,7 @@
|
||||
}
|
||||
if (!dominated) return;
|
||||
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
@@ -7203,7 +7087,6 @@
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
if (state === 'GENERATING') {
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
disableInlineEdit();
|
||||
refreshParamsPanel();
|
||||
@@ -7264,7 +7147,6 @@
|
||||
pendingAcceptedSession = null;
|
||||
awaitingAcceptResult = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup. Try Accept again.', 5000);
|
||||
break;
|
||||
@@ -8367,15 +8249,6 @@ void main() {
|
||||
// matches the original off-white risograph paper.
|
||||
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
|
||||
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
|
||||
// showShaderOverlay is async: it appends its canvas, then awaits
|
||||
// createImageBitmap and the GL setup before it publishes shaderState. A
|
||||
// teardown that landed inside that window found shaderState still null,
|
||||
// returned, and then watched the construction publish itself over a session
|
||||
// that had already left GENERATING, with no teardown left to run. That is
|
||||
// the generating loader frozen over a page that already cycles (issue #719).
|
||||
// Every teardown bumps this epoch; a construction abandons its own canvas as
|
||||
// soon as it sees the epoch move.
|
||||
let shaderEpoch = 0;
|
||||
|
||||
// The element's effective background tone, used as the uniform halftone
|
||||
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground
|
||||
@@ -8522,28 +8395,14 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop a shader node no shaderState owns (an abandoned construction). */
|
||||
function removeStrayShaderNode() {
|
||||
const stray = uiGetById(PREFIX + '-shader');
|
||||
if (stray) stray.remove();
|
||||
}
|
||||
|
||||
function hideShaderOverlay() {
|
||||
// Bump first, unconditionally: this is what tells an in-flight
|
||||
// showShaderOverlay to abandon itself rather than publish over a session
|
||||
// that has already moved on.
|
||||
shaderEpoch += 1;
|
||||
if (!shaderState) {
|
||||
removeStrayShaderNode();
|
||||
return;
|
||||
}
|
||||
if (!shaderState) return;
|
||||
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
|
||||
if (shaderState.canvas) shaderState.canvas.remove();
|
||||
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
|
||||
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
shaderState = null;
|
||||
removeStrayShaderNode();
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
@@ -8568,16 +8427,6 @@ void main() {
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
// hideShaderOverlay just bumped the epoch, so this run owns it until the
|
||||
// next teardown. Every step past an await re-checks before it publishes.
|
||||
const epoch = shaderEpoch;
|
||||
const abandoned = (node, gl) => {
|
||||
if (epoch === shaderEpoch) return false;
|
||||
node.remove();
|
||||
const lose = gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
return true;
|
||||
};
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = PREFIX + '-shader';
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
@@ -8600,7 +8449,6 @@ void main() {
|
||||
if (!gl) {
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
if (abandoned(canvas, null)) return;
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
@@ -8640,22 +8488,16 @@ void main() {
|
||||
}
|
||||
|
||||
// Upload the screenshot as a texture
|
||||
if (abandoned(canvas, gl)) return;
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
if (abandoned(canvas, gl)) return;
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
if (abandoned(canvas, gl)) {
|
||||
if (bitmap.close) bitmap.close();
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
@@ -8674,7 +8516,6 @@ void main() {
|
||||
const paperRgb = paper || resolvePaperRgb(el);
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if (abandoned(canvas, gl)) return;
|
||||
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
|
||||
function frame() {
|
||||
if (!shaderState) return;
|
||||
@@ -8711,7 +8552,7 @@ void main() {
|
||||
clientSentAt: Date.now(),
|
||||
};
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
const acceptWrapper = findVariantsWrapper(currentSessionId);
|
||||
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
@@ -8754,7 +8595,6 @@ void main() {
|
||||
.catch(() => {
|
||||
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
@@ -8806,7 +8646,7 @@ void main() {
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
@@ -8933,7 +8773,7 @@ void main() {
|
||||
}
|
||||
|
||||
function commitAcceptedVariantToDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!accepted || !accepted.firstElementChild) return false;
|
||||
@@ -9161,7 +9001,7 @@ void main() {
|
||||
}
|
||||
|
||||
function restoreFromActiveSessions(activeSessions, reason) {
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
|
||||
if (svelteComponentSession?.sessionId === currentSessionId) return false;
|
||||
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
|
||||
@@ -9274,13 +9114,10 @@ void main() {
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
// replaced the wrapper by then (keeps static-server / no-HMR flows alive).
|
||||
// Every match, not the first: a target inside a `.map()` renders one
|
||||
// wrapper per item, and hiding only one leaves the rest of the
|
||||
// discarded variants on screen.
|
||||
const discardWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (discardWrappers.length > 0) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (wrapper) {
|
||||
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
||||
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
|
||||
else wrapper.style.display = 'none';
|
||||
}
|
||||
setTimeout(function() {
|
||||
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
||||
@@ -9288,19 +9125,16 @@ void main() {
|
||||
removeDiscardStateStylesheet();
|
||||
return;
|
||||
}
|
||||
const lateWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (lateWrappers.length === 0) {
|
||||
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (!lateWrapper) {
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
// Duplicates all render from one source element, so HMR ownership is
|
||||
// uniform across them; the first is a fair witness for the set.
|
||||
const lateWrapper = lateWrappers[0];
|
||||
if (recoverySuperseded) {
|
||||
if (hasFrameworkHmrOwnership(lateWrapper)) {
|
||||
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
} else {
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -9309,20 +9143,18 @@ void main() {
|
||||
// the final source rewrite, reload once after a grace window so the
|
||||
// discarded source becomes authoritative without a reconciler race.
|
||||
setTimeout(function() {
|
||||
const staleWrappers = discardedWrappers(cleanupSessionId);
|
||||
const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
||||
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
// A reload restores every wrapper's original at once, so there is
|
||||
// nothing per-wrapper to do here.
|
||||
if (staleWrappers.length > 0) location.reload();
|
||||
if (staleWrapper) location.reload();
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}, 2000);
|
||||
}
|
||||
hideBar(instantChrome);
|
||||
@@ -9510,13 +9342,8 @@ void main() {
|
||||
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
||||
}
|
||||
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
|
||||
// Which path resumed matters in the journal: an init resume is a fresh
|
||||
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
|
||||
// used to log the same `browser_resumed`, which made issue #719 take a
|
||||
// DOM reconstruction to diagnose.
|
||||
const resumeReason = opts.reason || 'browser_resumed';
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
|
||||
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
|
||||
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
|
||||
@@ -9615,38 +9442,16 @@ void main() {
|
||||
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// A resume can BE the arrival, not just a re-entry after one. The server's
|
||||
// generation preflight runs live-wrap with --defer-source-write, so the
|
||||
// wrapper and every variant reach the DOM in one HMR batch, and the
|
||||
// deferred-wrapper scout (constructed at init) runs before the variant
|
||||
// MutationObserver (constructed at Go) on that batch. Finish the same
|
||||
// transition the observer would have finished. Without hideShaderOverlay
|
||||
// the generating shader stays frozen over the target and the session looks
|
||||
// stuck at GENERATING while the bar already cycles (issue #719).
|
||||
if (state === 'CYCLING') {
|
||||
recoveryWaitingForAnchor = false;
|
||||
hideShaderOverlay();
|
||||
if (isInsert) finalizeInsertSession();
|
||||
disableInlineEdit();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
||||
sendCheckpoint('variants_progress');
|
||||
} else {
|
||||
queueCheckpoint(resumeReason);
|
||||
// Only variants_progress and variants_ready count as publication
|
||||
// progress. When the resume is the arrival, the observer never gets to
|
||||
// report it (this function disconnects and re-creates it below, which
|
||||
// drops the records it had already queued for this same batch), so
|
||||
// without this the server never learns the variants were published.
|
||||
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
sendCheckpoint('variants_ready');
|
||||
}
|
||||
queueCheckpoint('browser_resumed');
|
||||
}
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -12968,7 +12773,7 @@ void main() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
|
||||
if (!wrapper) return;
|
||||
scout.disconnect();
|
||||
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
|
||||
if (resumeSession(deferredResumeRevision)) {
|
||||
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1490,7 +1490,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
|
||||
return isNeutralAuthoredColor(m[1]);
|
||||
}
|
||||
|
||||
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
|
||||
|
||||
function scanJs(text, start, onChar) {
|
||||
let stringQuote = '';
|
||||
let inTemplate = false;
|
||||
let paren = 0;
|
||||
let brace = 0;
|
||||
const interpBrace = [];
|
||||
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const prev = text[i - 1];
|
||||
const next = text[i + 1];
|
||||
|
||||
if (stringQuote) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === stringQuote) stringQuote = '';
|
||||
continue;
|
||||
}
|
||||
if (inTemplate && interpBrace.length === 0) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === '$' && next === '{') {
|
||||
brace++;
|
||||
interpBrace.push(brace);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === '`') { inTemplate = false; continue; }
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"') { stringQuote = char; continue; }
|
||||
if (char === '`') { inTemplate = true; continue; }
|
||||
if (char === '(') { paren++; continue; }
|
||||
if (char === ')') { paren--; continue; }
|
||||
if (char === '{') { brace++; continue; }
|
||||
if (char === '}') {
|
||||
brace--;
|
||||
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
|
||||
continue;
|
||||
}
|
||||
if (onChar(char, i, prev, next, { paren, brace })) return;
|
||||
}
|
||||
}
|
||||
|
||||
function containingMarkupTag(line, index) {
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
const tagStart = line.indexOf('<', i);
|
||||
if (tagStart === -1) break;
|
||||
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
|
||||
i = tagStart + 1;
|
||||
continue;
|
||||
}
|
||||
let tagEnd = -1;
|
||||
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
|
||||
if (char === '>' && depth.brace === 0) {
|
||||
tagEnd = j;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (tagEnd === -1) break;
|
||||
if (index >= tagStart && index <= tagEnd) {
|
||||
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
|
||||
}
|
||||
i = tagEnd + 1;
|
||||
}
|
||||
return { text: line, start: 0 };
|
||||
}
|
||||
|
||||
function findTernarySplit(text) {
|
||||
let qPos = -1;
|
||||
let qParen = 0;
|
||||
let qBrace = 0;
|
||||
let nested = 0;
|
||||
let colonPos = -1;
|
||||
let split = null;
|
||||
|
||||
const isQuestion = (char, prev, next) =>
|
||||
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
|
||||
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
|
||||
|
||||
scanJs(text, 0, (char, i, prev, next, depth) => {
|
||||
if (colonPos === -1) {
|
||||
if (qPos === -1 && isQuestion(char, prev, next)) {
|
||||
qPos = i;
|
||||
qParen = depth.paren;
|
||||
qBrace = depth.brace;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
|
||||
nested++;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
|
||||
if (nested) nested--;
|
||||
else colonPos = i;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (char === ',' && sameDepth(depth)) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1, i),
|
||||
suffix: text.slice(i),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!split && qPos !== -1 && colonPos !== -1) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1),
|
||||
suffix: '',
|
||||
};
|
||||
}
|
||||
return split;
|
||||
}
|
||||
|
||||
function exclusiveClassScopes(text) {
|
||||
const split = findTernarySplit(text);
|
||||
if (!split) return [text];
|
||||
return [
|
||||
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
|
||||
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
|
||||
];
|
||||
}
|
||||
|
||||
function grayOnColorScopes(line, index) {
|
||||
return exclusiveClassScopes(containingMarkupTag(line, index).text);
|
||||
}
|
||||
|
||||
function grayOnColorPairs(line, grayClass, index) {
|
||||
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
|
||||
}
|
||||
|
||||
const REGEX_MATCHERS = [
|
||||
// --- Side-tab ---
|
||||
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
|
||||
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
|
||||
fmt: () => 'bg-clip-text + bg-gradient' },
|
||||
// --- Tailwind gray on colored bg ---
|
||||
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
|
||||
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
|
||||
fmt: (m, line) => {
|
||||
const bg = grayOnColorPairs(line, m[0], m.index)
|
||||
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
|
||||
.find(Boolean);
|
||||
return `${m[0]} on ${bg?.[0] || '?'}`;
|
||||
} },
|
||||
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
||||
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
|
||||
// --- Tailwind AI palette ---
|
||||
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
|
||||
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
|
||||
|
||||
@@ -217,7 +217,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -2060,7 +2060,7 @@
|
||||
if (anchor) return anchor;
|
||||
}
|
||||
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (wrapper) {
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0 && visibleVariant > 0) {
|
||||
@@ -2131,14 +2131,14 @@
|
||||
|
||||
function isInsertGeneratingSession() {
|
||||
if (state !== 'GENERATING' || !currentSessionId) return false;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
|
||||
}
|
||||
|
||||
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
|
||||
function ensureInsertPlaceholder() {
|
||||
if (!isInsertGeneratingSession()) return placeholderElement;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0) return placeholderElement;
|
||||
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
|
||||
@@ -3156,7 +3156,7 @@
|
||||
|| svelteComponentSession.wrapperEl
|
||||
|| null;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
@@ -4900,7 +4900,7 @@
|
||||
return Object.values(svelteComponentSession.paramsByVariant || {})
|
||||
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
|
||||
.reduce((total, variant) => total + parseVariantParams(variant).length, 0);
|
||||
@@ -5004,7 +5004,7 @@
|
||||
scheduleCyclingBarSync(sessionId, num);
|
||||
return true;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
updateVariantStateStylesheet(sessionId, num);
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
@@ -5820,7 +5820,6 @@
|
||||
return;
|
||||
}
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
completeParameterGenerationIfReady();
|
||||
@@ -6217,71 +6216,6 @@
|
||||
return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
|
||||
}
|
||||
|
||||
function sourceHasSessionWrapper(text, sessionId) {
|
||||
const src = String(text || '');
|
||||
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|
||||
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|
||||
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
|
||||
* wrapper deleted from source look identical in the DOM, and only the second
|
||||
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
|
||||
* injecting raw JSX; reading the file as plain text and matching the session
|
||||
* marker honors that, because no DOM is ever built from what comes back.
|
||||
* Marker present means the component is simply not mounted right now (a
|
||||
* closed modal, another route) and the variant observer keeps waiting.
|
||||
* Marker absent after the same retry budget the HTML path uses means the
|
||||
* file was edited out from under the session, which no reload, HMR push, or
|
||||
* server restart can repair, so the session self-discards and hands the
|
||||
* surface back to the picker.
|
||||
*/
|
||||
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
|
||||
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
|
||||
const retryLater = () => {
|
||||
setTimeout(() => {
|
||||
if (!stillActive()) return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
};
|
||||
// Discarding is durable (the session moves to the discarded phase and the
|
||||
// picker replaces it), so it needs evidence that the wrapper is gone: a
|
||||
// read that answers without the marker, or a 404 (the file itself was
|
||||
// renamed or deleted). Either kind retries on the shared budget first.
|
||||
// A read that fails for any other reason (the server briefly away, a
|
||||
// transient fetch error) says nothing about the wrapper; after the budget
|
||||
// the session is kept, the user told, and the next event retries.
|
||||
const onNoWrapper = (reason) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
discardOrphanedSession(reason);
|
||||
};
|
||||
const onUnreadable = (detail) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
|
||||
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
|
||||
};
|
||||
fetch(url)
|
||||
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
|
||||
.then(text => {
|
||||
if (!stillActive()) return;
|
||||
if (sourceHasSessionWrapper(text, sessionId)) return;
|
||||
onNoWrapper('variant wrapper missing from source');
|
||||
})
|
||||
.catch(err => {
|
||||
const detail = err && err.message ? err.message : 'fetch failed';
|
||||
if (/source read failed: 404$/.test(detail)) {
|
||||
onNoWrapper('source file missing (404) while checking for the variant wrapper');
|
||||
return;
|
||||
}
|
||||
onUnreadable(detail);
|
||||
});
|
||||
}
|
||||
|
||||
function completeSourceInjection(wrapper, sessionId, opts) {
|
||||
recoveryWaitingForAnchor = false;
|
||||
if (pendingVariantAnchorRetryObserver) {
|
||||
@@ -6362,7 +6296,7 @@
|
||||
}
|
||||
rememberSessionFileMeta({ file: filePath });
|
||||
if (isJsxSourceFile(filePath)) {
|
||||
const liveWrapper = findVariantsWrapper(sessionId);
|
||||
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
|
||||
return;
|
||||
@@ -6392,7 +6326,14 @@
|
||||
return;
|
||||
}
|
||||
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
||||
probeJsxWrapperForOrphan(filePath, sessionId, opts);
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
|
||||
setTimeout(() => {
|
||||
if (sessionId !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -6434,7 +6375,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const existingWrapper = findVariantsWrapper(sessionId);
|
||||
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (existingWrapper) {
|
||||
const wrapper = srcWrapper.cloneNode(true);
|
||||
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
|
||||
@@ -6591,7 +6532,7 @@
|
||||
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
||||
return;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const visEl = pickVariantContent(wrapper, visibleVariant);
|
||||
if (visEl) selectedElement = visEl;
|
||||
@@ -6601,7 +6542,7 @@
|
||||
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
||||
return svelteComponentSession.mountedVariant;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
for (const variant of variants) {
|
||||
@@ -6716,17 +6657,8 @@
|
||||
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
|
||||
* one wrapper per item, so the hide, the release, and the existence checks
|
||||
* all have to speak about the same set.
|
||||
*/
|
||||
function discardedWrappers(sessionId) {
|
||||
if (!sessionId) return [];
|
||||
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
|
||||
}
|
||||
|
||||
function releaseDiscardedStaticWrapper(wrapper) {
|
||||
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
if (!wrapper) return;
|
||||
const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
|
||||
const content = orig?.firstElementChild;
|
||||
@@ -6737,18 +6669,6 @@
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the discard hide on every wrapper it covered. Releasing only the
|
||||
* first match left the other mapped items sitting at display:none with
|
||||
* their original content never restored, on exactly the static and
|
||||
* missed-HMR flows this fallback exists for.
|
||||
*/
|
||||
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
|
||||
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
|
||||
}
|
||||
|
||||
function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
|
||||
if (!sessionId || !document.body) return;
|
||||
if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
|
||||
@@ -6929,42 +6849,6 @@
|
||||
// MutationObserver for progressive variant reveal
|
||||
//
|
||||
|
||||
// A session id can have more than one wrapper in the DOM: the target may sit
|
||||
// inside a `.map()` callback (the wrapper renders once per item), or the
|
||||
// agent may have relocated the wrapper out of the shared primitive live-wrap
|
||||
// scaffolded into. A plain first match can then pin an empty scaffold while
|
||||
// the real variants sit in a later wrapper, which strands the session at
|
||||
// 0/N and leaves the bar, the params panel, and accept all reading the
|
||||
// wrong element. Prefer a wrapper that actually holds variants. With zero
|
||||
// or one match this is exactly the querySelector it replaces.
|
||||
//
|
||||
// Every lookup of the ACTIVE session's wrapper goes through here. The
|
||||
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
|
||||
// existence checks, selector strings for stylesheets and observers (which
|
||||
// want to cover every match), `querySelectorAll` sweeps, and the parsed
|
||||
// source document, which is not this document.
|
||||
function pickPopulatedVariantsWrapper(selector) {
|
||||
const matches = document.querySelectorAll(selector);
|
||||
if (matches.length < 2) return matches[0] || null;
|
||||
for (const candidate of matches) {
|
||||
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
/** The wrapper holding `sessionId`'s variants, or null without an id. */
|
||||
function findVariantsWrapper(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
|
||||
}
|
||||
|
||||
/** Any live variant wrapper, for the resume paths that have no id yet. */
|
||||
function findAnyVariantsWrapper() {
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
|
||||
}
|
||||
|
||||
function startVariantObserver(sessionId) {
|
||||
let updating = false; // re-entrancy guard
|
||||
|
||||
@@ -6994,7 +6878,7 @@
|
||||
}
|
||||
if (!dominated) return;
|
||||
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
@@ -7203,7 +7087,6 @@
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
if (state === 'GENERATING') {
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
disableInlineEdit();
|
||||
refreshParamsPanel();
|
||||
@@ -7264,7 +7147,6 @@
|
||||
pendingAcceptedSession = null;
|
||||
awaitingAcceptResult = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup. Try Accept again.', 5000);
|
||||
break;
|
||||
@@ -8367,15 +8249,6 @@ void main() {
|
||||
// matches the original off-white risograph paper.
|
||||
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
|
||||
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
|
||||
// showShaderOverlay is async: it appends its canvas, then awaits
|
||||
// createImageBitmap and the GL setup before it publishes shaderState. A
|
||||
// teardown that landed inside that window found shaderState still null,
|
||||
// returned, and then watched the construction publish itself over a session
|
||||
// that had already left GENERATING, with no teardown left to run. That is
|
||||
// the generating loader frozen over a page that already cycles (issue #719).
|
||||
// Every teardown bumps this epoch; a construction abandons its own canvas as
|
||||
// soon as it sees the epoch move.
|
||||
let shaderEpoch = 0;
|
||||
|
||||
// The element's effective background tone, used as the uniform halftone
|
||||
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground
|
||||
@@ -8522,28 +8395,14 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop a shader node no shaderState owns (an abandoned construction). */
|
||||
function removeStrayShaderNode() {
|
||||
const stray = uiGetById(PREFIX + '-shader');
|
||||
if (stray) stray.remove();
|
||||
}
|
||||
|
||||
function hideShaderOverlay() {
|
||||
// Bump first, unconditionally: this is what tells an in-flight
|
||||
// showShaderOverlay to abandon itself rather than publish over a session
|
||||
// that has already moved on.
|
||||
shaderEpoch += 1;
|
||||
if (!shaderState) {
|
||||
removeStrayShaderNode();
|
||||
return;
|
||||
}
|
||||
if (!shaderState) return;
|
||||
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
|
||||
if (shaderState.canvas) shaderState.canvas.remove();
|
||||
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
|
||||
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
shaderState = null;
|
||||
removeStrayShaderNode();
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
@@ -8568,16 +8427,6 @@ void main() {
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
// hideShaderOverlay just bumped the epoch, so this run owns it until the
|
||||
// next teardown. Every step past an await re-checks before it publishes.
|
||||
const epoch = shaderEpoch;
|
||||
const abandoned = (node, gl) => {
|
||||
if (epoch === shaderEpoch) return false;
|
||||
node.remove();
|
||||
const lose = gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
return true;
|
||||
};
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = PREFIX + '-shader';
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
@@ -8600,7 +8449,6 @@ void main() {
|
||||
if (!gl) {
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
if (abandoned(canvas, null)) return;
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
@@ -8640,22 +8488,16 @@ void main() {
|
||||
}
|
||||
|
||||
// Upload the screenshot as a texture
|
||||
if (abandoned(canvas, gl)) return;
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
if (abandoned(canvas, gl)) return;
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
if (abandoned(canvas, gl)) {
|
||||
if (bitmap.close) bitmap.close();
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
@@ -8674,7 +8516,6 @@ void main() {
|
||||
const paperRgb = paper || resolvePaperRgb(el);
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if (abandoned(canvas, gl)) return;
|
||||
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
|
||||
function frame() {
|
||||
if (!shaderState) return;
|
||||
@@ -8711,7 +8552,7 @@ void main() {
|
||||
clientSentAt: Date.now(),
|
||||
};
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
const acceptWrapper = findVariantsWrapper(currentSessionId);
|
||||
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
@@ -8754,7 +8595,6 @@ void main() {
|
||||
.catch(() => {
|
||||
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
@@ -8806,7 +8646,7 @@ void main() {
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
@@ -8933,7 +8773,7 @@ void main() {
|
||||
}
|
||||
|
||||
function commitAcceptedVariantToDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!accepted || !accepted.firstElementChild) return false;
|
||||
@@ -9161,7 +9001,7 @@ void main() {
|
||||
}
|
||||
|
||||
function restoreFromActiveSessions(activeSessions, reason) {
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
|
||||
if (svelteComponentSession?.sessionId === currentSessionId) return false;
|
||||
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
|
||||
@@ -9274,13 +9114,10 @@ void main() {
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
// replaced the wrapper by then (keeps static-server / no-HMR flows alive).
|
||||
// Every match, not the first: a target inside a `.map()` renders one
|
||||
// wrapper per item, and hiding only one leaves the rest of the
|
||||
// discarded variants on screen.
|
||||
const discardWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (discardWrappers.length > 0) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (wrapper) {
|
||||
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
||||
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
|
||||
else wrapper.style.display = 'none';
|
||||
}
|
||||
setTimeout(function() {
|
||||
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
||||
@@ -9288,19 +9125,16 @@ void main() {
|
||||
removeDiscardStateStylesheet();
|
||||
return;
|
||||
}
|
||||
const lateWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (lateWrappers.length === 0) {
|
||||
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (!lateWrapper) {
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
// Duplicates all render from one source element, so HMR ownership is
|
||||
// uniform across them; the first is a fair witness for the set.
|
||||
const lateWrapper = lateWrappers[0];
|
||||
if (recoverySuperseded) {
|
||||
if (hasFrameworkHmrOwnership(lateWrapper)) {
|
||||
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
} else {
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -9309,20 +9143,18 @@ void main() {
|
||||
// the final source rewrite, reload once after a grace window so the
|
||||
// discarded source becomes authoritative without a reconciler race.
|
||||
setTimeout(function() {
|
||||
const staleWrappers = discardedWrappers(cleanupSessionId);
|
||||
const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
||||
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
// A reload restores every wrapper's original at once, so there is
|
||||
// nothing per-wrapper to do here.
|
||||
if (staleWrappers.length > 0) location.reload();
|
||||
if (staleWrapper) location.reload();
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}, 2000);
|
||||
}
|
||||
hideBar(instantChrome);
|
||||
@@ -9510,13 +9342,8 @@ void main() {
|
||||
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
||||
}
|
||||
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
|
||||
// Which path resumed matters in the journal: an init resume is a fresh
|
||||
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
|
||||
// used to log the same `browser_resumed`, which made issue #719 take a
|
||||
// DOM reconstruction to diagnose.
|
||||
const resumeReason = opts.reason || 'browser_resumed';
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
|
||||
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
|
||||
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
|
||||
@@ -9615,38 +9442,16 @@ void main() {
|
||||
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// A resume can BE the arrival, not just a re-entry after one. The server's
|
||||
// generation preflight runs live-wrap with --defer-source-write, so the
|
||||
// wrapper and every variant reach the DOM in one HMR batch, and the
|
||||
// deferred-wrapper scout (constructed at init) runs before the variant
|
||||
// MutationObserver (constructed at Go) on that batch. Finish the same
|
||||
// transition the observer would have finished. Without hideShaderOverlay
|
||||
// the generating shader stays frozen over the target and the session looks
|
||||
// stuck at GENERATING while the bar already cycles (issue #719).
|
||||
if (state === 'CYCLING') {
|
||||
recoveryWaitingForAnchor = false;
|
||||
hideShaderOverlay();
|
||||
if (isInsert) finalizeInsertSession();
|
||||
disableInlineEdit();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
||||
sendCheckpoint('variants_progress');
|
||||
} else {
|
||||
queueCheckpoint(resumeReason);
|
||||
// Only variants_progress and variants_ready count as publication
|
||||
// progress. When the resume is the arrival, the observer never gets to
|
||||
// report it (this function disconnects and re-creates it below, which
|
||||
// drops the records it had already queued for this same batch), so
|
||||
// without this the server never learns the variants were published.
|
||||
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
sendCheckpoint('variants_ready');
|
||||
}
|
||||
queueCheckpoint('browser_resumed');
|
||||
}
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -12968,7 +12773,7 @@ void main() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
|
||||
if (!wrapper) return;
|
||||
scout.disconnect();
|
||||
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
|
||||
if (resumeSession(deferredResumeRevision)) {
|
||||
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1490,7 +1490,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
|
||||
return isNeutralAuthoredColor(m[1]);
|
||||
}
|
||||
|
||||
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
|
||||
|
||||
function scanJs(text, start, onChar) {
|
||||
let stringQuote = '';
|
||||
let inTemplate = false;
|
||||
let paren = 0;
|
||||
let brace = 0;
|
||||
const interpBrace = [];
|
||||
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const prev = text[i - 1];
|
||||
const next = text[i + 1];
|
||||
|
||||
if (stringQuote) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === stringQuote) stringQuote = '';
|
||||
continue;
|
||||
}
|
||||
if (inTemplate && interpBrace.length === 0) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === '$' && next === '{') {
|
||||
brace++;
|
||||
interpBrace.push(brace);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === '`') { inTemplate = false; continue; }
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"') { stringQuote = char; continue; }
|
||||
if (char === '`') { inTemplate = true; continue; }
|
||||
if (char === '(') { paren++; continue; }
|
||||
if (char === ')') { paren--; continue; }
|
||||
if (char === '{') { brace++; continue; }
|
||||
if (char === '}') {
|
||||
brace--;
|
||||
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
|
||||
continue;
|
||||
}
|
||||
if (onChar(char, i, prev, next, { paren, brace })) return;
|
||||
}
|
||||
}
|
||||
|
||||
function containingMarkupTag(line, index) {
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
const tagStart = line.indexOf('<', i);
|
||||
if (tagStart === -1) break;
|
||||
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
|
||||
i = tagStart + 1;
|
||||
continue;
|
||||
}
|
||||
let tagEnd = -1;
|
||||
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
|
||||
if (char === '>' && depth.brace === 0) {
|
||||
tagEnd = j;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (tagEnd === -1) break;
|
||||
if (index >= tagStart && index <= tagEnd) {
|
||||
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
|
||||
}
|
||||
i = tagEnd + 1;
|
||||
}
|
||||
return { text: line, start: 0 };
|
||||
}
|
||||
|
||||
function findTernarySplit(text) {
|
||||
let qPos = -1;
|
||||
let qParen = 0;
|
||||
let qBrace = 0;
|
||||
let nested = 0;
|
||||
let colonPos = -1;
|
||||
let split = null;
|
||||
|
||||
const isQuestion = (char, prev, next) =>
|
||||
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
|
||||
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
|
||||
|
||||
scanJs(text, 0, (char, i, prev, next, depth) => {
|
||||
if (colonPos === -1) {
|
||||
if (qPos === -1 && isQuestion(char, prev, next)) {
|
||||
qPos = i;
|
||||
qParen = depth.paren;
|
||||
qBrace = depth.brace;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
|
||||
nested++;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
|
||||
if (nested) nested--;
|
||||
else colonPos = i;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (char === ',' && sameDepth(depth)) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1, i),
|
||||
suffix: text.slice(i),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!split && qPos !== -1 && colonPos !== -1) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1),
|
||||
suffix: '',
|
||||
};
|
||||
}
|
||||
return split;
|
||||
}
|
||||
|
||||
function exclusiveClassScopes(text) {
|
||||
const split = findTernarySplit(text);
|
||||
if (!split) return [text];
|
||||
return [
|
||||
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
|
||||
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
|
||||
];
|
||||
}
|
||||
|
||||
function grayOnColorScopes(line, index) {
|
||||
return exclusiveClassScopes(containingMarkupTag(line, index).text);
|
||||
}
|
||||
|
||||
function grayOnColorPairs(line, grayClass, index) {
|
||||
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
|
||||
}
|
||||
|
||||
const REGEX_MATCHERS = [
|
||||
// --- Side-tab ---
|
||||
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
|
||||
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
|
||||
fmt: () => 'bg-clip-text + bg-gradient' },
|
||||
// --- Tailwind gray on colored bg ---
|
||||
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
|
||||
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
|
||||
fmt: (m, line) => {
|
||||
const bg = grayOnColorPairs(line, m[0], m.index)
|
||||
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
|
||||
.find(Boolean);
|
||||
return `${m[0]} on ${bg?.[0] || '?'}`;
|
||||
} },
|
||||
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
||||
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
|
||||
// --- Tailwind AI palette ---
|
||||
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
|
||||
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
|
||||
|
||||
@@ -217,7 +217,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -2060,7 +2060,7 @@
|
||||
if (anchor) return anchor;
|
||||
}
|
||||
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (wrapper) {
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0 && visibleVariant > 0) {
|
||||
@@ -2131,14 +2131,14 @@
|
||||
|
||||
function isInsertGeneratingSession() {
|
||||
if (state !== 'GENERATING' || !currentSessionId) return false;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
|
||||
}
|
||||
|
||||
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
|
||||
function ensureInsertPlaceholder() {
|
||||
if (!isInsertGeneratingSession()) return placeholderElement;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0) return placeholderElement;
|
||||
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
|
||||
@@ -3156,7 +3156,7 @@
|
||||
|| svelteComponentSession.wrapperEl
|
||||
|| null;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
@@ -4900,7 +4900,7 @@
|
||||
return Object.values(svelteComponentSession.paramsByVariant || {})
|
||||
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
|
||||
.reduce((total, variant) => total + parseVariantParams(variant).length, 0);
|
||||
@@ -5004,7 +5004,7 @@
|
||||
scheduleCyclingBarSync(sessionId, num);
|
||||
return true;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
updateVariantStateStylesheet(sessionId, num);
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
@@ -5820,7 +5820,6 @@
|
||||
return;
|
||||
}
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
completeParameterGenerationIfReady();
|
||||
@@ -6217,71 +6216,6 @@
|
||||
return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
|
||||
}
|
||||
|
||||
function sourceHasSessionWrapper(text, sessionId) {
|
||||
const src = String(text || '');
|
||||
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|
||||
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|
||||
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
|
||||
* wrapper deleted from source look identical in the DOM, and only the second
|
||||
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
|
||||
* injecting raw JSX; reading the file as plain text and matching the session
|
||||
* marker honors that, because no DOM is ever built from what comes back.
|
||||
* Marker present means the component is simply not mounted right now (a
|
||||
* closed modal, another route) and the variant observer keeps waiting.
|
||||
* Marker absent after the same retry budget the HTML path uses means the
|
||||
* file was edited out from under the session, which no reload, HMR push, or
|
||||
* server restart can repair, so the session self-discards and hands the
|
||||
* surface back to the picker.
|
||||
*/
|
||||
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
|
||||
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
|
||||
const retryLater = () => {
|
||||
setTimeout(() => {
|
||||
if (!stillActive()) return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
};
|
||||
// Discarding is durable (the session moves to the discarded phase and the
|
||||
// picker replaces it), so it needs evidence that the wrapper is gone: a
|
||||
// read that answers without the marker, or a 404 (the file itself was
|
||||
// renamed or deleted). Either kind retries on the shared budget first.
|
||||
// A read that fails for any other reason (the server briefly away, a
|
||||
// transient fetch error) says nothing about the wrapper; after the budget
|
||||
// the session is kept, the user told, and the next event retries.
|
||||
const onNoWrapper = (reason) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
discardOrphanedSession(reason);
|
||||
};
|
||||
const onUnreadable = (detail) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
|
||||
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
|
||||
};
|
||||
fetch(url)
|
||||
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
|
||||
.then(text => {
|
||||
if (!stillActive()) return;
|
||||
if (sourceHasSessionWrapper(text, sessionId)) return;
|
||||
onNoWrapper('variant wrapper missing from source');
|
||||
})
|
||||
.catch(err => {
|
||||
const detail = err && err.message ? err.message : 'fetch failed';
|
||||
if (/source read failed: 404$/.test(detail)) {
|
||||
onNoWrapper('source file missing (404) while checking for the variant wrapper');
|
||||
return;
|
||||
}
|
||||
onUnreadable(detail);
|
||||
});
|
||||
}
|
||||
|
||||
function completeSourceInjection(wrapper, sessionId, opts) {
|
||||
recoveryWaitingForAnchor = false;
|
||||
if (pendingVariantAnchorRetryObserver) {
|
||||
@@ -6362,7 +6296,7 @@
|
||||
}
|
||||
rememberSessionFileMeta({ file: filePath });
|
||||
if (isJsxSourceFile(filePath)) {
|
||||
const liveWrapper = findVariantsWrapper(sessionId);
|
||||
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
|
||||
return;
|
||||
@@ -6392,7 +6326,14 @@
|
||||
return;
|
||||
}
|
||||
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
||||
probeJsxWrapperForOrphan(filePath, sessionId, opts);
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
|
||||
setTimeout(() => {
|
||||
if (sessionId !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -6434,7 +6375,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const existingWrapper = findVariantsWrapper(sessionId);
|
||||
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (existingWrapper) {
|
||||
const wrapper = srcWrapper.cloneNode(true);
|
||||
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
|
||||
@@ -6591,7 +6532,7 @@
|
||||
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
||||
return;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const visEl = pickVariantContent(wrapper, visibleVariant);
|
||||
if (visEl) selectedElement = visEl;
|
||||
@@ -6601,7 +6542,7 @@
|
||||
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
||||
return svelteComponentSession.mountedVariant;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
for (const variant of variants) {
|
||||
@@ -6716,17 +6657,8 @@
|
||||
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
|
||||
* one wrapper per item, so the hide, the release, and the existence checks
|
||||
* all have to speak about the same set.
|
||||
*/
|
||||
function discardedWrappers(sessionId) {
|
||||
if (!sessionId) return [];
|
||||
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
|
||||
}
|
||||
|
||||
function releaseDiscardedStaticWrapper(wrapper) {
|
||||
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
if (!wrapper) return;
|
||||
const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
|
||||
const content = orig?.firstElementChild;
|
||||
@@ -6737,18 +6669,6 @@
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the discard hide on every wrapper it covered. Releasing only the
|
||||
* first match left the other mapped items sitting at display:none with
|
||||
* their original content never restored, on exactly the static and
|
||||
* missed-HMR flows this fallback exists for.
|
||||
*/
|
||||
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
|
||||
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
|
||||
}
|
||||
|
||||
function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
|
||||
if (!sessionId || !document.body) return;
|
||||
if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
|
||||
@@ -6929,42 +6849,6 @@
|
||||
// MutationObserver for progressive variant reveal
|
||||
//
|
||||
|
||||
// A session id can have more than one wrapper in the DOM: the target may sit
|
||||
// inside a `.map()` callback (the wrapper renders once per item), or the
|
||||
// agent may have relocated the wrapper out of the shared primitive live-wrap
|
||||
// scaffolded into. A plain first match can then pin an empty scaffold while
|
||||
// the real variants sit in a later wrapper, which strands the session at
|
||||
// 0/N and leaves the bar, the params panel, and accept all reading the
|
||||
// wrong element. Prefer a wrapper that actually holds variants. With zero
|
||||
// or one match this is exactly the querySelector it replaces.
|
||||
//
|
||||
// Every lookup of the ACTIVE session's wrapper goes through here. The
|
||||
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
|
||||
// existence checks, selector strings for stylesheets and observers (which
|
||||
// want to cover every match), `querySelectorAll` sweeps, and the parsed
|
||||
// source document, which is not this document.
|
||||
function pickPopulatedVariantsWrapper(selector) {
|
||||
const matches = document.querySelectorAll(selector);
|
||||
if (matches.length < 2) return matches[0] || null;
|
||||
for (const candidate of matches) {
|
||||
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
/** The wrapper holding `sessionId`'s variants, or null without an id. */
|
||||
function findVariantsWrapper(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
|
||||
}
|
||||
|
||||
/** Any live variant wrapper, for the resume paths that have no id yet. */
|
||||
function findAnyVariantsWrapper() {
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
|
||||
}
|
||||
|
||||
function startVariantObserver(sessionId) {
|
||||
let updating = false; // re-entrancy guard
|
||||
|
||||
@@ -6994,7 +6878,7 @@
|
||||
}
|
||||
if (!dominated) return;
|
||||
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
@@ -7203,7 +7087,6 @@
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
if (state === 'GENERATING') {
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
disableInlineEdit();
|
||||
refreshParamsPanel();
|
||||
@@ -7264,7 +7147,6 @@
|
||||
pendingAcceptedSession = null;
|
||||
awaitingAcceptResult = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup. Try Accept again.', 5000);
|
||||
break;
|
||||
@@ -8367,15 +8249,6 @@ void main() {
|
||||
// matches the original off-white risograph paper.
|
||||
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
|
||||
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
|
||||
// showShaderOverlay is async: it appends its canvas, then awaits
|
||||
// createImageBitmap and the GL setup before it publishes shaderState. A
|
||||
// teardown that landed inside that window found shaderState still null,
|
||||
// returned, and then watched the construction publish itself over a session
|
||||
// that had already left GENERATING, with no teardown left to run. That is
|
||||
// the generating loader frozen over a page that already cycles (issue #719).
|
||||
// Every teardown bumps this epoch; a construction abandons its own canvas as
|
||||
// soon as it sees the epoch move.
|
||||
let shaderEpoch = 0;
|
||||
|
||||
// The element's effective background tone, used as the uniform halftone
|
||||
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground
|
||||
@@ -8522,28 +8395,14 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop a shader node no shaderState owns (an abandoned construction). */
|
||||
function removeStrayShaderNode() {
|
||||
const stray = uiGetById(PREFIX + '-shader');
|
||||
if (stray) stray.remove();
|
||||
}
|
||||
|
||||
function hideShaderOverlay() {
|
||||
// Bump first, unconditionally: this is what tells an in-flight
|
||||
// showShaderOverlay to abandon itself rather than publish over a session
|
||||
// that has already moved on.
|
||||
shaderEpoch += 1;
|
||||
if (!shaderState) {
|
||||
removeStrayShaderNode();
|
||||
return;
|
||||
}
|
||||
if (!shaderState) return;
|
||||
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
|
||||
if (shaderState.canvas) shaderState.canvas.remove();
|
||||
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
|
||||
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
shaderState = null;
|
||||
removeStrayShaderNode();
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
@@ -8568,16 +8427,6 @@ void main() {
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
// hideShaderOverlay just bumped the epoch, so this run owns it until the
|
||||
// next teardown. Every step past an await re-checks before it publishes.
|
||||
const epoch = shaderEpoch;
|
||||
const abandoned = (node, gl) => {
|
||||
if (epoch === shaderEpoch) return false;
|
||||
node.remove();
|
||||
const lose = gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
return true;
|
||||
};
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = PREFIX + '-shader';
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
@@ -8600,7 +8449,6 @@ void main() {
|
||||
if (!gl) {
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
if (abandoned(canvas, null)) return;
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
@@ -8640,22 +8488,16 @@ void main() {
|
||||
}
|
||||
|
||||
// Upload the screenshot as a texture
|
||||
if (abandoned(canvas, gl)) return;
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
if (abandoned(canvas, gl)) return;
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
if (abandoned(canvas, gl)) {
|
||||
if (bitmap.close) bitmap.close();
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
@@ -8674,7 +8516,6 @@ void main() {
|
||||
const paperRgb = paper || resolvePaperRgb(el);
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if (abandoned(canvas, gl)) return;
|
||||
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
|
||||
function frame() {
|
||||
if (!shaderState) return;
|
||||
@@ -8711,7 +8552,7 @@ void main() {
|
||||
clientSentAt: Date.now(),
|
||||
};
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
const acceptWrapper = findVariantsWrapper(currentSessionId);
|
||||
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
@@ -8754,7 +8595,6 @@ void main() {
|
||||
.catch(() => {
|
||||
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
@@ -8806,7 +8646,7 @@ void main() {
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
@@ -8933,7 +8773,7 @@ void main() {
|
||||
}
|
||||
|
||||
function commitAcceptedVariantToDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!accepted || !accepted.firstElementChild) return false;
|
||||
@@ -9161,7 +9001,7 @@ void main() {
|
||||
}
|
||||
|
||||
function restoreFromActiveSessions(activeSessions, reason) {
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
|
||||
if (svelteComponentSession?.sessionId === currentSessionId) return false;
|
||||
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
|
||||
@@ -9274,13 +9114,10 @@ void main() {
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
// replaced the wrapper by then (keeps static-server / no-HMR flows alive).
|
||||
// Every match, not the first: a target inside a `.map()` renders one
|
||||
// wrapper per item, and hiding only one leaves the rest of the
|
||||
// discarded variants on screen.
|
||||
const discardWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (discardWrappers.length > 0) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (wrapper) {
|
||||
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
||||
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
|
||||
else wrapper.style.display = 'none';
|
||||
}
|
||||
setTimeout(function() {
|
||||
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
||||
@@ -9288,19 +9125,16 @@ void main() {
|
||||
removeDiscardStateStylesheet();
|
||||
return;
|
||||
}
|
||||
const lateWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (lateWrappers.length === 0) {
|
||||
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (!lateWrapper) {
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
// Duplicates all render from one source element, so HMR ownership is
|
||||
// uniform across them; the first is a fair witness for the set.
|
||||
const lateWrapper = lateWrappers[0];
|
||||
if (recoverySuperseded) {
|
||||
if (hasFrameworkHmrOwnership(lateWrapper)) {
|
||||
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
} else {
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -9309,20 +9143,18 @@ void main() {
|
||||
// the final source rewrite, reload once after a grace window so the
|
||||
// discarded source becomes authoritative without a reconciler race.
|
||||
setTimeout(function() {
|
||||
const staleWrappers = discardedWrappers(cleanupSessionId);
|
||||
const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
||||
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
// A reload restores every wrapper's original at once, so there is
|
||||
// nothing per-wrapper to do here.
|
||||
if (staleWrappers.length > 0) location.reload();
|
||||
if (staleWrapper) location.reload();
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}, 2000);
|
||||
}
|
||||
hideBar(instantChrome);
|
||||
@@ -9510,13 +9342,8 @@ void main() {
|
||||
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
||||
}
|
||||
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
|
||||
// Which path resumed matters in the journal: an init resume is a fresh
|
||||
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
|
||||
// used to log the same `browser_resumed`, which made issue #719 take a
|
||||
// DOM reconstruction to diagnose.
|
||||
const resumeReason = opts.reason || 'browser_resumed';
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
|
||||
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
|
||||
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
|
||||
@@ -9615,38 +9442,16 @@ void main() {
|
||||
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// A resume can BE the arrival, not just a re-entry after one. The server's
|
||||
// generation preflight runs live-wrap with --defer-source-write, so the
|
||||
// wrapper and every variant reach the DOM in one HMR batch, and the
|
||||
// deferred-wrapper scout (constructed at init) runs before the variant
|
||||
// MutationObserver (constructed at Go) on that batch. Finish the same
|
||||
// transition the observer would have finished. Without hideShaderOverlay
|
||||
// the generating shader stays frozen over the target and the session looks
|
||||
// stuck at GENERATING while the bar already cycles (issue #719).
|
||||
if (state === 'CYCLING') {
|
||||
recoveryWaitingForAnchor = false;
|
||||
hideShaderOverlay();
|
||||
if (isInsert) finalizeInsertSession();
|
||||
disableInlineEdit();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
||||
sendCheckpoint('variants_progress');
|
||||
} else {
|
||||
queueCheckpoint(resumeReason);
|
||||
// Only variants_progress and variants_ready count as publication
|
||||
// progress. When the resume is the arrival, the observer never gets to
|
||||
// report it (this function disconnects and re-creates it below, which
|
||||
// drops the records it had already queued for this same batch), so
|
||||
// without this the server never learns the variants were published.
|
||||
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
sendCheckpoint('variants_ready');
|
||||
}
|
||||
queueCheckpoint('browser_resumed');
|
||||
}
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -12968,7 +12773,7 @@ void main() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
|
||||
if (!wrapper) return;
|
||||
scout.disconnect();
|
||||
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
|
||||
if (resumeSession(deferredResumeRevision)) {
|
||||
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1490,7 +1490,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
|
||||
return isNeutralAuthoredColor(m[1]);
|
||||
}
|
||||
|
||||
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
|
||||
|
||||
function scanJs(text, start, onChar) {
|
||||
let stringQuote = '';
|
||||
let inTemplate = false;
|
||||
let paren = 0;
|
||||
let brace = 0;
|
||||
const interpBrace = [];
|
||||
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const prev = text[i - 1];
|
||||
const next = text[i + 1];
|
||||
|
||||
if (stringQuote) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === stringQuote) stringQuote = '';
|
||||
continue;
|
||||
}
|
||||
if (inTemplate && interpBrace.length === 0) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === '$' && next === '{') {
|
||||
brace++;
|
||||
interpBrace.push(brace);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === '`') { inTemplate = false; continue; }
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"') { stringQuote = char; continue; }
|
||||
if (char === '`') { inTemplate = true; continue; }
|
||||
if (char === '(') { paren++; continue; }
|
||||
if (char === ')') { paren--; continue; }
|
||||
if (char === '{') { brace++; continue; }
|
||||
if (char === '}') {
|
||||
brace--;
|
||||
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
|
||||
continue;
|
||||
}
|
||||
if (onChar(char, i, prev, next, { paren, brace })) return;
|
||||
}
|
||||
}
|
||||
|
||||
function containingMarkupTag(line, index) {
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
const tagStart = line.indexOf('<', i);
|
||||
if (tagStart === -1) break;
|
||||
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
|
||||
i = tagStart + 1;
|
||||
continue;
|
||||
}
|
||||
let tagEnd = -1;
|
||||
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
|
||||
if (char === '>' && depth.brace === 0) {
|
||||
tagEnd = j;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (tagEnd === -1) break;
|
||||
if (index >= tagStart && index <= tagEnd) {
|
||||
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
|
||||
}
|
||||
i = tagEnd + 1;
|
||||
}
|
||||
return { text: line, start: 0 };
|
||||
}
|
||||
|
||||
function findTernarySplit(text) {
|
||||
let qPos = -1;
|
||||
let qParen = 0;
|
||||
let qBrace = 0;
|
||||
let nested = 0;
|
||||
let colonPos = -1;
|
||||
let split = null;
|
||||
|
||||
const isQuestion = (char, prev, next) =>
|
||||
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
|
||||
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
|
||||
|
||||
scanJs(text, 0, (char, i, prev, next, depth) => {
|
||||
if (colonPos === -1) {
|
||||
if (qPos === -1 && isQuestion(char, prev, next)) {
|
||||
qPos = i;
|
||||
qParen = depth.paren;
|
||||
qBrace = depth.brace;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
|
||||
nested++;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
|
||||
if (nested) nested--;
|
||||
else colonPos = i;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (char === ',' && sameDepth(depth)) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1, i),
|
||||
suffix: text.slice(i),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!split && qPos !== -1 && colonPos !== -1) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1),
|
||||
suffix: '',
|
||||
};
|
||||
}
|
||||
return split;
|
||||
}
|
||||
|
||||
function exclusiveClassScopes(text) {
|
||||
const split = findTernarySplit(text);
|
||||
if (!split) return [text];
|
||||
return [
|
||||
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
|
||||
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
|
||||
];
|
||||
}
|
||||
|
||||
function grayOnColorScopes(line, index) {
|
||||
return exclusiveClassScopes(containingMarkupTag(line, index).text);
|
||||
}
|
||||
|
||||
function grayOnColorPairs(line, grayClass, index) {
|
||||
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
|
||||
}
|
||||
|
||||
const REGEX_MATCHERS = [
|
||||
// --- Side-tab ---
|
||||
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
|
||||
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
|
||||
fmt: () => 'bg-clip-text + bg-gradient' },
|
||||
// --- Tailwind gray on colored bg ---
|
||||
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
|
||||
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
|
||||
fmt: (m, line) => {
|
||||
const bg = grayOnColorPairs(line, m[0], m.index)
|
||||
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
|
||||
.find(Boolean);
|
||||
return `${m[0]} on ${bg?.[0] || '?'}`;
|
||||
} },
|
||||
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
||||
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
|
||||
// --- Tailwind AI palette ---
|
||||
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
|
||||
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
|
||||
|
||||
@@ -217,7 +217,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -2060,7 +2060,7 @@
|
||||
if (anchor) return anchor;
|
||||
}
|
||||
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (wrapper) {
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0 && visibleVariant > 0) {
|
||||
@@ -2131,14 +2131,14 @@
|
||||
|
||||
function isInsertGeneratingSession() {
|
||||
if (state !== 'GENERATING' || !currentSessionId) return false;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
|
||||
}
|
||||
|
||||
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
|
||||
function ensureInsertPlaceholder() {
|
||||
if (!isInsertGeneratingSession()) return placeholderElement;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0) return placeholderElement;
|
||||
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
|
||||
@@ -3156,7 +3156,7 @@
|
||||
|| svelteComponentSession.wrapperEl
|
||||
|| null;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
@@ -4900,7 +4900,7 @@
|
||||
return Object.values(svelteComponentSession.paramsByVariant || {})
|
||||
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
|
||||
.reduce((total, variant) => total + parseVariantParams(variant).length, 0);
|
||||
@@ -5004,7 +5004,7 @@
|
||||
scheduleCyclingBarSync(sessionId, num);
|
||||
return true;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
updateVariantStateStylesheet(sessionId, num);
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
@@ -5820,7 +5820,6 @@
|
||||
return;
|
||||
}
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
completeParameterGenerationIfReady();
|
||||
@@ -6217,71 +6216,6 @@
|
||||
return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
|
||||
}
|
||||
|
||||
function sourceHasSessionWrapper(text, sessionId) {
|
||||
const src = String(text || '');
|
||||
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|
||||
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|
||||
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
|
||||
* wrapper deleted from source look identical in the DOM, and only the second
|
||||
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
|
||||
* injecting raw JSX; reading the file as plain text and matching the session
|
||||
* marker honors that, because no DOM is ever built from what comes back.
|
||||
* Marker present means the component is simply not mounted right now (a
|
||||
* closed modal, another route) and the variant observer keeps waiting.
|
||||
* Marker absent after the same retry budget the HTML path uses means the
|
||||
* file was edited out from under the session, which no reload, HMR push, or
|
||||
* server restart can repair, so the session self-discards and hands the
|
||||
* surface back to the picker.
|
||||
*/
|
||||
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
|
||||
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
|
||||
const retryLater = () => {
|
||||
setTimeout(() => {
|
||||
if (!stillActive()) return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
};
|
||||
// Discarding is durable (the session moves to the discarded phase and the
|
||||
// picker replaces it), so it needs evidence that the wrapper is gone: a
|
||||
// read that answers without the marker, or a 404 (the file itself was
|
||||
// renamed or deleted). Either kind retries on the shared budget first.
|
||||
// A read that fails for any other reason (the server briefly away, a
|
||||
// transient fetch error) says nothing about the wrapper; after the budget
|
||||
// the session is kept, the user told, and the next event retries.
|
||||
const onNoWrapper = (reason) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
discardOrphanedSession(reason);
|
||||
};
|
||||
const onUnreadable = (detail) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
|
||||
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
|
||||
};
|
||||
fetch(url)
|
||||
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
|
||||
.then(text => {
|
||||
if (!stillActive()) return;
|
||||
if (sourceHasSessionWrapper(text, sessionId)) return;
|
||||
onNoWrapper('variant wrapper missing from source');
|
||||
})
|
||||
.catch(err => {
|
||||
const detail = err && err.message ? err.message : 'fetch failed';
|
||||
if (/source read failed: 404$/.test(detail)) {
|
||||
onNoWrapper('source file missing (404) while checking for the variant wrapper');
|
||||
return;
|
||||
}
|
||||
onUnreadable(detail);
|
||||
});
|
||||
}
|
||||
|
||||
function completeSourceInjection(wrapper, sessionId, opts) {
|
||||
recoveryWaitingForAnchor = false;
|
||||
if (pendingVariantAnchorRetryObserver) {
|
||||
@@ -6362,7 +6296,7 @@
|
||||
}
|
||||
rememberSessionFileMeta({ file: filePath });
|
||||
if (isJsxSourceFile(filePath)) {
|
||||
const liveWrapper = findVariantsWrapper(sessionId);
|
||||
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
|
||||
return;
|
||||
@@ -6392,7 +6326,14 @@
|
||||
return;
|
||||
}
|
||||
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
||||
probeJsxWrapperForOrphan(filePath, sessionId, opts);
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
|
||||
setTimeout(() => {
|
||||
if (sessionId !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -6434,7 +6375,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const existingWrapper = findVariantsWrapper(sessionId);
|
||||
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (existingWrapper) {
|
||||
const wrapper = srcWrapper.cloneNode(true);
|
||||
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
|
||||
@@ -6591,7 +6532,7 @@
|
||||
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
||||
return;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const visEl = pickVariantContent(wrapper, visibleVariant);
|
||||
if (visEl) selectedElement = visEl;
|
||||
@@ -6601,7 +6542,7 @@
|
||||
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
||||
return svelteComponentSession.mountedVariant;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
for (const variant of variants) {
|
||||
@@ -6716,17 +6657,8 @@
|
||||
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
|
||||
* one wrapper per item, so the hide, the release, and the existence checks
|
||||
* all have to speak about the same set.
|
||||
*/
|
||||
function discardedWrappers(sessionId) {
|
||||
if (!sessionId) return [];
|
||||
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
|
||||
}
|
||||
|
||||
function releaseDiscardedStaticWrapper(wrapper) {
|
||||
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
if (!wrapper) return;
|
||||
const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
|
||||
const content = orig?.firstElementChild;
|
||||
@@ -6737,18 +6669,6 @@
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the discard hide on every wrapper it covered. Releasing only the
|
||||
* first match left the other mapped items sitting at display:none with
|
||||
* their original content never restored, on exactly the static and
|
||||
* missed-HMR flows this fallback exists for.
|
||||
*/
|
||||
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
|
||||
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
|
||||
}
|
||||
|
||||
function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
|
||||
if (!sessionId || !document.body) return;
|
||||
if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
|
||||
@@ -6929,42 +6849,6 @@
|
||||
// MutationObserver for progressive variant reveal
|
||||
//
|
||||
|
||||
// A session id can have more than one wrapper in the DOM: the target may sit
|
||||
// inside a `.map()` callback (the wrapper renders once per item), or the
|
||||
// agent may have relocated the wrapper out of the shared primitive live-wrap
|
||||
// scaffolded into. A plain first match can then pin an empty scaffold while
|
||||
// the real variants sit in a later wrapper, which strands the session at
|
||||
// 0/N and leaves the bar, the params panel, and accept all reading the
|
||||
// wrong element. Prefer a wrapper that actually holds variants. With zero
|
||||
// or one match this is exactly the querySelector it replaces.
|
||||
//
|
||||
// Every lookup of the ACTIVE session's wrapper goes through here. The
|
||||
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
|
||||
// existence checks, selector strings for stylesheets and observers (which
|
||||
// want to cover every match), `querySelectorAll` sweeps, and the parsed
|
||||
// source document, which is not this document.
|
||||
function pickPopulatedVariantsWrapper(selector) {
|
||||
const matches = document.querySelectorAll(selector);
|
||||
if (matches.length < 2) return matches[0] || null;
|
||||
for (const candidate of matches) {
|
||||
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
/** The wrapper holding `sessionId`'s variants, or null without an id. */
|
||||
function findVariantsWrapper(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
|
||||
}
|
||||
|
||||
/** Any live variant wrapper, for the resume paths that have no id yet. */
|
||||
function findAnyVariantsWrapper() {
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
|
||||
}
|
||||
|
||||
function startVariantObserver(sessionId) {
|
||||
let updating = false; // re-entrancy guard
|
||||
|
||||
@@ -6994,7 +6878,7 @@
|
||||
}
|
||||
if (!dominated) return;
|
||||
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
@@ -7203,7 +7087,6 @@
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
if (state === 'GENERATING') {
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
disableInlineEdit();
|
||||
refreshParamsPanel();
|
||||
@@ -7264,7 +7147,6 @@
|
||||
pendingAcceptedSession = null;
|
||||
awaitingAcceptResult = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup. Try Accept again.', 5000);
|
||||
break;
|
||||
@@ -8367,15 +8249,6 @@ void main() {
|
||||
// matches the original off-white risograph paper.
|
||||
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
|
||||
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
|
||||
// showShaderOverlay is async: it appends its canvas, then awaits
|
||||
// createImageBitmap and the GL setup before it publishes shaderState. A
|
||||
// teardown that landed inside that window found shaderState still null,
|
||||
// returned, and then watched the construction publish itself over a session
|
||||
// that had already left GENERATING, with no teardown left to run. That is
|
||||
// the generating loader frozen over a page that already cycles (issue #719).
|
||||
// Every teardown bumps this epoch; a construction abandons its own canvas as
|
||||
// soon as it sees the epoch move.
|
||||
let shaderEpoch = 0;
|
||||
|
||||
// The element's effective background tone, used as the uniform halftone
|
||||
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground
|
||||
@@ -8522,28 +8395,14 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop a shader node no shaderState owns (an abandoned construction). */
|
||||
function removeStrayShaderNode() {
|
||||
const stray = uiGetById(PREFIX + '-shader');
|
||||
if (stray) stray.remove();
|
||||
}
|
||||
|
||||
function hideShaderOverlay() {
|
||||
// Bump first, unconditionally: this is what tells an in-flight
|
||||
// showShaderOverlay to abandon itself rather than publish over a session
|
||||
// that has already moved on.
|
||||
shaderEpoch += 1;
|
||||
if (!shaderState) {
|
||||
removeStrayShaderNode();
|
||||
return;
|
||||
}
|
||||
if (!shaderState) return;
|
||||
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
|
||||
if (shaderState.canvas) shaderState.canvas.remove();
|
||||
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
|
||||
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
shaderState = null;
|
||||
removeStrayShaderNode();
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
@@ -8568,16 +8427,6 @@ void main() {
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
// hideShaderOverlay just bumped the epoch, so this run owns it until the
|
||||
// next teardown. Every step past an await re-checks before it publishes.
|
||||
const epoch = shaderEpoch;
|
||||
const abandoned = (node, gl) => {
|
||||
if (epoch === shaderEpoch) return false;
|
||||
node.remove();
|
||||
const lose = gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
return true;
|
||||
};
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = PREFIX + '-shader';
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
@@ -8600,7 +8449,6 @@ void main() {
|
||||
if (!gl) {
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
if (abandoned(canvas, null)) return;
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
@@ -8640,22 +8488,16 @@ void main() {
|
||||
}
|
||||
|
||||
// Upload the screenshot as a texture
|
||||
if (abandoned(canvas, gl)) return;
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
if (abandoned(canvas, gl)) return;
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
if (abandoned(canvas, gl)) {
|
||||
if (bitmap.close) bitmap.close();
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
@@ -8674,7 +8516,6 @@ void main() {
|
||||
const paperRgb = paper || resolvePaperRgb(el);
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if (abandoned(canvas, gl)) return;
|
||||
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
|
||||
function frame() {
|
||||
if (!shaderState) return;
|
||||
@@ -8711,7 +8552,7 @@ void main() {
|
||||
clientSentAt: Date.now(),
|
||||
};
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
const acceptWrapper = findVariantsWrapper(currentSessionId);
|
||||
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
@@ -8754,7 +8595,6 @@ void main() {
|
||||
.catch(() => {
|
||||
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
@@ -8806,7 +8646,7 @@ void main() {
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
@@ -8933,7 +8773,7 @@ void main() {
|
||||
}
|
||||
|
||||
function commitAcceptedVariantToDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!accepted || !accepted.firstElementChild) return false;
|
||||
@@ -9161,7 +9001,7 @@ void main() {
|
||||
}
|
||||
|
||||
function restoreFromActiveSessions(activeSessions, reason) {
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
|
||||
if (svelteComponentSession?.sessionId === currentSessionId) return false;
|
||||
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
|
||||
@@ -9274,13 +9114,10 @@ void main() {
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
// replaced the wrapper by then (keeps static-server / no-HMR flows alive).
|
||||
// Every match, not the first: a target inside a `.map()` renders one
|
||||
// wrapper per item, and hiding only one leaves the rest of the
|
||||
// discarded variants on screen.
|
||||
const discardWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (discardWrappers.length > 0) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (wrapper) {
|
||||
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
||||
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
|
||||
else wrapper.style.display = 'none';
|
||||
}
|
||||
setTimeout(function() {
|
||||
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
||||
@@ -9288,19 +9125,16 @@ void main() {
|
||||
removeDiscardStateStylesheet();
|
||||
return;
|
||||
}
|
||||
const lateWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (lateWrappers.length === 0) {
|
||||
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (!lateWrapper) {
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
// Duplicates all render from one source element, so HMR ownership is
|
||||
// uniform across them; the first is a fair witness for the set.
|
||||
const lateWrapper = lateWrappers[0];
|
||||
if (recoverySuperseded) {
|
||||
if (hasFrameworkHmrOwnership(lateWrapper)) {
|
||||
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
} else {
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -9309,20 +9143,18 @@ void main() {
|
||||
// the final source rewrite, reload once after a grace window so the
|
||||
// discarded source becomes authoritative without a reconciler race.
|
||||
setTimeout(function() {
|
||||
const staleWrappers = discardedWrappers(cleanupSessionId);
|
||||
const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
||||
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
// A reload restores every wrapper's original at once, so there is
|
||||
// nothing per-wrapper to do here.
|
||||
if (staleWrappers.length > 0) location.reload();
|
||||
if (staleWrapper) location.reload();
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}, 2000);
|
||||
}
|
||||
hideBar(instantChrome);
|
||||
@@ -9510,13 +9342,8 @@ void main() {
|
||||
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
||||
}
|
||||
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
|
||||
// Which path resumed matters in the journal: an init resume is a fresh
|
||||
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
|
||||
// used to log the same `browser_resumed`, which made issue #719 take a
|
||||
// DOM reconstruction to diagnose.
|
||||
const resumeReason = opts.reason || 'browser_resumed';
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
|
||||
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
|
||||
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
|
||||
@@ -9615,38 +9442,16 @@ void main() {
|
||||
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// A resume can BE the arrival, not just a re-entry after one. The server's
|
||||
// generation preflight runs live-wrap with --defer-source-write, so the
|
||||
// wrapper and every variant reach the DOM in one HMR batch, and the
|
||||
// deferred-wrapper scout (constructed at init) runs before the variant
|
||||
// MutationObserver (constructed at Go) on that batch. Finish the same
|
||||
// transition the observer would have finished. Without hideShaderOverlay
|
||||
// the generating shader stays frozen over the target and the session looks
|
||||
// stuck at GENERATING while the bar already cycles (issue #719).
|
||||
if (state === 'CYCLING') {
|
||||
recoveryWaitingForAnchor = false;
|
||||
hideShaderOverlay();
|
||||
if (isInsert) finalizeInsertSession();
|
||||
disableInlineEdit();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
||||
sendCheckpoint('variants_progress');
|
||||
} else {
|
||||
queueCheckpoint(resumeReason);
|
||||
// Only variants_progress and variants_ready count as publication
|
||||
// progress. When the resume is the arrival, the observer never gets to
|
||||
// report it (this function disconnects and re-creates it below, which
|
||||
// drops the records it had already queued for this same batch), so
|
||||
// without this the server never learns the variants were published.
|
||||
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
sendCheckpoint('variants_ready');
|
||||
}
|
||||
queueCheckpoint('browser_resumed');
|
||||
}
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -12968,7 +12773,7 @@ void main() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
|
||||
if (!wrapper) return;
|
||||
scout.disconnect();
|
||||
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
|
||||
if (resumeSession(deferredResumeRevision)) {
|
||||
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1490,7 +1490,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
|
||||
return isNeutralAuthoredColor(m[1]);
|
||||
}
|
||||
|
||||
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
|
||||
|
||||
function scanJs(text, start, onChar) {
|
||||
let stringQuote = '';
|
||||
let inTemplate = false;
|
||||
let paren = 0;
|
||||
let brace = 0;
|
||||
const interpBrace = [];
|
||||
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const prev = text[i - 1];
|
||||
const next = text[i + 1];
|
||||
|
||||
if (stringQuote) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === stringQuote) stringQuote = '';
|
||||
continue;
|
||||
}
|
||||
if (inTemplate && interpBrace.length === 0) {
|
||||
if (char === '\\') { i++; continue; }
|
||||
if (char === '$' && next === '{') {
|
||||
brace++;
|
||||
interpBrace.push(brace);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === '`') { inTemplate = false; continue; }
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"') { stringQuote = char; continue; }
|
||||
if (char === '`') { inTemplate = true; continue; }
|
||||
if (char === '(') { paren++; continue; }
|
||||
if (char === ')') { paren--; continue; }
|
||||
if (char === '{') { brace++; continue; }
|
||||
if (char === '}') {
|
||||
brace--;
|
||||
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
|
||||
continue;
|
||||
}
|
||||
if (onChar(char, i, prev, next, { paren, brace })) return;
|
||||
}
|
||||
}
|
||||
|
||||
function containingMarkupTag(line, index) {
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
const tagStart = line.indexOf('<', i);
|
||||
if (tagStart === -1) break;
|
||||
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
|
||||
i = tagStart + 1;
|
||||
continue;
|
||||
}
|
||||
let tagEnd = -1;
|
||||
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
|
||||
if (char === '>' && depth.brace === 0) {
|
||||
tagEnd = j;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (tagEnd === -1) break;
|
||||
if (index >= tagStart && index <= tagEnd) {
|
||||
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
|
||||
}
|
||||
i = tagEnd + 1;
|
||||
}
|
||||
return { text: line, start: 0 };
|
||||
}
|
||||
|
||||
function findTernarySplit(text) {
|
||||
let qPos = -1;
|
||||
let qParen = 0;
|
||||
let qBrace = 0;
|
||||
let nested = 0;
|
||||
let colonPos = -1;
|
||||
let split = null;
|
||||
|
||||
const isQuestion = (char, prev, next) =>
|
||||
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
|
||||
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
|
||||
|
||||
scanJs(text, 0, (char, i, prev, next, depth) => {
|
||||
if (colonPos === -1) {
|
||||
if (qPos === -1 && isQuestion(char, prev, next)) {
|
||||
qPos = i;
|
||||
qParen = depth.paren;
|
||||
qBrace = depth.brace;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
|
||||
nested++;
|
||||
return false;
|
||||
}
|
||||
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
|
||||
if (nested) nested--;
|
||||
else colonPos = i;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (char === ',' && sameDepth(depth)) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1, i),
|
||||
suffix: text.slice(i),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!split && qPos !== -1 && colonPos !== -1) {
|
||||
split = {
|
||||
common: text.slice(0, qPos),
|
||||
consequent: text.slice(qPos + 1, colonPos),
|
||||
alternate: text.slice(colonPos + 1),
|
||||
suffix: '',
|
||||
};
|
||||
}
|
||||
return split;
|
||||
}
|
||||
|
||||
function exclusiveClassScopes(text) {
|
||||
const split = findTernarySplit(text);
|
||||
if (!split) return [text];
|
||||
return [
|
||||
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
|
||||
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
|
||||
];
|
||||
}
|
||||
|
||||
function grayOnColorScopes(line, index) {
|
||||
return exclusiveClassScopes(containingMarkupTag(line, index).text);
|
||||
}
|
||||
|
||||
function grayOnColorPairs(line, grayClass, index) {
|
||||
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
|
||||
}
|
||||
|
||||
const REGEX_MATCHERS = [
|
||||
// --- Side-tab ---
|
||||
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
|
||||
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
|
||||
fmt: () => 'bg-clip-text + bg-gradient' },
|
||||
// --- Tailwind gray on colored bg ---
|
||||
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
|
||||
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
|
||||
fmt: (m, line) => {
|
||||
const bg = grayOnColorPairs(line, m[0], m.index)
|
||||
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
|
||||
.find(Boolean);
|
||||
return `${m[0]} on ${bg?.[0] || '?'}`;
|
||||
} },
|
||||
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
||||
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
|
||||
// --- Tailwind AI palette ---
|
||||
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
|
||||
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
|
||||
|
||||
@@ -217,7 +217,7 @@ function checkColors(opts) {
|
||||
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
||||
|
||||
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
|
||||
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
||||
if (grayMatch && colorBgMatch) {
|
||||
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
||||
}
|
||||
|
||||
@@ -2060,7 +2060,7 @@
|
||||
if (anchor) return anchor;
|
||||
}
|
||||
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (wrapper) {
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0 && visibleVariant > 0) {
|
||||
@@ -2131,14 +2131,14 @@
|
||||
|
||||
function isInsertGeneratingSession() {
|
||||
if (state !== 'GENERATING' || !currentSessionId) return false;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
|
||||
}
|
||||
|
||||
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
|
||||
function ensureInsertPlaceholder() {
|
||||
if (!isInsertGeneratingSession()) return placeholderElement;
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
|
||||
if (variantCount > 0) return placeholderElement;
|
||||
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
|
||||
@@ -3156,7 +3156,7 @@
|
||||
|| svelteComponentSession.wrapperEl
|
||||
|| null;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
@@ -4900,7 +4900,7 @@
|
||||
return Object.values(svelteComponentSession.paramsByVariant || {})
|
||||
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
|
||||
.reduce((total, variant) => total + parseVariantParams(variant).length, 0);
|
||||
@@ -5004,7 +5004,7 @@
|
||||
scheduleCyclingBarSync(sessionId, num);
|
||||
return true;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
updateVariantStateStylesheet(sessionId, num);
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
@@ -5820,7 +5820,6 @@
|
||||
return;
|
||||
}
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
completeParameterGenerationIfReady();
|
||||
@@ -6217,71 +6216,6 @@
|
||||
return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
|
||||
}
|
||||
|
||||
function sourceHasSessionWrapper(text, sessionId) {
|
||||
const src = String(text || '');
|
||||
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|
||||
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|
||||
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
|
||||
* wrapper deleted from source look identical in the DOM, and only the second
|
||||
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
|
||||
* injecting raw JSX; reading the file as plain text and matching the session
|
||||
* marker honors that, because no DOM is ever built from what comes back.
|
||||
* Marker present means the component is simply not mounted right now (a
|
||||
* closed modal, another route) and the variant observer keeps waiting.
|
||||
* Marker absent after the same retry budget the HTML path uses means the
|
||||
* file was edited out from under the session, which no reload, HMR push, or
|
||||
* server restart can repair, so the session self-discards and hands the
|
||||
* surface back to the picker.
|
||||
*/
|
||||
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
|
||||
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
|
||||
const retryLater = () => {
|
||||
setTimeout(() => {
|
||||
if (!stillActive()) return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
};
|
||||
// Discarding is durable (the session moves to the discarded phase and the
|
||||
// picker replaces it), so it needs evidence that the wrapper is gone: a
|
||||
// read that answers without the marker, or a 404 (the file itself was
|
||||
// renamed or deleted). Either kind retries on the shared budget first.
|
||||
// A read that fails for any other reason (the server briefly away, a
|
||||
// transient fetch error) says nothing about the wrapper; after the budget
|
||||
// the session is kept, the user told, and the next event retries.
|
||||
const onNoWrapper = (reason) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
discardOrphanedSession(reason);
|
||||
};
|
||||
const onUnreadable = (detail) => {
|
||||
if (!stillActive()) return;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
|
||||
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
|
||||
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
|
||||
};
|
||||
fetch(url)
|
||||
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
|
||||
.then(text => {
|
||||
if (!stillActive()) return;
|
||||
if (sourceHasSessionWrapper(text, sessionId)) return;
|
||||
onNoWrapper('variant wrapper missing from source');
|
||||
})
|
||||
.catch(err => {
|
||||
const detail = err && err.message ? err.message : 'fetch failed';
|
||||
if (/source read failed: 404$/.test(detail)) {
|
||||
onNoWrapper('source file missing (404) while checking for the variant wrapper');
|
||||
return;
|
||||
}
|
||||
onUnreadable(detail);
|
||||
});
|
||||
}
|
||||
|
||||
function completeSourceInjection(wrapper, sessionId, opts) {
|
||||
recoveryWaitingForAnchor = false;
|
||||
if (pendingVariantAnchorRetryObserver) {
|
||||
@@ -6362,7 +6296,7 @@
|
||||
}
|
||||
rememberSessionFileMeta({ file: filePath });
|
||||
if (isJsxSourceFile(filePath)) {
|
||||
const liveWrapper = findVariantsWrapper(sessionId);
|
||||
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
|
||||
return;
|
||||
@@ -6392,7 +6326,14 @@
|
||||
return;
|
||||
}
|
||||
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
||||
probeJsxWrapperForOrphan(filePath, sessionId, opts);
|
||||
const attempt = opts._orphanAttempt || 0;
|
||||
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
|
||||
setTimeout(() => {
|
||||
if (sessionId !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
|
||||
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -6434,7 +6375,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const existingWrapper = findVariantsWrapper(sessionId);
|
||||
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (existingWrapper) {
|
||||
const wrapper = srcWrapper.cloneNode(true);
|
||||
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
|
||||
@@ -6591,7 +6532,7 @@
|
||||
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
||||
return;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(currentSessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const visEl = pickVariantContent(wrapper, visibleVariant);
|
||||
if (visEl) selectedElement = visEl;
|
||||
@@ -6601,7 +6542,7 @@
|
||||
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
||||
return svelteComponentSession.mountedVariant;
|
||||
}
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
for (const variant of variants) {
|
||||
@@ -6716,17 +6657,8 @@
|
||||
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
|
||||
* one wrapper per item, so the hide, the release, and the existence checks
|
||||
* all have to speak about the same set.
|
||||
*/
|
||||
function discardedWrappers(sessionId) {
|
||||
if (!sessionId) return [];
|
||||
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
|
||||
}
|
||||
|
||||
function releaseDiscardedStaticWrapper(wrapper) {
|
||||
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
if (!wrapper) return;
|
||||
const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
|
||||
const content = orig?.firstElementChild;
|
||||
@@ -6737,18 +6669,6 @@
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the discard hide on every wrapper it covered. Releasing only the
|
||||
* first match left the other mapped items sitting at display:none with
|
||||
* their original content never restored, on exactly the static and
|
||||
* missed-HMR flows this fallback exists for.
|
||||
*/
|
||||
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
|
||||
removeDiscardStateStylesheet(sessionId);
|
||||
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
|
||||
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
|
||||
}
|
||||
|
||||
function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
|
||||
if (!sessionId || !document.body) return;
|
||||
if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
|
||||
@@ -6929,42 +6849,6 @@
|
||||
// MutationObserver for progressive variant reveal
|
||||
//
|
||||
|
||||
// A session id can have more than one wrapper in the DOM: the target may sit
|
||||
// inside a `.map()` callback (the wrapper renders once per item), or the
|
||||
// agent may have relocated the wrapper out of the shared primitive live-wrap
|
||||
// scaffolded into. A plain first match can then pin an empty scaffold while
|
||||
// the real variants sit in a later wrapper, which strands the session at
|
||||
// 0/N and leaves the bar, the params panel, and accept all reading the
|
||||
// wrong element. Prefer a wrapper that actually holds variants. With zero
|
||||
// or one match this is exactly the querySelector it replaces.
|
||||
//
|
||||
// Every lookup of the ACTIVE session's wrapper goes through here. The
|
||||
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
|
||||
// existence checks, selector strings for stylesheets and observers (which
|
||||
// want to cover every match), `querySelectorAll` sweeps, and the parsed
|
||||
// source document, which is not this document.
|
||||
function pickPopulatedVariantsWrapper(selector) {
|
||||
const matches = document.querySelectorAll(selector);
|
||||
if (matches.length < 2) return matches[0] || null;
|
||||
for (const candidate of matches) {
|
||||
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
/** The wrapper holding `sessionId`'s variants, or null without an id. */
|
||||
function findVariantsWrapper(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
|
||||
}
|
||||
|
||||
/** Any live variant wrapper, for the resume paths that have no id yet. */
|
||||
function findAnyVariantsWrapper() {
|
||||
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
|
||||
}
|
||||
|
||||
function startVariantObserver(sessionId) {
|
||||
let updating = false; // re-entrancy guard
|
||||
|
||||
@@ -6994,7 +6878,7 @@
|
||||
}
|
||||
if (!dominated) return;
|
||||
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
@@ -7203,7 +7087,6 @@
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
if (state === 'GENERATING') {
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
disableInlineEdit();
|
||||
refreshParamsPanel();
|
||||
@@ -7264,7 +7147,6 @@
|
||||
pendingAcceptedSession = null;
|
||||
awaitingAcceptResult = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup. Try Accept again.', 5000);
|
||||
break;
|
||||
@@ -8367,15 +8249,6 @@ void main() {
|
||||
// matches the original off-white risograph paper.
|
||||
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
|
||||
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
|
||||
// showShaderOverlay is async: it appends its canvas, then awaits
|
||||
// createImageBitmap and the GL setup before it publishes shaderState. A
|
||||
// teardown that landed inside that window found shaderState still null,
|
||||
// returned, and then watched the construction publish itself over a session
|
||||
// that had already left GENERATING, with no teardown left to run. That is
|
||||
// the generating loader frozen over a page that already cycles (issue #719).
|
||||
// Every teardown bumps this epoch; a construction abandons its own canvas as
|
||||
// soon as it sees the epoch move.
|
||||
let shaderEpoch = 0;
|
||||
|
||||
// The element's effective background tone, used as the uniform halftone
|
||||
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground
|
||||
@@ -8522,28 +8395,14 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop a shader node no shaderState owns (an abandoned construction). */
|
||||
function removeStrayShaderNode() {
|
||||
const stray = uiGetById(PREFIX + '-shader');
|
||||
if (stray) stray.remove();
|
||||
}
|
||||
|
||||
function hideShaderOverlay() {
|
||||
// Bump first, unconditionally: this is what tells an in-flight
|
||||
// showShaderOverlay to abandon itself rather than publish over a session
|
||||
// that has already moved on.
|
||||
shaderEpoch += 1;
|
||||
if (!shaderState) {
|
||||
removeStrayShaderNode();
|
||||
return;
|
||||
}
|
||||
if (!shaderState) return;
|
||||
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
|
||||
if (shaderState.canvas) shaderState.canvas.remove();
|
||||
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
|
||||
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
shaderState = null;
|
||||
removeStrayShaderNode();
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
@@ -8568,16 +8427,6 @@ void main() {
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
// hideShaderOverlay just bumped the epoch, so this run owns it until the
|
||||
// next teardown. Every step past an await re-checks before it publishes.
|
||||
const epoch = shaderEpoch;
|
||||
const abandoned = (node, gl) => {
|
||||
if (epoch === shaderEpoch) return false;
|
||||
node.remove();
|
||||
const lose = gl?.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
return true;
|
||||
};
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = PREFIX + '-shader';
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
@@ -8600,7 +8449,6 @@ void main() {
|
||||
if (!gl) {
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
if (abandoned(canvas, null)) return;
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
@@ -8640,22 +8488,16 @@ void main() {
|
||||
}
|
||||
|
||||
// Upload the screenshot as a texture
|
||||
if (abandoned(canvas, gl)) return;
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
if (abandoned(canvas, gl)) return;
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
if (abandoned(canvas, gl)) {
|
||||
if (bitmap.close) bitmap.close();
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
@@ -8674,7 +8516,6 @@ void main() {
|
||||
const paperRgb = paper || resolvePaperRgb(el);
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if (abandoned(canvas, gl)) return;
|
||||
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
|
||||
function frame() {
|
||||
if (!shaderState) return;
|
||||
@@ -8711,7 +8552,7 @@ void main() {
|
||||
clientSentAt: Date.now(),
|
||||
};
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
const acceptWrapper = findVariantsWrapper(currentSessionId);
|
||||
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
@@ -8754,7 +8595,6 @@ void main() {
|
||||
.catch(() => {
|
||||
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
|
||||
setLiveState('CYCLING');
|
||||
hideShaderOverlay();
|
||||
showOrUpdateCyclingBar();
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
@@ -8806,7 +8646,7 @@ void main() {
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
@@ -8933,7 +8773,7 @@ void main() {
|
||||
}
|
||||
|
||||
function commitAcceptedVariantToDom(sessionId, variantId) {
|
||||
const wrapper = findVariantsWrapper(sessionId);
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!accepted || !accepted.firstElementChild) return false;
|
||||
@@ -9161,7 +9001,7 @@ void main() {
|
||||
}
|
||||
|
||||
function restoreFromActiveSessions(activeSessions, reason) {
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
|
||||
if (svelteComponentSession?.sessionId === currentSessionId) return false;
|
||||
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
|
||||
@@ -9274,13 +9114,10 @@ void main() {
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
// replaced the wrapper by then (keeps static-server / no-HMR flows alive).
|
||||
// Every match, not the first: a target inside a `.map()` renders one
|
||||
// wrapper per item, and hiding only one leaves the rest of the
|
||||
// discarded variants on screen.
|
||||
const discardWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (discardWrappers.length > 0) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (wrapper) {
|
||||
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
||||
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
|
||||
else wrapper.style.display = 'none';
|
||||
}
|
||||
setTimeout(function() {
|
||||
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
||||
@@ -9288,19 +9125,16 @@ void main() {
|
||||
removeDiscardStateStylesheet();
|
||||
return;
|
||||
}
|
||||
const lateWrappers = discardedWrappers(cleanupSessionId);
|
||||
if (lateWrappers.length === 0) {
|
||||
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (!lateWrapper) {
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
// Duplicates all render from one source element, so HMR ownership is
|
||||
// uniform across them; the first is a fair witness for the set.
|
||||
const lateWrapper = lateWrappers[0];
|
||||
if (recoverySuperseded) {
|
||||
if (hasFrameworkHmrOwnership(lateWrapper)) {
|
||||
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
} else {
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -9309,20 +9143,18 @@ void main() {
|
||||
// the final source rewrite, reload once after a grace window so the
|
||||
// discarded source becomes authoritative without a reconciler race.
|
||||
setTimeout(function() {
|
||||
const staleWrappers = discardedWrappers(cleanupSessionId);
|
||||
const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
||||
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
|
||||
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||
return;
|
||||
}
|
||||
removeDiscardStateStylesheet(cleanupSessionId);
|
||||
// A reload restores every wrapper's original at once, so there is
|
||||
// nothing per-wrapper to do here.
|
||||
if (staleWrappers.length > 0) location.reload();
|
||||
if (staleWrapper) location.reload();
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
||||
}, 2000);
|
||||
}
|
||||
hideBar(instantChrome);
|
||||
@@ -9510,13 +9342,8 @@ void main() {
|
||||
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
||||
}
|
||||
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
|
||||
// Which path resumed matters in the journal: an init resume is a fresh
|
||||
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
|
||||
// used to log the same `browser_resumed`, which made issue #719 take a
|
||||
// DOM reconstruction to diagnose.
|
||||
const resumeReason = opts.reason || 'browser_resumed';
|
||||
const wrapper = findAnyVariantsWrapper();
|
||||
function resumeSession(recoveryRevision = liveInteractionRevision) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
|
||||
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
|
||||
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
|
||||
@@ -9615,38 +9442,16 @@ void main() {
|
||||
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// A resume can BE the arrival, not just a re-entry after one. The server's
|
||||
// generation preflight runs live-wrap with --defer-source-write, so the
|
||||
// wrapper and every variant reach the DOM in one HMR batch, and the
|
||||
// deferred-wrapper scout (constructed at init) runs before the variant
|
||||
// MutationObserver (constructed at Go) on that batch. Finish the same
|
||||
// transition the observer would have finished. Without hideShaderOverlay
|
||||
// the generating shader stays frozen over the target and the session looks
|
||||
// stuck at GENERATING while the bar already cycles (issue #719).
|
||||
if (state === 'CYCLING') {
|
||||
recoveryWaitingForAnchor = false;
|
||||
hideShaderOverlay();
|
||||
if (isInsert) finalizeInsertSession();
|
||||
disableInlineEdit();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
||||
sendCheckpoint('variants_progress');
|
||||
} else {
|
||||
queueCheckpoint(resumeReason);
|
||||
// Only variants_progress and variants_ready count as publication
|
||||
// progress. When the resume is the arrival, the observer never gets to
|
||||
// report it (this function disconnects and re-creates it below, which
|
||||
// drops the records it had already queued for this same batch), so
|
||||
// without this the server never learns the variants were published.
|
||||
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
sendCheckpoint('variants_ready');
|
||||
}
|
||||
queueCheckpoint('browser_resumed');
|
||||
}
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -12968,7 +12773,7 @@ void main() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
|
||||
if (!wrapper) return;
|
||||
scout.disconnect();
|
||||
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
|
||||
if (resumeSession(deferredResumeRevision)) {
|
||||
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
`skill/` is the source of truth for the Impeccable skill: `SKILL.src.md`, `reference/`, `scripts/`, and `agents/`. `skill/scripts/` holds the launcher (`impeccable`, `impeccable.cmd`), the pinned engine `VERSION`, `command-metadata.json`, and the in-page live-mode JS; every skill verb (`{{scripts_path}}/impeccable <verb>`) runs in the engine binary, which is built in a separate repo and pinned by the root `ENGINE_VERSION` file. Build logic lives in `scripts/`, with provider configs in `scripts/lib/transformers/`. `cli/` is the npm shim that runs the same binary, the browser extension lives in `extension/`, and regression coverage in `tests/` with fixtures under `tests/fixtures/` and the behavior goldens under `tests/oracle/`. `dist/` and `build/` are generated and gitignored. The root harness folders (`.agents/`, `.claude/`, `.cursor/`, etc.) and `plugin/` are generated distribution artifacts that are tracked for direct repo installs, not hand-authored source.
|
||||
`skill/` is the source of truth for the Impeccable skill: `SKILL.src.md`, `reference/`, `scripts/`, and `agents/`. Build logic lives in `scripts/`, with provider configs in `scripts/lib/transformers/`. The CLI and anti-pattern detector live in `cli/`, the browser extension in `extension/`, the Astro website in `site/`, Cloudflare Pages Functions in `functions/`, and regression coverage in `tests/` with fixtures under `tests/fixtures/`. `dist/` and `build/` are generated and gitignored. The root harness folders (`.agents/`, `.claude/`, `.cursor/`, etc.) and `plugin/` are generated distribution artifacts that are tracked for direct repo installs, not hand-authored source.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
@@ -12,12 +12,11 @@
|
||||
- `bun run rebuild` - clean and rebuild everything from scratch without syncing tracked harness folders.
|
||||
- `bun run rebuild:release` - clean and rebuild everything, including tracked harness output sync.
|
||||
- `bun test tests/build.test.js` - run a focused Bun test.
|
||||
- `bun run fetch:engine` - download the pinned engine binary for this machine into `skill/scripts/bin/<os>-<arch>/` (or set `IMPECCABLE_BIN` to a local build). The oracle and framework suites skip without it.
|
||||
- `bun run test` - run the full Bun + Node test suite (includes the oracle replay against the engine binary and the plugin loader E2E, which installs the committed `plugin/` subtree into a sandboxed real Claude Code and skips cleanly when the `claude` CLI is absent).
|
||||
- `bun run test` - run the full Bun + Node test suite (includes the plugin loader E2E, which installs the committed `plugin/` subtree into a sandboxed real Claude Code and skips cleanly when the `claude` CLI is absent).
|
||||
- `bun run test:live-e2e` - opt-in live-mode E2E against framework fixtures (~2 min; needs `npx playwright install chromium` once).
|
||||
- `bun run test:skill-behavior` - opt-in LLM-backed checks that the SKILL.md Setup flow actually drives the agent (runs claude-sonnet-5 / gpt-5.6-luna / gemini-3.5-flash / deepseek-v4-flash; needs `.env` with provider keys).
|
||||
- `bun run test:plugin-e2e` - just the plugin loader E2E, for fast iteration on `plugin/`, `skill/agents/`, or `scripts/build.js` changes.
|
||||
- `bun run build:extension` - rebuild the extension bundle (it runs `cargo xtask bundle`, which also refreshes the in-page detector bundle).
|
||||
- `bun run build:browser` / `bun run build:extension` - rebuild browser-specific bundles.
|
||||
|
||||
Run `bun run build` after changing anything in `skill/`, transformer code, or user-facing counts. It validates the generated distribution under `dist/` without touching tracked root harness outputs. Use `bun run build:release` only when intentionally refreshing generated provider permutations for release/main-sync or build-system work.
|
||||
|
||||
@@ -33,27 +32,39 @@ Some repo workflows need to run outside the sandbox in the desktop app:
|
||||
|
||||
- GitHub SSH operations that depend on the 1Password SSH agent, such as `gh pr checkout`, may fail in the sandbox with `sign_and_send_pubkey` or no 1Password approval prompt. Rerun them outside the sandbox instead of falling back to unrelated workarounds.
|
||||
- `bun run build:release` rewrites committed harness directories such as `.agents/skills/`. In the sandbox, Bun can hit filesystem errors while removing/recreating those trees (for example `EFAULT` on `.agents/skills`). Rerun the release build outside the sandbox before treating it as a real build failure.
|
||||
- The oracle and framework suites spawn the engine binary many times; run them with Node (`node --test tests/oracle.test.mjs`), which is what `bun run test` does.
|
||||
- Puppeteer/headless-Chrome tests, especially `node --test tests/detect-antipatterns-browser.test.mjs` and the browser portion of `bun run test`, can hang in the sandbox while launching Chrome. Run them outside the sandbox for authoritative results.
|
||||
- The jsdom fixture suite is intentionally run with Node, not Bun: use `node --test tests/detect-antipatterns-fixtures.test.mjs` or the `bun run test` script. A direct `bun test tests/detect-antipatterns-fixtures.test.mjs` can time out and is not the supported signal.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
Use ESM, semicolons, and the existing two-space indentation style in JS, HTML, and CSS. Prefer small, single-purpose modules over large abstractions. Keep filenames descriptive and lowercase with hyphens where needed; skill entrypoints stay as `SKILL.md`, build and test helpers use `.js` or `.mjs`. In source frontmatter, use clear kebab-case names and concise descriptions. There is no dedicated formatter or linter configured here, so match surrounding code closely.
|
||||
Use ESM, semicolons, and the existing two-space indentation style in JS, HTML, and CSS. Prefer small, single-purpose modules over large abstractions. Keep filenames descriptive and lowercase with hyphens where needed; skill entrypoints stay as `SKILL.md`, helper scripts use `.js` or `.mjs`. In source frontmatter, use clear kebab-case names and concise descriptions. There is no dedicated formatter or linter configured here, so match surrounding code closely.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
Tests use Bun’s test runner plus Node’s built-in `--test`. Name tests `*.test.js` or `*.test.mjs` and place new fixtures near the behavior they cover, usually under `tests/fixtures/`. Prefer targeted test runs while iterating, then finish with `bun run test`. If you change generated outputs or provider transforms, verify both source parsing and at least one affected provider path in `dist/`.
|
||||
|
||||
For changes to the live-mode page JS (`skill/scripts/live-browser*.js`) or an `ENGINE_VERSION` bump, also run `bun run test:live-e2e` (kept out of the default suite because it does real `npm install` per fixture and boots framework dev servers). Scope to one fixture with `IMPECCABLE_E2E_ONLY=<fixture-name>` while iterating; pass `IMPECCABLE_E2E_DEBUG=1` for page-DOM and dev-server-log dumps on failure. Schema and authoring guide for new fixtures live in `tests/framework-fixtures/README.md`.
|
||||
For changes to `skill/scripts/live-*.{mjs,js}` or `skill/scripts/live/**`, also run `bun run test:live-e2e` (kept out of the default suite because it does real `npm install` per fixture and boots framework dev servers). Scope to one fixture with `IMPECCABLE_E2E_ONLY=<fixture-name>` while iterating; pass `IMPECCABLE_E2E_DEBUG=1` for page-DOM and dev-server-log dumps on failure. Schema and authoring guide for new fixtures live in `tests/framework-fixtures/README.md`.
|
||||
|
||||
Set `IMPECCABLE_E2E_AGENT=llm` to swap the deterministic fake agent for an API-backed one (`tests/live-e2e/agents/llm-agent.mjs`). Claude Haiku 4.5 is the primary path whenever `ANTHROPIC_API_KEY` is set. DeepSeek V4 Flash is the secondary cheap fallback when only `DEEPSEEK_API_KEY` is set, and can be forced with `IMPECCABLE_E2E_LLM_PROVIDER=deepseek` or `bun run test:live-e2e -- --llm-provider=deepseek`; override either model via `IMPECCABLE_E2E_LLM_MODEL` or `--llm-model=<model>`. Tests skip cleanly when the selected provider key is unset. This path hits the API — use it for verification, not CI.
|
||||
|
||||
For changes to `skill/SKILL.src.md`'s Setup section or any Setup-touching reference file (`init.md`, `document.md`, `brand.md`, `product.md`, sub-command refs), also run `bun run test:skill-behavior`. The suite spawns current real models (claude-sonnet-5, gpt-5.6-luna, gemini-3.5-flash, deepseek-v4-flash) with the source SKILL.md inlined as system prompt and a workspace-scoped tool set, then asserts on the tool-call trace. Provider keys live in repo-root `.env`; missing keys skip cleanly. Scope to one provider with `IMPECCABLE_SKILL_BEHAVIOR_MODELS=<id>`; add `IMPECCABLE_SKILL_BEHAVIOR_VERBOSE=1` to dump per-scenario traces. Baseline and per-scenario assertions live in `tests/skill-behavior/README.md`.
|
||||
For changes to `skill/SKILL.src.md`'s Setup section, `skill/scripts/context.mjs`, or any Setup-touching reference file (`init.md`, `document.md`, `brand.md`, `product.md`, sub-command refs), also run `bun run test:skill-behavior`. The suite spawns current real models (claude-sonnet-5, gpt-5.6-luna, gemini-3.5-flash, deepseek-v4-flash) with the source SKILL.md inlined as system prompt and a workspace-scoped tool set, then asserts on the tool-call trace. Provider keys live in repo-root `.env`; missing keys skip cleanly. Scope to one provider with `IMPECCABLE_SKILL_BEHAVIOR_MODELS=<id>`; add `IMPECCABLE_SKILL_BEHAVIOR_VERBOSE=1` to dump per-scenario traces. Baseline and per-scenario assertions live in `tests/skill-behavior/README.md`.
|
||||
|
||||
Other area-to-suite obligations (the canonical mapping is the `triggers` lists in `scripts/test-suites.mjs`; CLAUDE.md carries the full table): an `ENGINE_VERSION` bump owes `bun run test:new-work-e2e` (Playwright, offline), `bun run test:live-e2e-accept-cleanup` (provider-billed), and `bun run test:live-svelte-adapter-deepseek` (DeepSeek-billed) on top of the default run.
|
||||
Other area-to-suite obligations (the canonical mapping is the `triggers` lists in `scripts/test-suites.mjs`; CLAUDE.md carries the full table): `serve-question.mjs` / `generate-image.mjs` / `concept-seed.mjs` changes owe `bun run test:new-work-e2e` (Playwright, offline); `cli/bin/commands/skills.mjs` changes owe `bun run test:cli-remote-e2e` (hits impeccable.style); accept/browser/server/wrap or SvelteKit adapter changes owe `bun run test:live-e2e-accept-cleanup` (provider-billed), and Svelte adapter/component changes owe `bun run test:live-svelte-adapter-deepseek` (DeepSeek-billed).
|
||||
|
||||
## Anti-pattern detection rules
|
||||
|
||||
The rule engine lives in the engine repo, not here. What this repo owns is the behavior contract: `docs/CLI-CONTRACT.md` describes every verb, `tests/oracle/` holds the recorded goldens and replays them against the binary (`tests/oracle.test.mjs`), and `tests/fixtures/antipatterns/*.html` are the fixtures those goldens scan. A rule change lands in the engine, then here as a new oracle case (`node tests/oracle/record.mjs --bin <prefix>`, golden reviewed by hand) and, when it introduces new design guidance, an edit to `skill/SKILL.src.md` or `skill/reference/*.md`. Rule counts quoted in `README.md` and `README.npm.md` are checked by the build against `extension/detector/antipatterns.json` when that vendored file is present.
|
||||
`cli/engine/detect-antipatterns.mjs` is the source of truth for the rule engine. It feeds the CLI, the site overlay (`cli/engine/detect-antipatterns-browser.js`, regenerated by `bun run build:browser`), the Chrome extension (`extension/detector/`, regenerated by `bun run build:extension`), and the homepage `DETECTION_COUNT` in `site/public/js/generated/counts.js` (regenerated by `bun run build`). After any rule change run all three builds plus `bun run test` so nothing drifts.
|
||||
|
||||
TDD order is non-negotiable:
|
||||
|
||||
1. Add a fixture at `tests/fixtures/antipatterns/{rule-id}.html` with two columns (should-flag / should-pass), each case identified by a unique heading. ≥4 flag cases and ≥5 false-positive shapes. **Use explicit pixel dimensions in CSS** — jsdom does no layout.
|
||||
2. Add a failing test in `tests/detect-antipatterns-fixtures.test.mjs` using the snippet-substring pattern (regex `/"([^"]+)"/` against `SHOULD_FLAG` / `SHOULD_PASS` lists).
|
||||
3. Add the rule entry to the `ANTIPATTERNS` array (`id`, `category` = `slop` or `quality`, `name`, `description`, optional `skillSection` / `skillGuideline`).
|
||||
4. Implement a pure `checkXxx(opts)` returning `[{ id, snippet }]` — no DOM access inside.
|
||||
5. Add two adapters that wrap the pure check: `checkElementXxxDOM(el)` for the browser (`getComputedStyle` + `getBoundingClientRect`) and `checkElementXxx(el, tag, window)` for jsdom (`parseFloat(style.width)` instead of layout). Wire **both** adapters into **both** element loops in `cli/engine/detect-antipatterns.mjs` (browser loop ~line 1837, jsdom loop in `detectHtml` ~line 2058). Forgetting one is the most common mistake.
|
||||
6. Verify on a live page at `http://localhost:4321/fixtures/antipatterns/{rule-id}.html` and on the homepage. The two adapter paths can disagree.
|
||||
|
||||
Conventions: wrap the identifying heading text in straight double quotes inside snippets so the fixture test can extract it. jsdom-specific helpers `resolveBackground()`, `resolveGradientStops()`, and `parseGradientColors()` exist because `background:` shorthand isn't decomposed and computed colors aren't normalized in jsdom — use them. Reference rules to copy from: `side-tab` (border), `low-contrast` (color+gradient), `icon-tile-stack` (sibling relationship), `flat-type-hierarchy` (page-level).
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
@@ -77,4 +88,4 @@ Tags are per-component because the three components ship independently: `skill-v
|
||||
|
||||
## Contributor Notes
|
||||
|
||||
Do not edit generated provider files directly unless you are intentionally patching generated output as part of a build-system change. Prefer fixing the root source in `skill/`, `scripts/`, or `cli/` (or the engine repo for verb behavior), then regenerate artifacts for validation. Stage generated harness artifacts only for release/main-sync or build-system work.
|
||||
Do not edit generated provider files directly unless you are intentionally patching generated output as part of a build-system change. Prefer fixing the root source in `skill/`, `scripts/`, or `cli/`, then regenerate artifacts for validation. Stage generated harness artifacts only for release/main-sync or build-system work.
|
||||
|
||||
@@ -6,22 +6,8 @@ There is **one** user-invocable skill, `impeccable`, with **23 commands** undern
|
||||
|
||||
- `SKILL.src.md` — frontmatter (with the auto-trigger-optimized description and the `allowed-tools` list), shared design laws, and the **Commands** router table. Provider `SKILL.md` files are generated from this source.
|
||||
- `reference/` — one `<command>.md` per command (`audit.md`, `polish.md`, `critique.md`, etc.), the shared playbooks the router loads outside the command table (`new-work.md`, `craft-floor.md`, `operate.md`, `routing.md`), and the native platform references (`ios.md`, `android.md`). When a sub-command is matched, the router loads its reference file.
|
||||
- `scripts/command-metadata.json` — single source of truth for each command's description, argument hint, and (eventually) category. Both the build and the engine's `pin` verb read from this.
|
||||
- `scripts/impeccable` (+ `impeccable.cmd`, `VERSION`): the launcher every skill verb goes through. See **Engine binary** below.
|
||||
- `impeccable pin` — an engine verb that creates/removes lightweight redirect shims so users can have `/audit` as a standalone shortcut that delegates to `/impeccable audit`.
|
||||
|
||||
### Engine binary (the runtime behind every verb)
|
||||
|
||||
The skill has no runtime of its own. Every command the skill text runs is `{{scripts_path}}/impeccable <verb>` (Setup step 1 says `impeccable context`; `impeccable.cmd` is the Windows twin for shells without `sh`). `skill/scripts/impeccable` is a POSIX `sh` launcher: it execs `$IMPECCABLE_BIN` if set, else the sibling `scripts/bin/<os>-<arch>/impeccable[.exe]`, else `~/.impeccable/bin/impeccable`, else the version-pinned user cache `~/.impeccable/bin/<VERSION>/`, else `impeccable` on PATH, and as a last resort downloads the pinned version into that cache. It exports `IMPECCABLE_SKILL_DIR` (the skill dir, for `reference/*.md` and `command-metadata.json`) and `IMPECCABLE_SELF` (how the binary spells itself in the commands it prints).
|
||||
|
||||
The binary is built from **this repo's Cargo workspace** (`Cargo.toml` at the root, `crates/*`; `cargo build --release -p impeccable`). Its verbs are the old script basenames (`context`, `doctor`, `pin`, `hook`, `hook-before-edit`, `live*`, `detect`, ...) with two aliases: `signals` for context-signals and `hooks` for hook-admin. Its observable behavior is specified in `docs/CLI-CONTRACT.md` and pinned by `tests/oracle/`. **Read `docs/ENGINE.md` before touching `crates/`**: it maps the crates and the browser-bundle flow.
|
||||
|
||||
- **The rule engine is in the workspace.** Every `check_*` / `scan_*`, the browser rule adapters and the visual-contrast decisions live in `crates/core`, Apache-2.0 like everything else; `crates/foundation` holds what they are written against (JS semantics, color, the registry, the `Dom` trait, the plain-data input and output types) and `crates/core` re-exports it, so consumers name one crate. `crates/wasm` compiles the same source to WebAssembly for the extension, the live overlay and the site, and `cargo xtask bundle` builds those artifacts. There is no build-time download and no exact toolchain pin: `cargo build --release -p impeccable` works offline on stable.
|
||||
- **`ENGINE_VERSION`** (repo root) pins the engine release (`engine-v<X>` on this repo's GitHub Releases, built by `.github/workflows/release-engine.yml` when `bun run release:engine` pushes the tag). The build copies it to `skill/scripts/VERSION`, which the launcher reads to name the download and the cache dir; `cli/bin/cli.js` reads the same version from `package.json`'s `optionalDependencies`. Bumping it is a release-time decision, like the other manifest versions.
|
||||
- **Binaries are never tracked.** `skill/scripts/bin/` and `**/skills/impeccable/scripts/bin/` are gitignored, so the tracked provider dirs and `plugin/` ship launcher-only and users get the binary on first run. `bun run build:release` produces launcher-only zips by default; `IMPECCABLE_BUNDLE_ENGINE=1 bun run build:release` fetches every target (`scripts/fetch-engine.mjs --all --lenient`) and stages `bin/<os-arch>/` into the dist skill copies **after** the root harness dirs and `plugin/` were synced, so `dist/universal.zip` is self-contained for offline installs while git stays clean. Bundling is opt-in because five targets in every provider copy put `universal.zip` near 340 MB, past the 25 MB Cloudflare Pages file cap that `impeccable install` downloads through.
|
||||
- **Tests get a binary** from `IMPECCABLE_BIN`, then `skill/scripts/bin/<os-arch>/` (`bun run fetch:engine`; `IMPECCABLE_BIN=<local build> bun run fetch:engine` copies a local build there), then `target/release/impeccable` from a plain `cargo build --release -p impeccable`. `tests/lib/engine-bin.mjs` is the one resolver; suites that need the binary skip cleanly without it.
|
||||
- **The oracle is the behavior gate.** `tests/oracle/` holds goldens recorded from the JS scripts before they left the tree, plus reviewed deltas in `DELTAS.md`; `tests/oracle.test.mjs` replays them against the binary in `bun run test`. New cases are recorded from the binary (`record.mjs --bin`) and reviewed by hand. `tests/oracle/vectors/calls/` is the frozen function-level snapshot; it cannot be regenerated.
|
||||
- **What stays JavaScript here:** the in-page live-mode JS (`skill/scripts/live-browser*.js`, `modern-screenshot.umd.js`), the build and test tooling, the extension shell, and the npm shim.
|
||||
- `scripts/command-metadata.json` — single source of truth for each command's description, argument hint, and (eventually) category. Both the build and `pin.mjs` read from this.
|
||||
- `scripts/pin.mjs` — creates/removes lightweight redirect shims so users can have `/audit` as a standalone shortcut that delegates to `/impeccable audit`.
|
||||
|
||||
**Do not add standalone skills** unless there's a strong reason. The consolidation was deliberate: the `/` menu pollution problem is real and gets worse as users install more plugins.
|
||||
|
||||
@@ -53,36 +39,36 @@ A second axis, **orthogonal to mode**. Mode answers "what does the visitor come
|
||||
- **android** — a native Android app. Loads `reference/android.md` (Material Design 3 distilled).
|
||||
- **adaptive** — a cross-platform app shipping both iOS and Android from one codebase (Flutter, React Native, KMP) that adapts per OS. Loads **both** `reference/ios.md` and `reference/android.md`. A Flutter/RN app that uses one look on both platforms (Material-everywhere is the Flutter default) is not adaptive; it takes that single platform's value.
|
||||
|
||||
PRODUCT.md carries a `## Platform` section with a bare value (`web` / `ios` / `android` / `adaptive`). The `context` verb parses it; a **missing field defaults to `web`** so legacy projects are unaffected. A line that names both native targets (e.g. `ios, android`) is also read as `adaptive`; any other unrecognized value falls back to web **and** `impeccable context` prints a WARNING directive naming the bad value, so a toolchain name or typo never silently gets web guidance. `impeccable context` inlines the native reference(s) directly into its output when the value is `ios`, `android`, or `adaptive` (both), so native conventions land in context without a second model-directed read. `init` (Step 3) confirms an ambiguous platform as part of the product-truth interview, and Step 4 records it as the bare value.
|
||||
PRODUCT.md carries a `## Platform` section with a bare value (`web` / `ios` / `android` / `adaptive`). It's parsed by `extractPlatform()` in `skill/scripts/context.mjs`, built on the generic `extractSectionValue()` helper; a **missing field defaults to `web`** so legacy projects are unaffected. A line that names both native targets (e.g. `ios, android`) is also read as `adaptive`; any other unrecognized value falls back to web **and** the `context.mjs` CLI prints a WARNING directive naming the bad value, so a toolchain name or typo never silently gets web guidance. `context.mjs` inlines the native reference(s) directly into its output when the value is `ios`, `android`, or `adaptive` (both), so native conventions land in context without a second model-directed read. `init` (Step 3) confirms an ambiguous platform as part of the product-truth interview, and Step 4 records it as the bare value.
|
||||
|
||||
`ios.md` and `android.md` are distilled from the MIT-licensed [ehmo/platform-design-skills](https://github.com/ehmo/platform-design-skills); attribution is in `NOTICE.md`.
|
||||
|
||||
Where a command's native guidance diverges too much to share a file, it gets a **native variant**: `reference/<command>.native.md`, listed in SKILL.md's Commands table and routed **instead of** the web file when `setup.platform` is native (Setup step 2). One variant covers ios, android, and adaptive; per-OS specifics stay in the platform refs, which Setup loads regardless. Variants today: `audit.native.md`, `adapt.native.md` (their web files carry a one-line web-only guard that redirects stray native readers). `audit.native.md` mirrors `audit.md`'s report skeleton; change the skeleton in both together. Commands whose divergence the platform refs already cover (`animate`, `layout`) carry nothing extra; don't add in-file translation notes, they make native runs pay for web content.
|
||||
|
||||
**Live mode, `impeccable detect`, and the design hook are web-only.** They operate on a browser / HTML rules, so SKILL.md's routing skips live and `impeccable detect` for any native (`ios` / `android` / `adaptive`) project, and the `hook` and `hook-before-edit` verbs skip their scan when PRODUCT.md declares a native platform — a React Native project is made of exactly the `.tsx` / `.ts` / `.js` files the hook watches.
|
||||
**Live mode, the `detect` CLI, and the design hook are web-only.** They operate on a browser / HTML rules, so SKILL.md's routing skips live and `detect.mjs` for any native (`ios` / `android` / `adaptive`) project, and the hook (`hook-lib.mjs` `resolveProjectPlatform` / `isNativePlatform`, also used by `hook-before-edit.mjs`) skips its scan when PRODUCT.md declares a native platform — a React Native project is made of exactly the `.tsx` / `.ts` / `.js` files the hook watches.
|
||||
|
||||
### Artifact staleness and the doctor pass
|
||||
|
||||
Impeccable writes files into user projects, so a released version has to cope with artifacts an older one wrote. Three kinds of drift travel under "out of date" and they are handled separately:
|
||||
|
||||
1. **Tool version drift** (installed skill older than published). Emitted by `impeccable context` as `UPDATE_AVAILABLE`. Predates this system, unchanged.
|
||||
2. **Schema drift** (an artifact carries fields nothing reads, is missing fields now expected, or sits in a retired location). Deterministic; the engine's staleness module.
|
||||
1. **Tool version drift** (installed skill older than published). `computeUpdateDirective()` in `context.mjs`, emitted as `UPDATE_AVAILABLE`. Predates this system, unchanged.
|
||||
2. **Schema drift** (an artifact carries fields nothing reads, is missing fields now expected, or sits in a retired location). Deterministic. `skill/scripts/lib/staleness.mjs`.
|
||||
3. **Truth drift** (the code moved on and the document no longer describes it). Not mechanical. `document` and `init` own the rewrite; the deep pass measures a proxy and is required to say it is a proxy.
|
||||
|
||||
**Two tiers, and the split is a performance contract, not a preference.**
|
||||
|
||||
- **Tier 1** runs inside `impeccable context` at boot. It may only spend what a boot already spends: markdown already in memory, a bounded set of stats, and the small JSON files the boot reads regardless. **No directory walks, no git, no cross-workspace sweep.** The one walk it uses is the target-candidate discovery the boot has already paid for. Adding an expensive check here taxes every session in every project.
|
||||
- **Tier 2** is the deep pass behind `impeccable doctor`, run on demand. Git log, per-workspace sweep, ignore-list validation against the live rule registry, hook launcher resolution.
|
||||
- **Tier 1** is `collectBootFindings()` in `lib/staleness.mjs`, called from `appendStalenessDirective()` in `context.mjs`. It may only spend what a boot already spends: markdown already in memory, a bounded set of stats, and the small JSON files the boot reads regardless. **No directory walks, no git, no cross-workspace sweep.** The one walk it uses (`discoverTargetCandidates`) is one `resolveTargetSelection` has already paid for. Adding an expensive check here taxes every session in every project.
|
||||
- **Tier 2** is `lib/staleness-deep.mjs`, run on demand by `skill/scripts/doctor.mjs`. Git log, per-workspace sweep, ignore-list validation against the live `ANTIPATTERNS` registry, hook script resolution.
|
||||
|
||||
**Findings are data.** `{ id, artifact, path, severity, summary, fix }`, so the boot directive, the text report, and `--json` all render one set. Severity says what should happen, not how bad it is: `auto` (fix silently on the next write to that file), `mention` (state once, carry on), `route` (name the command that owns the repair). `doctor --fix` applies only `auto`, and only where no judgment is involved.
|
||||
|
||||
**Emission discipline.** Boot output is already heavy, so Tier 1 emits **one** `CONTEXT_STALE` directive for the whole set, and `mention` and `route` findings are throttled to once a week per project (cached in `~/.impeccable/staleness-check.json`, alongside the update cache, so no gitignore entry is owed). `auto` findings are never throttled and never shown to the user. Opt out with `"stalenessCheck": false` or `IMPECCABLE_NO_STALENESS_CHECK=1`. **An oracle case that asserts on other boot directives should pin that env var.**
|
||||
**Emission discipline.** Boot output is already heavy, so Tier 1 emits **one** `CONTEXT_STALE` directive for the whole set, and `lib/staleness-notice.mjs` throttles `mention` and `route` findings to once a week per project (cached in `~/.impeccable/staleness-check.json`, alongside the update cache, so no gitignore entry is owed). `auto` findings are never throttled and never shown to the user. Opt out with `"stalenessCheck": false` or `IMPECCABLE_NO_STALENESS_CHECK=1`. **A test that asserts on other boot directives should set that env var**, which is why the update-check suite in `tests/context.test.mjs` does.
|
||||
|
||||
**Provenance stamps.** PRODUCT.md carries `<!-- impeccable:product-schema N -->` (schema constants live in the engine; template in `init.md`). Without it, every check is a heuristic reconstruction of what era a file came from. **Stamps are schema versions, not release versions**: a PRODUCT.md written by v4.0.0 is not stale under v4.0.1, and a schema version changes only when the shape does. **DESIGN.md deliberately carries no stamp** because it follows the external design.md spec that Stitch's linter validates, and every DESIGN.md signal (sidecar `schemaVersion`, sidecar mtime, section coverage, git drift) is measurable without one.
|
||||
**Provenance stamps.** PRODUCT.md carries `<!-- impeccable:product-schema N -->` (constants in `lib/artifact-schema.mjs`, template in `init.md`). Without it, every check is a heuristic reconstruction of what era a file came from. **Stamps are schema versions, not release versions**: a PRODUCT.md written by v4.0.0 is not stale under v4.0.1, and a schema version changes only when the shape does. **DESIGN.md deliberately carries no stamp** because it follows the external design.md spec that Stitch's linter validates, and every DESIGN.md signal (sidecar `schemaVersion`, sidecar mtime, section coverage, git drift) is measurable without one.
|
||||
|
||||
**When you retire a PRODUCT.md field, add it to the engine's deprecated-sections list** with the reason (and record the new boot output as an oracle case). The reason is not decoration: told only that a field is deprecated, models preserve it "just in case", which is how a retired axis keeps steering current output.
|
||||
**When you retire a PRODUCT.md field, add it to `PRODUCT_DEPRECATED_SECTIONS`** in `lib/artifact-schema.mjs` with the reason. The reason is not decoration: told only that a field is deprecated, models preserve it "just in case", which is how a retired axis keeps steering current output.
|
||||
|
||||
**`doctor` is a utility command, not a design command.** It follows the `hooks` and `pin` pattern (a line in SKILL.src.md plus `reference/doctor.md`), not the Commands-table pattern. It is deliberately **not** in `IMPECCABLE_SUB_COMMANDS`, `command-metadata.json`, `SKILL_CATEGORIES`, or the `pin` verb's valid-command list, and it does not count toward the 23. Keep maintenance tooling out of the design menu.
|
||||
**`doctor` is a utility command, not a design command.** It follows the `hooks` and `pin` pattern (a line in SKILL.src.md plus `reference/doctor.md`), not the Commands-table pattern. It is deliberately **not** in `IMPECCABLE_SUB_COMMANDS`, `command-metadata.json`, `SKILL_CATEGORIES`, or `pin.mjs`'s `VALID_COMMANDS`, and it does not count toward the 23. Keep maintenance tooling out of the design menu.
|
||||
|
||||
## Repo split: public product vs private service (impeccable-site)
|
||||
|
||||
@@ -90,7 +76,7 @@ As of v4 the repo holds only the open-source product layer: the skill, CLI, exte
|
||||
|
||||
Consequences here:
|
||||
|
||||
- `impeccable concept-seed` has no local catalog. It resolves data via `IMPECCABLE_CATALOG_DIR` (private repo, evals, tests), then the roll API at impeccable.style, then a degraded promotion-only seed. Oracle cases run against `tests/fixtures/concept-catalog/`.
|
||||
- `skill/scripts/concept-seed.mjs` has no local catalog. It resolves data via `IMPECCABLE_CATALOG_DIR` (private repo, evals, tests), then the roll API at impeccable.style, then a degraded promotion-only seed. Tests run against `tests/fixtures/concept-catalog/`.
|
||||
- The choice-ping telemetry (`--chosen`) honors `DO_NOT_TRACK` and `IMPECCABLE_NO_TELEMETRY` and only fires for API-dealt rolls.
|
||||
- Site copy, changelog, theme, and count validation for site pages happen in impeccable-site; this repo's `validateProse` scans only the READMEs.
|
||||
- The release script reads the changelog from `../impeccable-site/site/pages/changelog.astro` when releasing from here.
|
||||
@@ -104,7 +90,7 @@ The build's `validateProse` step (in `scripts/build.js`) enforces a denylist: em
|
||||
|
||||
`validateProse` scans `README.md` and `README.npm.md`; site copy is validated in impeccable-site.
|
||||
|
||||
**`skill/` is checked too, by a second gate.** `validateProse` skips it because the full ruleset does not fit LLM-facing reference instructions. `validateSkillProse` then scans `skill/**/*.md` (markdown only, not the launcher or page JS under `skill/scripts/`) and fails the build on em dashes plus the subset of phrases with no technical reading: `load-bearing`, `highest-leverage`, `biggest unlock`, `reflex defaults`, `collapses into monoculture`, `data-driven`, `delve`, `tapestry`, `in today's`, `gone are the days`, `let's dive in`, `in summary`, `in conclusion`. The words it does *not* enforce in `skill/` (`seamless`, `robust`, `elevate`, and friends) are the ones with legitimate technical uses. Net effect: an em dash in `skill/reference/*.md` fails `bun run build`; an em dash in a `scripts/*.js` code comment does not.
|
||||
**`skill/` is checked too, by a second gate.** `validateProse` skips it because the full ruleset does not fit LLM-facing reference instructions. `validateSkillProse` then scans `skill/**/*.md` (markdown only, not `skill/scripts/**` code or comments) and fails the build on em dashes plus the subset of phrases with no technical reading: `load-bearing`, `highest-leverage`, `biggest unlock`, `reflex defaults`, `collapses into monoculture`, `data-driven`, `delve`, `tapestry`, `in today's`, `gone are the days`, `let's dive in`, `in summary`, `in conclusion`. The words it does *not* enforce in `skill/` (`seamless`, `robust`, `elevate`, and friends) are the ones with legitimate technical uses. Net effect: an em dash in `skill/reference/*.md` fails `bun run build`; an em dash in a `skill/scripts/*.mjs` code comment does not.
|
||||
|
||||
The deeper structural issues (negation pivot, triadic auto-pilot, uniform paragraph rhythm, hollow confidence) require human judgment. `docs/STYLE.md` lists them. Use them on every editorial pass.
|
||||
|
||||
@@ -114,14 +100,11 @@ The build system compiles the impeccable skill from `skill/` to provider-specifi
|
||||
|
||||
```bash
|
||||
bun run build # Build dist/ provider output without syncing root harness dirs
|
||||
bun run build:release # Build dist/ provider output, sync root harness dirs + plugin/, stage engine binaries into dist zips
|
||||
bun run build:release # Build dist/ provider output and sync root harness dirs + plugin/
|
||||
bun run rebuild # Clean and rebuild without root harness sync
|
||||
bun run rebuild:release # Clean and rebuild with root harness sync
|
||||
bun run fetch:engine # Download the pinned engine binary for this machine into skill/scripts/bin/
|
||||
```
|
||||
|
||||
The skill's `scripts/` payload is copied verbatim to every provider (launcher with its executable bit, `impeccable.cmd`, `VERSION`, `command-metadata.json`, page JS); nothing under `skill/scripts/bin/` is read as source. The in-page detector bundle and the extension's detector pieces are produced by `cargo xtask bundle`, which `bun run build:extension` runs; the page JS and the bundling itself live in the `impeccable-bundle` library crate (`crates/bundle`) so a downstream rule pack can build the same artifacts for its own wasm module.
|
||||
|
||||
Source files use placeholders that get replaced per-provider:
|
||||
- `{{model}}` — Model name (Claude, Gemini, GPT, etc.)
|
||||
- `{{config_file}}` — Config file name (CLAUDE.md, .cursorrules, etc.)
|
||||
@@ -157,25 +140,9 @@ bun run test # Default suite: unit + static framework fixtures
|
||||
bun run test:live-e2e # Opt-in: full-cycle live-mode E2E across framework fixtures
|
||||
bun run test:skill-behavior # Opt-in: LLM-backed checks that the skill text actually drives the agent's setup flow
|
||||
bun run test:plugin-e2e # Just the plugin loader E2E (also part of the default suite)
|
||||
bun run test:cleanup # Kill live servers a previous run of THIS checkout left behind
|
||||
```
|
||||
|
||||
Unit tests (build orchestration, transformers, validators) run via `bun test`. Everything that spawns the engine binary (`tests/oracle.test.mjs`, `tests/framework-fixtures.test.mjs`) runs via `node --test`; both skip cleanly when no binary is found (`bun run fetch:engine` or `IMPECCABLE_BIN`). The `test` script handles this split automatically. Verb behavior is not unit-tested here at all: the oracle goldens and the engine repo's own tests own it.
|
||||
|
||||
### Live servers must not outlive their test process
|
||||
|
||||
A live server does not die with the process that started it: a direct child survives its parent, and `impeccable live-server --background` is orphaned to pid 1 by design (`spawn_detached_with_args` in `crates/live/src/server.rs`). Teardown in an `after()` hook or a `finally` covers only the exits JavaScript can observe, so a `SIGKILL`, a Ctrl-C, or a wedged runner used to leave servers squatting the live suite's fixed ports for days (issue #717).
|
||||
|
||||
Three pieces keep that from recurring, and a new test that starts a server owes the first one:
|
||||
|
||||
- **`armLiveServerReaper()`** (`tests/lib/live-servers.mjs`), called once at module scope by any test file that starts a live server. It stamps the process environment with a unique marker, installs exit and signal handlers, and spawns a detached reaper holding a pipe to the process. When the process dies for any reason at all, the pipe closes and the reaper kills the servers carrying that marker. Wrap direct children in `trackServerChild()` so the common case is a cheap `child.kill()`. On this branch the two places that start one are `tests/live-e2e/session.mjs` and the oracle's daemon steps (`runDaemonStep` in `tests/oracle/lib.mjs`); both already arm it.
|
||||
|
||||
The mechanism is deliberately implementation-agnostic, which is what let it survive the Node-to-Rust swap unchanged: it keys on the environment rather than on anything the server implements. That works because the daemon spawn does `env_clear().envs(env)` against `Io::stdio()`'s `env`, which is `std::env::vars()`, so the detached Rust process carries the parent's environment and the markers reach it. If a future change scrubs or narrows that env, the guard goes silently blind, so keep the daemon inheriting it.
|
||||
- **The runner guard.** `scripts/run-tests.mjs` runs each suite command as its own process-group leader, ends that group on `SIGINT` / `SIGTERM` / `SIGHUP` and on the wall-clock cap, and after every suite checks whether any live server carrying that suite's run id is still alive. If one is, it kills it and fails the run. Bypass with `IMPECCABLE_SKIP_LEAK_CHECK=1`. The same group is what `IMPECCABLE_TEST_WALL_CLOCK_MS` (or a suite's `wallClockMs`) SIGKILLs when a command wedges, so a suite blocked in a synchronous call still ends and still gets swept.
|
||||
- **`bun run test:cleanup`.** A one-shot sweep for leftovers from earlier runs.
|
||||
- **`tests/live-server-leak.test.mjs`** pins the guarantee against the real engine binary (resolved through `tests/lib/engine-bin.mjs`, skipped when there is none): it boots `impeccable live-server`, SIGKILLs the process that started it, and fails if the server outlives it.
|
||||
|
||||
**Everything that kills is scoped by an environment marker this repo's harness exported**, never by process name, port, or path. A sweep can never touch a live server that another checkout, or the user's own session, is running. Keep it that way, and keep marker values opaque: every one is a random token or a hash of the checkout path (`repoMarker()`), drawn from `[A-Za-z0-9_-]` so it can never contain whitespace. `ps -E` flattens the environment into one whitespace-separated line, so a value free to hold a space could hide the end of its own entry and let one checkout's cleanup reach another's servers. `assertMarkerValue` refuses such a value; the readable path travels separately as `IMPECCABLE_TEST_REPO_PATH`, which nothing matches on.
|
||||
Unit tests (build orchestration, detector logic) run via `bun test`. Fixture tests (jsdom-based HTML detection) run via `node --test` because bun is too slow with jsdom. The `test` script handles this split automatically.
|
||||
|
||||
### Which opt-in suite a change owes
|
||||
|
||||
@@ -183,15 +150,14 @@ The default suite does not cover everything. When a change touches one of these
|
||||
|
||||
| Area touched | Run | Cost |
|
||||
|---|---|---|
|
||||
| `ENGINE_VERSION` bump, `skill/scripts/live-browser*.js` | `bun run test:live-e2e` | ~2 min, real npm installs + dev servers, needs Playwright Chromium |
|
||||
| `ENGINE_VERSION` bump | also `bun run test:live-e2e-accept-cleanup` | bills a provider API key |
|
||||
| `ENGINE_VERSION` bump | `bun run test:live-svelte-adapter-deepseek` | bills DeepSeek |
|
||||
| `SKILL.src.md` Setup, Setup-adjacent reference files, `ENGINE_VERSION` bump | `bun run test:skill-behavior` | ~5 min, bills all four provider keys |
|
||||
| `ENGINE_VERSION` bump | `bun run test:new-work-e2e` | Playwright, offline, no API cost |
|
||||
| `skill/scripts/live-*.{mjs,js}`, `skill/scripts/live/**` | `bun run test:live-e2e` | ~2 min, real npm installs + dev servers, needs Playwright Chromium |
|
||||
| `live-accept` / `live-browser` / `live-server` / `live-wrap` / `live/sveltekit-adapter` | also `bun run test:live-e2e-accept-cleanup` | bills a provider API key |
|
||||
| `live/sveltekit-adapter.mjs`, `live/svelte-component.mjs` | `bun run test:live-svelte-adapter-deepseek` | bills DeepSeek |
|
||||
| `SKILL.src.md` Setup, `context.mjs`, Setup-adjacent reference files | `bun run test:skill-behavior` | ~5 min, bills all four provider keys |
|
||||
| `serve-question.mjs`, `generate-image.mjs`, `concept-seed.mjs` | `bun run test:new-work-e2e` | Playwright, offline, no API cost |
|
||||
| `cli/bin/commands/skills.mjs` | `bun run test:cli-remote-e2e` | hits impeccable.style |
|
||||
| `plugin/`, `skill/agents/`, `scripts/build.js`, plugin manifest validator | `bun run test:plugin-e2e` | ~1 s; already in the default suite, needs the `claude` CLI |
|
||||
|
||||
Verb-level behavior changes happen in the engine repo; the check they owe here is `bun run test` with a binary present (the oracle), and a new oracle case when the contract grows.
|
||||
|
||||
**Plugin loader E2E** (`tests/plugin-e2e.test.mjs`, in the default suite): installs the committed `./plugin` subtree into a real Claude Code, sandboxed via `CLAUDE_CONFIG_DIR` in a temp dir, and asserts the component inventory from `claude plugin details`: the skill parses, every `plugin/agents/*.md` is visible, hooks are discovered. This is the only check that catches loader-contract surprises the unit guards can't know about (PR #494 shipped an `agents` manifest key that silently loaded zero agents; `claude plugin validate` never flags plugin-manifest problems). Runs in about a second; skips cleanly when the `claude` CLI is not on PATH. The known contract itself (allowed manifest keys, no `agents` key, trailing-slash `skills` path, source agents shipped) is pinned deterministically by `scripts/lib/validate-plugin-manifest.js`, unit-tested in `tests/validate-plugin-manifest.test.js` and enforced as a `bun run build` gate. Never add a key to the generated plugin manifest without verifying it against a real install and extending `KNOWN_LOADER_KEYS`.
|
||||
|
||||
**Important:** `tests/build.test.js` uses `spyOn(transformers, 'transformCursor')` with the named exports from `scripts/lib/transformers/index.js`. Those named exports (`transformCursor`, `transformClaudeCode`, etc.) are kept specifically for test spying, even though `build.js` itself uses `createTransformer + PROVIDERS` directly. **Do not delete them as "dead code"** — I made that mistake once and broke 8 tests.
|
||||
@@ -208,13 +174,13 @@ IMPECCABLE_E2E_DEBUG=1 bun run test:live-e2e # dump page DOM + de
|
||||
|
||||
**One-time setup**: `npx playwright install chromium` (the suite uses a specific Chromium build keyed to the bundled Playwright version).
|
||||
|
||||
**Kept out of the default `bun run test`** because (a) it does real `npm install` per fixture, (b) it boots framework dev servers, (c) wall time is ~2 minutes, and (d) it requires Playwright's browser cache. Run it locally before shipping changes to the page JS or before bumping `ENGINE_VERSION`. (Its helpers still drive the live verbs by script path; retargeting them at the launcher is pending.)
|
||||
**Kept out of the default `bun run test`** because (a) it does real `npm install` per fixture, (b) it boots framework dev servers, (c) wall time is ~2 minutes, and (d) it requires Playwright's browser cache. Run it locally before shipping changes to anything in `skill/scripts/live-*.{mjs,js}` or `skill/scripts/live/**`.
|
||||
|
||||
Three live-mode invariants worth knowing before editing (established by the 2026-07 rewrite, full rationale in `docs/LIVE-REWRITE-PLAN.md`; the implementation is the engine's `live` crate now, the contract is unchanged):
|
||||
Three live-mode invariants worth knowing before editing (established by the 2026-07 rewrite, full rationale in `docs/LIVE-REWRITE-PLAN.md`):
|
||||
|
||||
- **Roots.** `impeccable live` resolves appRoot/repoRoot/contextRoot once at boot and persists `.impeccable/live/roots.json`; every live verb re-anchors on that manifest and chdirs onto its appRoot. Never derive a live path from ambient cwd; go through the manifest.
|
||||
- **Roots.** `skill/scripts/live/roots.mjs` resolves appRoot/repoRoot/contextRoot once at boot and persists `.impeccable/live/roots.json`; every live CLI calls `enterLiveRoot()` in its main guard and chdirs onto the manifest's appRoot. Never derive a live path from ambient cwd in a new script; go through the manifest.
|
||||
- **Svelte preview modules must live under `node_modules/.impeccable-live`.** SvelteKit restricts vite `server.fs.allow` to src/lib, src/routes, .svelte-kit, and node_modules; a preview tree under `.impeccable/` 403s. Staleness is handled by per-publish revision dirs (`r<N>/`, bumped by the server on every done-reply), not by file watching.
|
||||
- **`svelte` is a devDependency for tests only.** The Svelte scaffolder and accept pipeline resolve the compiler from the USER app's node_modules at runtime; the fixture sweep and oracle cases symlink this repo's copy into staged fixtures.
|
||||
- **`svelte` is a devDependency for tests only.** The AST scaffolder (`live/svelte-ast.mjs`) and accept pipeline (`live/accept-css.mjs`) resolve the compiler from the USER app's node_modules at runtime; unit tests and the static fixture sweep symlink this repo's copy into staged fixtures. Skill scripts still ship dependency-free.
|
||||
|
||||
The agent is pluggable via a one-method interface in `tests/live-e2e/agent.mjs`: `generateVariants(event, context) → { scopedCss, variants[] }`. The default fake agent emits canned variants that exercise all three param kinds (`range`, `steps`, `toggle`). The orchestrator (wrap, write, accept, carbonize) is agent-agnostic.
|
||||
|
||||
@@ -242,33 +208,37 @@ IMPECCABLE_SKILL_BEHAVIOR_VERBOSE=1 bun run test:skill-behavior # dump per-sc
|
||||
|
||||
**Adding a scenario.** Write the fixture in `tests/skill-behavior/fixtures.mjs`, add the `it()` block in `scenarios.test.mjs` (the harness uses the source `skill/` dir via a symlink, so no rebuild needed), and update the baseline table in the suite's README. The harness's `fileLoaded(trace, filename)` helper checks both `read` and bash `cat` — different models prefer different tools.
|
||||
|
||||
**The harness symlinks source, not built output.** This is deliberate so SKILL.md / reference edits show up immediately without `bun run build:skills`; the launcher under `skill/scripts/` resolves the binary the same way tests do. The trade-off: reference files surface their raw `{{placeholders}}`, but the assertions key on tool calls rather than content, so it doesn't matter for correctness.
|
||||
**The harness symlinks source, not built output.** This is deliberate so SKILL.md / reference / `scripts/context.mjs` edits show up immediately without `bun run build:skills`. The trade-off: reference files surface their raw `{{placeholders}}`, but the assertions key on tool calls rather than content, so it doesn't matter for correctness.
|
||||
|
||||
## CLI
|
||||
|
||||
`cli/` is the npm package `impeccable`, now a thin shim: `cli/bin/cli.js` locates the engine binary (`IMPECCABLE_BIN`, then the `@impeccable/cli-<os>-<arch>` optional dependency pinned at `ENGINE_VERSION`, then `~/.impeccable/bin/<version>/`, then a checksum-verified download into that cache) and execs it with argv. The verbs users see (`detect`, `ignores`, `install`, `update`, `check`, `link`, `help`, the legacy `skills` namespace) are the binary's. `cli/platform-packages/<os>-<arch>/package.json` are the templates the engine release publishes; the version pinned in `package.json` `optionalDependencies` must equal `ENGINE_VERSION`.
|
||||
The CLI lives in this repo under `cli/`: `cli/bin/` (entry + sub-commands), `cli/engine/` (the detect-antipatterns rule engine + browser variant), `cli/lib/` (helpers shared by CLI and Cloudflare Pages Functions). Published to npm as `impeccable`.
|
||||
|
||||
```bash
|
||||
npx impeccable detect [file-or-dir-or-url...] # detect anti-patterns
|
||||
npx impeccable detect --json src/ # JSON output
|
||||
npx impeccable install # install skills
|
||||
npx impeccable --help # show help
|
||||
npx impeccable detect --fast --json src/ # regex-only, JSON output
|
||||
npx impeccable live # start browser overlay server
|
||||
npx impeccable skills install # install skills
|
||||
npx impeccable --help # show help
|
||||
```
|
||||
|
||||
The package no longer exports a JS detector API (`main` / `exports` are gone); the in-page bundle for the extension and site comes from the engine repo.
|
||||
The browser detector (`cli/engine/detect-antipatterns-browser.js`) is generated from the main engine. After changing `cli/engine/detect-antipatterns.mjs`, rebuild it:
|
||||
|
||||
```bash
|
||||
bun run build:browser
|
||||
```
|
||||
|
||||
**IMPORTANT**: Always use `node` (not `bun`) to run the detect CLI. Bun's jsdom implementation is extremely slow and will cause scans with HTML files to hang for minutes.
|
||||
|
||||
## Versioning
|
||||
|
||||
**Feature PRs do not bump versions and do not add changelog entries.** Bumping is a release step, not part of the change that earns the release: a version in a feature branch conflicts with every other open branch, and a changelog entry describes a release that has not happened. Land the code first; the maintainer bumps and writes the changelog when cutting the release. This holds even though the "Bump when: ..." notes below name the source dirs — those say *which* component a change belongs to, not *when* to edit the manifest. The only PR that touches a manifest version is one whose purpose is the release itself.
|
||||
|
||||
There are three independently versioned components plus the engine pin. Only bump the one(s) that actually changed:
|
||||
|
||||
**Engine pin** (`ENGINE_VERSION`, root):
|
||||
- The engine release the launcher downloads and the npm shim's `optionalDependencies` pin. Bump it when a new engine release is published; keep `package.json` `optionalDependencies` at the same version and run `bun run build` (it rewrites `skill/scripts/VERSION`). A skill release that needs the new engine bumps this together with the skill version.
|
||||
There are three independently versioned components. Only bump the one(s) that actually changed:
|
||||
|
||||
**CLI** (npm package):
|
||||
- `package.json` → `version`
|
||||
- Bump when: CLI shim code changes (`cli/bin/cli.js`, `cli/platform-packages/`)
|
||||
- Bump when: CLI code changes (`cli/bin/`, `cli/engine/detect-antipatterns.mjs`, etc.)
|
||||
|
||||
**Skills** (Claude Code plugin / skill definitions):
|
||||
- `.claude-plugin/plugin.json` → `version` (source of truth)
|
||||
@@ -278,7 +248,7 @@ There are three independently versioned components plus the engine pin. Only bum
|
||||
|
||||
**Chrome extension**:
|
||||
- `extension/manifest.json` → `version`
|
||||
- Bump when: extension code changes (`extension/`), or a rule change alters what the shipped bundle detects. The extension runs the rules as WebAssembly in an offscreen document; `extension/detector/` is built at package time by `cargo xtask bundle` and is not tracked, so an extension release always needs `bun run build:extension` (and therefore a Rust toolchain plus `wasm-pack`) before the zip is attached.
|
||||
- Bump when: extension code changes (`extension/`)
|
||||
|
||||
**Website changelog** (`site/pages/changelog.astro` in the private impeccable-site repo):
|
||||
- Add a new `<article>` entry at the top of the relevant component's group, and move the `cf-entry--current` class + `Current` badge onto it (off the previous newest skill entry). The component is derived from the entry `id` prefix: `cli-*`, `ext-*`, else skill.
|
||||
@@ -305,16 +275,6 @@ Skill releases attach `dist/universal.zip`. Extension releases run `bun run buil
|
||||
|
||||
If you need to fix release notes after the fact (typo, missing thank-you, formatting bug): `gh release edit <tag> --notes-file <md>`. The release script's `htmlToMarkdown` function is the cleanest source for regenerating notes from the changelog.
|
||||
|
||||
### Release order is mechanically enforced (triage decision D4)
|
||||
|
||||
The skill launcher, the npm shim (`cli/bin/cli.js`), and `impeccable install` all resolve the engine binary for the pinned `ENGINE_VERSION`. Nothing they do works until the engine release exists first. **The order is: publish the engine release, then the platform packages, then release/merge the skill (or CLI):**
|
||||
|
||||
1. Publish engine `engine-v<ENGINE_VERSION>`: `bun run release:engine` tags and pushes; `release-engine.yml` builds the five `impeccable-<os>-<arch>[.exe]` binaries plus a `.sha256` beside each and publishes the release on this repo. The whole workspace builds from source, so nothing has to ship ahead of it.
|
||||
2. Publish the five `@impeccable/cli-<os>-<arch>@<ENGINE_VERSION>` npm platform packages.
|
||||
3. Only then tag/publish the skill or CLI release, and only then merge a branch that bumps `ENGINE_VERSION` (the `sync-generated-output.yml` workflow rewrites provider dirs on merge to `main`).
|
||||
|
||||
`scripts/check-engine-release.mjs` verifies step 1 and 2 for the pinned version (ranged-GET each release asset, registry-probe each npm package; honors `IMPECCABLE_DOWNLOAD_BASE`). It exits non-zero and names exactly which assets are missing. `scripts/release.mjs` runs it as a hard gate before tagging the **skill** and **CLI** components and refuses to proceed when any asset is absent; the **extension** release is exempt because it ships a vendored WASM detector and never execs the engine. `IMPECCABLE_SKIP_ENGINE_CHECK=1` bypasses the gate only for the case where the assets exist but the registry probe is unreachable. CI's `engine-release-ready` job runs the same script; it is `continue-on-error: true` with a loud `::warning` until the first engine release is published, at which point flip it to `false` so a mis-ordered merge fails CI.
|
||||
|
||||
## Adding New Commands
|
||||
|
||||
All commands live under `/impeccable`. To add a new one:
|
||||
@@ -323,7 +283,7 @@ All commands live under `/impeccable`. To add a new one:
|
||||
2. Add a row to the **Sub-command reference table** in `skill/SKILL.src.md`
|
||||
3. Add an entry to the **Command menu** section in the same file
|
||||
4. Add the command name to `IMPECCABLE_SUB_COMMANDS` in `scripts/lib/utils.js`
|
||||
5. Add it to the `pin` verb's valid-command list (`crates/context`) and record the pin/unpin oracle case
|
||||
5. Add it to `VALID_COMMANDS` in `skill/scripts/pin.mjs`
|
||||
6. Add its metadata (description + argumentHint) to `skill/scripts/command-metadata.json`
|
||||
7. Add its category to `SKILL_CATEGORIES` in `scripts/lib/skill-categories.js`
|
||||
8. Add its relationships to `COMMAND_RELATIONSHIPS` in impeccable-site's `sub-pages-data.js`
|
||||
@@ -341,26 +301,39 @@ The build validator (`generateCounts` in `scripts/build.js`) checks these files
|
||||
|
||||
## Adding or modifying anti-pattern detection rules
|
||||
|
||||
The rule logic lives in `crates/core`: every check, the browser rule adapters over the `Dom` trait, and the visual-contrast decisions. `crates/wasm` compiles the same source for the extension, the live overlay and the site. Everything a rule change touches:
|
||||
`cli/engine/detect-antipatterns.mjs` is the source of truth for the rule engine. It powers the CLI, the public-site overlay, the Chrome extension, and the homepage rule count. Five places stay in sync:
|
||||
|
||||
| Where | What it is |
|
||||
| Where | How it stays in sync |
|
||||
|---|---|
|
||||
| `docs/CLI-CONTRACT.md` | Hand-edited: the observable contract of `impeccable detect` and every other verb |
|
||||
| `crates/foundation` | What checks are written against: the rule registry (`registry.rs`, also published as `antipatterns.json`), findings, color, the `Dom` trait, `SnapshotDom`, and the plain-data input and output types |
|
||||
| `crates/core` | The checks themselves, plus the re-exports that let consumers name one crate |
|
||||
| `crates/html`, `crates/browser`, `crates/detect` | The engines: parsing, cascade, CDP, snapshots, file walking, output. They call the checks through `impeccable_core::checks::*` and `impeccable_core::browser::*` |
|
||||
| `tests/fixtures/antipatterns/{rule-id}.html` | Hand-edited fixture (two columns, should-flag / should-pass, unique headings, explicit pixel dimensions) |
|
||||
| `tests/oracle/golden/*` | Recorded from the binary with `node tests/oracle/record.mjs --bin detect-`, reviewed by hand |
|
||||
| `tests/oracle/vectors/calls/` | Frozen function-level vectors; replayed by `crates/core/tests/vectors.rs` through `impeccable_core::vectors::call` |
|
||||
| `crates/live/assets/detect-antipatterns-browser.js` | The in-page bundle, a tracked generated file. `cargo xtask bundle` rewrites it; the binary embeds it and serves it as `/detect.js` |
|
||||
| `extension/detector/` | The five generated pieces (`core.js`, `core_bg.wasm`, `snapshot.js`, `overlay.js`, `antipatterns.json`) written by `cargo xtask bundle`, which `bun run build:extension` runs. Gitignored, never tracked; the build's rule-count check reads `antipatterns.json` when present |
|
||||
| `cli/engine/detect-antipatterns.mjs` (`ANTIPATTERNS` array + `checkXxx` logic) | Hand-edited |
|
||||
| `cli/engine/detect-antipatterns-browser.js` | `bun run build:browser` |
|
||||
| `extension/detector/detect.js` + `extension/detector/antipatterns.json` | `bun run build:extension` |
|
||||
| impeccable-site `site/public/js/generated/counts.js` | its own build |
|
||||
| `skill/SKILL.src.md` and `reference/*.md` | Hand-edited if the rule introduces new design guidance |
|
||||
|
||||
Order for a new rule: fixture here first, registry row in `crates/foundation/src/registry.rs`, the check in `crates/core` against that fixture, oracle case + golden, `cargo xtask bundle` to refresh the tracked live asset, then `bun run build && bun run test` with a binary present. Rule counts quoted in `README.md` / `README.npm.md` are validated by `generateCounts` against the vendored registry.
|
||||
Always run all three builds and the test suite after a rule change:
|
||||
|
||||
### Rule packs (downstream crates adding rules)
|
||||
```bash
|
||||
bun run build && bun run build:browser && bun run build:extension && bun run test
|
||||
```
|
||||
|
||||
A crate that depends on this workspace can add rules without forking it: implement `impeccable_core::rule_pack::RulePack` (text plus the two browser DOM hooks) and, for the static engine, `impeccable_html::StaticRulePack`, call `impeccable_core::rule_pack::install(&PACK)` at startup, and hand the pack to the engine through `TextOptions` / `ScanOptions`, `DetectHtmlOptions`, `StaticHtmlEngine`, or `BrowserConfig`. Every hook runs after the built-ins and before inline ignores, so built-in output with no pack installed is byte-identical, which the oracle enforces. The registry keeps `ANTIPATTERNS` as the built-in list and `registry::extend` appends a pack's rows, panicking on an id collision. `crates/wasm --features detect` exposes the two file engines as JSON exports (`detect_text_json`, `detect_html_source_json`) for hosts that cannot exec the binary; Pristine consumes that path. Full contract in `docs/ENGINE.md` ("Rule packs"). The shipped `impeccable` binary installs no pack, and nothing in this repo should start doing so.
|
||||
### TDD order (non-negotiable)
|
||||
|
||||
1. **Fixture** at `tests/fixtures/antipatterns/{rule-id}.html` with two columns (should-flag / should-pass), each case identified by a unique heading. Cover ≥4 flag cases and ≥5 false-positive shapes. Use **explicit pixel dimensions in CSS** because jsdom does no layout.
|
||||
2. **Failing test** in `tests/detect-antipatterns-fixtures.test.mjs` using the snippet-substring pattern (regex `/"([^"]+)"/` against `SHOULD_FLAG` / `SHOULD_PASS` lists). Run it and watch it fail before implementing.
|
||||
3. **Rule entry** in the `ANTIPATTERNS` array: `id`, `category` (`slop` for AI tells, `quality` for real design or a11y issues), `name`, `description`, optional `skillSection` and `skillGuideline`.
|
||||
4. **Pure check function** `checkXxx(opts)` returning `[{ id, snippet }]`. No DOM access in the pure function.
|
||||
5. **Two adapters**: `checkElementXxxDOM(el)` for the browser (`getComputedStyle` + `getBoundingClientRect`) and `checkElementXxx(el, tag, window)` for jsdom (`parseFloat(style.width)` instead of layout). `cli/engine/detect-antipatterns.mjs` is now a thin facade over `cli/engine/{registry,rules,engines,shared}`: the registry entry goes in `registry/antipatterns.mjs`, the pure check + adapters in `rules/checks.mjs`, and the wiring into **both** element loops in `engines/static-html/detect-html.mjs` (jsdom) and `browser/injected/index.mjs` (concatenated into the browser bundle). Forgetting one loop is the most common mistake; symptom is "test passes, live page silent" or vice versa.
|
||||
6. **Verify on a live page**: `http://localhost:4321/fixtures/antipatterns/{rule-id}.html` and the homepage (no false positives). The two adapter paths can disagree, so manual browser checks catch what the fixture test can't.
|
||||
|
||||
### Conventions and jsdom gotchas
|
||||
|
||||
- **Snippet format**: wrap the identifying heading text in straight double quotes (e.g. `'icon tile above h3 "Lightning Fast"'`) so the fixture test can extract it. For rules not anchored to a heading, pick another stable identifier.
|
||||
- **jsdom doesn't lay out**: `getBoundingClientRect()` returns 0×0. Read `parseFloat(style.width)` and `parseFloat(style.height)` from explicit CSS instead.
|
||||
- **`background:` shorthand isn't decomposed in jsdom**: use the existing `resolveBackground()` and `resolveGradientStops()` helpers (in `engines/static-html/detect-html.mjs`).
|
||||
- **Computed colors aren't normalized in jsdom**: `parseGradientColors()` handles both hex and rgb forms.
|
||||
|
||||
Reference rules to copy from (all in `cli/engine/rules/checks.mjs`): `side-tab` (border), `low-contrast` (color + gradient), `icon-tile-stack` (sibling relationship), `flat-type-hierarchy` (page-level), `kicker-above-heading` (heading-anchored with rule-ownership stand-down).
|
||||
|
||||
## Evals Framework (separate private repo)
|
||||
|
||||
|
||||
Generated
-1844
File diff suppressed because it is too large
Load Diff
-38
@@ -1,38 +0,0 @@
|
||||
# The impeccable runtime: one Cargo workspace next to the skill it powers.
|
||||
# `cargo build --release -p impeccable` produces the engine binary the launcher
|
||||
# (skill/scripts/impeccable) runs. See docs/ENGINE.md.
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["crates/*"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0"
|
||||
publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
impeccable-foundation = { path = "crates/foundation" }
|
||||
impeccable-common = { path = "crates/common" }
|
||||
impeccable-core = { path = "crates/core" }
|
||||
impeccable-detect = { path = "crates/detect" }
|
||||
impeccable-html = { path = "crates/html" }
|
||||
impeccable-browser = { path = "crates/browser" }
|
||||
impeccable-live = { path = "crates/live" }
|
||||
impeccable-context = { path = "crates/context" }
|
||||
impeccable-hook = { path = "crates/hook" }
|
||||
impeccable-comp = { path = "crates/comp" }
|
||||
impeccable-comp-verbs = { path = "crates/comp-verbs" }
|
||||
impeccable-bundle = { path = "crates/bundle" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1", features = ["preserve_order"] }
|
||||
thiserror = "2"
|
||||
regex = "1"
|
||||
once_cell = "1"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
panic = "abort"
|
||||
@@ -1 +0,0 @@
|
||||
0.1.0
|
||||
@@ -9,3 +9,7 @@ The `skill/reference/ios.md` and `skill/reference/android.md` platform reference
|
||||
**Original work:** https://github.com/ehmo/platform-design-skills
|
||||
**Original license:** MIT
|
||||
**Author:** ehmo
|
||||
|
||||
## Static HTML parser bundle
|
||||
|
||||
`cli/engine/vendor/static-html-parsers.mjs` is a generated bundle of the parser packages the static-HTML detector needs at runtime. Skill and plugin installs copy that file with the detector; they do not install these packages from npm. Complete copyright and license texts for every package included in the bundle ship beside it in `cli/engine/vendor/static-html-parsers.LICENSES.txt`.
|
||||
|
||||
@@ -95,8 +95,6 @@ Visit [the Neo Mirai case study](https://impeccable.style/cases/neo-mirai) to se
|
||||
|
||||
## Installation
|
||||
|
||||
The skill needs no runtime of its own. Every skill copy ships a small launcher (`scripts/impeccable`, plus `impeccable.cmd` for Windows) that runs the Impeccable engine, a self-contained binary that either sits next to the launcher or is downloaded once on first run into `~/.impeccable/bin/`. Node is only involved if you use the `npx impeccable` installer, which is a shim around the same binary; the manual and Git options below work without it.
|
||||
|
||||
### Option 1: CLI installer (Recommended)
|
||||
|
||||
From the root of your project, run:
|
||||
@@ -377,13 +375,11 @@ On Claude Code, GitHub Copilot, Codex, Cursor, and Grok Build, `npx impeccable i
|
||||
|
||||
Installed hook surfaces:
|
||||
|
||||
- Claude Code: `.claude/settings.local.json` (gitignored, machine-local) runs `${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/impeccable hook`. A hook moved into the shared `settings.json` is honored in place.
|
||||
- GitHub Copilot: `.github/hooks/impeccable.json` (committed, shared by the Copilot CLI and the cloud agent) runs `.github/skills/impeccable/scripts/impeccable hook`. The Copilot CLI activates it once the file is on the repository's default branch and the folder is trusted.
|
||||
- Cursor: `.cursor/hooks.json` runs `.cursor/skills/impeccable/scripts/impeccable hook-before-edit`.
|
||||
- Codex: `.codex/hooks.json` runs `.agents/skills/impeccable/scripts/impeccable hook`, with a `commandWindows` sibling that calls `impeccable.cmd` for cmd.exe.
|
||||
- Grok Build: `.grok/hooks/impeccable.json` runs `.grok/skills/impeccable/scripts/impeccable hook`. Requires `/hooks-trust` or `--trust`. Findings reach the model on Stop, not after each edit.
|
||||
|
||||
Every command goes through the launcher shipped in the skill's `scripts/` directory (`impeccable`, or `impeccable.cmd` on Windows), guarded so a missing launcher is a silent no-op. The launcher runs the engine binary that ships next to it, or downloads the pinned version once into `~/.impeccable/bin/`. No Node or other runtime is required for the hook or the skill.
|
||||
- Claude Code: `.claude/settings.local.json` (gitignored, machine-local) runs `${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs`. A hook moved into the shared `settings.json` is honored in place.
|
||||
- GitHub Copilot: `.github/hooks/impeccable.json` (committed, shared by the Copilot CLI and the cloud agent) runs `.github/skills/impeccable/scripts/hook.mjs`. The Copilot CLI activates it once the file is on the repository's default branch and the folder is trusted.
|
||||
- Cursor: `.cursor/hooks.json` runs `.cursor/skills/impeccable/scripts/hook-before-edit.mjs`.
|
||||
- Codex: `.codex/hooks.json` runs `.agents/skills/impeccable/scripts/hook.mjs`.
|
||||
- Grok Build: `.grok/hooks/impeccable.json` runs `.grok/skills/impeccable/scripts/hook.mjs`. Requires `/hooks-trust` or `--trust`. Findings reach the model on Stop, not after each edit.
|
||||
|
||||
The installer preserves unrelated hook entries and settings. If a hook manifest is malformed, install/update aborts by default; rerun with `--force` to back up the malformed file as `.bak` and replace it.
|
||||
|
||||
@@ -416,12 +412,12 @@ npx impeccable update
|
||||
|
||||
## CLI
|
||||
|
||||
Impeccable includes a standalone CLI for detecting anti-patterns without an AI harness. `npx impeccable` is a small shim that runs the same engine binary the skill uses (installed as a platform-specific optional dependency, or fetched once into `~/.impeccable/bin/`); Node is needed only for `npx` itself, and you can also download the binary directly and put it on your PATH.
|
||||
Impeccable includes a standalone CLI for detecting anti-patterns without an AI harness:
|
||||
|
||||
```bash
|
||||
npx impeccable detect src/ # scan a directory
|
||||
npx impeccable detect index.html # scan an HTML file
|
||||
npx impeccable detect https://example.com # scan a URL (uses an installed Chrome, Chromium, or Edge)
|
||||
npx impeccable detect https://example.com # scan a URL (Puppeteer)
|
||||
npx impeccable detect --json . # CI-friendly JSON output
|
||||
npx impeccable detect --no-config src/ # raw scan, ignoring project config/context
|
||||
npx impeccable ignores list # show detector ignores
|
||||
|
||||
+17
-19
@@ -1,44 +1,43 @@
|
||||
# Impeccable CLI
|
||||
|
||||
Detect UI anti-patterns and design quality issues from the command line, and install the Impeccable design skill into your AI coding harness. The detector scans HTML, CSS, JSX, TSX, Vue, and Svelte files for 61 deterministic rules, including AI-generated UI tells, accessibility violations, and general design quality problems.
|
||||
|
||||
The npm package is a small launcher. It runs the `impeccable` engine binary for your platform, installed alongside it as an optional dependency (`@impeccable/cli-<os>-<arch>`), and falls back to a per-user cache or a one-time download when that package is missing.
|
||||
Detect UI anti-patterns and design quality issues from the command line. Scans HTML, CSS, JSX, TSX, Vue, and Svelte files for 61 deterministic rules, including AI-generated UI tells, accessibility violations, and general design quality problems.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install skills into your AI harness (Claude, Cursor, Gemini, etc.)
|
||||
npx impeccable install
|
||||
npx impeccable skills install
|
||||
|
||||
# Non-interactive install for a specific scope
|
||||
npx impeccable install -y --providers=claude,codex --scope=project
|
||||
npx impeccable skills install -y --providers=claude,codex --scope=project
|
||||
|
||||
# First command to run inside your AI harness
|
||||
/impeccable init
|
||||
|
||||
# Update skills to the latest version
|
||||
npx impeccable update
|
||||
npx impeccable skills update
|
||||
|
||||
# Install or update skills without hook manifests
|
||||
npx impeccable install --no-hooks
|
||||
npx impeccable skills install --no-hooks
|
||||
|
||||
# Link skills from a Git submodule checkout
|
||||
npx impeccable link --source=.impeccable --providers=claude,cursor
|
||||
npx impeccable skills link --source=.impeccable --providers=claude,cursor
|
||||
|
||||
# List all available commands
|
||||
npx impeccable help
|
||||
npx impeccable skills help
|
||||
|
||||
# Scan files or directories for anti-patterns
|
||||
npx impeccable detect src/
|
||||
|
||||
# Scan a live URL (uses an installed Chrome, Chromium, or Edge)
|
||||
# Scan a live URL (requires Puppeteer)
|
||||
npx impeccable detect https://example.com
|
||||
|
||||
# JSON output for CI/tooling
|
||||
npx impeccable detect --json src/
|
||||
```
|
||||
|
||||
`npx impeccable skills <command>` is the legacy namespace and still works.
|
||||
# Deprecated compatibility flag; full scan still runs
|
||||
npx impeccable detect --fast src/
|
||||
```
|
||||
|
||||
## What It Detects
|
||||
|
||||
@@ -72,17 +71,16 @@ Operational failure takes precedence when a multi-target scan is partial. In JSO
|
||||
```
|
||||
impeccable detect [options] [file-or-dir-or-url...]
|
||||
|
||||
--json Output findings as JSON
|
||||
--scope Only report rules in a design domain (type, layout)
|
||||
--help Show help
|
||||
--fast Regex-only mode (skip jsdom, faster but less accurate)
|
||||
--json Output findings as JSON
|
||||
--help Show help
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 22.18+ to run `npx impeccable`. The engine itself is a self-contained binary and needs no runtime; the skill installed into your harness calls it directly.
|
||||
- For URL scans, an installed Chrome, Chromium, or Edge (set `IMPECCABLE_BROWSER` to point at one).
|
||||
|
||||
Binary lookup order: `IMPECCABLE_BIN`, the platform package, `~/.impeccable/bin/<version>/`, then a download of the pinned version into that cache. Set `IMPECCABLE_BIN` to a local build to skip all of that.
|
||||
- Node.js 22.18+
|
||||
- `jsdom` (included as dependency, used for HTML scanning)
|
||||
- `puppeteer` (optional, only needed for URL scanning)
|
||||
|
||||
## Part of Impeccable
|
||||
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
/**
|
||||
* Anti-Pattern Browser Detector for Impeccable
|
||||
* Copyright (c) 2026 Paul Bakaus
|
||||
*
|
||||
* GENERATED -- do not edit. Source: crates/core/src/browser (rules, WASM) +
|
||||
* browser-bundle/*.js (DOM probe, overlay UI).
|
||||
* Rebuild: cargo xtask bundle
|
||||
*
|
||||
* Usage: <script src="detect-antipatterns-browser.js"></script>
|
||||
* Re-scan: window.impeccableScan()
|
||||
*/
|
||||
(function () {
|
||||
if (typeof window === 'undefined') return;
|
||||
@@ -1,202 +0,0 @@
|
||||
// --- browser-bundle/10-probe.js ---
|
||||
// The DOM probe the WASM rule core calls back into. Pure measurement: one
|
||||
// function per DOM API the rules read (see crates/core/src/browser/dom.rs for
|
||||
// the contract). Elements travel as handles (indexes into a registry; 0 is
|
||||
// null). Nothing in here decides anything about a design.
|
||||
|
||||
const __els = [null];
|
||||
let __ids = new WeakMap();
|
||||
const __csCache = [null];
|
||||
// Drop every handle (a new scan re-interns what it touches; JS keeps
|
||||
// Elements, never handles, across calls).
|
||||
function __resetRegistry() {
|
||||
__els.length = 1;
|
||||
__csCache.length = 1;
|
||||
__ids = new WeakMap();
|
||||
}
|
||||
function __intern(el) {
|
||||
if (!el) return 0;
|
||||
let id = __ids.get(el);
|
||||
if (id === undefined) {
|
||||
id = __els.length;
|
||||
__els.push(el);
|
||||
__csCache.push(null);
|
||||
__ids.set(el, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
function __el(id) {
|
||||
return __els[id] || null;
|
||||
}
|
||||
function __cs(id) {
|
||||
let cs = __csCache[id];
|
||||
if (!cs) {
|
||||
cs = getComputedStyle(__els[id]);
|
||||
__csCache[id] = cs;
|
||||
}
|
||||
return cs;
|
||||
}
|
||||
function __ids_of(list) {
|
||||
const out = new Array(list.length);
|
||||
for (let i = 0; i < list.length; i++) out[i] = __intern(list[i]);
|
||||
return out;
|
||||
}
|
||||
const __SEL_ERR = 0xFFFFFFFF;
|
||||
function __rectArray(r) {
|
||||
return [r.x, r.y, r.width, r.height, r.top, r.right, r.bottom, r.left];
|
||||
}
|
||||
|
||||
const __impeccableDom = {
|
||||
document_element() { return __intern(document.documentElement); },
|
||||
body() { return __intern(document.body); },
|
||||
query_all(root, selector) {
|
||||
try {
|
||||
const scope = root ? __el(root) : document;
|
||||
return __ids_of(scope.querySelectorAll(selector));
|
||||
} catch { return [__SEL_ERR]; }
|
||||
},
|
||||
query_one(root, selector) {
|
||||
try {
|
||||
const scope = root ? __el(root) : document;
|
||||
return __intern(scope.querySelector(selector));
|
||||
} catch { return __SEL_ERR; }
|
||||
},
|
||||
inner_width() { return window.innerWidth; },
|
||||
inner_height() { return window.innerHeight; },
|
||||
scroll_x() { return window.scrollX; },
|
||||
scroll_y() { return window.scrollY; },
|
||||
hostname() { return location.hostname; },
|
||||
element_from_point(x, y) { return __intern(document.elementFromPoint(x, y)); },
|
||||
elements_from_point(x, y) {
|
||||
return typeof document.elementsFromPoint === 'function' ? __ids_of(document.elementsFromPoint(x, y)) : [];
|
||||
},
|
||||
css_escape(s) { return CSS.escape(s); },
|
||||
// JSON `[[["prop","value"],...], ...]` of the first @keyframes rule named
|
||||
// `name` (document.styleSheets order, nested rules walked breadth-first
|
||||
// exactly like keyframesToggleVisibilityDOM); undefined when none.
|
||||
keyframes(name) {
|
||||
if (!name) return undefined;
|
||||
for (const sheet of document.styleSheets) {
|
||||
let rules;
|
||||
try { rules = sheet.cssRules || sheet.rules; } catch { continue; }
|
||||
if (!rules) continue;
|
||||
const stack = [...rules];
|
||||
while (stack.length) {
|
||||
const rule = stack.shift();
|
||||
if (rule.cssRules && rule.type !== 7) { stack.push(...rule.cssRules); continue; }
|
||||
if (rule.type !== 7 || rule.name !== name) continue;
|
||||
const frames = [];
|
||||
for (const frame of rule.cssRules || []) {
|
||||
const fs = frame.style;
|
||||
if (!fs) continue;
|
||||
const decls = [];
|
||||
for (let i = 0; i < fs.length; i++) {
|
||||
const prop = fs[i];
|
||||
decls.push([prop, fs.getPropertyValue(prop)]);
|
||||
}
|
||||
frames.push(decls);
|
||||
}
|
||||
return JSON.stringify(frames);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
linked_stylesheet_text() {
|
||||
// The CSSOM walk lives in 15-snapshot.js so the standalone snapshot
|
||||
// producer carries it too; both routes read the same corpus.
|
||||
return __snapLinkedStylesheetText();
|
||||
},
|
||||
document_html_for_patterns() {
|
||||
const docClone = document.documentElement.cloneNode(true);
|
||||
for (const node of docClone.querySelectorAll('[id^="impeccable-live-"]')) node.remove();
|
||||
return docClone.outerHTML;
|
||||
},
|
||||
tag_name(el) { return __el(el).tagName; },
|
||||
namespace_uri(el) { return __el(el).namespaceURI || ''; },
|
||||
parent(el) { return __intern(__el(el).parentElement); },
|
||||
children(el) { return __ids_of(__el(el).children); },
|
||||
previous_element_sibling(el) { return __intern(__el(el).previousElementSibling); },
|
||||
next_element_sibling(el) { return __intern(__el(el).nextElementSibling); },
|
||||
contains(a, b) { return __el(a).contains(__el(b)); },
|
||||
matches(el, selector) {
|
||||
try { return __el(el).matches(selector) ? 1 : 0; } catch { return __SEL_ERR; }
|
||||
},
|
||||
closest(el, selector) {
|
||||
try { return __intern(__el(el).closest(selector)); } catch { return __SEL_ERR; }
|
||||
},
|
||||
attr(el, name) {
|
||||
const v = __el(el).getAttribute(name);
|
||||
return v == null ? undefined : v;
|
||||
},
|
||||
id_prop(el) {
|
||||
const v = __el(el).id;
|
||||
return typeof v === 'string' ? v : undefined;
|
||||
},
|
||||
class_name_prop(el) {
|
||||
const v = __el(el).className;
|
||||
return typeof v === 'string' ? v : undefined;
|
||||
},
|
||||
text_content(el) { return __el(el).textContent || ''; },
|
||||
inner_text(el) {
|
||||
const v = __el(el).innerText;
|
||||
return typeof v === 'string' && v ? v : undefined;
|
||||
},
|
||||
direct_text_nodes(el) {
|
||||
const out = [];
|
||||
for (const n of __el(el).childNodes) {
|
||||
if (n.nodeType === 3) out.push(n.textContent || '');
|
||||
}
|
||||
return out;
|
||||
},
|
||||
is_content_editable(el) { return !!__el(el).isContentEditable; },
|
||||
hidden_prop(el) { return !!__el(el).hidden; },
|
||||
style(el, prop) {
|
||||
const v = __cs(el)[prop];
|
||||
return v == null ? '' : String(v);
|
||||
},
|
||||
pseudo_style(el, pseudo, prop) {
|
||||
let ps;
|
||||
try { ps = getComputedStyle(__el(el), pseudo); } catch { return undefined; }
|
||||
if (!ps) return undefined;
|
||||
const v = ps[prop];
|
||||
return v == null ? '' : String(v);
|
||||
},
|
||||
rect(el) {
|
||||
const node = __el(el);
|
||||
if (typeof node.getBoundingClientRect !== 'function') return [];
|
||||
return __rectArray(node.getBoundingClientRect());
|
||||
},
|
||||
client_width(el) { return __el(el).clientWidth; },
|
||||
client_height(el) { return __el(el).clientHeight; },
|
||||
client_left(el) { return __el(el).clientLeft; },
|
||||
scroll_width(el) { return __el(el).scrollWidth; },
|
||||
scroll_left(el) { return __el(el).scrollLeft; },
|
||||
offset_width(el) { return __el(el).offsetWidth; },
|
||||
offset_height(el) { return __el(el).offsetHeight; },
|
||||
check_visibility(el) {
|
||||
const node = __el(el);
|
||||
if (typeof node.checkVisibility !== 'function') return -1;
|
||||
return node.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true }) ? 1 : 0;
|
||||
},
|
||||
// getDirectTextRect(el) from the JS driver: union of the client rects of
|
||||
// the element's non-blank direct text nodes.
|
||||
direct_text_rect(el) {
|
||||
const node = __el(el);
|
||||
const rects = [];
|
||||
for (const child of node.childNodes) {
|
||||
if (child.nodeType !== 3 || !(child.textContent || '').trim()) continue;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(child);
|
||||
for (const rect of range.getClientRects()) {
|
||||
if (rect.width >= 1 && rect.height >= 1) rects.push(rect);
|
||||
}
|
||||
range.detach?.();
|
||||
}
|
||||
if (rects.length === 0) return [];
|
||||
const left = Math.min(...rects.map(r => r.left));
|
||||
const top = Math.min(...rects.map(r => r.top));
|
||||
const right = Math.max(...rects.map(r => r.right));
|
||||
const bottom = Math.max(...rects.map(r => r.bottom));
|
||||
return [left, top, right - left, bottom - top, top, right, bottom, left];
|
||||
},
|
||||
};
|
||||
@@ -1,736 +0,0 @@
|
||||
// --- browser-bundle/15-snapshot.js ---
|
||||
// The page snapshot producer and the live-page IO the rules cannot do from
|
||||
// a snapshot. Pure measurement: what the probe in 10-probe.js reads on
|
||||
// demand, this reads once and serializes, so the WASM core can run where
|
||||
// the page's Content-Security-Policy keeps WebAssembly out (the extension's
|
||||
// offscreen document; see crates/core/src/browser/snapshot.rs for the
|
||||
// consumer and the field contract). Nothing in here decides anything about
|
||||
// a design: no thresholds, no rule names, no snippet strings.
|
||||
//
|
||||
// Exposed as `__impeccableSnapshot`:
|
||||
// capture(options) -> { json, elements, stats } | { error }
|
||||
// answer(needs, elements) -> facts for the core (`hitTests` -> `hits`)
|
||||
// idOf(el, elements) -> the element's snapshot id (0 when absent)
|
||||
// visualIO(elements) -> the IO half of the visual-contrast pass
|
||||
// (image loads, canvas pixel reads) over live
|
||||
// Elements, keyed by snapshot id
|
||||
// STYLE_PROPS / PSEUDO_PROPS / STATE_PSEUDOS (the capture contract)
|
||||
|
||||
// Computed-style properties the rules read. Mirrors STYLE_PROPS in
|
||||
// crates/core/src/browser/snapshot.rs (cargo xtask bundle checks the two
|
||||
// lists agree).
|
||||
const __SNAP_STYLE_PROPS = [
|
||||
"animationIterationCount", "animationName", "animationTimingFunction",
|
||||
"backdropFilter", "background", "backgroundClip", "backgroundColor",
|
||||
"backgroundImage", "backgroundPosition", "backgroundSize", "blockSize",
|
||||
"borderBottomColor", "borderBottomWidth", "borderBottomStyle",
|
||||
"borderLeftColor", "borderLeftWidth", "borderLeftStyle", "borderRadius",
|
||||
"borderRightColor", "borderRightWidth", "borderRightStyle",
|
||||
"borderTopColor", "borderTopWidth", "borderTopStyle", "bottom", "boxShadow",
|
||||
"clip", "clip-path", "clipPath", "color", "content", "contentVisibility",
|
||||
"cssFloat", "display", "filter", "float", "fontFamily", "fontSize",
|
||||
"fontStyle", "fontVariant", "fontVariantCaps", "fontWeight", "height",
|
||||
"hyphens", "inlineSize", "inset", "insetBlock", "insetBlockEnd",
|
||||
"insetBlockStart", "insetInline", "insetInlineEnd", "insetInlineStart",
|
||||
"left", "letterSpacing", "lineHeight", "marginBottom", "marginLeft",
|
||||
"marginRight", "marginTop", "maxHeight", "maxWidth", "minHeight", "minWidth",
|
||||
"mixBlendMode", "objectFit", "objectPosition", "opacity", "outline",
|
||||
"outlineColor", "outlineOffset", "outlineStyle", "outlineWidth", "overflow",
|
||||
"overflowX", "overflowY", "paddingBottom", "paddingLeft", "paddingRight",
|
||||
"paddingTop", "pointerEvents", "position", "right", "textAlign",
|
||||
"textDecoration", "textDecorationLine", "textIndent", "textOverflow",
|
||||
"textShadow", "textTransform", "top", "transform", "transitionDuration",
|
||||
"transitionProperty", "transitionTimingFunction", "verticalAlign",
|
||||
"visibility", "webkitBackgroundClip", "webkitClipPath", "webkitHyphens",
|
||||
"webkitTextFillColor", "whiteSpace", "width", "wordBreak", "zIndex",
|
||||
];
|
||||
// `::before` / `::after` properties, recorded where `content` is set.
|
||||
const __SNAP_PSEUDO_PROPS = [
|
||||
"content", "position", "opacity", "display", "width", "height", "top",
|
||||
"right", "bottom", "left", "backgroundColor", "backgroundImage",
|
||||
"background", "borderRadius", "transform", "visibility",
|
||||
];
|
||||
// Pseudo-class states recorded per element (`el.matches(':name')`), so the
|
||||
// snapshot selector engine can answer `:checked` / `:disabled` / ... the way
|
||||
// the live DOM would. Mirrors STATE_PSEUDOS in crates/core/src/browser/selector.rs.
|
||||
const __SNAP_STATE_PSEUDOS = [
|
||||
"hover", "active", "focus", "focus-within", "focus-visible", "target",
|
||||
"target-within", "checked", "indeterminate", "disabled", "required",
|
||||
"invalid", "user-invalid", "user-valid", "in-range", "out-of-range",
|
||||
"placeholder-shown", "default", "open", "autofill", "-webkit-autofill",
|
||||
"popover-open", "modal", "fullscreen", "-webkit-full-screen",
|
||||
"picture-in-picture", "playing", "buffering", "seeking", "muted",
|
||||
"volume-locked",
|
||||
];
|
||||
const __SNAP_NS = { "http://www.w3.org/1999/xhtml": 0, "http://www.w3.org/2000/svg": 1, "http://www.w3.org/1998/Math/MathML": 2 };
|
||||
const __SNAP_DEFAULT_MAX_ELEMENTS = 30000;
|
||||
const __SNAP_DEFAULT_MAX_BYTES = 48 * 1024 * 1024;
|
||||
|
||||
function __snapRect4(r) { return [r.x, r.y, r.width, r.height]; }
|
||||
function __snapNum(v) { return typeof v === 'number' ? v : null; }
|
||||
|
||||
// getDirectTextRect(el): union of the client rects of the element's
|
||||
// non-blank direct text nodes (same measure as 10-probe.js).
|
||||
function __snapDirectTextRect(node) {
|
||||
const rects = [];
|
||||
for (const child of node.childNodes) {
|
||||
if (child.nodeType !== 3 || !(child.textContent || '').trim()) continue;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(child);
|
||||
for (const rect of range.getClientRects()) {
|
||||
if (rect.width >= 1 && rect.height >= 1) rects.push(rect);
|
||||
}
|
||||
range.detach?.();
|
||||
}
|
||||
if (rects.length === 0) return null;
|
||||
const left = Math.min(...rects.map(r => r.left));
|
||||
const top = Math.min(...rects.map(r => r.top));
|
||||
const right = Math.max(...rects.map(r => r.right));
|
||||
const bottom = Math.max(...rects.map(r => r.bottom));
|
||||
return [left, top, right - left, bottom - top];
|
||||
}
|
||||
|
||||
// ─── Linked stylesheet corpus (JS: injected/index.mjs #709) ────────────────
|
||||
|
||||
// JS: injected/index.mjs#pseudoElementHostSelector
|
||||
function __snapPseudoElementHostSelector(selector) {
|
||||
const raw = String(selector || '');
|
||||
const legacyNames = new Set(['before', 'after', 'first-letter', 'first-line']);
|
||||
const isNameChar = char => /[a-zA-Z0-9_-]/.test(char || '');
|
||||
const consumeFunction = (start) => {
|
||||
let depth = 0;
|
||||
let quote = '';
|
||||
for (let i = start; i < raw.length; i += 1) {
|
||||
const char = raw[i];
|
||||
if (char === '\\') { i += 1; continue; }
|
||||
if (quote) { if (char === quote) quote = ''; continue; }
|
||||
if (char === '"' || char === "'") { quote = char; continue; }
|
||||
if (char === '(') depth += 1;
|
||||
if (char === ')' && --depth === 0) return i + 1;
|
||||
}
|
||||
return raw.length;
|
||||
};
|
||||
|
||||
let output = '';
|
||||
let found = false;
|
||||
for (let i = 0; i < raw.length;) {
|
||||
const char = raw[i];
|
||||
if (char === '\\') {
|
||||
output += raw.slice(i, Math.min(raw.length, i + 2));
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
const quote = char;
|
||||
const start = i;
|
||||
i += 1;
|
||||
while (i < raw.length) {
|
||||
if (raw[i] === '\\') { i += 2; continue; }
|
||||
const value = raw[i];
|
||||
i += 1;
|
||||
if (value === quote) break;
|
||||
}
|
||||
output += raw.slice(start, i);
|
||||
continue;
|
||||
}
|
||||
if (char !== ':') { output += char; i += 1; continue; }
|
||||
|
||||
let end = i + 1;
|
||||
let isPseudoElement = false;
|
||||
if (raw[end] === ':') {
|
||||
end += 1;
|
||||
const nameStart = end;
|
||||
while (isNameChar(raw[end])) end += 1;
|
||||
isPseudoElement = end > nameStart;
|
||||
} else {
|
||||
const nameStart = end;
|
||||
while (isNameChar(raw[end])) end += 1;
|
||||
isPseudoElement = legacyNames.has(raw.slice(nameStart, end).toLowerCase());
|
||||
}
|
||||
if (!isPseudoElement) { output += char; i += 1; continue; }
|
||||
if (raw[end] === '(') end = consumeFunction(end);
|
||||
found = true;
|
||||
if (!output || /[\s>+~,]/.test(output[output.length - 1])) output += '*';
|
||||
i = end;
|
||||
}
|
||||
if (!found) return null;
|
||||
return output.trim().replace(/,\s*(?=,|$)/g, '');
|
||||
}
|
||||
|
||||
// JS: injected/index.mjs#selectorNodesForLiveDom
|
||||
function __snapSelectorNodesForLiveDom(root, selector) {
|
||||
const raw = String(selector || '').trim();
|
||||
if (!raw) return null;
|
||||
const fallback = __snapPseudoElementHostSelector(raw);
|
||||
if (fallback == null) {
|
||||
// An empty result from a valid full selector is authoritative. In
|
||||
// particular, do not broaden inactive :hover/:focus/:not() rules to
|
||||
// their host element by stripping pseudo-classes.
|
||||
try { return Array.from(root.querySelectorAll(raw)); }
|
||||
catch { return null; }
|
||||
}
|
||||
// Resolve pseudo-elements to their originating live elements. An attached
|
||||
// pseudo-element (`.card::before`) belongs to the element before it, while
|
||||
// a hostless pseudo-element after a combinator (`main > ::before`) belongs
|
||||
// to a matching element at that position (`main > *`).
|
||||
if (!fallback || /^[,\s]*$/.test(fallback)) return null;
|
||||
try { return Array.from(root.querySelectorAll(fallback)); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
let __snapContainerProbeSequence = 0;
|
||||
|
||||
function __snapIsContainerCssRule(rule) {
|
||||
return rule?.constructor?.name === 'CSSContainerRule'
|
||||
|| /^\s*@container\b/i.test(rule?.cssText || '');
|
||||
}
|
||||
|
||||
function __snapStyleRuleAppliesToLiveMatches(rule, matches) {
|
||||
const style = rule?.style;
|
||||
if (!style || !matches?.length || typeof getComputedStyle !== 'function') return false;
|
||||
const sequence = ++__snapContainerProbeSequence;
|
||||
const property = `--impeccable-container-probe-${sequence}-${Math.random().toString(36).slice(2)}`;
|
||||
const value = `impeccable-container-active-${sequence}`;
|
||||
const previousValue = style.getPropertyValue(property);
|
||||
const previousPriority = style.getPropertyPriority(property);
|
||||
try { style.setProperty(property, value, 'important'); }
|
||||
catch { return false; }
|
||||
|
||||
const pseudoElements = [...new Set(
|
||||
String(rule.selectorText || '').match(/::[a-zA-Z-]+(?:\([^)]*\))?/g) || [],
|
||||
)];
|
||||
try {
|
||||
return matches.some(el => [null, ...pseudoElements].some(pseudo => {
|
||||
try {
|
||||
const computed = pseudo ? getComputedStyle(el, pseudo) : getComputedStyle(el);
|
||||
return computed.getPropertyValue(property).trim() === value;
|
||||
} catch { return false; }
|
||||
}));
|
||||
} finally {
|
||||
if (previousValue) style.setProperty(property, previousValue, previousPriority);
|
||||
else style.removeProperty(property);
|
||||
}
|
||||
}
|
||||
|
||||
function __snapConditionalCssRuleIsActive(rule) {
|
||||
const type = Number(rule?.type);
|
||||
const constructorName = rule?.constructor?.name || '';
|
||||
if (constructorName === 'CSSMediaRule' || type === 4) {
|
||||
const condition = rule.conditionText || rule.media?.mediaText || '';
|
||||
if (!condition || typeof window.matchMedia !== 'function') return true;
|
||||
try { return window.matchMedia(condition).matches; }
|
||||
catch { return true; }
|
||||
}
|
||||
if (constructorName === 'CSSSupportsRule' || type === 12) {
|
||||
const condition = rule.conditionText || '';
|
||||
if (!condition || typeof CSS === 'undefined' || typeof CSS.supports !== 'function') return true;
|
||||
try { return CSS.supports(condition); }
|
||||
catch { return true; }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function __snapSplitCssCommaList(value) {
|
||||
const parts = [];
|
||||
let current = '';
|
||||
let quote = '';
|
||||
let escaped = false;
|
||||
for (const char of String(value || '')) {
|
||||
if (escaped) { current += char; escaped = false; continue; }
|
||||
if (char === '\\') { current += char; escaped = true; continue; }
|
||||
if (quote) { current += char; if (char === quote) quote = ''; continue; }
|
||||
if (char === '"' || char === "'") { quote = char; current += char; continue; }
|
||||
if (char === ',') { parts.push(current); current = ''; continue; }
|
||||
current += char;
|
||||
}
|
||||
parts.push(current);
|
||||
return parts;
|
||||
}
|
||||
|
||||
function __snapNormalizeAnimationName(value) {
|
||||
const name = String(value || '').trim();
|
||||
if (name.length >= 2 && name[0] === name[name.length - 1] && (name[0] === '"' || name[0] === "'")) {
|
||||
return name.slice(1, -1);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function __snapAnimationNamesDeclaredByRule(rule) {
|
||||
const style = rule?.style;
|
||||
if (!style) return [];
|
||||
let value = '';
|
||||
try {
|
||||
value = style.animationName
|
||||
|| style.getPropertyValue?.('animation-name')
|
||||
|| style.webkitAnimationName
|
||||
|| style.getPropertyValue?.('-webkit-animation-name')
|
||||
|| '';
|
||||
} catch { return []; }
|
||||
return __snapSplitCssCommaList(value)
|
||||
.map(__snapNormalizeAnimationName)
|
||||
.filter(name => name && name.toLowerCase() !== 'none');
|
||||
}
|
||||
|
||||
function __snapKeyframesRuleName(rule, cssText) {
|
||||
const constructorName = rule?.constructor?.name || '';
|
||||
const type = Number(rule?.type);
|
||||
const isKeyframes = constructorName === 'CSSKeyframesRule'
|
||||
|| constructorName === 'WebKitCSSKeyframesRule'
|
||||
|| type === 7
|
||||
|| /^\s*@(?:-webkit-)?keyframes\b/i.test(cssText);
|
||||
if (!isKeyframes) return '';
|
||||
const match = String(cssText || '').match(/^\s*@(?:-webkit-)?keyframes\s+([^\s{]+)/i);
|
||||
return __snapNormalizeAnimationName(rule?.name || match?.[1] || '');
|
||||
}
|
||||
|
||||
function __snapCssPropertyName(property) {
|
||||
if (property.startsWith('--')) return property;
|
||||
return property.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
|
||||
}
|
||||
|
||||
function __snapResolvedAnimationKeyframes(candidateNames) {
|
||||
if (typeof document.getAnimations !== 'function') return null;
|
||||
let animations;
|
||||
try { animations = document.getAnimations(); }
|
||||
catch { return null; }
|
||||
|
||||
const resolved = new Map();
|
||||
const metadata = new Set(['offset', 'computedOffset', 'easing', 'composite']);
|
||||
for (const animation of animations) {
|
||||
const name = __snapNormalizeAnimationName(animation?.animationName || '');
|
||||
if (!name || !candidateNames.has(name) || resolved.has(name)) continue;
|
||||
let frames;
|
||||
try { frames = animation.effect?.getKeyframes?.() || []; }
|
||||
catch { continue; }
|
||||
const blocks = [];
|
||||
for (const frame of frames) {
|
||||
const rawOffset = Number.isFinite(frame.computedOffset) ? frame.computedOffset : frame.offset;
|
||||
if (!Number.isFinite(rawOffset)) continue;
|
||||
const offset = Math.round(rawOffset * 1000000) / 10000;
|
||||
const declarations = Object.entries(frame)
|
||||
.filter(([property, value]) => !metadata.has(property) && value != null && value !== '')
|
||||
.map(([property, value]) => `${__snapCssPropertyName(property)}: ${value};`);
|
||||
const easing = String(frame.easing || '').trim();
|
||||
if (easing && easing.toLowerCase() !== 'linear') {
|
||||
declarations.push(`animation-timing-function: ${easing};`);
|
||||
}
|
||||
if (declarations.length === 0) continue;
|
||||
blocks.push(`${offset}% { ${declarations.join(' ')} }`);
|
||||
}
|
||||
if (blocks.length > 0) resolved.set(name, `@keyframes ${name} { ${blocks.join(' ')} }`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// Read CSS that is absent from document.outerHTML. Inline <style> blocks are
|
||||
// already present in the HTML pattern corpus, so limit this walk to linked
|
||||
// stylesheets. Flatten grouping rules so each declaration keeps its selector,
|
||||
// and admit only selector rules that target the live DOM. That prevents
|
||||
// unused utilities from feeding both selector-scoped and page-level checks.
|
||||
// Same-origin CSS and readable CORS sheets participate; browser security
|
||||
// exceptions for cross-origin sheets are expected and skipped.
|
||||
// JS: injected/index.mjs#linkedStylesheetText
|
||||
function __snapLinkedStylesheetText() {
|
||||
const parts = [];
|
||||
const seen = new Set();
|
||||
const animationNames = new Set();
|
||||
const keyframeCandidates = new Map();
|
||||
const appendRules = (rules, requiresAppliedMatch = false) => {
|
||||
for (const rule of rules) {
|
||||
if (rule.styleSheet) { appendSheet(rule.styleSheet); continue; }
|
||||
const cssText = rule.cssText || '';
|
||||
if (rule.selectorText) {
|
||||
const matches = __snapSelectorNodesForLiveDom(document, rule.selectorText);
|
||||
// Only declarations with a resolvable live host enter the corpus.
|
||||
// Unresolvable selectors are uncertain, not evidence that a pattern
|
||||
// rendered, and retaining them would leak unused CSS into findings.
|
||||
if (
|
||||
matches?.length > 0
|
||||
&& (!requiresAppliedMatch || __snapStyleRuleAppliesToLiveMatches(rule, matches))
|
||||
) {
|
||||
parts.push(cssText);
|
||||
for (const name of __snapAnimationNamesDeclaredByRule(rule)) animationNames.add(name);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let nested = [];
|
||||
let hasNestedRules = false;
|
||||
try {
|
||||
const ruleList = rule.cssRules;
|
||||
hasNestedRules = ruleList != null;
|
||||
nested = Array.from(ruleList || []);
|
||||
} catch { continue; }
|
||||
const keyframesName = __snapKeyframesRuleName(rule, cssText);
|
||||
if (keyframesName) {
|
||||
// Keyframes do not merge: when a name is defined more than once, the
|
||||
// later effective definition replaces the earlier one.
|
||||
keyframeCandidates.set(keyframesName, { name: keyframesName, cssText });
|
||||
continue;
|
||||
}
|
||||
if (hasNestedRules) {
|
||||
if (!__snapConditionalCssRuleIsActive(rule)) continue;
|
||||
appendRules(nested, requiresAppliedMatch || __snapIsContainerCssRule(rule));
|
||||
continue;
|
||||
}
|
||||
// Other selector-less leaf at-rules cannot be tied to a rendered node.
|
||||
}
|
||||
};
|
||||
const appendSheet = (sheet) => {
|
||||
if (!sheet || seen.has(sheet)) return;
|
||||
seen.add(sheet);
|
||||
let rules;
|
||||
try { rules = Array.from(sheet.cssRules || sheet.rules || []); }
|
||||
catch { return; }
|
||||
appendRules(rules);
|
||||
};
|
||||
let sheets;
|
||||
try { sheets = Array.from(document.styleSheets || []); }
|
||||
catch { return ''; }
|
||||
for (const sheet of sheets) {
|
||||
const owner = sheet.ownerNode;
|
||||
if (owner?.tagName?.toLowerCase() !== 'link') continue;
|
||||
if (!/\bstylesheet\b/i.test(owner.getAttribute?.('rel') || '')) continue;
|
||||
appendSheet(sheet);
|
||||
}
|
||||
// Motion checks need the effective body of a live animation's keyframes.
|
||||
// Let the browser resolve duplicate names across source order, imports,
|
||||
// conditional groups, and cascade layers, then serialize those computed
|
||||
// frames back into the pattern corpus. Browsers also make container-nested
|
||||
// keyframes globally available, so lexical grouping is not a reliable
|
||||
// activity signal. When the Web Animations API is unavailable, fall back to
|
||||
// the last source-order definition referenced by a retained linked rule.
|
||||
const resolvedKeyframes = __snapResolvedAnimationKeyframes(new Set(keyframeCandidates.keys()));
|
||||
if (resolvedKeyframes) {
|
||||
parts.push(...resolvedKeyframes.values());
|
||||
} else {
|
||||
for (const candidate of keyframeCandidates.values()) {
|
||||
if (!animationNames.has(candidate.name)) continue;
|
||||
parts.push(candidate.cssText);
|
||||
}
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
// Every @keyframes rule, in document.styleSheets order (nested rules walked
|
||||
// breadth-first like 10-probe.js keyframes()); first rule per name wins.
|
||||
function __snapKeyframes() {
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
for (const sheet of document.styleSheets) {
|
||||
let rules;
|
||||
try { rules = sheet.cssRules || sheet.rules; } catch { continue; }
|
||||
if (!rules) continue;
|
||||
const stack = [...rules];
|
||||
while (stack.length) {
|
||||
const rule = stack.shift();
|
||||
if (rule.cssRules && rule.type !== 7) { stack.push(...rule.cssRules); continue; }
|
||||
if (rule.type !== 7 || seen.has(rule.name)) continue;
|
||||
seen.add(rule.name);
|
||||
const frames = [];
|
||||
for (const frame of rule.cssRules || []) {
|
||||
const fs = frame.style;
|
||||
if (!fs) continue;
|
||||
const decls = [];
|
||||
for (let i = 0; i < fs.length; i++) {
|
||||
const prop = fs[i];
|
||||
decls.push([prop, fs.getPropertyValue(prop)]);
|
||||
}
|
||||
frames.push(decls);
|
||||
}
|
||||
out.push([rule.name, frames]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Which recorded pseudo-class states each element carries: one document
|
||||
// query per state (cheap), instead of N x states `matches` calls.
|
||||
function __snapStates(ids) {
|
||||
const states = new Map();
|
||||
for (const name of __SNAP_STATE_PSEUDOS) {
|
||||
let list;
|
||||
try { list = document.querySelectorAll(':' + name); } catch { continue; }
|
||||
for (const el of list) {
|
||||
const id = ids.get(el);
|
||||
if (!id) continue;
|
||||
let arr = states.get(id);
|
||||
if (!arr) { arr = []; states.set(id, arr); }
|
||||
arr.push(name);
|
||||
}
|
||||
}
|
||||
// Custom elements without a definition (`:defined` is the common case;
|
||||
// record its complement).
|
||||
try {
|
||||
for (const el of document.querySelectorAll(':not(:defined)')) {
|
||||
const id = ids.get(el);
|
||||
if (!id) continue;
|
||||
let arr = states.get(id);
|
||||
if (!arr) { arr = []; states.set(id, arr); }
|
||||
arr.push('undefined');
|
||||
}
|
||||
} catch { /* older engines */ }
|
||||
return states;
|
||||
}
|
||||
|
||||
// The drawable IO both adapters share: fetch an image for sampling (the
|
||||
// 800ms budget and the CORS opt-in for cross-origin URLs are load policy,
|
||||
// not rule logic), draw a drawable to a cached canvas, read one pixel.
|
||||
function __createDrawableIO() {
|
||||
const images = new Map(); // src -> Promise<Image|null>
|
||||
const rasters = new WeakMap(); // drawable -> { ctx, plan } | { ctx: null, error }
|
||||
return {
|
||||
loadImageEl(src) {
|
||||
if (!src) return Promise.resolve(null);
|
||||
if (images.has(src)) return images.get(src);
|
||||
const promise = new Promise(resolve => {
|
||||
const img = new Image();
|
||||
let settled = false;
|
||||
const finish = value => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(value);
|
||||
};
|
||||
const timer = setTimeout(() => finish(null), 800);
|
||||
try {
|
||||
const absolute = new URL(src, location.href);
|
||||
if (absolute.origin !== location.origin && absolute.protocol !== 'data:' && absolute.protocol !== 'blob:') {
|
||||
img.crossOrigin = 'anonymous';
|
||||
}
|
||||
} catch {
|
||||
// Let the browser resolve unusual URLs itself.
|
||||
}
|
||||
img.onload = () => finish(img);
|
||||
img.onerror = () => finish(null);
|
||||
img.src = src;
|
||||
});
|
||||
images.set(src, promise);
|
||||
return promise;
|
||||
},
|
||||
// Draw `drawable` to a canvas of plan.width x plan.height (cached per
|
||||
// drawable, failures included) and read the pixel at (px, py).
|
||||
// -> { data: [r, g, b, a] } | { error: message } | { noContext: true }
|
||||
readPixel(drawable, plan, px, py) {
|
||||
let cached = rasters.get(drawable);
|
||||
if (!cached) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = plan.width;
|
||||
canvas.height = plan.height;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx) return { noContext: true };
|
||||
try {
|
||||
ctx.drawImage(drawable, 0, 0, canvas.width, canvas.height);
|
||||
cached = { ctx, plan };
|
||||
} catch (err) {
|
||||
cached = { ctx: null, error: err?.message || '' };
|
||||
}
|
||||
rasters.set(drawable, cached);
|
||||
}
|
||||
if (!cached.ctx) return { error: cached.error || '' };
|
||||
try {
|
||||
const data = cached.ctx.getImageData(px, py, 1, 1).data;
|
||||
return { data: [data[0], data[1], data[2], data[3]] };
|
||||
} catch (err) {
|
||||
return { error: err?.message || '' };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const __impeccableSnapshot = {
|
||||
STYLE_PROPS: __SNAP_STYLE_PROPS,
|
||||
PSEUDO_PROPS: __SNAP_PSEUDO_PROPS,
|
||||
STATE_PSEUDOS: __SNAP_STATE_PSEUDOS,
|
||||
|
||||
// Serialize the page. `options.maxElements` / `options.maxBytes` are the
|
||||
// guards (defaults 30k elements / 48 MB); `options.exclude(el)` skips a
|
||||
// subtree (the extension passes its own overlay nodes, exactly the nodes
|
||||
// the rules skip through their `.impeccable-*` selectors anyway).
|
||||
capture(options = {}) {
|
||||
const t0 = performance.now();
|
||||
const maxElements = options.maxElements || __SNAP_DEFAULT_MAX_ELEMENTS;
|
||||
const maxBytes = options.maxBytes || __SNAP_DEFAULT_MAX_BYTES;
|
||||
const root = document.documentElement;
|
||||
if (!root) return { error: 'no document element' };
|
||||
|
||||
// 1. Walk in document order, assign ids.
|
||||
const elements = [null];
|
||||
const ids = new WeakMap();
|
||||
const stack = [root];
|
||||
while (stack.length) {
|
||||
const el = stack.pop();
|
||||
if (options.exclude && options.exclude(el)) continue;
|
||||
const id = elements.length;
|
||||
elements.push(el);
|
||||
ids.set(el, id);
|
||||
if (elements.length > maxElements) {
|
||||
return { error: `page has more than ${maxElements} elements` };
|
||||
}
|
||||
const kids = el.children;
|
||||
for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i]);
|
||||
}
|
||||
|
||||
// 2. Intern style values.
|
||||
const strings = [];
|
||||
const stringIndex = new Map();
|
||||
const intern = (v) => {
|
||||
const s = v == null ? '' : String(v);
|
||||
let i = stringIndex.get(s);
|
||||
if (i === undefined) { i = strings.length; strings.push(s); stringIndex.set(s, i); }
|
||||
return i;
|
||||
};
|
||||
|
||||
const states = __snapStates(ids);
|
||||
const els = new Array(elements.length - 1);
|
||||
for (let id = 1; id < elements.length; id++) {
|
||||
const el = elements[id];
|
||||
const rec = { t: el.tagName };
|
||||
const nsUri = el.namespaceURI || '';
|
||||
const ns = __SNAP_NS[nsUri];
|
||||
if (ns === undefined) { rec.n = 3; rec.nu = nsUri; } else if (ns !== 0) { rec.n = ns; }
|
||||
const parent = el.parentElement;
|
||||
if (parent) rec.p = ids.get(parent) || 0;
|
||||
// childNodes: element ids, text data, CDATA as [data].
|
||||
const c = [];
|
||||
for (const n of el.childNodes) {
|
||||
if (n.nodeType === 1) {
|
||||
const cid = ids.get(n);
|
||||
if (cid) c.push(cid);
|
||||
} else if (n.nodeType === 3) {
|
||||
c.push(n.textContent || '');
|
||||
} else if (n.nodeType === 4) {
|
||||
c.push([n.textContent || '']);
|
||||
}
|
||||
}
|
||||
rec.c = c;
|
||||
const names = el.getAttributeNames();
|
||||
if (names.length) rec.a = names.map(name => [name, el.getAttribute(name)]);
|
||||
const cs = getComputedStyle(el);
|
||||
rec.s = __SNAP_STYLE_PROPS.map(p => intern(cs[p]));
|
||||
for (const [key, pseudo] of [['b', '::before'], ['f', '::after']]) {
|
||||
let ps;
|
||||
try { ps = getComputedStyle(el, pseudo); } catch { continue; }
|
||||
if (!ps) continue;
|
||||
const content = ps.content;
|
||||
if (content == null || content === '' || content === 'none') continue;
|
||||
rec[key] = __SNAP_PSEUDO_PROPS.map(p => intern(ps[p]));
|
||||
}
|
||||
if (typeof el.getBoundingClientRect === 'function') rec.r = __snapRect4(el.getBoundingClientRect());
|
||||
rec.m = [
|
||||
__snapNum(el.clientWidth), __snapNum(el.clientHeight), __snapNum(el.clientLeft),
|
||||
__snapNum(el.scrollWidth), __snapNum(el.scrollLeft),
|
||||
__snapNum(el.offsetWidth), __snapNum(el.offsetHeight),
|
||||
];
|
||||
rec.v = typeof el.checkVisibility === 'function'
|
||||
? (el.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true }) ? 1 : 0)
|
||||
: -1;
|
||||
const dtr = __snapDirectTextRect(el);
|
||||
if (dtr) rec.d = dtr;
|
||||
if (el.isContentEditable) rec.e = true;
|
||||
if (el.hidden) rec.h = true;
|
||||
if (typeof el.id !== 'string') rec.i = true;
|
||||
if (typeof el.className !== 'string') rec.k = true;
|
||||
const st = states.get(id);
|
||||
if (st) rec.st = st;
|
||||
const tag = rec.t;
|
||||
if (tag === 'IMG' || tag === 'VIDEO' || tag === 'CANVAS' || tag === 'PICTURE') {
|
||||
rec.md = {
|
||||
nw: el.naturalWidth || 0, nh: el.naturalHeight || 0,
|
||||
vw: el.videoWidth || 0, vh: el.videoHeight || 0,
|
||||
w: typeof el.width === 'number' ? el.width : 0,
|
||||
h: typeof el.height === 'number' ? el.height : 0,
|
||||
cur: el.currentSrc || '', src: typeof el.src === 'string' ? el.src : '',
|
||||
};
|
||||
}
|
||||
els[id - 1] = rec;
|
||||
}
|
||||
|
||||
// 3. Document-level facts.
|
||||
const docClone = root.cloneNode(true);
|
||||
for (const node of docClone.querySelectorAll('[id^="impeccable-live-"]')) node.remove();
|
||||
const body = document.body;
|
||||
let bodyInnerText = null;
|
||||
if (body) {
|
||||
const v = body.innerText;
|
||||
bodyInnerText = typeof v === 'string' ? v : null;
|
||||
}
|
||||
const snapshot = {
|
||||
v: 1,
|
||||
hostname: location.hostname,
|
||||
quirks: document.compatMode === 'BackCompat',
|
||||
innerWidth: window.innerWidth,
|
||||
innerHeight: window.innerHeight,
|
||||
scrollX: window.scrollX,
|
||||
scrollY: window.scrollY,
|
||||
html: docClone.outerHTML,
|
||||
keyframes: __snapKeyframes(),
|
||||
linkedCss: __snapLinkedStylesheetText(),
|
||||
styleProps: __SNAP_STYLE_PROPS,
|
||||
pseudoProps: __SNAP_PSEUDO_PROPS,
|
||||
strings,
|
||||
els,
|
||||
documentElement: ids.get(root) || 0,
|
||||
body: body ? (ids.get(body) || 0) : 0,
|
||||
bodyInnerText,
|
||||
hits: options.hits || [],
|
||||
};
|
||||
const json = JSON.stringify(snapshot);
|
||||
if (json.length > maxBytes) {
|
||||
return { error: `snapshot is ${json.length} bytes (limit ${maxBytes})` };
|
||||
}
|
||||
return {
|
||||
json,
|
||||
elements,
|
||||
ids,
|
||||
stats: { elements: elements.length - 1, bytes: json.length, ms: performance.now() - t0 },
|
||||
};
|
||||
},
|
||||
|
||||
idOf(el, capture) {
|
||||
if (!el || !capture) return 0;
|
||||
return capture.ids.get(el) || 0;
|
||||
},
|
||||
|
||||
// Answer the core's pending questions from the live page.
|
||||
answer(needs, capture) {
|
||||
const facts = { hits: [] };
|
||||
for (const [x, y] of (needs && needs.hitTests) || []) {
|
||||
const top = document.elementFromPoint(x, y);
|
||||
const stack = typeof document.elementsFromPoint === 'function' ? document.elementsFromPoint(x, y) : [];
|
||||
facts.hits.push({
|
||||
x, y,
|
||||
top: this.idOf(top, capture),
|
||||
stack: [...stack].map(el => this.idOf(el, capture)).filter(Boolean),
|
||||
});
|
||||
}
|
||||
return facts;
|
||||
},
|
||||
|
||||
// The IO half of the visual-contrast pass over live Elements: image
|
||||
// loading and canvas pixel reads (see createVisualContrast in
|
||||
// 35-visual.js for the adapter contract). Refs are snapshot ids for page
|
||||
// elements and `{ url }` for separately loaded images.
|
||||
visualIO(capture) {
|
||||
const io = __createDrawableIO();
|
||||
const loadedByUrl = new Map();
|
||||
const drawableOf = (ref) => {
|
||||
if (ref && typeof ref === 'object' && ref.url) return loadedByUrl.get(ref.url) || null;
|
||||
return capture.elements[ref] || null;
|
||||
};
|
||||
return {
|
||||
// -> { ref: { url }, w, h } | null (w = naturalWidth || width)
|
||||
async loadImage(src) {
|
||||
const img = await io.loadImageEl(src);
|
||||
if (!img) return null;
|
||||
loadedByUrl.set(src, img);
|
||||
return { ref: { url: src }, w: img.naturalWidth || img.width || 0, h: img.naturalHeight || img.height || 0 };
|
||||
},
|
||||
// -> { data: [r, g, b, a] } | { error: message } | { noContext: true }
|
||||
readPixel(ref, plan, px, py) {
|
||||
const drawable = drawableOf(ref);
|
||||
if (!drawable) return { error: 'drawable unavailable' };
|
||||
return io.readPixel(drawable, plan, px, py);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,64 +0,0 @@
|
||||
// --- browser-bundle/30-scan-common.js ---
|
||||
// Scan-config plumbing shared by the in-page bundle (50-scan.js) and the
|
||||
// extension's offscreen document (60-offscreen.js): which visual-contrast
|
||||
// mode a scan runs in, the options it resolves to, which analyses the lazy
|
||||
// (scroll-into-view) pass re-tries, and the scanId echo. `config` is the
|
||||
// page's `window.__IMPECCABLE_CONFIG__` in the page and the extension's scan
|
||||
// config offscreen.
|
||||
|
||||
// Visual contrast has three modes. Explicit true runs the full sampled
|
||||
// pass; explicit false disables it entirely (the deterministic-only mode
|
||||
// the test suites use). Unset — the default overlay run — samples ONLY
|
||||
// image-backed text: the one class the analytic walk deliberately skips,
|
||||
// because a url() layer's pixels are unknowable without looking. In-page
|
||||
// sampling draws the source image alone to a canvas (glyph ink never
|
||||
// pollutes it), and a cross-origin image without CORS reports unresolved
|
||||
// instead of guessing.
|
||||
function __visualContrastMode(options = {}, config = {}) {
|
||||
const explicit = typeof options.visualContrast === 'boolean'
|
||||
? options.visualContrast
|
||||
: typeof config?.visualContrast === 'boolean'
|
||||
? config.visualContrast
|
||||
: null;
|
||||
if (explicit === true) return 'full';
|
||||
if (explicit === false) return false;
|
||||
return 'image-only';
|
||||
}
|
||||
|
||||
function __visualContrastOptions(options = {}, config = {}) {
|
||||
config = config || {};
|
||||
const scrollOffscreen = typeof options.scrollOffscreen === 'boolean'
|
||||
? options.scrollOffscreen
|
||||
: typeof options.visualContrastScrollOffscreen === 'boolean'
|
||||
? options.visualContrastScrollOffscreen
|
||||
: typeof config.visualContrastScrollOffscreen === 'boolean'
|
||||
? config.visualContrastScrollOffscreen
|
||||
: false;
|
||||
return {
|
||||
...options,
|
||||
maxCandidates: Number.isFinite(options.visualContrastMaxCandidates)
|
||||
? options.visualContrastMaxCandidates
|
||||
: Number.isFinite(options.maxCandidates)
|
||||
? options.maxCandidates
|
||||
: Number.isFinite(config.visualContrastMaxCandidates)
|
||||
? config.visualContrastMaxCandidates
|
||||
: undefined,
|
||||
scrollOffscreen,
|
||||
};
|
||||
}
|
||||
|
||||
// The analyses the lazy pass watches: unresolved only because the text was
|
||||
// outside the viewport, and addressable.
|
||||
function __lazyVisualContrastCandidates(analyses) {
|
||||
return (analyses || []).filter(result =>
|
||||
result?.status === 'unresolved' &&
|
||||
result.reason === 'text outside viewport' &&
|
||||
result.selector
|
||||
);
|
||||
}
|
||||
|
||||
function __scanResultMeta(options = {}) {
|
||||
const scanId = options.scanId;
|
||||
if (typeof scanId !== 'string' && typeof scanId !== 'number') return {};
|
||||
return { scanId: String(scanId) };
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
// --- browser-bundle/35-visual.js ---
|
||||
// Visual-contrast sampling. Only the async / IO acts live here (image
|
||||
// loading, canvas pixel reads, scrollIntoView, paint waits) plus the
|
||||
// control flow that awaits them; every decision — candidate gates,
|
||||
// reasons, sample points, painted-rect math, thresholds, blending, method
|
||||
// and reason strings, percentiles, the result objects — is a call into the
|
||||
// WASM core (crates/core/src/browser/visual.rs via `IO.core('vc_*', ...)`).
|
||||
//
|
||||
// The same orchestration runs in two places, so the IO is an adapter:
|
||||
// - in the page (50-scan.js): nodes are Elements, the core is called
|
||||
// synchronously, images and canvases are right here;
|
||||
// - in the extension's offscreen document (60-offscreen.js): nodes are
|
||||
// snapshot ids, the core runs over the snapshot and its hit-test needs
|
||||
// are answered by the content script between calls, images and pixels
|
||||
// are read by the content script and travel back as facts.
|
||||
//
|
||||
// createVisualContrast(IO) -> { collectVisualContrastCandidates(options),
|
||||
// analyzeVisualContrastCandidate(candidate), analyzeVisualContrast(options),
|
||||
// waitForVisualPaint() }
|
||||
//
|
||||
// IO contract (N = the adapter's node representation):
|
||||
// core(fn, ...args) -> Promise<result> wasm export by name
|
||||
// coreSync(fn, ...args) -> result (only used by the sync
|
||||
// candidate collector; offscreen may throw)
|
||||
// node(handle) / handle(N) handle <-> N
|
||||
// parentOrBody(N) -> N (`node.parentElement || document.body`)
|
||||
// intrinsicImg(N) -> [w, h] naturalWidth||videoWidth||width
|
||||
// intrinsicRaster(N) -> [w, h] width||videoWidth
|
||||
// imgSrc(N) -> currentSrc || src || ''
|
||||
// loadImage(src) -> Promise<{ ref, w, h } | null>
|
||||
// readPixel(ref, plan, px, py) -> Promise<{ data } | { error } | { noContext }>
|
||||
// ref is an N (page drawable) or a loadImage ref
|
||||
// querySelector(selector) -> N | null (scroll retry only)
|
||||
// scroll() -> { x, y }
|
||||
// scrollTo(x, y), scrollIntoView(N), waitForPaint() -> Promise
|
||||
|
||||
function createVisualContrast(IO) {
|
||||
const __j = JSON.stringify;
|
||||
const __p = JSON.parse;
|
||||
const core = async (fn, ...args) => __p(await IO.core(fn, ...args));
|
||||
const coreRaw = (fn, ...args) => IO.core(fn, ...args);
|
||||
|
||||
function collectVisualContrastCandidates(options = {}) {
|
||||
return __p(IO.coreSync('collect_visual_contrast_candidates', __j({
|
||||
maxCandidates: options.maxCandidates,
|
||||
imageOnly: options.imageOnly,
|
||||
})));
|
||||
}
|
||||
|
||||
async function collectVisualContrastCandidatesAsync(options = {}) {
|
||||
return core('collect_visual_contrast_candidates', __j({
|
||||
maxCandidates: options.maxCandidates,
|
||||
imageOnly: options.imageOnly,
|
||||
}));
|
||||
}
|
||||
|
||||
// Draw the drawable to a (cached) canvas and read one pixel: the plan and
|
||||
// the pixel address come from the core, the read from the IO.
|
||||
async function sampleDrawablePixel(ref, intrinsic, sourcePoint) {
|
||||
const plan = await core('vc_raster_plan', intrinsic[0], intrinsic[1]);
|
||||
const px = await core('vc_raster_pixel', __j(plan), sourcePoint.x, sourcePoint.y);
|
||||
const read = await IO.readPixel(ref, plan, px.x, px.y);
|
||||
if (read.noContext) return core('vc_raster_no_context_sample');
|
||||
if (read.error !== undefined) {
|
||||
const reason = await coreRaw('vc_raster_error_reason', read.error || '');
|
||||
return core('vc_raster_failure_sample', reason);
|
||||
}
|
||||
const d = read.data;
|
||||
return core('vc_pixel_sample', d[0], d[1], d[2], d[3]);
|
||||
}
|
||||
|
||||
async function sampleCssBackground(node, point, textColor) {
|
||||
const plan = await core('vc_css_plan', IO.handle(node), __j(textColor));
|
||||
if (plan.kind === 'sample') return plan.sample;
|
||||
// A url() layer: load, map the point onto the painted image, read a pixel.
|
||||
const img = await IO.loadImage(plan.url);
|
||||
if (!img) return core('vc_css_url_no_image');
|
||||
const src = await core('vc_css_url_source_point', IO.handle(node), img.w, img.h, plan.size, plan.position, point.x, point.y);
|
||||
if (!src.point) return src.sample;
|
||||
return core('vc_css_url_finish', __j(await sampleDrawablePixel(img.ref, [img.w, img.h], src.point)));
|
||||
}
|
||||
|
||||
async function sampleImageElement(imgNode, point) {
|
||||
const intrinsic = IO.intrinsicImg(imgNode);
|
||||
const geo = await core('vc_img_source_point', IO.handle(imgNode), intrinsic[0], intrinsic[1], point.x, point.y);
|
||||
if (!geo.point) return geo.sample;
|
||||
const sample = await sampleDrawablePixel(imgNode, intrinsic, geo.point);
|
||||
const finished = await core('vc_img_finish', __j(sample));
|
||||
if (finished.status === 'sampled') return finished;
|
||||
|
||||
const src = IO.imgSrc(imgNode);
|
||||
if (src) {
|
||||
const loaded = await IO.loadImage(src);
|
||||
if (loaded) {
|
||||
const loadedPoint = await core('vc_img_loaded_source_point', __j(geo.painted), loaded.w, loaded.h, point.x, point.y);
|
||||
if (loadedPoint) {
|
||||
const loadedSample = await core('vc_img_finish', __j(await sampleDrawablePixel(loaded.ref, [loaded.w, loaded.h], loadedPoint)));
|
||||
if (loadedSample.status === 'sampled') return loadedSample;
|
||||
}
|
||||
}
|
||||
}
|
||||
return sample;
|
||||
}
|
||||
|
||||
async function sampleVisualBackgroundAtPoint(el, point, textColor, depth = 0) {
|
||||
const walk = await core('vc_stack_nodes', IO.handle(el), point.x, point.y, depth);
|
||||
if (walk.unresolved) return walk.unresolved;
|
||||
const nodes = walk.nodes.map(n => ({ node: IO.node(n.el), kind: n.kind }));
|
||||
const unresolved = [];
|
||||
|
||||
for (const { node, kind } of nodes) {
|
||||
if (kind === 'img') {
|
||||
const sample = await sampleImageElement(node, point);
|
||||
if (sample.status === 'sampled') return sample;
|
||||
unresolved.push(sample.reason);
|
||||
continue;
|
||||
}
|
||||
if (kind === 'raster') {
|
||||
const intrinsic = IO.intrinsicRaster(node);
|
||||
const sourcePoint = await core('vc_raster_source_point', IO.handle(node), intrinsic[0], intrinsic[1], point.x, point.y);
|
||||
if (sourcePoint) {
|
||||
const sample = await core('vc_raster_finish', IO.handle(node), __j(await sampleDrawablePixel(node, intrinsic, sourcePoint)));
|
||||
if (sample.status === 'sampled') return sample;
|
||||
unresolved.push(sample.reason);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const sample = await sampleCssBackground(node, point, textColor);
|
||||
if (sample.status === 'sampled') {
|
||||
if (await IO.core('vc_sample_is_opaque', __j(sample))) return sample;
|
||||
const parent = IO.parentOrBody(node);
|
||||
const under = await sampleVisualBackgroundAtPoint(parent, point, textColor, depth + 1);
|
||||
return core('vc_alpha_composite', __j(sample), __j(under));
|
||||
}
|
||||
unresolved.push(sample.reason);
|
||||
}
|
||||
|
||||
return core('vc_unresolved_from_reasons', __j(unresolved));
|
||||
}
|
||||
|
||||
async function analyzeVisualContrastCandidate(candidate) {
|
||||
const prepared = await core('vc_prepare_analysis', __j(candidate));
|
||||
if (prepared.early) return prepared.early;
|
||||
const el = IO.node(prepared.el);
|
||||
const samples = [];
|
||||
for (const point of prepared.points) {
|
||||
samples.push(await sampleVisualBackgroundAtPoint(el, point, prepared.textColor));
|
||||
}
|
||||
return core('vc_finish_analysis', __j(candidate), __j(prepared.textColor), __j(samples), prepared.points.length);
|
||||
}
|
||||
|
||||
function waitForVisualPaint() {
|
||||
return IO.waitForPaint();
|
||||
}
|
||||
|
||||
async function analyzeVisualContrast(options = {}) {
|
||||
// imageOnly is enforced inside the collector, before the candidate cap.
|
||||
const candidates = await collectVisualContrastCandidatesAsync(options);
|
||||
const results = [];
|
||||
const shouldScrollOffscreen = options.scrollOffscreen === true;
|
||||
const restoreScroll = IO.scroll();
|
||||
for (const candidate of candidates) {
|
||||
if (shouldScrollOffscreen) {
|
||||
const now = IO.scroll();
|
||||
if (now.x !== restoreScroll.x || now.y !== restoreScroll.y) {
|
||||
IO.scrollTo(restoreScroll.x, restoreScroll.y);
|
||||
await waitForVisualPaint();
|
||||
}
|
||||
}
|
||||
let result = await analyzeVisualContrastCandidate(candidate);
|
||||
if (shouldScrollOffscreen && await IO.core('vc_needs_scroll_retry', __j(result))) {
|
||||
const el = IO.querySelector(candidate.selector);
|
||||
if (el && IO.scrollIntoView(el)) {
|
||||
await waitForVisualPaint();
|
||||
result = await analyzeVisualContrastCandidate(candidate);
|
||||
}
|
||||
}
|
||||
results.push(result);
|
||||
}
|
||||
if (shouldScrollOffscreen) {
|
||||
const now = IO.scroll();
|
||||
if (now.x !== restoreScroll.x || now.y !== restoreScroll.y) IO.scrollTo(restoreScroll.x, restoreScroll.y);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
return { collectVisualContrastCandidates, analyzeVisualContrastCandidate, analyzeVisualContrast, waitForVisualPaint };
|
||||
}
|
||||
|
||||
// The in-page adapter: live Elements, the wasm namespace, this document.
|
||||
// Elements (never handles) cross the awaits: a re-scan resets the probe
|
||||
// registry, so a handle is only valid until the next await.
|
||||
function createInPageVisualIO(wasm) {
|
||||
const io = __createDrawableIO();
|
||||
return {
|
||||
core: (fn, ...args) => wasm[fn](...args),
|
||||
coreSync: (fn, ...args) => wasm[fn](...args),
|
||||
node: (handle) => __el(handle),
|
||||
handle: (el) => __intern(el),
|
||||
parentOrBody: (el) => el.parentElement || document.body,
|
||||
intrinsicImg: (d) => [d.naturalWidth || d.videoWidth || d.width || 0, d.naturalHeight || d.videoHeight || d.height || 0],
|
||||
intrinsicRaster: (d) => [d.width || d.videoWidth || 0, d.height || d.videoHeight || 0],
|
||||
imgSrc: (img) => img.currentSrc || img.src || '',
|
||||
async loadImage(src) {
|
||||
const img = await io.loadImageEl(src);
|
||||
if (!img) return null;
|
||||
return { ref: img, w: img.naturalWidth || img.width || 0, h: img.naturalHeight || img.height || 0 };
|
||||
},
|
||||
readPixel: (drawable, plan, px, py) => io.readPixel(drawable, plan, px, py),
|
||||
querySelector(selector) {
|
||||
try { return document.querySelector(selector); } catch { return null; }
|
||||
},
|
||||
scroll: () => ({ x: window.scrollX, y: window.scrollY }),
|
||||
scrollTo: (x, y) => window.scrollTo(x, y),
|
||||
scrollIntoView(el) {
|
||||
if (typeof el.scrollIntoView !== 'function') return false;
|
||||
el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
|
||||
return true;
|
||||
},
|
||||
waitForPaint: () => new Promise(resolve => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve));
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1,568 +0,0 @@
|
||||
// --- browser-bundle/40-overlay.js ---
|
||||
// The overlay UI: outlines + labels per flagged element, the page-level
|
||||
// banner, the hover spotlight, visibility toggling. Pure presentation over a
|
||||
// findings list — no rules, no thresholds, no snippet strings; the rule
|
||||
// names and categories come from the registry it is handed. Ported from
|
||||
// cli/engine/browser/injected/index.mjs Section 7.
|
||||
//
|
||||
// createImpeccableOverlay({ extensionMode, antipatterns }) ->
|
||||
// { highlight(el, findings), showPageBanner(findings), clearOverlays(),
|
||||
// remove(), toggleOverlays() -> visible, spotlight(target),
|
||||
// unspotlight(), highlightSelector(selector), setFirstScanDone(),
|
||||
// overlays }
|
||||
// Used by the in-page bundle (50-scan.js) and, as `overlay.js`, by the
|
||||
// extension's content script.
|
||||
|
||||
function createImpeccableOverlay({ extensionMode = false, antipatterns = [] } = {}) {
|
||||
// Kinpaku gold — pinned to the site's brand token (see
|
||||
// site/styles/kinpaku-tokens.css --ks-kinpaku). Keep this in sync with
|
||||
// the picker's C.brand in skill/scripts/live-browser.js and the kit's
|
||||
// picker section in site/styles/kinpaku-kit.css.
|
||||
//
|
||||
// One color across both light and dark host pages. The outline is a
|
||||
// 2px gesture pointing at an element + a labeled tag — it's a marker,
|
||||
// not body text, so it doesn't need WCAG AA against the page. The
|
||||
// label text inside the gold tag is dark (LABEL_INK) which has ~16:1
|
||||
// against the leaf gold, so reading the rule name is solid in both
|
||||
// modes. Hover deepens the gold (preserves chroma — never drops it,
|
||||
// dropping chroma washes the gold into a sand/olive tone).
|
||||
const BRAND_COLOR = 'oklch(84% 0.19 80.46)';
|
||||
const BRAND_COLOR_HOVER = 'oklch(74% 0.18 80)';
|
||||
const LABEL_INK = 'oklch(4% 0.004 95)';
|
||||
const LABEL_BG = BRAND_COLOR;
|
||||
const OUTLINE_COLOR = BRAND_COLOR;
|
||||
|
||||
// Inject hover styles via CSS (more reliable than JS event listeners)
|
||||
const styleEl = document.createElement('style');
|
||||
styleEl.textContent = `
|
||||
@keyframes impeccable-reveal {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
.impeccable-overlay:not(.impeccable-banner) {
|
||||
pointer-events: none;
|
||||
outline: 2px solid ${OUTLINE_COLOR};
|
||||
border-radius: 4px;
|
||||
transition: outline-color 0.15s ease;
|
||||
animation: impeccable-reveal 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
animation-play-state: paused;
|
||||
border-top-left-radius: 0;
|
||||
}
|
||||
.impeccable-overlay.impeccable-visible {
|
||||
animation-play-state: running;
|
||||
}
|
||||
.impeccable-overlay.impeccable-hover {
|
||||
outline-color: ${BRAND_COLOR_HOVER};
|
||||
z-index: 100001 !important;
|
||||
}
|
||||
.impeccable-overlay.impeccable-hover .impeccable-label {
|
||||
background: ${BRAND_COLOR_HOVER};
|
||||
}
|
||||
.impeccable-overlay.impeccable-spotlight {
|
||||
z-index: 100002 !important;
|
||||
}
|
||||
.impeccable-overlay.impeccable-spotlight-dimmed {
|
||||
opacity: 0.15 !important;
|
||||
animation: none !important;
|
||||
filter: blur(3px);
|
||||
}
|
||||
.impeccable-spotlight-backdrop {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
backdrop-filter: blur(3px) brightness(0.6);
|
||||
-webkit-backdrop-filter: blur(3px) brightness(0.6);
|
||||
pointer-events: none;
|
||||
z-index: 99998;
|
||||
opacity: 0;
|
||||
outline: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
.impeccable-spotlight-backdrop.impeccable-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
.impeccable-hidden .impeccable-overlay${extensionMode ? '' : ':not(.impeccable-banner)'} {
|
||||
display: none !important;
|
||||
}
|
||||
`;
|
||||
(document.head || document.documentElement).appendChild(styleEl);
|
||||
|
||||
let firstScanDone = false;
|
||||
|
||||
// Spotlight backdrop element (created lazily on first use)
|
||||
let spotlightBackdrop = null;
|
||||
let spotlightTarget = null;
|
||||
|
||||
function getSpotlightBackdrop() {
|
||||
if (!spotlightBackdrop) {
|
||||
spotlightBackdrop = document.createElement('div');
|
||||
spotlightBackdrop.className = 'impeccable-spotlight-backdrop';
|
||||
document.body.appendChild(spotlightBackdrop);
|
||||
}
|
||||
return spotlightBackdrop;
|
||||
}
|
||||
|
||||
function updateSpotlightClipPath() {
|
||||
if (!spotlightBackdrop || !spotlightTarget) return;
|
||||
const r = spotlightTarget.getBoundingClientRect();
|
||||
// Match the overlay's outer edge: element rect + 4px (2px overlay offset + 2px outline width)
|
||||
const inset = 4;
|
||||
const radius = 6; // outline border-radius (4) + outline width (2)
|
||||
const x1 = r.left - inset;
|
||||
const y1 = r.top - inset;
|
||||
const x2 = r.right + inset;
|
||||
const y2 = r.bottom + inset;
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
// Outer rect + rounded inner rect (evenodd creates a hole)
|
||||
const path = `M0 0H${vw}V${vh}H0Z M${x1 + radius} ${y1}H${x2 - radius}A${radius} ${radius} 0 0 1 ${x2} ${y1 + radius}V${y2 - radius}A${radius} ${radius} 0 0 1 ${x2 - radius} ${y2}H${x1 + radius}A${radius} ${radius} 0 0 1 ${x1} ${y2 - radius}V${y1 + radius}A${radius} ${radius} 0 0 1 ${x1 + radius} ${y1}Z`;
|
||||
spotlightBackdrop.style.clipPath = `path(evenodd, "${path}")`;
|
||||
}
|
||||
|
||||
function showSpotlight(target) {
|
||||
if (!target || !target.getBoundingClientRect) return;
|
||||
// Respect the spotlightBlur setting: if disabled, don't show the backdrop
|
||||
if (window.__IMPECCABLE_CONFIG__?.spotlightBlur === false) {
|
||||
spotlightTarget = target;
|
||||
return;
|
||||
}
|
||||
spotlightTarget = target;
|
||||
const bd = getSpotlightBackdrop();
|
||||
updateSpotlightClipPath();
|
||||
bd.classList.add('impeccable-visible');
|
||||
}
|
||||
|
||||
function hideSpotlight() {
|
||||
spotlightTarget = null;
|
||||
if (spotlightBackdrop) spotlightBackdrop.classList.remove('impeccable-visible');
|
||||
}
|
||||
|
||||
function isInViewport(el) {
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.top >= 0 && r.left >= 0 && r.bottom <= window.innerHeight && r.right <= window.innerWidth;
|
||||
}
|
||||
|
||||
// Reposition spotlight on scroll/resize
|
||||
window.addEventListener('scroll', () => {
|
||||
if (spotlightTarget) updateSpotlightClipPath();
|
||||
}, { passive: true });
|
||||
window.addEventListener('resize', () => {
|
||||
if (spotlightTarget) updateSpotlightClipPath();
|
||||
});
|
||||
|
||||
const overlays = [];
|
||||
const ANTIPATTERNS = antipatterns || [];
|
||||
const TYPE_LABELS = {};
|
||||
const RULE_CATEGORY = {};
|
||||
for (const ap of ANTIPATTERNS) {
|
||||
TYPE_LABELS[ap.id] = ap.name.toLowerCase();
|
||||
RULE_CATEGORY[ap.id] = ap.category || 'quality';
|
||||
}
|
||||
|
||||
function isInFixedContext(el) {
|
||||
let p = el;
|
||||
while (p && p !== document.body) {
|
||||
if (getComputedStyle(p).position === 'fixed') return true;
|
||||
p = p.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function positionOverlay(overlay) {
|
||||
const el = overlay._targetEl;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (overlay._isFixed) {
|
||||
// Viewport-relative coords for fixed targets
|
||||
overlay.style.top = `${rect.top - 2}px`;
|
||||
overlay.style.left = `${rect.left - 2}px`;
|
||||
} else {
|
||||
// Document-relative coords for normal targets
|
||||
overlay.style.top = `${rect.top + scrollY - 2}px`;
|
||||
overlay.style.left = `${rect.left + scrollX - 2}px`;
|
||||
}
|
||||
overlay.style.width = `${rect.width + 4}px`;
|
||||
overlay.style.height = `${rect.height + 4}px`;
|
||||
}
|
||||
|
||||
function repositionOverlays() {
|
||||
for (const o of overlays) {
|
||||
if (!o._targetEl || o.classList.contains('impeccable-banner')) continue;
|
||||
// Skip overlays whose target is currently hidden (display: none on the overlay)
|
||||
if (o.style.display === 'none') continue;
|
||||
positionOverlay(o);
|
||||
}
|
||||
}
|
||||
|
||||
let resizeRAF;
|
||||
const onResize = () => {
|
||||
cancelAnimationFrame(resizeRAF);
|
||||
resizeRAF = requestAnimationFrame(repositionOverlays);
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
// Reposition on scroll too -- catches sticky/parallax shifts
|
||||
window.addEventListener('scroll', onResize, { passive: true });
|
||||
// Reposition when body resizes (lazy-loaded images, dynamic content, fonts loading)
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const bodyResizeObserver = new ResizeObserver(onResize);
|
||||
bodyResizeObserver.observe(document.body);
|
||||
}
|
||||
|
||||
// Track target element visibility via IntersectionObserver.
|
||||
// Uses a huge rootMargin so all *rendered* elements count as intersecting,
|
||||
// while display:none / closed <details> / hidden modals etc. do not.
|
||||
// This is event-driven -- no polling needed.
|
||||
let overlayIndex = 0;
|
||||
const visibilityObserver = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const overlay = entry.target._impeccableOverlay;
|
||||
if (!overlay) continue;
|
||||
if (entry.isIntersecting) {
|
||||
overlay.style.display = '';
|
||||
positionOverlay(overlay);
|
||||
if (!overlay._revealed) {
|
||||
overlay._revealed = true;
|
||||
if (firstScanDone) {
|
||||
// Subsequent reveals (re-scans, scroll-into-view): instant, no animation
|
||||
overlay.style.animation = 'none';
|
||||
} else {
|
||||
// Initial scan: staggered cascade reveal
|
||||
overlay.style.animationDelay = `${Math.min((overlay._staggerIndex || 0) * 60, 600)}ms`;
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
overlay.classList.add('impeccable-visible');
|
||||
if (overlay._checkLabel) overlay._checkLabel();
|
||||
});
|
||||
}
|
||||
} else {
|
||||
overlay.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}, { rootMargin: '99999px' });
|
||||
|
||||
function detachOverlay(overlay) {
|
||||
if (!overlay) return;
|
||||
if (typeof overlay._cleanup === 'function') {
|
||||
try { overlay._cleanup(); } catch { /* best effort overlay teardown */ }
|
||||
}
|
||||
if (overlay._targetEl && overlay._targetEl._impeccableOverlay === overlay) {
|
||||
visibilityObserver.unobserve(overlay._targetEl);
|
||||
delete overlay._targetEl._impeccableOverlay;
|
||||
}
|
||||
const idx = overlays.indexOf(overlay);
|
||||
if (idx >= 0) overlays.splice(idx, 1);
|
||||
overlay.remove();
|
||||
}
|
||||
|
||||
// Reposition overlays after CSS transitions end (e.g. reveal animations).
|
||||
// Listens at document level so it catches transitions on ancestor elements
|
||||
// (the transform may be on a parent, not the flagged element itself).
|
||||
document.addEventListener('transitionend', (e) => {
|
||||
if (e.propertyName !== 'transform') return;
|
||||
for (const o of overlays) {
|
||||
if (!o._targetEl || o.classList.contains('impeccable-banner') || o.style.display === 'none') continue;
|
||||
if (e.target === o._targetEl || e.target.contains(o._targetEl)) {
|
||||
positionOverlay(o);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const highlight = function(el, findings) {
|
||||
if (el._impeccableOverlay) detachOverlay(el._impeccableOverlay);
|
||||
const hasSlop = findings.some(f => RULE_CATEGORY[f.type || f.id] === 'slop');
|
||||
|
||||
const fixed = isInFixedContext(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
const outline = document.createElement('div');
|
||||
outline.className = 'impeccable-overlay';
|
||||
outline._targetEl = el;
|
||||
outline._isFixed = fixed;
|
||||
Object.assign(outline.style, {
|
||||
position: fixed ? 'fixed' : 'absolute',
|
||||
top: fixed ? `${rect.top - 2}px` : `${rect.top + scrollY - 2}px`,
|
||||
left: fixed ? `${rect.left - 2}px` : `${rect.left + scrollX - 2}px`,
|
||||
width: `${rect.width + 4}px`, height: `${rect.height + 4}px`,
|
||||
zIndex: '99999', boxSizing: 'border-box',
|
||||
});
|
||||
|
||||
// Build per-finding label entries: ✦ prefix for slop
|
||||
const entries = findings.map(f => {
|
||||
const name = TYPE_LABELS[f.type || f.id] || f.type || f.id;
|
||||
const prefix = RULE_CATEGORY[f.type || f.id] === 'slop' ? '\u2726 ' : '';
|
||||
return { name: prefix + name, detail: f.detail || f.snippet };
|
||||
});
|
||||
const allText = entries.map(e => e.name).join(', ');
|
||||
|
||||
const label = document.createElement('div');
|
||||
label.className = 'impeccable-label';
|
||||
Object.assign(label.style, {
|
||||
position: 'absolute', bottom: '100%', left: '-2px',
|
||||
display: 'flex', alignItems: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: '11px', fontWeight: '600', letterSpacing: '0.02em',
|
||||
color: LABEL_INK, lineHeight: '14px',
|
||||
background: LABEL_BG,
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
borderRadius: '4px 4px 0 0',
|
||||
});
|
||||
|
||||
const textSpan = document.createElement('span');
|
||||
textSpan.style.padding = '3px 8px';
|
||||
textSpan.textContent = allText;
|
||||
label.appendChild(textSpan);
|
||||
|
||||
// State for cycling mode
|
||||
let cycleMode = false;
|
||||
let cycleIndex = 0;
|
||||
let isHovered = false;
|
||||
let prevBtn, nextBtn;
|
||||
|
||||
function updateCycleText() {
|
||||
const e = entries[cycleIndex];
|
||||
textSpan.textContent = isHovered ? e.detail : e.name;
|
||||
}
|
||||
|
||||
function enableCycleMode() {
|
||||
if (cycleMode || entries.length < 2) return;
|
||||
cycleMode = true;
|
||||
|
||||
const btnStyle = {
|
||||
background: 'none', border: 'none', color: 'rgba(255,255,255,0.7)',
|
||||
fontSize: '11px', cursor: 'pointer', padding: '3px 4px',
|
||||
fontFamily: 'system-ui, sans-serif', lineHeight: '14px',
|
||||
pointerEvents: 'auto',
|
||||
};
|
||||
|
||||
const navGroup = document.createElement('span');
|
||||
Object.assign(navGroup.style, {
|
||||
display: 'inline-flex', alignItems: 'center', flexShrink: '0',
|
||||
});
|
||||
|
||||
prevBtn = document.createElement('button');
|
||||
prevBtn.textContent = '\u2039';
|
||||
Object.assign(prevBtn.style, btnStyle);
|
||||
prevBtn.style.paddingLeft = '6px';
|
||||
prevBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
cycleIndex = (cycleIndex - 1 + entries.length) % entries.length;
|
||||
updateCycleText();
|
||||
});
|
||||
|
||||
nextBtn = document.createElement('button');
|
||||
nextBtn.textContent = '\u203A';
|
||||
Object.assign(nextBtn.style, btnStyle);
|
||||
nextBtn.style.paddingRight = '2px';
|
||||
nextBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
cycleIndex = (cycleIndex + 1) % entries.length;
|
||||
updateCycleText();
|
||||
});
|
||||
|
||||
navGroup.appendChild(prevBtn);
|
||||
navGroup.appendChild(nextBtn);
|
||||
label.insertBefore(navGroup, textSpan);
|
||||
textSpan.style.padding = '3px 8px 3px 4px';
|
||||
updateCycleText();
|
||||
}
|
||||
|
||||
outline.appendChild(label);
|
||||
|
||||
// Start hidden; the IntersectionObserver will show it once the target is rendered
|
||||
outline.style.display = 'none';
|
||||
outline._staggerIndex = overlayIndex++;
|
||||
el._impeccableOverlay = outline;
|
||||
visibilityObserver.observe(el);
|
||||
|
||||
// After first paint, check label width vs outline
|
||||
outline._checkLabel = () => {
|
||||
if (entries.length > 1 && label.offsetWidth > outline.offsetWidth) {
|
||||
enableCycleMode();
|
||||
}
|
||||
};
|
||||
|
||||
// Hover: show detail text, darken
|
||||
const onMouseEnter = () => {
|
||||
isHovered = true;
|
||||
outline.classList.add('impeccable-hover');
|
||||
outline.style.outlineColor = BRAND_COLOR_HOVER;
|
||||
label.style.background = BRAND_COLOR_HOVER;
|
||||
if (cycleMode) {
|
||||
updateCycleText();
|
||||
} else {
|
||||
textSpan.textContent = entries.map(e => e.detail).join(' | ');
|
||||
}
|
||||
};
|
||||
const onMouseLeave = () => {
|
||||
isHovered = false;
|
||||
outline.classList.remove('impeccable-hover');
|
||||
outline.style.outlineColor = '';
|
||||
label.style.background = LABEL_BG;
|
||||
if (cycleMode) {
|
||||
updateCycleText();
|
||||
} else {
|
||||
textSpan.textContent = allText;
|
||||
}
|
||||
};
|
||||
el.addEventListener('mouseenter', onMouseEnter);
|
||||
el.addEventListener('mouseleave', onMouseLeave);
|
||||
outline._cleanup = () => {
|
||||
el.removeEventListener('mouseenter', onMouseEnter);
|
||||
el.removeEventListener('mouseleave', onMouseLeave);
|
||||
};
|
||||
|
||||
document.body.appendChild(outline);
|
||||
overlays.push(outline);
|
||||
};
|
||||
|
||||
const showPageBanner = function(findings) {
|
||||
if (!findings.length) return;
|
||||
const banner = document.createElement('div');
|
||||
banner.className = 'impeccable-overlay impeccable-banner';
|
||||
Object.assign(banner.style, {
|
||||
position: 'fixed', top: '0', left: '0', right: '0', zIndex: '100000',
|
||||
background: LABEL_BG, color: LABEL_INK,
|
||||
fontFamily: 'system-ui, sans-serif', fontSize: '13px',
|
||||
display: 'flex', alignItems: 'center', pointerEvents: 'auto',
|
||||
height: '36px', overflow: 'hidden', maxWidth: '100vw',
|
||||
transform: 'translateY(-100%)',
|
||||
transition: 'transform 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
});
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
banner.style.transform = 'translateY(0)';
|
||||
}));
|
||||
|
||||
// Scrollable findings area
|
||||
const scrollArea = document.createElement('div');
|
||||
Object.assign(scrollArea.style, {
|
||||
flex: '1', minWidth: '0', overflowX: 'auto', overflowY: 'hidden',
|
||||
display: 'flex', gap: '8px', alignItems: 'center',
|
||||
padding: '0 12px', scrollSnapType: 'x mandatory',
|
||||
scrollbarWidth: 'none',
|
||||
});
|
||||
for (const f of findings) {
|
||||
const prefix = RULE_CATEGORY[f.type] === 'slop' ? '\u2726 ' : '';
|
||||
const tag = document.createElement('span');
|
||||
tag.textContent = `${prefix}${TYPE_LABELS[f.type] || f.type}: ${f.detail}`;
|
||||
Object.assign(tag.style, {
|
||||
background: 'rgba(255,255,255,0.15)', padding: '2px 8px',
|
||||
borderRadius: '3px', fontSize: '12px', fontFamily: 'ui-monospace, monospace',
|
||||
whiteSpace: 'nowrap', flexShrink: '0', scrollSnapAlign: 'start',
|
||||
});
|
||||
scrollArea.appendChild(tag);
|
||||
}
|
||||
banner.appendChild(scrollArea);
|
||||
|
||||
// Controls area (only in standalone mode, not extension)
|
||||
if (!extensionMode) {
|
||||
const controls = document.createElement('div');
|
||||
Object.assign(controls.style, {
|
||||
display: 'flex', alignItems: 'center', gap: '2px',
|
||||
padding: '0 8px', flexShrink: '0',
|
||||
});
|
||||
|
||||
// Toggle visibility button
|
||||
const toggle = document.createElement('button');
|
||||
toggle.textContent = '\u25C9'; // circle with dot (visible state)
|
||||
toggle.title = 'Toggle overlay visibility';
|
||||
Object.assign(toggle.style, {
|
||||
background: 'none', border: 'none',
|
||||
color: 'white', fontSize: '16px', cursor: 'pointer', padding: '0 4px',
|
||||
opacity: '0.85', transition: 'opacity 0.15s',
|
||||
});
|
||||
let overlaysVisible = true;
|
||||
toggle.addEventListener('click', () => {
|
||||
overlaysVisible = !overlaysVisible;
|
||||
document.body.classList.toggle('impeccable-hidden', !overlaysVisible);
|
||||
toggle.textContent = overlaysVisible ? '\u25C9' : '\u25CB'; // filled vs empty circle
|
||||
toggle.style.opacity = overlaysVisible ? '0.85' : '0.5';
|
||||
});
|
||||
controls.appendChild(toggle);
|
||||
|
||||
// Close button
|
||||
const close = document.createElement('button');
|
||||
close.textContent = '\u00d7';
|
||||
close.title = 'Dismiss banner';
|
||||
Object.assign(close.style, {
|
||||
background: 'none', border: 'none',
|
||||
color: 'white', fontSize: '18px', cursor: 'pointer', padding: '0 4px',
|
||||
});
|
||||
close.addEventListener('click', () => banner.remove());
|
||||
controls.appendChild(close);
|
||||
|
||||
banner.appendChild(controls);
|
||||
}
|
||||
document.body.appendChild(banner);
|
||||
overlays.push(banner);
|
||||
};
|
||||
|
||||
function clearOverlays() {
|
||||
for (const o of [...overlays]) detachOverlay(o);
|
||||
overlays.length = 0;
|
||||
visibilityObserver.disconnect();
|
||||
overlayIndex = 0;
|
||||
}
|
||||
|
||||
// Tear the UI down entirely (the extension's `remove` command).
|
||||
function remove() {
|
||||
clearOverlays();
|
||||
styleEl.remove();
|
||||
if (spotlightBackdrop) { spotlightBackdrop.remove(); spotlightBackdrop = null; }
|
||||
document.body.classList.remove('impeccable-hidden');
|
||||
}
|
||||
|
||||
// Toggle every overlay; returns the new visibility.
|
||||
function toggleOverlays() {
|
||||
const visible = !document.body.classList.contains('impeccable-hidden');
|
||||
document.body.classList.toggle('impeccable-hidden', visible);
|
||||
return !visible;
|
||||
}
|
||||
|
||||
// Spotlight the overlay of the element `selector` names (scrolling it into
|
||||
// view first so positionOverlay reads the post-scroll rect).
|
||||
function highlightSelector(selector) {
|
||||
try {
|
||||
const target = selector ? document.querySelector(selector) : null;
|
||||
if (!target) return;
|
||||
if (!isInViewport(target) && target.scrollIntoView) {
|
||||
target.scrollIntoView({ behavior: 'instant', block: 'center' });
|
||||
}
|
||||
for (const o of overlays) {
|
||||
if (o.classList.contains('impeccable-banner')) continue;
|
||||
const isMatch = o._targetEl === target;
|
||||
o.classList.toggle('impeccable-spotlight', isMatch);
|
||||
o.classList.toggle('impeccable-spotlight-dimmed', !isMatch);
|
||||
if (isMatch) {
|
||||
// Force the matching overlay visible immediately, don't wait for IntersectionObserver
|
||||
o.style.display = '';
|
||||
o.style.animation = 'none';
|
||||
o.classList.add('impeccable-visible');
|
||||
o._revealed = true;
|
||||
positionOverlay(o);
|
||||
}
|
||||
}
|
||||
showSpotlight(target);
|
||||
} catch { /* invalid selector */ }
|
||||
}
|
||||
|
||||
function unspotlight() {
|
||||
hideSpotlight();
|
||||
for (const o of overlays) {
|
||||
o.classList.remove('impeccable-spotlight');
|
||||
o.classList.remove('impeccable-spotlight-dimmed');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
highlight,
|
||||
showPageBanner,
|
||||
clearOverlays,
|
||||
remove,
|
||||
toggleOverlays,
|
||||
spotlight: showSpotlight,
|
||||
unspotlight,
|
||||
highlightSelector,
|
||||
setFirstScanDone() { firstScanDone = true; },
|
||||
overlays,
|
||||
TYPE_LABELS,
|
||||
RULE_CATEGORY,
|
||||
};
|
||||
}
|
||||
@@ -1,474 +0,0 @@
|
||||
// --- browser-bundle/50-scan.js ---
|
||||
// The in-page scan/detect API, the WASM core bridge (group-map
|
||||
// marshalling), and the extension-mode message loop of the standalone
|
||||
// bundle. Ported from cli/engine/browser/injected/index.mjs Section 7; every
|
||||
// rule decision is a call into the WASM core (`__impeccable.*`), the DOM
|
||||
// reads it needs go through the probe, the overlay UI is 40-overlay.js and
|
||||
// the visual-contrast sampling 35-visual.js.
|
||||
|
||||
const IS_BROWSER = typeof window !== 'undefined';
|
||||
|
||||
// ─── Section 7: Browser UI (IS_BROWSER only) ────────────────────────────────
|
||||
|
||||
if (IS_BROWSER && !__impeccable) {
|
||||
// The core could not start (in practice: a Content-Security-Policy whose
|
||||
// script-src lacks 'wasm-unsafe-eval'). Keep the API surface so callers get
|
||||
// one clear error instead of "impeccableDetect is not a function".
|
||||
const reason = __impeccableInitError && __impeccableInitError.message
|
||||
? __impeccableInitError.message
|
||||
: String(__impeccableInitError);
|
||||
const message = `[impeccable] detector core unavailable: ${reason} (a Content-Security-Policy without 'wasm-unsafe-eval' blocks WebAssembly)`;
|
||||
const fail = () => { throw new Error(message); };
|
||||
const _myScript = document.currentScript;
|
||||
const EXTENSION_MODE = (_myScript && _myScript.dataset.impeccableExtension === 'true')
|
||||
|| document.documentElement.dataset.impeccableExtension === 'true';
|
||||
console.warn(message);
|
||||
window.impeccableDetect = fail;
|
||||
window.impeccableDetectAsync = async () => fail();
|
||||
window.impeccableScan = fail;
|
||||
window.impeccableScanAsync = async () => fail();
|
||||
window.impeccableMeasureHiddenText = fail;
|
||||
window.impeccableCollectVisualContrastCandidates = fail;
|
||||
window.impeccableAnalyzeVisualContrast = async () => fail();
|
||||
window.impeccableGetLastVisualContrastAnalyses = () => [];
|
||||
window.__impeccableCoreError = message;
|
||||
if (EXTENSION_MODE) {
|
||||
window.addEventListener('message', (e) => {
|
||||
if (e.source !== window || !e.data || e.data.source !== 'impeccable-command') return;
|
||||
if (e.data.action === 'scan') window.postMessage({ source: 'impeccable-error', message }, '*');
|
||||
});
|
||||
window.postMessage({ source: 'impeccable-ready' }, '*');
|
||||
}
|
||||
} else if (IS_BROWSER) {
|
||||
// Detect extension mode via the script tag's data attribute or the document element fallback.
|
||||
// currentScript is reliable for synchronously-executing scripts (which our IIFE is).
|
||||
const _myScript = document.currentScript;
|
||||
const EXTENSION_MODE = (_myScript && _myScript.dataset.impeccableExtension === 'true')
|
||||
|| document.documentElement.dataset.impeccableExtension === 'true';
|
||||
|
||||
const ui = createImpeccableOverlay({
|
||||
extensionMode: EXTENSION_MODE,
|
||||
antipatterns: JSON.parse(__impeccable.antipatterns_json()),
|
||||
});
|
||||
const {
|
||||
collectVisualContrastCandidates,
|
||||
analyzeVisualContrastCandidate,
|
||||
analyzeVisualContrast,
|
||||
waitForVisualPaint,
|
||||
} = createVisualContrast(createInPageVisualIO(__impeccable));
|
||||
|
||||
// ── WASM core bridge ──────────────────────────────────────────────────────
|
||||
// The rule core runs collectBrowserFindings in WASM and hands back element
|
||||
// handles; this side keeps a Map<Element, findings[]> so later additions
|
||||
// (visual contrast) can join the same groups, and serializes through the
|
||||
// core so selectors/labels/severities come from one place.
|
||||
|
||||
function collectConfigJson() {
|
||||
const config = window.__IMPECCABLE_CONFIG__ || {};
|
||||
return JSON.stringify({
|
||||
extensionMode: EXTENSION_MODE,
|
||||
disabledRules: Array.isArray(config.disabledRules) ? config.disabledRules : [],
|
||||
// The live overlay resolves the project's ignoreValues for this page
|
||||
// and forwards the survivors here (live-browser-ignores.js); the core
|
||||
// applies them where the findings are assembled, because the overlay
|
||||
// draws its markers from the collected findings.
|
||||
disabledValues: Array.isArray(config.disabledValues) ? config.disabledValues : [],
|
||||
designSystem: config.designSystem == null ? null : config.designSystem,
|
||||
lineLengthMax: config.lineLengthMax == null ? null : config.lineLengthMax,
|
||||
skipScan: config.skipScan === true,
|
||||
});
|
||||
}
|
||||
|
||||
// A page matched by detector.ignoreFiles is waived wholesale: every scan
|
||||
// stage answers empty so the badge and toast read zero. Mirrors
|
||||
// shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live
|
||||
// overlay resolves the globs per page (live-browser-ignores.js) and
|
||||
// forwards the verdict as config.skipScan. The core repeats this guard on
|
||||
// the parsed config so the snapshot route answers empty too.
|
||||
function skipScanActive() {
|
||||
return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true;
|
||||
}
|
||||
|
||||
function serializeFindings(allFindings) {
|
||||
const groups = allFindings.map(({ el, findings }) => ({ el: __intern(el), findings }));
|
||||
return JSON.parse(__impeccable.serialize_findings(JSON.stringify(groups)));
|
||||
}
|
||||
|
||||
const printSummary = function(allFindings) {
|
||||
if (allFindings.length === 0) {
|
||||
console.log('%c[impeccable] No anti-patterns found.', 'color: #22c55e; font-weight: bold');
|
||||
return;
|
||||
}
|
||||
console.group(
|
||||
`%c[impeccable] ${allFindings.length} anti-pattern${allFindings.length === 1 ? '' : 's'} found`,
|
||||
'color: oklch(84% 0.19 80.46); font-weight: bold'
|
||||
);
|
||||
for (const { el, findings } of allFindings) {
|
||||
for (const f of findings) {
|
||||
console.log(`%c${f.type || f.id}%c ${f.detail || f.snippet}`,
|
||||
'color: oklch(84% 0.19 80.46); font-weight: bold', 'color: inherit', el);
|
||||
}
|
||||
}
|
||||
console.groupEnd();
|
||||
};
|
||||
|
||||
function browserFindingsFromMap(groupMap) {
|
||||
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings }));
|
||||
}
|
||||
|
||||
function collectBrowserFindings() {
|
||||
if (skipScanActive()) {
|
||||
return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] };
|
||||
}
|
||||
__resetRegistry();
|
||||
const collected = JSON.parse(__impeccable.collect_browser_findings(collectConfigJson()));
|
||||
const groupMap = new Map();
|
||||
for (const g of collected.groups) {
|
||||
// Handle 0 is the JS `document.body` null key (a bare document).
|
||||
groupMap.set(__el(g.el), g.findings);
|
||||
}
|
||||
return {
|
||||
groupMap,
|
||||
allFindings: browserFindingsFromMap(groupMap),
|
||||
pageLevelFindings: collected.pageLevel,
|
||||
};
|
||||
}
|
||||
|
||||
// Config plumbing shared with the extension's offscreen document lives in
|
||||
// 30-scan-common.js; here the config is the page's __IMPECCABLE_CONFIG__.
|
||||
const pageConfig = () => window.__IMPECCABLE_CONFIG__ || {};
|
||||
const visualContrastMode = (options = {}) => __visualContrastMode(options, pageConfig());
|
||||
const shouldRunVisualContrast = (options = {}) => visualContrastMode(options) !== false;
|
||||
const visualContrastOptions = (options = {}) => __visualContrastOptions(options, pageConfig());
|
||||
const scanResultMeta = __scanResultMeta;
|
||||
|
||||
let lastVisualContrastAnalyses = [];
|
||||
let lazyVisualContrastObserver = null;
|
||||
let lazyVisualContrastPending = new WeakMap();
|
||||
const lazyVisualContrastResolving = new WeakSet();
|
||||
let scanGeneration = 0;
|
||||
|
||||
function rememberVisualContrastAnalysis(result) {
|
||||
if (!result?.selector) {
|
||||
lastVisualContrastAnalyses.push(result);
|
||||
return;
|
||||
}
|
||||
const idx = lastVisualContrastAnalyses.findIndex(item => item.selector === result.selector);
|
||||
if (idx >= 0) lastVisualContrastAnalyses[idx] = result;
|
||||
else lastVisualContrastAnalyses.push(result);
|
||||
}
|
||||
|
||||
function disconnectLazyVisualContrastObserver() {
|
||||
if (lazyVisualContrastObserver) {
|
||||
lazyVisualContrastObserver.disconnect();
|
||||
lazyVisualContrastObserver = null;
|
||||
}
|
||||
lazyVisualContrastPending = new WeakMap();
|
||||
}
|
||||
|
||||
function addVisualContrastResult(groupMap, result, options = {}) {
|
||||
const elId = __impeccable.visual_contrast_result_el(JSON.stringify(result));
|
||||
const el = __el(elId);
|
||||
if (!el) return false;
|
||||
const existing = groupMap.get(el) || [];
|
||||
const finding = JSON.parse(__impeccable.visual_contrast_result_finding(elId, JSON.stringify(existing), JSON.stringify(result)));
|
||||
if (!finding) return false;
|
||||
if (groupMap.has(el)) groupMap.get(el).push(finding);
|
||||
else groupMap.set(el, [finding]);
|
||||
if (options.decorate && el !== document.body && el !== document.documentElement) {
|
||||
ui.highlight(el, groupMap.get(el) || []);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
function postSerializedFindings(groupMap, options = {}) {
|
||||
if (!EXTENSION_MODE) return;
|
||||
const allFindings = browserFindingsFromMap(groupMap);
|
||||
window.postMessage({
|
||||
source: 'impeccable-results',
|
||||
findings: serializeFindings(allFindings),
|
||||
count: allFindings.length,
|
||||
...scanResultMeta(options),
|
||||
}, '*');
|
||||
}
|
||||
|
||||
function postExtensionError(err) {
|
||||
if (!EXTENSION_MODE) return;
|
||||
window.postMessage({
|
||||
source: 'impeccable-error',
|
||||
message: err?.message || String(err),
|
||||
}, '*');
|
||||
}
|
||||
|
||||
function reportVisualContrastError(err, detail = {}) {
|
||||
window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-error', {
|
||||
detail: {
|
||||
...detail,
|
||||
message: err?.message || String(err),
|
||||
},
|
||||
}));
|
||||
if (EXTENSION_MODE) {
|
||||
postExtensionError(err);
|
||||
} else {
|
||||
console.warn('[impeccable] visual contrast scan failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleLazyVisualContrast(groupMap, analyses, options = {}, runtime = {}) {
|
||||
disconnectLazyVisualContrastObserver();
|
||||
if (options.visualContrastLazy === false || options.scrollOffscreen !== false) return;
|
||||
if (typeof IntersectionObserver === 'undefined') return;
|
||||
const unresolved = __lazyVisualContrastCandidates(analyses);
|
||||
if (unresolved.length === 0) return;
|
||||
const generation = runtime.generation || scanGeneration;
|
||||
|
||||
lazyVisualContrastObserver = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue;
|
||||
const el = entry.target;
|
||||
const candidate = lazyVisualContrastPending.get(el);
|
||||
if (!candidate || lazyVisualContrastResolving.has(el)) continue;
|
||||
lazyVisualContrastObserver?.unobserve(el);
|
||||
lazyVisualContrastPending.delete(el);
|
||||
lazyVisualContrastResolving.add(el);
|
||||
waitForVisualPaint()
|
||||
.then(() => analyzeVisualContrastCandidate(candidate))
|
||||
.then(result => {
|
||||
if (generation !== scanGeneration) return;
|
||||
rememberVisualContrastAnalysis(result);
|
||||
const added = addVisualContrastResult(groupMap, result, { decorate: true });
|
||||
if (added) {
|
||||
postSerializedFindings(groupMap, options);
|
||||
window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-resolved', {
|
||||
detail: {
|
||||
selector: result.selector,
|
||||
status: result.status,
|
||||
finding: result.finding || null,
|
||||
},
|
||||
}));
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
reportVisualContrastError(err, { selector: candidate.selector });
|
||||
})
|
||||
.finally(() => {
|
||||
lazyVisualContrastResolving.delete(el);
|
||||
});
|
||||
}
|
||||
}, { threshold: 0.5 });
|
||||
|
||||
for (const candidate of unresolved) {
|
||||
let el = null;
|
||||
try {
|
||||
el = document.querySelector(candidate.selector);
|
||||
} catch {
|
||||
el = null;
|
||||
}
|
||||
if (!el) continue;
|
||||
lazyVisualContrastPending.set(el, candidate);
|
||||
lazyVisualContrastObserver.observe(el);
|
||||
}
|
||||
}
|
||||
|
||||
async function addVisualContrastFindings(groupMap, options = {}, runtime = {}) {
|
||||
if (!shouldRunVisualContrast(options)) {
|
||||
lastVisualContrastAnalyses = [];
|
||||
disconnectLazyVisualContrastObserver();
|
||||
return [];
|
||||
}
|
||||
const resolvedOptions = visualContrastOptions(options);
|
||||
if (visualContrastMode(options) === 'image-only') resolvedOptions.imageOnly = true;
|
||||
const analyses = await analyzeVisualContrast(resolvedOptions);
|
||||
if (runtime.generation && runtime.generation !== scanGeneration) return analyses;
|
||||
lastVisualContrastAnalyses = analyses;
|
||||
for (const result of analyses) {
|
||||
addVisualContrastResult(groupMap, result, { decorate: runtime.decorate });
|
||||
}
|
||||
if (runtime.decorate || runtime.scheduleLazy) scheduleLazyVisualContrast(groupMap, analyses, resolvedOptions, runtime);
|
||||
return analyses;
|
||||
}
|
||||
|
||||
async function collectBrowserFindingsAsync(options = {}, runtime = {}) {
|
||||
const collected = collectBrowserFindings();
|
||||
// The visual pass walks the DOM on its own; on a skipScan page it would
|
||||
// repopulate the emptied scan, so it is skipped with everything else.
|
||||
if (skipScanActive()) {
|
||||
lastVisualContrastAnalyses = [];
|
||||
return { ...collected, allFindings: [], visualContrastAnalyses: [] };
|
||||
}
|
||||
await addVisualContrastFindings(collected.groupMap, options, runtime);
|
||||
return {
|
||||
...collected,
|
||||
allFindings: browserFindingsFromMap(collected.groupMap),
|
||||
visualContrastAnalyses: lastVisualContrastAnalyses,
|
||||
};
|
||||
}
|
||||
|
||||
function clearOverlays() {
|
||||
scanGeneration += 1;
|
||||
disconnectLazyVisualContrastObserver();
|
||||
ui.clearOverlays();
|
||||
}
|
||||
|
||||
function renderBrowserFindings(collected, options = {}) {
|
||||
const { allFindings, pageLevelFindings } = collected;
|
||||
|
||||
for (const { el, findings } of allFindings) {
|
||||
if (el === document.body || el === document.documentElement) continue;
|
||||
ui.highlight(el, findings);
|
||||
}
|
||||
|
||||
if (pageLevelFindings.length > 0) {
|
||||
ui.showPageBanner(pageLevelFindings);
|
||||
}
|
||||
|
||||
if (!EXTENSION_MODE) printSummary(allFindings);
|
||||
|
||||
// In extension mode, post serialized results for the DevTools panel
|
||||
if (EXTENSION_MODE) {
|
||||
window.postMessage({
|
||||
source: 'impeccable-results',
|
||||
findings: serializeFindings(allFindings),
|
||||
count: allFindings.length,
|
||||
...scanResultMeta(options),
|
||||
}, '*');
|
||||
}
|
||||
|
||||
// After this scan completes, all subsequent reveals are instant (no stagger, no animation)
|
||||
setTimeout(() => { ui.setFirstScanDone(); }, 1000);
|
||||
|
||||
return allFindings;
|
||||
}
|
||||
|
||||
const scan = function(options = {}) {
|
||||
clearOverlays();
|
||||
const generation = scanGeneration;
|
||||
const collected = collectBrowserFindings();
|
||||
const allFindings = renderBrowserFindings(collected, options);
|
||||
if (!skipScanActive() && shouldRunVisualContrast(options)) {
|
||||
addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation })
|
||||
.then(() => {
|
||||
if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options);
|
||||
})
|
||||
.catch(err => {
|
||||
reportVisualContrastError(err);
|
||||
});
|
||||
}
|
||||
return allFindings;
|
||||
};
|
||||
|
||||
const scanAsync = async function(options = {}) {
|
||||
clearOverlays();
|
||||
const generation = scanGeneration;
|
||||
if (shouldRunVisualContrast(options)) {
|
||||
const collected = await collectBrowserFindingsAsync(options, { generation, scheduleLazy: true });
|
||||
if (generation !== scanGeneration) return [];
|
||||
return renderBrowserFindings(collected, options);
|
||||
}
|
||||
lastVisualContrastAnalyses = [];
|
||||
return renderBrowserFindings(collectBrowserFindings(), options);
|
||||
};
|
||||
|
||||
const detect = function(options = {}) {
|
||||
lastVisualContrastAnalyses = [];
|
||||
const { allFindings } = collectBrowserFindings();
|
||||
return options.serialize === false ? allFindings : serializeFindings(allFindings);
|
||||
};
|
||||
|
||||
const detectAsync = async function(options = {}) {
|
||||
if (shouldRunVisualContrast(options)) {
|
||||
const { allFindings } = await collectBrowserFindingsAsync(options);
|
||||
return options.serialize === false ? allFindings : serializeFindings(allFindings);
|
||||
}
|
||||
lastVisualContrastAnalyses = [];
|
||||
const { allFindings } = collectBrowserFindings();
|
||||
return options.serialize === false ? allFindings : serializeFindings(allFindings);
|
||||
};
|
||||
|
||||
if (EXTENSION_MODE) {
|
||||
// Extension mode: listen for commands, don't auto-scan
|
||||
window.addEventListener('message', (e) => {
|
||||
if (e.source !== window || !e.data || e.data.source !== 'impeccable-command') return;
|
||||
if (e.data.action === 'scan') {
|
||||
if (e.data.config) window.__IMPECCABLE_CONFIG__ = e.data.config;
|
||||
try {
|
||||
scan(e.data.config || {});
|
||||
} catch (err) {
|
||||
postExtensionError(err);
|
||||
}
|
||||
}
|
||||
if (e.data.action === 'toggle-overlays') {
|
||||
const visible = ui.toggleOverlays();
|
||||
window.postMessage({ source: 'impeccable-overlays-toggled', visible }, '*');
|
||||
}
|
||||
if (e.data.action === 'remove') {
|
||||
clearOverlays();
|
||||
ui.remove();
|
||||
}
|
||||
if (e.data.action === 'highlight') {
|
||||
ui.highlightSelector(e.data.selector);
|
||||
}
|
||||
if (e.data.action === 'unhighlight') {
|
||||
ui.unspotlight();
|
||||
}
|
||||
});
|
||||
window.postMessage({ source: 'impeccable-ready' }, '*');
|
||||
} else {
|
||||
if (window.__IMPECCABLE_CONFIG__?.autoScan !== false) {
|
||||
const runAutoScan = () => {
|
||||
try {
|
||||
scan();
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] scan failed', err);
|
||||
}
|
||||
};
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => setTimeout(runAutoScan, 100));
|
||||
} else {
|
||||
setTimeout(runAutoScan, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.impeccableDetect = detect;
|
||||
window.impeccableDetectAsync = detectAsync;
|
||||
window.impeccableScan = scan;
|
||||
window.impeccableScanAsync = scanAsync;
|
||||
// Raw measurement for the URL engine's content-hidden-at-rest pass: it
|
||||
// drives a reveal sweep from Node and thresholds the result itself.
|
||||
window.impeccableMeasureHiddenText = () => JSON.parse(__impeccable.measure_hidden_text());
|
||||
window.impeccableCollectVisualContrastCandidates = collectVisualContrastCandidates;
|
||||
window.impeccableAnalyzeVisualContrast = analyzeVisualContrast;
|
||||
window.impeccableGetLastVisualContrastAnalyses = () => lastVisualContrastAnalyses.slice();
|
||||
|
||||
// The snapshot route (what the extension runs when the page's CSP keeps
|
||||
// WebAssembly out of every world it can reach), exposed here so the two
|
||||
// routes can be A/B'd on the same page: capture, run the same core over
|
||||
// the snapshot (answering its hit-test needs from the live page), and
|
||||
// serialize through it. Deterministic findings only; the visual-contrast
|
||||
// pass over a snapshot is the extension's (see 60-offscreen.js).
|
||||
window.impeccableSnapshotCapture = (options) => __impeccableSnapshot.capture(options);
|
||||
window.impeccableDetectFromSnapshot = function (options = {}) {
|
||||
const t0 = performance.now();
|
||||
const cap = __impeccableSnapshot.capture(options);
|
||||
if (cap.error) throw new Error(cap.error);
|
||||
const t1 = performance.now();
|
||||
let out = JSON.parse(__impeccable.collect_findings_from_snapshot(cap.json, collectConfigJson()));
|
||||
let rounds = 1;
|
||||
while (out.needs) {
|
||||
__impeccable.snapshot_add_facts(JSON.stringify(__impeccableSnapshot.answer(out.needs, cap)));
|
||||
out = JSON.parse(__impeccable.collect_browser_findings(collectConfigJson()));
|
||||
if (__impeccable.snapshot_has_needs()) out = { needs: JSON.parse(__impeccable.snapshot_take_needs()) };
|
||||
rounds++;
|
||||
}
|
||||
const serialized = JSON.parse(__impeccable.serialize_findings(JSON.stringify(out.groups)));
|
||||
const unknownStyleProps = JSON.parse(__impeccable.snapshot_unknown_style_props());
|
||||
__impeccable.snapshot_clear();
|
||||
return {
|
||||
findings: serialized,
|
||||
pageLevel: out.pageLevel,
|
||||
stats: { ...cap.stats, rounds, unknownStyleProps, captureMs: t1 - t0, coreMs: performance.now() - t1 },
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
// --- browser-bundle/60-offscreen.js ---
|
||||
// The extension's offscreen document: hosts the WASM core (its own CSP
|
||||
// allows 'wasm-unsafe-eval'; a page's never has to) and runs the same scan
|
||||
// the in-page bundle runs, over a page snapshot the content script captured
|
||||
// (15-snapshot.js -> crates/core/src/browser/snapshot.rs). No rule logic
|
||||
// here: marshalling, the session protocol, and the visual-contrast IO
|
||||
// adapter whose every read is a question back to the content script.
|
||||
//
|
||||
// Protocol (content script <-> this document, chrome.runtime messages with
|
||||
// `target: 'impeccable-offscreen'`; each request is answered exactly once):
|
||||
//
|
||||
// { action: 'scan-start', session, snapshot, config }
|
||||
// -> { ask: { hitTests: [[x, y]] } } answer: { hits: [...] }
|
||||
// -> { ask: { io: { kind: 'loadImage', src } } }
|
||||
// answer: { ref, w, h } | null
|
||||
// -> { ask: { io: { kind: 'readPixel', ref, plan, px, py } } }
|
||||
// answer: { data } | { error } | { noContext }
|
||||
// -> { stage: 'findings', groups, pageLevel, serialized } answer: {}
|
||||
// -> { stage: 'visual', groups, serialized, lazy } answer: {}
|
||||
// -> { done: true }
|
||||
// -> { error: message }
|
||||
// -> { superseded: true } (a newer scan-start took the session over)
|
||||
// { action: 'scan-continue', session, answer } (the answer to the last ask/stage)
|
||||
// { action: 'analyze-candidate', session, snapshot, candidate, groups }
|
||||
// -> asks as above, then { result, el, finding, serialized } (el 0 = no addition)
|
||||
// { action: 'antipatterns' } -> the registry slice for the overlay labels
|
||||
// { action: 'ping' } -> { ok: true, ready }
|
||||
//
|
||||
// `groups` are `[{ el, findings }]` with snapshot ids; the content script
|
||||
// maps ids to Elements through the capture it made.
|
||||
|
||||
(function () {
|
||||
const TARGET = 'impeccable-offscreen';
|
||||
const sessions = new Map();
|
||||
|
||||
let corePromise = null;
|
||||
function coreReady() {
|
||||
if (!corePromise) corePromise = __impeccableLoadCore();
|
||||
return corePromise;
|
||||
}
|
||||
|
||||
// Coroutine over messages: `ask` answers the pending request with a
|
||||
// question and parks until the next 'scan-continue' brings the answer.
|
||||
function ask(session, payload) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const respond = session.respond;
|
||||
session.respond = null;
|
||||
session.resume = { resolve, reject };
|
||||
if (!respond) {
|
||||
reject(new Error('session has no pending request'));
|
||||
return;
|
||||
}
|
||||
respond(payload);
|
||||
});
|
||||
}
|
||||
|
||||
function finish(session, payload) {
|
||||
const respond = session.respond;
|
||||
session.respond = null;
|
||||
if (sessions.get(session.id) === session) sessions.delete(session.id);
|
||||
if (respond) respond(payload);
|
||||
}
|
||||
|
||||
// The core holds one loaded snapshot at a time, so scans (which park at
|
||||
// asks) run one after another; a second tab's scan waits its turn.
|
||||
let chain = Promise.resolve();
|
||||
function serialized(fn) {
|
||||
const run = chain.then(fn, fn);
|
||||
chain = run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
|
||||
// The visual-contrast IO over the snapshot: the core over the loaded
|
||||
// snapshot (hit-test needs answered by the content script between calls),
|
||||
// node = snapshot id, images and pixels read by the content script.
|
||||
function createOffscreenVisualIO(wasm, session) {
|
||||
async function core(fn, ...args) {
|
||||
for (;;) {
|
||||
const out = wasm[fn](...args);
|
||||
if (!wasm.snapshot_has_needs()) return out;
|
||||
const needs = JSON.parse(wasm.snapshot_take_needs());
|
||||
const facts = await ask(session, { ask: { hitTests: needs.hitTests || [] } });
|
||||
wasm.snapshot_add_facts(JSON.stringify(facts || { hits: [] }));
|
||||
}
|
||||
}
|
||||
const media = (id) => JSON.parse(wasm.snapshot_media(id)) || {};
|
||||
return {
|
||||
core,
|
||||
coreSync() { throw new Error('the offscreen adapter is asynchronous'); },
|
||||
node: (handle) => handle,
|
||||
handle: (id) => id,
|
||||
parentOrBody: (id) => wasm.snapshot_parent_or_body(id),
|
||||
intrinsicImg(id) { const m = media(id); return [m.nw || m.vw || m.w || 0, m.nh || m.vh || m.h || 0]; },
|
||||
intrinsicRaster(id) { const m = media(id); return [m.w || m.vw || 0, m.h || m.vh || 0]; },
|
||||
imgSrc(id) { const m = media(id); return m.cur || m.src || ''; },
|
||||
loadImage: (src) => ask(session, { ask: { io: { kind: 'loadImage', src } } }),
|
||||
readPixel: (ref, plan, px, py) => ask(session, { ask: { io: { kind: 'readPixel', ref, plan, px, py } } }),
|
||||
// Scrolling the page from a snapshot is not meaningful; the extension
|
||||
// never sets scrollOffscreen, and the lazy pass re-captures instead.
|
||||
querySelector: () => null,
|
||||
scroll() { const v = JSON.parse(wasm.snapshot_viewport()) || {}; return { x: v.scrollX || 0, y: v.scrollY || 0 }; },
|
||||
scrollTo() {},
|
||||
scrollIntoView: () => false,
|
||||
waitForPaint: () => Promise.resolve(),
|
||||
};
|
||||
}
|
||||
|
||||
function configJson(config) {
|
||||
config = config || {};
|
||||
return JSON.stringify({
|
||||
extensionMode: true,
|
||||
disabledRules: Array.isArray(config.disabledRules) ? config.disabledRules : [],
|
||||
disabledValues: Array.isArray(config.disabledValues) ? config.disabledValues : [],
|
||||
designSystem: config.designSystem == null ? null : config.designSystem,
|
||||
lineLengthMax: config.lineLengthMax == null ? null : config.lineLengthMax,
|
||||
skipScan: config.skipScan === true,
|
||||
});
|
||||
}
|
||||
|
||||
function serialize(wasm, groups) {
|
||||
return JSON.parse(wasm.serialize_findings(JSON.stringify(groups)));
|
||||
}
|
||||
|
||||
// addVisualContrastResult over id-keyed groups: the two decisions are the
|
||||
// core's; this only keeps the map.
|
||||
function addVisualContrastResult(wasm, groups, result) {
|
||||
const elId = wasm.visual_contrast_result_el(JSON.stringify(result));
|
||||
if (!elId) return 0;
|
||||
let group = groups.find(g => g.el === elId);
|
||||
const existing = group ? group.findings : [];
|
||||
const finding = JSON.parse(wasm.visual_contrast_result_finding(elId, JSON.stringify(existing), JSON.stringify(result)));
|
||||
if (!finding) return 0;
|
||||
if (group) group.findings.push(finding);
|
||||
else groups.push({ el: elId, findings: [finding] });
|
||||
return elId;
|
||||
}
|
||||
|
||||
async function runScan(session, msg) {
|
||||
const wasm = await coreReady();
|
||||
const n = wasm.snapshot_load(msg.snapshot);
|
||||
if (n === 0xFFFFFFFF) throw new Error('snapshot did not parse');
|
||||
const config = msg.config || {};
|
||||
const IO = createOffscreenVisualIO(wasm, session);
|
||||
const vc = createVisualContrast(IO);
|
||||
const t0 = performance.now();
|
||||
const collected = JSON.parse(await IO.core('collect_browser_findings', configJson(config)));
|
||||
const groups = collected.groups;
|
||||
const stats = { elements: n, coreMs: performance.now() - t0, unknownStyleProps: JSON.parse(wasm.snapshot_unknown_style_props()) };
|
||||
await ask(session, {
|
||||
stage: 'findings',
|
||||
groups,
|
||||
pageLevel: collected.pageLevel,
|
||||
serialized: serialize(wasm, groups),
|
||||
stats,
|
||||
});
|
||||
const options = config;
|
||||
// An ignoreFiles-waived page (config.skipScan) answers every stage empty:
|
||||
// the core already emptied the collect pass, and the visual pass would
|
||||
// repopulate it, so it is skipped with everything else (mirrors
|
||||
// skipScanActive() in 50-scan.js; offscreen is always extension mode).
|
||||
if (config.skipScan !== true && __visualContrastMode(options, config) !== false) {
|
||||
const resolved = __visualContrastOptions(options, config);
|
||||
if (__visualContrastMode(options, config) === 'image-only') resolved.imageOnly = true;
|
||||
const analyses = await vc.analyzeVisualContrast(resolved);
|
||||
const added = [];
|
||||
for (const result of analyses) {
|
||||
const el = addVisualContrastResult(wasm, groups, result);
|
||||
if (el) added.push(el);
|
||||
}
|
||||
const lazy = (resolved.visualContrastLazy === false || resolved.scrollOffscreen !== false)
|
||||
? []
|
||||
: __lazyVisualContrastCandidates(analyses);
|
||||
await ask(session, {
|
||||
stage: 'visual',
|
||||
groups,
|
||||
added,
|
||||
analyses,
|
||||
serialized: serialize(wasm, groups),
|
||||
lazy,
|
||||
stats: { visualMs: performance.now() - t0 - stats.coreMs },
|
||||
});
|
||||
}
|
||||
wasm.snapshot_clear();
|
||||
finish(session, { done: true });
|
||||
}
|
||||
|
||||
async function runCandidate(session, msg) {
|
||||
const wasm = await coreReady();
|
||||
const n = wasm.snapshot_load(msg.snapshot);
|
||||
if (n === 0xFFFFFFFF) throw new Error('snapshot did not parse');
|
||||
const IO = createOffscreenVisualIO(wasm, session);
|
||||
const vc = createVisualContrast(IO);
|
||||
const groups = Array.isArray(msg.groups) ? msg.groups : [];
|
||||
const result = await vc.analyzeVisualContrastCandidate(msg.candidate);
|
||||
const el = addVisualContrastResult(wasm, groups, result);
|
||||
const out = { result, el, groups, serialized: el ? serialize(wasm, groups) : null };
|
||||
wasm.snapshot_clear();
|
||||
finish(session, out);
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
if (!msg || msg.target !== TARGET) return false;
|
||||
if (msg.action === 'ping') {
|
||||
coreReady().then(() => sendResponse({ ok: true, ready: true }), (err) => sendResponse({ ok: false, error: err?.message || String(err) }));
|
||||
return true;
|
||||
}
|
||||
if (msg.action === 'antipatterns') {
|
||||
coreReady().then((wasm) => sendResponse({ antipatterns: JSON.parse(wasm.antipatterns_json()) }), (err) => sendResponse({ error: err?.message || String(err) }));
|
||||
return true;
|
||||
}
|
||||
if (msg.action === 'scan-start' || msg.action === 'analyze-candidate') {
|
||||
const prior = sessions.get(msg.session);
|
||||
if (prior) {
|
||||
// A restarted session (the content script re-scanned): drop the old
|
||||
// coroutine so it never answers a stale request.
|
||||
prior.superseded = true;
|
||||
if (prior.resume) prior.resume.reject(new Error('superseded'));
|
||||
if (prior.respond) { try { prior.respond({ superseded: true }); } catch { /* channel gone */ } }
|
||||
prior.respond = null;
|
||||
sessions.delete(msg.session);
|
||||
}
|
||||
const session = { id: msg.session, respond: sendResponse, resume: null, superseded: false };
|
||||
sessions.set(msg.session, session);
|
||||
const run = msg.action === 'scan-start' ? runScan : runCandidate;
|
||||
serialized(() => {
|
||||
if (session.superseded) return;
|
||||
return run(session, msg);
|
||||
}).catch((err) => {
|
||||
if (err && err.message === 'superseded') return;
|
||||
finish(session, { error: err?.message || String(err) });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (msg.action === 'scan-continue') {
|
||||
const session = sessions.get(msg.session);
|
||||
if (!session || !session.resume) {
|
||||
sendResponse({ error: 'no such session' });
|
||||
return false;
|
||||
}
|
||||
session.respond = sendResponse;
|
||||
const resume = session.resume;
|
||||
session.resume = null;
|
||||
resume.resolve(msg.answer);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
})();
|
||||
@@ -1 +0,0 @@
|
||||
})();
|
||||
@@ -1,27 +0,0 @@
|
||||
# browser-bundle: the page-side JavaScript of the detector
|
||||
|
||||
Plain JavaScript that runs inside a page or the extension: the DOM probe the
|
||||
wasm rule core calls back into, the page snapshot producer, the
|
||||
visual-contrast sampling IO, the overlay UI, the scan API and the extension's
|
||||
offscreen document. Measurement and presentation only; every rule decision
|
||||
is a call into the wasm rule core built from `crates/core` (`docs/ENGINE.md`).
|
||||
|
||||
Two consumers:
|
||||
|
||||
- `crates/browser` embeds `15-snapshot.js` (the snapshot producer the URL
|
||||
engine injects; no WebAssembly runs in the page).
|
||||
- `crates/bundle` (the `impeccable-bundle` library) embeds every file here
|
||||
with `include_str!` and concatenates them, in filename order, with the wasm
|
||||
core into the in-page bundle plus the extension's `extension/detector/`
|
||||
pieces. `cargo xtask bundle` is its caller inside this workspace: it writes
|
||||
`dist/detect-antipatterns-browser.js`, copies that bundle to the tracked
|
||||
`crates/live/assets/detect-antipatterns-browser.js` the engine embeds, and
|
||||
writes the extension pieces. A downstream crate with its own rule pack
|
||||
calls the library directly (`docs/ENGINE.md`).
|
||||
|
||||
Because the files are embedded, a new one here has to be added to
|
||||
`PAGE_JS` in `crates/bundle/src/lib.rs` (and to the order it is concatenated
|
||||
in); a test fails when the two lists disagree.
|
||||
|
||||
`15-snapshot.js` lists the computed-style properties the rules read; the
|
||||
bundle build checks that list against the core's and fails when they drift.
|
||||
@@ -4,6 +4,14 @@
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "vibe-design-plugins",
|
||||
"dependencies": {
|
||||
"css-select": "^7.0.0",
|
||||
"css-tree": "^3.2.1",
|
||||
"domutils": "^4.0.2",
|
||||
"fflate": "^0.8.3",
|
||||
"htmlparser2": "^12.0.0",
|
||||
"marked": "^18.0.5",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ai-sdk/anthropic": "^4.0.7",
|
||||
"@ai-sdk/google": "^4.0.8",
|
||||
@@ -13,17 +21,13 @@
|
||||
"@babel/parser": "^8.0.4",
|
||||
"ai": "^7.0.14",
|
||||
"archiver": "^8.0.0",
|
||||
"esbuild": "0.28.1",
|
||||
"playwright": "^1.59.1",
|
||||
"puppeteer": "^25.1.0",
|
||||
"svelte": "^5",
|
||||
"zod": "^4.3.6",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@impeccable/cli-darwin-arm64": "0.1.0",
|
||||
"@impeccable/cli-darwin-x64": "0.1.0",
|
||||
"@impeccable/cli-linux-arm64": "0.1.0",
|
||||
"@impeccable/cli-linux-x64": "0.1.0",
|
||||
"@impeccable/cli-windows-x64": "0.1.0",
|
||||
"puppeteer": "^25.1.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -70,6 +74,58 @@
|
||||
|
||||
"@babel/types": ["@babel/types@8.0.4", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.4" } }, "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
@@ -144,6 +200,8 @@
|
||||
|
||||
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
||||
|
||||
"boolbase": ["boolbase@2.0.0", "", {}, "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
|
||||
|
||||
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
|
||||
@@ -182,6 +240,12 @@
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"css-select": ["css-select@7.0.0", "", { "dependencies": { "boolbase": "^2.0.0", "css-what": "^8.0.0", "domhandler": "^6.0.1", "domutils": "^4.0.2", "nth-check": "^3.0.1" } }, "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g=="],
|
||||
|
||||
"css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
|
||||
|
||||
"css-what": ["css-what@8.0.0", "", {}, "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||
@@ -190,6 +254,14 @@
|
||||
|
||||
"devtools-protocol": ["devtools-protocol@0.0.1666840", "", {}, "sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg=="],
|
||||
|
||||
"dom-serializer": ["dom-serializer@3.1.1", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "entities": "^8.0.0" } }, "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw=="],
|
||||
|
||||
"domelementtype": ["domelementtype@3.0.0", "", {}, "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg=="],
|
||||
|
||||
"domhandler": ["domhandler@6.0.1", "", { "dependencies": { "domelementtype": "^3.0.0" } }, "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg=="],
|
||||
|
||||
"domutils": ["domutils@4.0.2", "", { "dependencies": { "dom-serializer": "^3.0.0", "domelementtype": "^3.0.0", "domhandler": "^6.0.0" } }, "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
@@ -198,12 +270,16 @@
|
||||
|
||||
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||
|
||||
"entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
@@ -236,6 +312,8 @@
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
||||
|
||||
"fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
|
||||
|
||||
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
||||
|
||||
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||
@@ -262,6 +340,8 @@
|
||||
|
||||
"hono": ["hono@4.12.14", "", {}, "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w=="],
|
||||
|
||||
"htmlparser2": ["htmlparser2@12.0.0", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "domutils": "^4.0.2", "entities": "^8.0.0" } }, "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw=="],
|
||||
|
||||
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
@@ -302,8 +382,12 @@
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"marked": ["marked@18.0.11", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
|
||||
|
||||
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
||||
|
||||
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
||||
@@ -324,6 +408,8 @@
|
||||
|
||||
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||
|
||||
"nth-check": ["nth-check@3.0.1", "", { "dependencies": { "boolbase": "^2.0.0" } }, "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
@@ -390,6 +476,8 @@
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
|
||||
+95
-80
@@ -1,87 +1,102 @@
|
||||
#!/usr/bin/env node
|
||||
// `impeccable` npm shim: finds the platform binary and execs it with argv.
|
||||
// Order: $IMPECCABLE_BIN, the @impeccable/cli-<os>-<arch> optional dependency,
|
||||
// the version-pinned user cache (~/.impeccable/bin/<version>/), then a
|
||||
// download into that cache from the public release channel.
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const pkg = require('../../package.json');
|
||||
const OS = { darwin: 'darwin', linux: 'linux', win32: 'windows' }[process.platform] || process.platform;
|
||||
const ARCH = { arm64: 'arm64', x64: 'x64' }[process.arch] || process.arch;
|
||||
const TARGET = `${OS}-${ARCH}`;
|
||||
const EXE = OS === 'windows' ? 'impeccable.exe' : 'impeccable';
|
||||
const PLATFORM_PKG = `@impeccable/cli-${TARGET}`;
|
||||
// The engine version travels as the pinned optionalDependency range.
|
||||
const VERSION = String(pkg.optionalDependencies?.[PLATFORM_PKG] || Object.values(pkg.optionalDependencies || {})[0] || '').replace(/^[^\d]*/, '');
|
||||
const CACHE_ROOT = process.env.IMPECCABLE_HOME || path.join(os.homedir(), '.impeccable');
|
||||
const CACHED = path.join(CACHE_ROOT, 'bin', VERSION, EXE);
|
||||
const BASE = (process.env.IMPECCABLE_DOWNLOAD_BASE || 'https://github.com/pbakaus/impeccable/releases/download').replace(/\/$/, '');
|
||||
const URL = `${BASE}/engine-v${VERSION}/impeccable-${TARGET}${OS === 'windows' ? '.exe' : ''}`;
|
||||
/**
|
||||
* Impeccable CLI
|
||||
*
|
||||
* Usage:
|
||||
* npx impeccable detect [file-or-dir-or-url...]
|
||||
* npx impeccable ignores <list|add-file|add-value|remove-...>
|
||||
* npx impeccable help|install|update
|
||||
* npx impeccable --help
|
||||
*/
|
||||
|
||||
function exists(p) { try { return !!p && fs.statSync(p).isFile(); } catch { return false; } }
|
||||
function fromPackage() {
|
||||
try { return path.join(path.dirname(require.resolve(`${PLATFORM_PKG}/package.json`)), 'bin', EXE); } catch { return null; }
|
||||
}
|
||||
async function download() {
|
||||
if (!VERSION) return null;
|
||||
const res = await fetch(URL, { redirect: 'follow' });
|
||||
if (!res.ok) return null;
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
// Fail closed, like the skill launcher and `impeccable install`: a sidecar
|
||||
// that cannot be fetched, or that carries no hash, refuses the download
|
||||
// instead of caching an unverified binary. Nothing is written until the
|
||||
// hash matches, so a refusal leaves the cache dir untouched.
|
||||
const sum = await fetch(`${URL}.sha256`, { redirect: 'follow' }).then(r => (r.ok ? r.text() : ''), () => '');
|
||||
const expected = sum.trim().split(/\s+/)[0].toLowerCase();
|
||||
if (!expected) {
|
||||
throw new Error(
|
||||
`cannot verify ${URL} against ${URL}.sha256 (sidecar unavailable or empty); `
|
||||
+ 'refusing the unverified download',
|
||||
);
|
||||
}
|
||||
if (createHash('sha256').update(buf).digest('hex') !== expected) {
|
||||
throw new Error(`checksum mismatch downloading ${URL}`);
|
||||
}
|
||||
fs.mkdirSync(path.dirname(CACHED), { recursive: true });
|
||||
const tmp = `${CACHED}.part.${process.pid}`;
|
||||
try {
|
||||
fs.writeFileSync(tmp, buf, { mode: 0o755 });
|
||||
fs.renameSync(tmp, CACHED);
|
||||
} catch (err) {
|
||||
try { fs.rmSync(tmp, { force: true }); } catch { /* best effort */ }
|
||||
throw err;
|
||||
}
|
||||
return CACHED;
|
||||
}
|
||||
async function locate() {
|
||||
const envBin = process.env.IMPECCABLE_BIN;
|
||||
if (exists(envBin)) return envBin;
|
||||
const fromPkg = fromPackage();
|
||||
if (exists(fromPkg)) return fromPkg;
|
||||
if (exists(CACHED)) return CACHED;
|
||||
return download().catch((err) => { process.stderr.write(`impeccable: ${err.message}\n`); return null; });
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join, dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SKILL_COMMANDS = new Set(['help', 'install', 'link', 'update', 'check']);
|
||||
|
||||
// Is this a detect target (the `npx impeccable src/` shorthand) or a mistyped
|
||||
// command? Flags, URLs, path-shaped args, and real files/dirs (e.g. an
|
||||
// extension-less `Dockerfile`) are targets; anything else is an unknown command.
|
||||
function looksLikeDetectTarget(arg) {
|
||||
const isFlag = arg.startsWith('-');
|
||||
const isUrl = /^https?:\/\//i.test(arg);
|
||||
const isPathShaped = arg.includes('/') || arg.includes('\\') || arg.includes('.');
|
||||
const isExistingPath = existsSync(resolve(arg));
|
||||
return isFlag || isUrl || isPathShaped || isExistingPath;
|
||||
}
|
||||
|
||||
const bin = await locate();
|
||||
if (!bin) {
|
||||
process.stderr.write(
|
||||
`impeccable: no binary for ${TARGET}. Install ${PLATFORM_PKG}@${VERSION}, set IMPECCABLE_BIN, `
|
||||
+ `or download impeccable-${TARGET} v${VERSION} from ${BASE} into ${CACHED}.\n`,
|
||||
);
|
||||
process.exit(127);
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0];
|
||||
|
||||
if (!command || command === '--help' || command === '-h') {
|
||||
console.log(`Usage: impeccable <command> [options]
|
||||
|
||||
Commands:
|
||||
detect [file-or-dir-or-url...] Scan for UI anti-patterns and design quality issues
|
||||
ignores Manage detector ignore rules, files, and values
|
||||
help List all available skills and commands
|
||||
install Install impeccable skills into your project or global harness
|
||||
link Symlink skills from a local checkout or submodule
|
||||
update Update skills to the latest version
|
||||
check Check if skill updates are available
|
||||
|
||||
Options:
|
||||
--help Show this help message
|
||||
--version Show version number
|
||||
|
||||
Compatibility:
|
||||
impeccable skills <command> Legacy namespace; still supported.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (command === '--version' || command === '-v') {
|
||||
const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8'));
|
||||
console.log(pkg.version);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (command === 'detect') {
|
||||
process.argv = [process.argv[0], process.argv[1], ...args.slice(1)];
|
||||
const { detectCli } = await import('../engine/detect-antipatterns.mjs');
|
||||
await detectCli();
|
||||
} else if (command === 'ignores' || command === 'ignore') {
|
||||
const { run } = await import('./commands/ignores.mjs');
|
||||
await run(args.slice(1));
|
||||
} else if (command === 'skills') {
|
||||
const { run } = await import('./commands/skills.mjs');
|
||||
await run(args.slice(1));
|
||||
} else if (SKILL_COMMANDS.has(command)) {
|
||||
const { run } = await import('./commands/skills.mjs');
|
||||
await run(args);
|
||||
} else if (looksLikeDetectTarget(command)) {
|
||||
// Default: treat as detect arguments (allow `npx impeccable src/` shorthand)
|
||||
process.argv = [process.argv[0], process.argv[1], ...args];
|
||||
const { detectCli } = await import('../engine/detect-antipatterns.mjs');
|
||||
await detectCli();
|
||||
} else if (command === 'init') {
|
||||
// The follow-up mistake from issue #472: `/impeccable init` belongs in an AI
|
||||
// coding agent's chat, and a user who typed it into their shell is likely to
|
||||
// retry it here as `npx impeccable init`.
|
||||
console.error(`"init" is not a CLI command. Type /impeccable init in your AI coding agent's chat (Claude Code, Cursor, Codex, ...), not in this terminal.`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
// An unknown bareword: a mistyped command (or an old cached version run
|
||||
// against newer docs). Fail loudly instead of silently statting it as a path.
|
||||
console.error(`Unknown command: "${command}"\n\nTo see a list of supported commands, run:\n impeccable --help`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
const result = spawnSync(bin, process.argv.slice(2), {
|
||||
stdio: 'inherit',
|
||||
env: { IMPECCABLE_SELF: 'npx impeccable', ...process.env },
|
||||
|
||||
main().catch(error => {
|
||||
if (error?.code === 'IMPECCABLE_PROMPT_ABORT') {
|
||||
console.log('\nAborted.');
|
||||
process.exit(130);
|
||||
}
|
||||
|
||||
console.error(error?.message || error);
|
||||
process.exit(1);
|
||||
});
|
||||
if (result.error) {
|
||||
process.stderr.write(`impeccable: failed to run ${bin}: ${result.error.message}\n`);
|
||||
process.exit(127);
|
||||
}
|
||||
process.exit(result.status === null ? 1 : result.status);
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
getConfigPath,
|
||||
getLocalConfigPath,
|
||||
normalizeIgnoreValue,
|
||||
readDetectionConfig,
|
||||
readRawDetectionConfig,
|
||||
writeDetectionConfig,
|
||||
extractFindingIgnoreValue,
|
||||
} from '../../lib/impeccable-config.mjs';
|
||||
|
||||
const ACTION_ALIASES = new Map([
|
||||
['status', 'list'],
|
||||
['ls', 'list'],
|
||||
['list', 'list'],
|
||||
['add-rule', 'add-rule'],
|
||||
['ignore-rule', 'add-rule'],
|
||||
['add-file', 'add-file'],
|
||||
['ignore-file', 'add-file'],
|
||||
['add-value', 'add-value'],
|
||||
['ignore-value', 'add-value'],
|
||||
['update-value', 'add-value'],
|
||||
['remove-rule', 'remove-rule'],
|
||||
['rm-rule', 'remove-rule'],
|
||||
['remove-file', 'remove-file'],
|
||||
['rm-file', 'remove-file'],
|
||||
['remove-value', 'remove-value'],
|
||||
['rm-value', 'remove-value'],
|
||||
['clear', 'clear'],
|
||||
]);
|
||||
|
||||
function printUsage() {
|
||||
console.log(`Usage: impeccable ignores <action> [options]
|
||||
|
||||
Manage detector ignores in .impeccable config.
|
||||
|
||||
Actions:
|
||||
list Show merged, shared, and local ignores
|
||||
add-rule <rule> [--all-values] Ignore a rule
|
||||
add-file <glob> Ignore files by glob
|
||||
add-value <rule> <value> Ignore one rule/value pair
|
||||
remove-rule <rule> Remove a rule ignore
|
||||
remove-file <glob> Remove a file ignore
|
||||
remove-value <rule> <value> Remove a rule/value ignore
|
||||
clear Clear detector ignores in the selected scope
|
||||
|
||||
Scope:
|
||||
--shared Write .impeccable/config.json (default)
|
||||
--local Write .impeccable/config.local.json
|
||||
--all For remove/clear, apply to shared and local
|
||||
|
||||
Value options:
|
||||
--file <glob> Scope add-value/remove-value to a file glob
|
||||
--reason <text> Store or update a reason on add-value
|
||||
|
||||
Examples:
|
||||
impeccable ignores add-file "src/legacy/**"
|
||||
impeccable ignores add-value overused-font Inter --reason "Brand font"
|
||||
impeccable ignores add-value design-system-color "*" --file "src/demo.css"
|
||||
impeccable ignores remove-value overused-font Inter`);
|
||||
}
|
||||
|
||||
function parseScope(args, { allowAll = false } = {}) {
|
||||
const rest = [];
|
||||
let local = false;
|
||||
let shared = false;
|
||||
let all = false;
|
||||
for (const arg of args) {
|
||||
if (arg === '--local') local = true;
|
||||
else if (arg === '--shared') shared = true;
|
||||
else if (arg === '--all') all = true;
|
||||
else rest.push(arg);
|
||||
}
|
||||
if ([local, shared, all].filter(Boolean).length > 1) {
|
||||
throw new Error(`Pass only one scope flag: --shared${allowAll ? ', --local, or --all' : ' or --local'}`);
|
||||
}
|
||||
if (all && !allowAll) throw new Error('--all is only supported for remove and clear actions');
|
||||
return { local, all, rest };
|
||||
}
|
||||
|
||||
// An empty glob used to be dropped by filter(Boolean), so `--file=` reported
|
||||
// success and wrote an entry with no files: the user asked to scope a rule to one
|
||||
// file and silently got the project-wide suppression instead. Refuse it.
|
||||
function requireGlob(raw, flag) {
|
||||
const glob = String(raw ?? '').trim();
|
||||
if (!glob) throw new Error(`${flag} requires a non-empty glob`);
|
||||
// A following flag is not a glob. `--file --reason "why"` consumed `--reason`
|
||||
// as the scope and left the reason text to fold into the value, storing
|
||||
// value="* why" files=["--reason"] and reporting success. Same silent-no-op
|
||||
// class as an unknown flag folding into the value; refuse it the same way.
|
||||
if (glob.startsWith('--')) throw new Error(`${flag} requires a glob, got the flag ${glob}`);
|
||||
return glob;
|
||||
}
|
||||
|
||||
function parseValueArgs(args, { allowUnscopedWildcard = false } = {}) {
|
||||
const positionals = [];
|
||||
const files = [];
|
||||
let reason = '';
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = String(args[i] || '');
|
||||
if (arg === '--reason') {
|
||||
const chunks = [];
|
||||
while (i + 1 < args.length && !String(args[i + 1]).startsWith('--')) chunks.push(args[++i]);
|
||||
reason = chunks.join(' ').trim();
|
||||
} else if (arg.startsWith('--reason=')) {
|
||||
reason = arg.slice('--reason='.length).trim();
|
||||
} else if (arg === '--file' || arg === '--files') {
|
||||
if (i + 1 >= args.length) throw new Error(`${arg} requires a glob`);
|
||||
files.push(requireGlob(args[++i], arg));
|
||||
} else if (arg.startsWith('--file=')) {
|
||||
files.push(requireGlob(arg.slice('--file='.length), '--file'));
|
||||
} else if (arg.startsWith('--files=')) {
|
||||
files.push(requireGlob(arg.slice('--files='.length), '--files'));
|
||||
} else if (arg.startsWith('--')) {
|
||||
throw new Error(`Unknown add-value flag: ${arg}`);
|
||||
} else {
|
||||
positionals.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
const [rule, ...valueParts] = positionals;
|
||||
const value = normalizeIgnoreValue(valueParts.join(' '));
|
||||
if (!rule || !value) throw new Error('Pass a rule id and value, e.g. impeccable ignores add-value overused-font Inter');
|
||||
// Sorted: the dedup key compares the files array, so an unsorted scope made
|
||||
// `--file b.css --file a.css` a different entry from `--file a.css --file b.css`.
|
||||
const scopedFiles = Array.from(new Set(files.filter(Boolean))).sort();
|
||||
if (value === '*' && scopedFiles.length === 0 && !allowUnscopedWildcard) {
|
||||
throw new Error('Wildcard value ignores must be scoped with --file <glob>.');
|
||||
}
|
||||
return {
|
||||
rule: String(rule).trim().toLowerCase(),
|
||||
value,
|
||||
files: scopedFiles,
|
||||
reason,
|
||||
};
|
||||
}
|
||||
|
||||
function formatValues(values) {
|
||||
if (!values.length) return '(none)';
|
||||
return values
|
||||
.map((entry) => {
|
||||
const fileSuffix = Array.isArray(entry.files) && entry.files.length
|
||||
? ` [${entry.files.join(', ')}]`
|
||||
: '';
|
||||
const reasonSuffix = entry.reason ? ` - ${entry.reason}` : '';
|
||||
return `${entry.rule}=${entry.value}${fileSuffix}${reasonSuffix}`;
|
||||
})
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
function formatConfig(label, config) {
|
||||
return [
|
||||
`${label}:`,
|
||||
` ignoreRules: ${config.ignoreRules.length ? config.ignoreRules.join(', ') : '(none)'}`,
|
||||
` ignoreFiles: ${config.ignoreFiles.length ? config.ignoreFiles.join(', ') : '(none)'}`,
|
||||
` ignoreValues: ${formatValues(config.ignoreValues)}`,
|
||||
` designSystem: ${config.designSystem?.enabled === false ? 'disabled' : 'enabled'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function list(cwd) {
|
||||
const merged = readDetectionConfig(cwd);
|
||||
const shared = readRawDetectionConfig(cwd);
|
||||
const local = readRawDetectionConfig(cwd, { local: true });
|
||||
return [
|
||||
'Impeccable detector ignores',
|
||||
` shared file: ${path.relative(cwd, getConfigPath(cwd)) || getConfigPath(cwd)}`,
|
||||
` local file: ${path.relative(cwd, getLocalConfigPath(cwd)) || getLocalConfigPath(cwd)}`,
|
||||
'',
|
||||
formatConfig('Merged', merged),
|
||||
'',
|
||||
formatConfig('Shared', shared),
|
||||
'',
|
||||
formatConfig('Local', local),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function readScopeConfig(cwd, local) {
|
||||
return readRawDetectionConfig(cwd, { local });
|
||||
}
|
||||
|
||||
function writeScopeConfig(cwd, config, local) {
|
||||
return writeDetectionConfig(cwd, config, { local });
|
||||
}
|
||||
|
||||
function parseRuleArgs(args) {
|
||||
const positionals = [];
|
||||
let allValues = false;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = String(args[i] || '');
|
||||
if (arg === '--all-values') {
|
||||
allValues = true;
|
||||
} else if (arg === '--reason') {
|
||||
while (i + 1 < args.length && !String(args[i + 1]).startsWith('--')) i++;
|
||||
} else if (arg.startsWith('--reason=')) {
|
||||
// Accepted for symmetry with add-value; ignoreRules stores ids only.
|
||||
} else if (arg.startsWith('--')) {
|
||||
throw new Error(`Unknown add-rule flag: ${arg}`);
|
||||
} else {
|
||||
positionals.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
rule: String(positionals[0] || '').trim().toLowerCase(),
|
||||
allValues,
|
||||
};
|
||||
}
|
||||
|
||||
function addRule(cwd, args) {
|
||||
const { local, rest } = parseScope(args);
|
||||
const { rule, allValues } = parseRuleArgs(rest);
|
||||
if (!rule) throw new Error('Pass a rule id, e.g. impeccable ignores add-rule side-tab');
|
||||
if (rule === 'overused-font' && !allValues) {
|
||||
throw new Error('overused-font is value-specific by default. Use add-value overused-font <font>, or add-rule overused-font --all-values for broad suppression.');
|
||||
}
|
||||
const config = readScopeConfig(cwd, local);
|
||||
if (!config.ignoreRules.includes(rule)) config.ignoreRules.push(rule);
|
||||
const target = writeScopeConfig(cwd, config, local);
|
||||
return `Added ${rule} to ${local ? 'local' : 'shared'} detector ignoreRules (${path.relative(cwd, target) || target}).`;
|
||||
}
|
||||
|
||||
function addFile(cwd, args) {
|
||||
const { local, rest } = parseScope(args);
|
||||
const glob = String(rest[0] || '').trim();
|
||||
if (!glob) throw new Error('Pass a glob, e.g. impeccable ignores add-file "src/legacy/**"');
|
||||
const config = readScopeConfig(cwd, local);
|
||||
if (!config.ignoreFiles.includes(glob)) config.ignoreFiles.push(glob);
|
||||
const target = writeScopeConfig(cwd, config, local);
|
||||
return `Added ${glob} to ${local ? 'local' : 'shared'} detector ignoreFiles (${path.relative(cwd, target) || target}).`;
|
||||
}
|
||||
|
||||
function addValue(cwd, args) {
|
||||
const { local, rest } = parseScope(args);
|
||||
const parsed = parseValueArgs(rest);
|
||||
if (parsed.value !== '*' && !extractFindingIgnoreValue({ antipattern: parsed.rule, ignoreValue: parsed.value })) {
|
||||
throw new Error(`${parsed.rule} has no extractable ignore value. Use impeccable ignores add-value ${parsed.rule} "*" --file <glob> to suppress it in matching files.`);
|
||||
}
|
||||
const config = readScopeConfig(cwd, local);
|
||||
const key = ignoreValueKey(parsed);
|
||||
const existing = config.ignoreValues.find((entry) => ignoreValueKey(entry) === key);
|
||||
if (existing) {
|
||||
if (parsed.reason) existing.reason = parsed.reason;
|
||||
if (parsed.files.length) existing.files = parsed.files;
|
||||
} else {
|
||||
// rule, value, files, createdAt, reason — the same order the normalizers emit,
|
||||
// so a fresh entry survives the next write untouched.
|
||||
const entry = {
|
||||
rule: parsed.rule,
|
||||
value: parsed.value,
|
||||
};
|
||||
if (parsed.files.length) entry.files = parsed.files;
|
||||
entry.createdAt = new Date().toISOString();
|
||||
if (parsed.reason) entry.reason = parsed.reason;
|
||||
config.ignoreValues.push(entry);
|
||||
}
|
||||
const target = writeScopeConfig(cwd, config, local);
|
||||
return `Added ${parsed.rule}=${parsed.value} to ${local ? 'local' : 'shared'} detector ignoreValues (${path.relative(cwd, target) || target}).`;
|
||||
}
|
||||
|
||||
function removeFromScopes(cwd, args, remover) {
|
||||
const { local, all, rest } = parseScope(args, { allowAll: true });
|
||||
const scopes = all ? [false, true] : [local];
|
||||
const removed = [];
|
||||
for (const isLocal of scopes) {
|
||||
const config = readScopeConfig(cwd, isLocal);
|
||||
const count = remover(config, rest);
|
||||
if (count > 0) {
|
||||
const target = writeScopeConfig(cwd, config, isLocal);
|
||||
removed.push(`${count} from ${isLocal ? 'local' : 'shared'} (${path.relative(cwd, target) || target})`);
|
||||
}
|
||||
}
|
||||
return removed.length ? `Removed ${removed.join(', ')}.` : 'No matching detector ignore found.';
|
||||
}
|
||||
|
||||
function removeRule(cwd, args) {
|
||||
return removeFromScopes(cwd, args, (config, rest) => {
|
||||
const rule = String(rest[0] || '').trim().toLowerCase();
|
||||
if (!rule) throw new Error('Pass a rule id, e.g. impeccable ignores remove-rule side-tab');
|
||||
const before = config.ignoreRules.length;
|
||||
config.ignoreRules = config.ignoreRules.filter((entry) => entry !== rule);
|
||||
return before - config.ignoreRules.length;
|
||||
});
|
||||
}
|
||||
|
||||
function removeFile(cwd, args) {
|
||||
return removeFromScopes(cwd, args, (config, rest) => {
|
||||
const glob = String(rest[0] || '').trim();
|
||||
if (!glob) throw new Error('Pass a glob, e.g. impeccable ignores remove-file "src/legacy/**"');
|
||||
const before = config.ignoreFiles.length;
|
||||
config.ignoreFiles = config.ignoreFiles.filter((entry) => entry !== glob);
|
||||
return before - config.ignoreFiles.length;
|
||||
});
|
||||
}
|
||||
|
||||
function removeValue(cwd, args) {
|
||||
return removeFromScopes(cwd, args, (config, rest) => {
|
||||
const parsed = parseValueArgs(rest, { allowUnscopedWildcard: true });
|
||||
const key = ignoreValueKey(parsed);
|
||||
const before = config.ignoreValues.length;
|
||||
config.ignoreValues = config.ignoreValues.filter((entry) => ignoreValueKey(entry) !== key);
|
||||
return before - config.ignoreValues.length;
|
||||
});
|
||||
}
|
||||
|
||||
function clear(cwd, args) {
|
||||
const { local, all, rest } = parseScope(args, { allowAll: true });
|
||||
if (rest.length > 0) throw new Error('clear does not take positional arguments');
|
||||
const scopes = all ? [false, true] : [local];
|
||||
for (const isLocal of scopes) {
|
||||
const config = readScopeConfig(cwd, isLocal);
|
||||
config.ignoreRules = [];
|
||||
config.ignoreFiles = [];
|
||||
config.ignoreValues = [];
|
||||
writeScopeConfig(cwd, config, isLocal);
|
||||
}
|
||||
return `Cleared detector ignores in ${all ? 'shared and local config' : local ? 'local config' : 'shared config'}.`;
|
||||
}
|
||||
|
||||
function ignoreValueKey(entry) {
|
||||
// Sorted: a file scope is a set. Comparing stored order made an on-disk scope
|
||||
// miss the sorted argv form, so a re-add duplicated the entry and a remove
|
||||
// silently failed. Every key that hashes `files` must sort — there are four.
|
||||
const files = Array.isArray(entry.files) && entry.files.length ? [...entry.files].sort().join('\x1f') : '';
|
||||
return `${String(entry.rule || '').trim().toLowerCase()}\0${normalizeIgnoreValue(entry.value)}\0${files}`;
|
||||
}
|
||||
|
||||
export async function run(args = [], opts = {}) {
|
||||
const cwd = opts.cwd || process.cwd();
|
||||
const actionArg = args[0] || 'list';
|
||||
if (actionArg === '--help' || actionArg === '-h') {
|
||||
printUsage();
|
||||
return;
|
||||
}
|
||||
const action = ACTION_ALIASES.get(String(actionArg).toLowerCase());
|
||||
if (!action) {
|
||||
throw new Error(`Unknown ignores action: ${actionArg}. Run "impeccable ignores --help".`);
|
||||
}
|
||||
const rest = args.slice(1);
|
||||
let out;
|
||||
switch (action) {
|
||||
case 'list': out = list(cwd); break;
|
||||
case 'add-rule': out = addRule(cwd, rest); break;
|
||||
case 'add-file': out = addFile(cwd, rest); break;
|
||||
case 'add-value': out = addValue(cwd, rest); break;
|
||||
case 'remove-rule': out = removeRule(cwd, rest); break;
|
||||
case 'remove-file': out = removeFile(cwd, rest); break;
|
||||
case 'remove-value': out = removeValue(cwd, rest); break;
|
||||
case 'clear': out = clear(cwd, rest); break;
|
||||
}
|
||||
if (out) console.log(out);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,501 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { loadDesignSystemForTarget } from '../design-system.mjs';
|
||||
import { RULE_SCOPES, filterByScopes } from '../registry/antipatterns.mjs';
|
||||
import { createBrowserDetector, detectUrl } from '../engines/browser/detect-url.mjs';
|
||||
import { detectHtml } from '../engines/static-html/detect-html.mjs';
|
||||
import { detectText } from '../engines/regex/detect-text.mjs';
|
||||
import {
|
||||
filterDetectionFindings,
|
||||
readDetectionConfig,
|
||||
shouldIgnoreDetectionFile,
|
||||
} from '../../lib/impeccable-config.mjs';
|
||||
import {
|
||||
HTML_EXTENSIONS,
|
||||
buildImportGraph,
|
||||
detectFrameworkConfig,
|
||||
isPortListening,
|
||||
walkDir,
|
||||
} from '../node/file-system.mjs';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatFindingSummary(count) {
|
||||
return `${count} anti-pattern${count === 1 ? '' : 's'} found.`;
|
||||
}
|
||||
|
||||
// Local filesystem path behind a file:// URL, or null when it can't be mapped.
|
||||
function fileUrlToLocalPath(url) {
|
||||
try {
|
||||
return fileURLToPath(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const URL_TARGET_RE = /^(?:https?|file):\/\//i;
|
||||
|
||||
// Some agent runners hand a shell-ready URL list to Node as one argv value.
|
||||
// A browser accepts the spaces as part of one encoded URL, producing a
|
||||
// plausible scan attributed to a bogus joined path. Expand only when every
|
||||
// whitespace-delimited token is independently a URL, preserving ordinary
|
||||
// filesystem paths that contain spaces.
|
||||
function expandJoinedUrlTargets(targets) {
|
||||
return targets.flatMap((target) => {
|
||||
if (!/\s/.test(target)) return [target];
|
||||
const parts = target.trim().split(/\s+/).filter(Boolean);
|
||||
return parts.length > 1 && parts.every(part => URL_TARGET_RE.test(part))
|
||||
? parts
|
||||
: [target];
|
||||
});
|
||||
}
|
||||
|
||||
// Advisory findings are detected but never treated as failures: they list in a
|
||||
// separate, visually dimmed section, are excluded from the failure count that
|
||||
// drives the exit code, and carry `"advisory": true` in JSON so consumers can
|
||||
// filter. Every advisory finding carries the flag (stamped by the registry via
|
||||
// findings.mjs).
|
||||
function isAdvisory(finding) {
|
||||
return Boolean(finding && (finding.advisory === true || finding.severity === 'advisory'));
|
||||
}
|
||||
|
||||
function partitionAdvisory(findings) {
|
||||
const primary = [];
|
||||
const advisory = [];
|
||||
for (const f of findings) (isAdvisory(f) ? advisory : primary).push(f);
|
||||
return { primary, advisory };
|
||||
}
|
||||
|
||||
// ANSI dim, when stderr is a TTY. Advisory output is chrome, so keep it quiet.
|
||||
function dim(text) {
|
||||
return process.stderr.isTTY ? `\x1b[2m${text}\x1b[0m` : text;
|
||||
}
|
||||
|
||||
function formatFindingsBody(findings) {
|
||||
const grouped = {};
|
||||
for (const f of findings) {
|
||||
if (!grouped[f.file]) grouped[f.file] = [];
|
||||
grouped[f.file].push(f);
|
||||
}
|
||||
const out = [];
|
||||
for (const [file, items] of Object.entries(grouped)) {
|
||||
const importNote = items[0]?.importedBy?.length ? ` (imported by ${items[0].importedBy.join(', ')})` : '';
|
||||
out.push(`\n${file}${importNote}`);
|
||||
for (const item of items) {
|
||||
out.push(` ${item.line ? `line ${item.line}: ` : ''}[${item.antipattern}] ${item.snippet}`);
|
||||
out.push(` → ${item.description}`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function formatAdvisorySection(advisory) {
|
||||
if (!advisory || advisory.length === 0) return '';
|
||||
const lines = [`\n${dim('── Advisory (not counted as failures) ──')}`];
|
||||
for (const line of formatFindingsBody(advisory)) lines.push(dim(line));
|
||||
lines.push(dim(`\n${advisory.length} advisory note${advisory.length === 1 ? '' : 's'}. Suppress with --no-advisory.`));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Text/JSON formatter. `findings` is the full set; advisory items are separated
|
||||
// out into their own section and excluded from the failure summary count. JSON
|
||||
// output keeps every finding (each advisory one flagged) in a single array.
|
||||
function formatFindings(findings, jsonMode) {
|
||||
if (jsonMode) return JSON.stringify(findings, null, 2);
|
||||
|
||||
const { primary, advisory } = partitionAdvisory(findings);
|
||||
const out = [...formatFindingsBody(primary)];
|
||||
out.push(`\n${formatFindingSummary(primary.length)}`);
|
||||
const advisorySection = formatAdvisorySection(advisory);
|
||||
if (advisorySection) out.push(advisorySection);
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stdin handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// `optionsFor` maps a local path to scan options carrying that path's own
|
||||
// project design system (or base options when null). Falls back to a plain
|
||||
// object so direct/legacy callers still work.
|
||||
async function detectLocalFile(filePath, options) {
|
||||
if (HTML_EXTENSIONS.has(path.extname(filePath).toLowerCase())) {
|
||||
return detectHtml(filePath, options);
|
||||
}
|
||||
return detectText(fs.readFileSync(filePath, 'utf-8'), filePath, options);
|
||||
}
|
||||
|
||||
async function handleStdin(optionsFor = () => ({})) {
|
||||
const resolve = typeof optionsFor === 'function' ? optionsFor : () => optionsFor;
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) chunks.push(chunk);
|
||||
const input = Buffer.concat(chunks).toString('utf-8');
|
||||
try {
|
||||
const parsed = JSON.parse(input);
|
||||
const fp = parsed?.tool_input?.file_path;
|
||||
if (fp && fs.existsSync(fp)) {
|
||||
return detectLocalFile(fp, resolve(fp));
|
||||
}
|
||||
} catch { /* not JSON */ }
|
||||
return detectText(input, '<stdin>', resolve(null));
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function confirm(question) {
|
||||
const rl = (await import('node:readline')).default.createInterface({
|
||||
input: process.stdin, output: process.stderr,
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
rl.question(`${question} [Y/n] `, (answer) => {
|
||||
rl.close();
|
||||
resolve(!answer || /^y(es)?$/i.test(answer.trim()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
console.log(`Usage: impeccable detect [options] [file-or-dir-or-url...]
|
||||
|
||||
Scan files or URLs for UI anti-patterns and design quality issues.
|
||||
|
||||
Options:
|
||||
--json Output results as JSON
|
||||
--quiet In text mode, only print the final findings count
|
||||
--scope <name> Only report rules in the given design domain
|
||||
(type, layout). Comma-separated.
|
||||
--viewport <WxH> Browser viewport for URL scans (default 1280x800),
|
||||
e.g. --viewport 390x844 for a mobile-width pass
|
||||
--no-config Do not apply project config, detector ignores, inline
|
||||
ignore comments, or DESIGN.md
|
||||
--no-inline-ignores Do not honor in-file impeccable-disable* ignore comments
|
||||
--no-design-system Do not load local DESIGN.md / .impeccable/design.json context
|
||||
--no-advisory Suppress advisory findings entirely (e.g. em-dash overuse)
|
||||
--help Show this help message
|
||||
|
||||
Advisory findings:
|
||||
Some rules are advisory: detected and listed in a separate section, but never
|
||||
counted as failures and never changing the exit code. They stay out of the
|
||||
failure count so they never block automation. --no-advisory hides them.
|
||||
|
||||
Output streams:
|
||||
Human-readable findings go to stderr so stdout stays available for structured
|
||||
output. Use --json for JSON on stdout, or redirect text with 2> findings.txt.
|
||||
|
||||
Exit status:
|
||||
0 Scan completed with no primary findings (advisories may still be listed)
|
||||
1 At least one requested target could not be scanned
|
||||
2 Scan completed with primary findings
|
||||
Operational failure takes precedence when a multi-target scan is partial.
|
||||
|
||||
Project config:
|
||||
Respects .impeccable/config.json and .impeccable/config.local.json detector
|
||||
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
|
||||
and detector.designSystem.enabled.
|
||||
|
||||
Inline ignores:
|
||||
In-file comments waive a finding where it lives and travel with the file:
|
||||
<!-- impeccable-disable overused-font -- exported brand doc -->
|
||||
.brand { font-family: Inter } /* impeccable-disable-line overused-font */
|
||||
// impeccable-disable-next-line bounce-easing: intentional bounce
|
||||
impeccable-disable applies to the whole file; -line / -next-line are scoped.
|
||||
List one or more rule ids (comma-separated), or omit them / use * for all.
|
||||
|
||||
Detection modes:
|
||||
HTML files Static HTML/CSS analysis (default, catches linked CSS)
|
||||
Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.)
|
||||
URLs Puppeteer full browser rendering (auto-detected;
|
||||
http(s):// and file:// URLs; accessible linked CSS included)
|
||||
|
||||
Examples:
|
||||
impeccable detect src/
|
||||
impeccable detect index.html
|
||||
impeccable detect https://example.com
|
||||
impeccable detect --json .
|
||||
impeccable detect --no-config src/`);
|
||||
}
|
||||
|
||||
async function detectCli() {
|
||||
let args = process.argv.slice(2).map(arg => {
|
||||
if (arg === '-json') return '--json';
|
||||
if (arg === '-fast') return '--fast';
|
||||
return arg;
|
||||
});
|
||||
if (args[0] === 'detect') args = args.slice(1);
|
||||
const jsonMode = args.includes('--json');
|
||||
const quietMode = args.includes('--quiet');
|
||||
const helpMode = args.includes('--help');
|
||||
const noAdvisory = args.includes('--no-advisory');
|
||||
// --fast (regex-only) is deprecated: since the jsdom removal, the static
|
||||
// HTML/CSS analysis is fast and covers every rule, so the regex-only path
|
||||
// only loses coverage for no real speed win. Accept the flag for back-compat
|
||||
// but ignore it and run the full scan.
|
||||
if (args.includes('--fast')) {
|
||||
process.stderr.write(
|
||||
'Note: --fast is deprecated and ignored. The full scan is fast now and runs every rule.\n',
|
||||
);
|
||||
}
|
||||
if (args.includes('--gpt') || args.includes('--gemini')) {
|
||||
process.stderr.write(
|
||||
'Note: --gpt and --gemini are deprecated and ignored. Generated-UI tells now run by default.\n',
|
||||
);
|
||||
}
|
||||
const configEnabled = !args.includes('--no-config');
|
||||
const detectionConfig = configEnabled
|
||||
? readDetectionConfig(process.cwd())
|
||||
: { ignoreRules: [], ignoreFiles: [], ignoreValues: [] };
|
||||
const scopes = [];
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] !== '--scope' && !args[i].startsWith('--scope=')) continue;
|
||||
const inline = args[i].startsWith('--scope=');
|
||||
const value = inline ? args[i].slice('--scope='.length) : args[i + 1];
|
||||
const parsed = (value && !value.startsWith('--'))
|
||||
? value.split(',').map(s => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
// A bare `--scope` would otherwise fall out of `targets` and scan unscoped;
|
||||
// fail loudly so a mistyped pre-scan never runs the wrong rule set.
|
||||
if (parsed.length === 0) {
|
||||
process.stderr.write(
|
||||
`Error: --scope requires a value. Valid scopes: ${[...RULE_SCOPES].join(', ')}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
scopes.push(...parsed);
|
||||
args.splice(i, inline ? 1 : 2);
|
||||
i -= 1;
|
||||
}
|
||||
let viewport = null;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] !== '--viewport' && !args[i].startsWith('--viewport=')) continue;
|
||||
const inline = args[i].startsWith('--viewport=');
|
||||
const value = inline ? args[i].slice('--viewport='.length) : args[i + 1];
|
||||
const match = /^(\d{2,5})x(\d{2,5})$/i.exec(value || '');
|
||||
if (!match) {
|
||||
process.stderr.write('Error: --viewport requires a WxH value, e.g. --viewport 390x844\n');
|
||||
process.exit(1);
|
||||
}
|
||||
viewport = { width: Number(match[1]), height: Number(match[2]) };
|
||||
args.splice(i, inline ? 1 : 2);
|
||||
i -= 1;
|
||||
}
|
||||
const unknownScopes = scopes.filter(s => !RULE_SCOPES.has(s));
|
||||
if (unknownScopes.length > 0) {
|
||||
process.stderr.write(
|
||||
`Error: unknown --scope value(s): ${unknownScopes.join(', ')}. Valid scopes: ${[...RULE_SCOPES].join(', ')}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const designSystemEnabled = configEnabled && !args.includes('--no-design-system') && detectionConfig.designSystem?.enabled !== false;
|
||||
// Inline `impeccable-disable*` waivers are part of the scanned file, so they
|
||||
// apply by default. `--no-config` (raw scan) and the dedicated
|
||||
// `--no-inline-ignores` both turn them off.
|
||||
const inlineIgnoresEnabled = configEnabled && !args.includes('--no-inline-ignores');
|
||||
let hadOperationalFailure = false;
|
||||
const baseScanOptions = {
|
||||
inlineIgnores: inlineIgnoresEnabled,
|
||||
onOperationalFailure: () => { hadOperationalFailure = true; },
|
||||
};
|
||||
if (viewport) baseScanOptions.viewport = viewport;
|
||||
// DESIGN.md must resolve from EACH scan target's own project root, not from
|
||||
// process.cwd(): scanning project B's files from inside project A applied A's
|
||||
// design rules (cross-project contamination). Resolve per target, memoized by
|
||||
// resolved project root so a multi-file scan pays the read once per project.
|
||||
// A target with no project marker above it gets no design system (never cwd's).
|
||||
const designSystemCache = new Map();
|
||||
const scanOptionsFor = (localPath) => {
|
||||
if (!designSystemEnabled || !localPath) return baseScanOptions;
|
||||
const designSystem = loadDesignSystemForTarget(localPath, { cache: designSystemCache });
|
||||
return designSystem ? { ...baseScanOptions, designSystem } : baseScanOptions;
|
||||
};
|
||||
const targets = expandJoinedUrlTargets(args.filter(a => !a.startsWith('--')));
|
||||
|
||||
if (helpMode) { printUsage(); process.exit(0); }
|
||||
|
||||
let allFindings = [];
|
||||
const reportLocalScanFailure = (target, error) => {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: cannot scan ${target}: ${error.message}\n`);
|
||||
};
|
||||
|
||||
if (!process.stdin.isTTY && targets.length === 0) {
|
||||
allFindings = await handleStdin(scanOptionsFor);
|
||||
} else {
|
||||
const paths = targets.length > 0 ? targets : [process.cwd()];
|
||||
// file:// URLs get the same Puppeteer-rendered pass as http(s) — the
|
||||
// real cascade, real computed styles, real layout. Callers that want a
|
||||
// browser-grade scan of a local artifact can pass file:///abs/path.html
|
||||
// instead of the bare path (which stays on the static engine).
|
||||
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length;
|
||||
let browserDetector = null;
|
||||
let browserSetupFailed = false;
|
||||
if (urlTargetCount > 1) {
|
||||
try {
|
||||
browserDetector = await createBrowserDetector();
|
||||
} catch (e) {
|
||||
browserSetupFailed = true;
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: ${e.message}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
for (const target of paths) {
|
||||
if (URL_TARGET_RE.test(target)) {
|
||||
if (browserSetupFailed) continue;
|
||||
// A file:// URL points at a local artifact, so its design system
|
||||
// resolves from that file's project. A remote http(s) URL has no
|
||||
// local project — it gets base options (no design system), never
|
||||
// process.cwd()'s.
|
||||
const urlOptions = /^file:/i.test(target)
|
||||
? scanOptionsFor(fileUrlToLocalPath(target))
|
||||
: baseScanOptions;
|
||||
try {
|
||||
const scanner = browserDetector
|
||||
? (url) => browserDetector.detectUrl(url, urlOptions)
|
||||
: (url) => detectUrl(url, urlOptions);
|
||||
allFindings.push(...await scanner(target));
|
||||
} catch (e) {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Error: ${e.message}\n`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolved = path.resolve(target);
|
||||
let stat;
|
||||
try { stat = fs.statSync(resolved); }
|
||||
catch {
|
||||
hadOperationalFailure = true;
|
||||
process.stderr.write(`Warning: cannot access ${target}\n`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
|
||||
if (!jsonMode && !quietMode) {
|
||||
const fwConfig = detectFrameworkConfig(resolved);
|
||||
if (fwConfig) {
|
||||
const probe = await isPortListening(fwConfig.port, fwConfig.fingerprint);
|
||||
if (probe.listening && probe.matched) {
|
||||
process.stderr.write(
|
||||
`\n${fwConfig.name} dev server detected on localhost:${fwConfig.port}.\n` +
|
||||
`For more accurate results, scan the running site:\n` +
|
||||
` npx impeccable detect http://localhost:${fwConfig.port}\n\n`
|
||||
);
|
||||
} else if (probe.listening && !probe.matched) {
|
||||
process.stderr.write(
|
||||
`\n${fwConfig.name} project detected (${path.basename(fwConfig.configPath)}).\n` +
|
||||
`Port ${fwConfig.port} is in use by another service. Start the ${fwConfig.name} dev server and scan via URL for best results.\n\n`
|
||||
);
|
||||
} else {
|
||||
process.stderr.write(
|
||||
`\n${fwConfig.name} project detected (${path.basename(fwConfig.configPath)}).\n` +
|
||||
`Start the dev server and scan via URL for best results:\n` +
|
||||
` npx impeccable detect http://localhost:${fwConfig.port}\n\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const files = walkDir(resolved, reportLocalScanFailure)
|
||||
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
|
||||
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
|
||||
|
||||
// Warn and confirm if scanning many files (static HTML/CSS processes each HTML file)
|
||||
if (files.length > 50 && process.stdin.isTTY && !jsonMode && !quietMode) {
|
||||
process.stderr.write(
|
||||
`\nFound ${files.length} files (${htmlCount} HTML) in ${target}.\n` +
|
||||
`Scanning may take a while${htmlCount > 10 ? ' (static HTML/CSS processes each HTML file individually)' : ''}.\n` +
|
||||
`Target a specific subdirectory to narrow scope.\n`
|
||||
);
|
||||
const ok = await confirm('Continue?');
|
||||
if (!ok) { process.stderr.write('Aborted.\n'); process.exit(0); }
|
||||
}
|
||||
|
||||
// Build import graph for multi-file awareness
|
||||
const unreadableFiles = new Set();
|
||||
const graph = buildImportGraph(files, (file, error) => {
|
||||
unreadableFiles.add(file);
|
||||
reportLocalScanFailure(file, error);
|
||||
});
|
||||
// Build reverse map: file -> set of files that import it
|
||||
const importedByMap = new Map();
|
||||
for (const [importer, imports] of graph) {
|
||||
for (const imported of imports) {
|
||||
if (!importedByMap.has(imported)) importedByMap.set(imported, new Set());
|
||||
importedByMap.get(imported).add(importer);
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (unreadableFiles.has(file)) continue;
|
||||
try {
|
||||
// Each file resolves its own project design system (cached by root),
|
||||
// so a scan spanning sibling projects applies the right rules per file.
|
||||
const fileOptions = scanOptionsFor(file);
|
||||
const fileFindings = await detectLocalFile(file, fileOptions);
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
if (importers && importers.size > 0) {
|
||||
const importerNames = [...importers].map(f => path.basename(f));
|
||||
for (const f of fileFindings) {
|
||||
f.importedBy = importerNames;
|
||||
}
|
||||
}
|
||||
allFindings.push(...fileFindings);
|
||||
} catch (error) {
|
||||
reportLocalScanFailure(file, error);
|
||||
}
|
||||
}
|
||||
} else if (stat.isFile()) {
|
||||
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
||||
try {
|
||||
const fileOptions = scanOptionsFor(resolved);
|
||||
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
||||
} catch (error) {
|
||||
reportLocalScanFailure(target, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (browserDetector) await browserDetector.close();
|
||||
}
|
||||
}
|
||||
|
||||
allFindings = filterDetectionFindings(allFindings, detectionConfig);
|
||||
allFindings = filterByScopes(allFindings, scopes);
|
||||
// --no-advisory drops advisory findings before any output or exit-code math.
|
||||
if (noAdvisory) allFindings = allFindings.filter((f) => !isAdvisory(f));
|
||||
|
||||
// The exit code and failure count reflect non-advisory findings only. An
|
||||
// advisory-only scan still prints its notes but exits 0 (a clean pass), so
|
||||
// advisory rules never break CI or block automation.
|
||||
const { primary, advisory } = partitionAdvisory(allFindings);
|
||||
// Exit 1 means at least one requested scan could not complete. It takes
|
||||
// precedence over exit 2 because findings from the remaining targets do not
|
||||
// turn a partial scan into a complete one.
|
||||
const exitCode = hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0);
|
||||
|
||||
if (allFindings.length > 0) {
|
||||
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
|
||||
else if (quietMode) {
|
||||
process.stderr.write(formatFindingSummary(primary.length) + '\n');
|
||||
if (advisory.length > 0) {
|
||||
process.stderr.write(dim(`${advisory.length} advisory note${advisory.length === 1 ? '' : 's'} (not counted).`) + '\n');
|
||||
}
|
||||
}
|
||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||
process.exit(exitCode);
|
||||
}
|
||||
if (jsonMode) process.stdout.write('[]\n');
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
export { formatFindings, handleStdin, confirm, printUsage, detectCli };
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Anti-Pattern Detector for Impeccable
|
||||
* Copyright (c) 2026 Paul Bakaus
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Public API facade. Runtime engines live under cli/engine/engines/.
|
||||
*/
|
||||
|
||||
import { detectCli } from './cli/main.mjs';
|
||||
|
||||
export { ANTIPATTERNS, RULE_ENGINE_SUPPORT, getAntipattern, getRulesForCategory, getRuleEngineSupport } from './registry/antipatterns.mjs';
|
||||
export { SAFE_TAGS, BORDER_SAFE_TAGS, OVERUSED_FONTS, GENERIC_FONTS, KNOWN_SERIF_FONTS } from './shared/constants.mjs';
|
||||
export { isNeutralColor, parseRgb, relativeLuminance, contrastRatio, parseGradientColors, hasChroma, getHue, colorToHex } from './shared/color.mjs';
|
||||
export { isFullPage } from './shared/page.mjs';
|
||||
export {
|
||||
checkElementBorders,
|
||||
checkElementMotion,
|
||||
checkElementGlow,
|
||||
checkPageTypography,
|
||||
checkPageLayout,
|
||||
checkHtmlPatterns,
|
||||
} from './rules/checks.mjs';
|
||||
export { createDetectorProfile, summarizeDetectorProfile } from './profile/profiler.mjs';
|
||||
export {
|
||||
parseFrontmatter as parseDesignFrontmatter,
|
||||
normalizeDesignSystem,
|
||||
loadDesignSystemForCwd,
|
||||
checkSourceDesignSystem,
|
||||
collectStaticDesignSystemFindings,
|
||||
} from './design-system.mjs';
|
||||
export { detectHtml } from './engines/static-html/detect-html.mjs';
|
||||
export { detectUrl, createBrowserDetector } from './engines/browser/detect-url.mjs';
|
||||
export { detectText, extractStyleBlocks, extractCSSinJS } from './engines/regex/detect-text.mjs';
|
||||
export {
|
||||
walkDir,
|
||||
hasScannableExtension,
|
||||
SCANNABLE_EXTENSIONS,
|
||||
SKIP_DIRS,
|
||||
buildImportGraph,
|
||||
resolveImport,
|
||||
detectFrameworkConfig,
|
||||
isPortListening,
|
||||
FRAMEWORK_CONFIGS,
|
||||
} from './node/file-system.mjs';
|
||||
export { formatFindings, detectCli } from './cli/main.mjs';
|
||||
|
||||
const isMainModule = process.argv[1]?.endsWith('detect-antipatterns.mjs') ||
|
||||
process.argv[1]?.endsWith('detect-antipatterns.mjs/');
|
||||
if (isMainModule) detectCli();
|
||||
@@ -0,0 +1,434 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { deriveAdvisoryFlag, finding } from '../../findings.mjs';
|
||||
import { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
|
||||
import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs';
|
||||
import { checkContentHiddenAtRest } from '../../rules/checks.mjs';
|
||||
|
||||
// On Windows, puppeteer's bundled Chrome lives in a user-writable cache
|
||||
// directory. Its GPU process can be denied (STATUS_ACCESS_DENIED) by security
|
||||
// software or the GPU sandbox because it launches from an untrusted path.
|
||||
// Chrome then crash-loops the GPU process, and each relaunch briefly flashes a
|
||||
// compositor surface, the black window users report during `detect <url>`
|
||||
// (issue #372). The system-installed Chrome runs from a trusted location with a
|
||||
// healthy GPU, so channel:'chrome' avoids the crash entirely; both use hardware
|
||||
// GPU, so contrast measurement is unaffected. Scope this to Windows only: other
|
||||
// platforms do not have the bug, so they keep the pinned bundled build for
|
||||
// consistent measurement across machines. Fall back to bundled when the switch
|
||||
// fails (Chrome not installed, or channel resolution fails). If the bundled
|
||||
// launch then also fails, surface the original system-Chrome error as the
|
||||
// cause so the real failure is not lost.
|
||||
async function launchBrowser(puppeteer, { headless = true, args = [] } = {}) {
|
||||
let channelError;
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
return await puppeteer.default.launch({ channel: 'chrome', headless, args });
|
||||
} catch (err) {
|
||||
// System Chrome unavailable or unlaunchable; fall through to the bundled
|
||||
// browser, but keep the error in case the fallback fails too.
|
||||
channelError = err;
|
||||
}
|
||||
}
|
||||
try {
|
||||
return await puppeteer.default.launch({ headless, args });
|
||||
} catch (err) {
|
||||
if (channelError && err && err.cause === undefined) err.cause = channelError;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Reveal sweep + invisible-text measurement for the content-hidden-at-rest
|
||||
// rule. Scrolls through the document with instant jumps (bypasses CSS
|
||||
// scroll-behavior: smooth) so IntersectionObserver / scroll reveal handlers
|
||||
// get every chance to fire, returns to the top, lets transitions settle,
|
||||
// then measures how much text still renders invisible. A healthy
|
||||
// reveal-on-scroll page drops to ~0 after the sweep; a page whose reveal
|
||||
// script died keeps most of its text at opacity 0.
|
||||
async function measureContentHiddenAfterReveal(page) {
|
||||
await page.evaluate(async () => {
|
||||
const step = Math.max(200, Math.floor(window.innerHeight * 0.7));
|
||||
const max = Math.max(
|
||||
document.documentElement.scrollHeight || 0,
|
||||
document.body?.scrollHeight || 0,
|
||||
);
|
||||
for (let y = 0; y <= max; y += step) {
|
||||
window.scrollTo({ top: y, left: 0, behavior: 'instant' });
|
||||
await new Promise(resolve => requestAnimationFrame(() => setTimeout(resolve, 40)));
|
||||
}
|
||||
window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
|
||||
await new Promise(resolve => setTimeout(resolve, 700));
|
||||
});
|
||||
return page.evaluate(() => {
|
||||
if (typeof window.impeccableMeasureHiddenText !== 'function') return null;
|
||||
return window.impeccableMeasureHiddenText();
|
||||
});
|
||||
}
|
||||
|
||||
function serializeDesignSystemForBrowser(designSystem) {
|
||||
if (!designSystem?.present) return null;
|
||||
return {
|
||||
present: true,
|
||||
hasFonts: designSystem.hasFonts === true,
|
||||
allowedFonts: Array.from(designSystem.allowedFonts || []),
|
||||
hasColors: designSystem.hasColors === true,
|
||||
allowedColors: Array.from(designSystem.allowedColorKeys?.values?.() || [])
|
||||
.map(entry => entry?.color)
|
||||
.filter(color => color && Number.isFinite(color.r) && Number.isFinite(color.g) && Number.isFinite(color.b))
|
||||
.map(color => ({ r: color.r, g: color.g, b: color.b })),
|
||||
hasRadii: designSystem.hasRadii === true,
|
||||
allowedRadii: (designSystem.allowedRadii || [])
|
||||
.map(entry => Number(entry?.px))
|
||||
.filter(px => Number.isFinite(px)),
|
||||
hasPillRadius: designSystem.hasPillRadius === true,
|
||||
};
|
||||
}
|
||||
|
||||
async function runVisualContrastFallback(page, serializedGroups, options, profile, target) {
|
||||
if (options?.visualContrast === false) return [];
|
||||
const maxCandidates = Number.isFinite(options?.visualContrastMaxCandidates)
|
||||
? options.visualContrastMaxCandidates
|
||||
: 12;
|
||||
const scrollOffscreen = options?.visualContrastScrollOffscreen !== false;
|
||||
const existingLowContrastSelectors = new Set(
|
||||
serializedGroups
|
||||
.filter(group => group.findings?.some(f => f.type === 'low-contrast'))
|
||||
.map(group => group.selector)
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
let browserAnalyses = [];
|
||||
const findings = [];
|
||||
if (options?.visualContrastBrowser !== false) {
|
||||
const browserFindings = await profileFindingsAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'visual-contrast',
|
||||
ruleId: 'browser-fallback',
|
||||
target,
|
||||
}, async () => {
|
||||
browserAnalyses = await page.evaluate(async ({ maxCandidates, scrollOffscreen }) => {
|
||||
if (typeof window.impeccableAnalyzeVisualContrast !== 'function') return [];
|
||||
return window.impeccableAnalyzeVisualContrast({ maxCandidates, scrollOffscreen });
|
||||
}, { maxCandidates, scrollOffscreen });
|
||||
return browserAnalyses
|
||||
.filter(result => result.finding && !existingLowContrastSelectors.has(result.selector))
|
||||
.map(result => result.finding);
|
||||
});
|
||||
findings.push(...browserFindings);
|
||||
}
|
||||
|
||||
let candidates = browserAnalyses.length > 0 ? browserAnalyses : [];
|
||||
if (candidates.length === 0) {
|
||||
candidates = await profileStepAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'visual-contrast',
|
||||
ruleId: 'collect-candidates',
|
||||
target,
|
||||
}, () => page.evaluate(({ maxCandidates }) => {
|
||||
if (typeof window.impeccableCollectVisualContrastCandidates !== 'function') return [];
|
||||
return window.impeccableCollectVisualContrastCandidates({ maxCandidates });
|
||||
}, { maxCandidates }));
|
||||
}
|
||||
|
||||
const viewport = options?.viewport || { width: 1280, height: 800 };
|
||||
const browserResolvedSelectors = new Set(
|
||||
browserAnalyses
|
||||
.filter(result => result.status === 'fail' || result.status === 'pass')
|
||||
.map(result => result.selector)
|
||||
.filter(Boolean)
|
||||
);
|
||||
const filtered = candidates.filter(candidate =>
|
||||
!existingLowContrastSelectors.has(candidate.selector) &&
|
||||
!browserResolvedSelectors.has(candidate.selector)
|
||||
);
|
||||
if (options?.visualContrastPixel === false) return findings;
|
||||
for (const candidate of filtered) {
|
||||
const result = await profileFindingsAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'visual-contrast',
|
||||
ruleId: 'pixel-diff',
|
||||
target,
|
||||
}, async () => {
|
||||
const finding = await captureVisualContrastCandidate(page, candidate, viewport);
|
||||
return finding ? [finding] : [];
|
||||
});
|
||||
findings.push(...result);
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Puppeteer detection (for URLs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function decodeUrlComponent(value) {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function splitScanUrl(url) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return { href: url, credentials: null };
|
||||
}
|
||||
if (!parsed.username && !parsed.password) {
|
||||
return { href: url, credentials: null };
|
||||
}
|
||||
const credentials =
|
||||
parsed.protocol === 'http:' || parsed.protocol === 'https:'
|
||||
? {
|
||||
username: decodeUrlComponent(parsed.username),
|
||||
password: decodeUrlComponent(parsed.password),
|
||||
}
|
||||
: null;
|
||||
parsed.username = '';
|
||||
parsed.password = '';
|
||||
return { href: parsed.href, credentials };
|
||||
}
|
||||
|
||||
function basicAuthHeader(credentials) {
|
||||
return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
|
||||
}
|
||||
|
||||
// page.authenticate is page-wide: a cross-origin redirect that then 401s
|
||||
// would receive these credentials. Attach Authorization only to the scan origin.
|
||||
async function applyOriginScopedAuth(page, href, credentials) {
|
||||
if (!credentials) return;
|
||||
let origin = '';
|
||||
try {
|
||||
origin = new URL(href).origin;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!origin) return;
|
||||
const header = basicAuthHeader(credentials);
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
let headers;
|
||||
try {
|
||||
if (new URL(request.url()).origin === origin) {
|
||||
headers = { ...request.headers(), authorization: header };
|
||||
}
|
||||
} catch {
|
||||
// invalid request URL: continue without auth
|
||||
}
|
||||
void request.continue(headers ? { headers } : undefined).catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
async function detectUrl(rawUrl, options = {}) {
|
||||
const { href: url, credentials } = splitScanUrl(rawUrl);
|
||||
const profile = options?.profile;
|
||||
const waitUntil = options?.waitUntil || 'networkidle0';
|
||||
const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
|
||||
const viewport = options?.viewport || { width: 1280, height: 800 };
|
||||
const externalBrowser = options?.browser || null;
|
||||
let puppeteer;
|
||||
if (!externalBrowser) {
|
||||
try {
|
||||
puppeteer = await profileStepAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'setup',
|
||||
ruleId: 'import-puppeteer',
|
||||
target: url,
|
||||
}, () => import('puppeteer'));
|
||||
} catch {
|
||||
throw new Error('puppeteer is required for URL scanning. Install: npm install puppeteer');
|
||||
}
|
||||
}
|
||||
|
||||
// Read the browser detection script — reuse it instead of reimplementing
|
||||
const browserScriptPath = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'..',
|
||||
'detect-antipatterns-browser.js'
|
||||
);
|
||||
let browserScript;
|
||||
try {
|
||||
browserScript = profileStep(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'setup',
|
||||
ruleId: 'read-browser-script',
|
||||
target: url,
|
||||
}, () => fs.readFileSync(browserScriptPath, 'utf-8'));
|
||||
} catch {
|
||||
throw new Error(`Browser script not found at ${browserScriptPath}`);
|
||||
}
|
||||
|
||||
// CI runners (GitHub Actions Ubuntu) block unprivileged user namespaces, so
|
||||
// Chrome can't initialize its sandbox there. Disable the sandbox only when
|
||||
// running in CI; local users keep the default hardened launch.
|
||||
const launchArgs = process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [];
|
||||
const browser = externalBrowser || await profileStepAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'load',
|
||||
ruleId: 'launch-browser',
|
||||
target: url,
|
||||
}, () => launchBrowser(puppeteer, { headless: options?.headless ?? true, args: launchArgs }));
|
||||
const page = await profileStepAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'load',
|
||||
ruleId: 'new-page',
|
||||
target: url,
|
||||
}, () => browser.newPage());
|
||||
|
||||
// Uncaught exceptions and parse errors surface as pageerror events. The
|
||||
// listener must attach before goto: a syntax error fires during the
|
||||
// initial parse, long before the load event. Dedupe by message; a single
|
||||
// broken loop can otherwise throw hundreds of identical errors.
|
||||
const pageErrors = [];
|
||||
if (options?.scriptErrors !== false) {
|
||||
page.on('pageerror', (err) => {
|
||||
const message = String(err?.message || err).split('\n')[0].trim().slice(0, 160);
|
||||
if (message && !pageErrors.includes(message)) pageErrors.push(message);
|
||||
});
|
||||
}
|
||||
|
||||
let results = [];
|
||||
try {
|
||||
await profileStepAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'load',
|
||||
ruleId: 'set-viewport',
|
||||
target: url,
|
||||
}, () => page.setViewport(viewport));
|
||||
await applyOriginScopedAuth(page, url, credentials);
|
||||
await profileStepAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'load',
|
||||
ruleId: `goto:${waitUntil}`,
|
||||
target: url,
|
||||
}, () => page.goto(url, { waitUntil, timeout: 30000 }));
|
||||
if (settleMs > 0) {
|
||||
await profileStepAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'load',
|
||||
ruleId: 'settle',
|
||||
target: url,
|
||||
}, () => new Promise(resolve => setTimeout(resolve, settleMs)));
|
||||
}
|
||||
|
||||
// Inject the browser detection script and collect results
|
||||
const browserDesignSystem = serializeDesignSystemForBrowser(options?.designSystem);
|
||||
await profileStepAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'scan',
|
||||
ruleId: 'configure-pure-detect',
|
||||
target: url,
|
||||
}, () => page.evaluate((designSystem) => {
|
||||
window.__IMPECCABLE_CONFIG__ = {
|
||||
...(window.__IMPECCABLE_CONFIG__ || {}),
|
||||
autoScan: false,
|
||||
...(designSystem ? { designSystem } : {}),
|
||||
};
|
||||
}, browserDesignSystem));
|
||||
await profileStepAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'scan',
|
||||
ruleId: 'inject-browser-script',
|
||||
target: url,
|
||||
}, () => page.evaluate(browserScript));
|
||||
let serializedGroups = [];
|
||||
results = await profileFindingsAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'scan',
|
||||
ruleId: 'browser-scan',
|
||||
target: url,
|
||||
}, async () => {
|
||||
serializedGroups = await page.evaluate(() => {
|
||||
if (!window.impeccableDetect) return [];
|
||||
return window.impeccableDetect({ decorate: false, serialize: true });
|
||||
});
|
||||
return serializedGroups.flatMap(({ findings }) =>
|
||||
findings.map(f => ({ id: f.type, snippet: f.detail, ignoreValue: f.ignoreValue || '', severity: f.severity || '' }))
|
||||
);
|
||||
});
|
||||
// Content invisible at rest: reveal sweep, then re-measure. Runs after
|
||||
// the main scan (which must see the true at-rest state) and before the
|
||||
// visual contrast fallback (the sweep restores scroll to the top).
|
||||
if (options?.contentHidden !== false) {
|
||||
const hiddenFindings = await profileFindingsAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'scan',
|
||||
ruleId: 'content-hidden-at-rest',
|
||||
target: url,
|
||||
}, async () => {
|
||||
const measured = await measureContentHiddenAfterReveal(page);
|
||||
return measured ? checkContentHiddenAtRest(measured) : [];
|
||||
});
|
||||
results.push(...hiddenFindings);
|
||||
}
|
||||
|
||||
for (const message of pageErrors.slice(0, 3)) {
|
||||
results.push({ id: 'script-error', snippet: message });
|
||||
}
|
||||
|
||||
const visualFindings = await runVisualContrastFallback(page, serializedGroups, options, profile, url);
|
||||
results.push(...visualFindings);
|
||||
} finally {
|
||||
await profileStepAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'load',
|
||||
ruleId: 'close-page',
|
||||
target: url,
|
||||
}, () => page.close().catch(() => {}));
|
||||
if (!externalBrowser) {
|
||||
await profileStepAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'load',
|
||||
ruleId: 'close-browser',
|
||||
target: url,
|
||||
}, () => browser.close());
|
||||
}
|
||||
}
|
||||
return results.map(f => {
|
||||
const item = finding(f.id, url, f.snippet);
|
||||
if (f.ignoreValue) item.ignoreValue = f.ignoreValue;
|
||||
// Per-finding severity promotion (e.g. hero-region pulsing dot)
|
||||
// overrides the registry default carried by finding().
|
||||
if (f.severity && f.severity !== item.severity) item.severity = f.severity;
|
||||
return deriveAdvisoryFlag(item);
|
||||
});
|
||||
}
|
||||
|
||||
async function createBrowserDetector(options = {}) {
|
||||
let puppeteer;
|
||||
try {
|
||||
puppeteer = await import('puppeteer');
|
||||
} catch {
|
||||
throw new Error('puppeteer is required for URL scanning. Install: npm install puppeteer');
|
||||
}
|
||||
const launchArgs = options.launchArgs || (process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : []);
|
||||
const browser = options.browser || await launchBrowser(puppeteer, {
|
||||
headless: options.headless ?? true,
|
||||
args: launchArgs,
|
||||
});
|
||||
const ownsBrowser = !options.browser;
|
||||
const defaults = {
|
||||
waitUntil: options.waitUntil || 'load',
|
||||
settleMs: Number.isFinite(options.settleMs) ? options.settleMs : 100,
|
||||
viewport: options.viewport || { width: 1280, height: 800 },
|
||||
};
|
||||
return {
|
||||
browser,
|
||||
async detectUrl(url, scanOptions = {}) {
|
||||
return detectUrl(url, {
|
||||
...defaults,
|
||||
...scanOptions,
|
||||
browser,
|
||||
});
|
||||
},
|
||||
async close() {
|
||||
if (ownsBrowser) await browser.close().catch(() => {});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser, splitScanUrl };
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,279 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { OVERUSED_FONTS, primaryFontFace } from '../../shared/constants.mjs';
|
||||
import {
|
||||
checkSourceDesignSystem,
|
||||
collectStaticDesignSystemFindings,
|
||||
mergeDesignSystemFindings,
|
||||
} from '../../design-system.mjs';
|
||||
import { isFullPage } from '../../shared/page.mjs';
|
||||
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
|
||||
import { deriveAdvisoryFlag, finding } from '../../findings.mjs';
|
||||
import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
|
||||
import {
|
||||
checkElementBorders,
|
||||
checkElementClippedOverflow,
|
||||
checkElementColors,
|
||||
checkElementGlow,
|
||||
checkElementGptBorderShadow,
|
||||
checkElementHeroEyebrow,
|
||||
checkElementHoverContrast,
|
||||
checkElementIconTile,
|
||||
checkElementItalicSerif,
|
||||
checkElementMotion,
|
||||
checkElementOversizedH1,
|
||||
checkElementQuality,
|
||||
checkElementRadialSpotlight,
|
||||
checkFlatTypeHierarchyFromDoc,
|
||||
checkCreamPalette,
|
||||
checkHtmlPatterns,
|
||||
checkKickerAboveHeadingFromDoc,
|
||||
scopedIgnoreActive,
|
||||
checkNumberedSectionLabelsFromDoc,
|
||||
checkPageLayout,
|
||||
checkPageQualityFromDoc,
|
||||
checkRepeatedContainerTextFromDoc,
|
||||
resolveBackground,
|
||||
resolveBorderRadiusPx,
|
||||
} from '../../rules/checks.mjs';
|
||||
import { detectText, runTextContentAnalyzers } from '../regex/detect-text.mjs';
|
||||
import {
|
||||
StaticDocument,
|
||||
buildStaticStyleMap,
|
||||
buildStaticWindow,
|
||||
collectStaticCssText,
|
||||
} from './css-cascade.mjs';
|
||||
|
||||
function checkStaticPageTypography(document, window) {
|
||||
const findings = [];
|
||||
const fonts = new Set();
|
||||
const overusedFound = new Set();
|
||||
for (const el of document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, td, th, dd, blockquote, figcaption, a, button, label, span, div')) {
|
||||
const hasText = el.childNodes.some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasText) continue;
|
||||
const primary = primaryFontFace(window.getComputedStyle(el).fontFamily);
|
||||
if (!primary) continue;
|
||||
fonts.add(primary);
|
||||
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
||||
}
|
||||
for (const font of overusedFound) {
|
||||
findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` });
|
||||
}
|
||||
findings.push(...checkFlatTypeHierarchyFromDoc(document, el => window.getComputedStyle(el)));
|
||||
return findings;
|
||||
}
|
||||
|
||||
function checkElementBrokenImage(el) {
|
||||
const src = (el.getAttribute && el.getAttribute('src')) ?? el.attribs?.src;
|
||||
// Missing src attribute entirely
|
||||
if (src === undefined || src === null) {
|
||||
return [{ id: 'broken-image', snippet: '<img> with no src attribute' }];
|
||||
}
|
||||
const trimmed = String(src).trim();
|
||||
// Empty or placeholder-only src values
|
||||
if (trimmed === '' || trimmed === '#') {
|
||||
return [{ id: 'broken-image', snippet: `<img src="${src}">` }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const STATIC_ELEMENT_RULES = [
|
||||
{ id: 'border-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementBorders(tag, style, null, resolveBorderRadiusPx(el, style, parseFloat(style.width) || 0, window), el) },
|
||||
{ id: 'color-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementColors(el, style, tag, window, customPropMap, false) },
|
||||
{ id: 'hover-color-rules', selector: '*', run: (el, tag, style, window) => checkElementHoverContrast(el, style, tag, window) },
|
||||
{ id: 'dark-glow', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementGlow(tag, style, resolveBackground(el.parentElement || el, window, customPropMap)) },
|
||||
{ id: 'motion-rules', selector: '*', run: (el, tag, style) => checkElementMotion(tag, style) },
|
||||
{ id: 'icon-tile-stack', selector: 'h1,h2,h3,h4,h5,h6', run: (el, tag, _style, window) => checkElementIconTile(el, tag, window) },
|
||||
{ id: 'italic-serif-display', selector: 'h1,h2', run: (el, tag, style) => checkElementItalicSerif(el, style, tag) },
|
||||
{ id: 'hero-eyebrow-chip', selector: 'h1', run: (el, tag, style, window, customPropMap) => checkElementHeroEyebrow(el, style, tag, window, customPropMap) },
|
||||
{ id: 'broken-image', selector: 'img', run: (el) => checkElementBrokenImage(el) },
|
||||
{ id: 'quality-rules', selector: '*', run: (el, tag, style, window) => checkElementQuality(el, style, tag, window) },
|
||||
{ id: 'oversized-h1', selector: 'h1', run: (el, tag, style, window) => checkElementOversizedH1(el, style, tag, window) },
|
||||
{ id: 'clipped-overflow-container', selector: '*', run: (el, tag, style, window) => checkElementClippedOverflow(el, style, tag, window) },
|
||||
{ id: 'gpt-thin-border-wide-shadow', selector: '*', run: (el, tag, style) => checkElementGptBorderShadow(el, style) },
|
||||
{ id: 'radial-spotlight-glow', selector: '*', run: (el, tag, style, window) => checkElementRadialSpotlight(el, style, tag, window) },
|
||||
];
|
||||
|
||||
async function detectHtml(filePath, options = {}) {
|
||||
const profile = options?.profile;
|
||||
const html = profileStep(profile, {
|
||||
engine: 'static-html',
|
||||
phase: 'setup',
|
||||
ruleId: 'read-html',
|
||||
target: filePath,
|
||||
}, () => fs.readFileSync(filePath, 'utf-8'));
|
||||
|
||||
let modules;
|
||||
try {
|
||||
modules = await profileStepAsync(profile, {
|
||||
engine: 'static-html',
|
||||
phase: 'setup',
|
||||
ruleId: 'import-static-parser',
|
||||
target: filePath,
|
||||
}, async () => {
|
||||
const parsers = await import(new URL('../../vendor/static-html-parsers.mjs', import.meta.url).href);
|
||||
const { htmlparser2, cssSelect, csstree, domutils } = parsers;
|
||||
return {
|
||||
parseDocument: htmlparser2.parseDocument,
|
||||
selectAll: cssSelect.selectAll,
|
||||
selectOne: cssSelect.selectOne,
|
||||
compile: cssSelect.compile,
|
||||
csstree,
|
||||
domutils,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
if (!globalThis.__impeccableStaticHtmlWarned) {
|
||||
globalThis.__impeccableStaticHtmlWarned = true;
|
||||
process.stderr.write(
|
||||
'impeccable detect: DEGRADED - HTML parser modules unavailable ' +
|
||||
'(htmlparser2, css-select, css-tree, domutils).\n' +
|
||||
'Falling back to regex matching. Custom properties, selector matching and computed ' +
|
||||
'contrast are NOT evaluated; findings are an undercount, not a clean bill of health.\n',
|
||||
);
|
||||
}
|
||||
if (typeof options.onOperationalFailure === 'function') {
|
||||
options.onOperationalFailure({
|
||||
engine: 'static-html',
|
||||
reason: 'parser-bundle-unavailable',
|
||||
target: filePath,
|
||||
});
|
||||
}
|
||||
return detectText(html, filePath, options);
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
const fileDir = path.dirname(resolvedPath);
|
||||
const root = profileStep(profile, {
|
||||
engine: 'static-html',
|
||||
phase: 'parse-html',
|
||||
ruleId: 'parse-document',
|
||||
target: filePath,
|
||||
}, () => modules.parseDocument(html, { lowerCaseAttributeNames: false, lowerCaseTags: true }));
|
||||
|
||||
const cssText = collectStaticCssText(root, fileDir, profile, filePath, modules);
|
||||
const document = new StaticDocument(root, modules);
|
||||
buildStaticStyleMap(root, document, cssText, modules, profile, filePath);
|
||||
const window = buildStaticWindow(document);
|
||||
|
||||
const customPropMap = null;
|
||||
|
||||
const findings = [];
|
||||
const runElementCheck = (ruleId, callback) => profile
|
||||
? profileFindings(profile, { engine: 'static-html', phase: 'element', ruleId, target: filePath }, callback)
|
||||
: callback();
|
||||
|
||||
const visitedByRule = new Map();
|
||||
for (const rule of STATIC_ELEMENT_RULES) {
|
||||
const elements = document.querySelectorAll(rule.selector);
|
||||
visitedByRule.set(rule.id, elements.length);
|
||||
for (const el of elements) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const style = window.getComputedStyle(el);
|
||||
for (const f of runElementCheck(rule.id, () => rule.run(el, tag, style, window, customPropMap))) {
|
||||
// Element-scoped waivers: a data-impeccable-ignore ancestor suppresses
|
||||
// matching findings for its subtree, same as the browser walk.
|
||||
if (scopedIgnoreActive(el, f.id)) continue;
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.designSystem) {
|
||||
const sourceDesignFindings = profileFindings(profile, {
|
||||
engine: 'static-html',
|
||||
phase: 'source',
|
||||
ruleId: 'design-system',
|
||||
target: filePath,
|
||||
}, () => checkSourceDesignSystem(html, filePath, { designSystem: options.designSystem }));
|
||||
const staticDesignFindings = profileFindings(profile, {
|
||||
engine: 'static-html',
|
||||
phase: 'page',
|
||||
ruleId: 'design-system',
|
||||
target: filePath,
|
||||
}, () => collectStaticDesignSystemFindings(document, window, filePath, options.designSystem));
|
||||
findings.push(...mergeDesignSystemFindings(staticDesignFindings, sourceDesignFindings));
|
||||
}
|
||||
|
||||
if (isFullPage(html)) {
|
||||
const runPageCheck = (ruleId, callback) => profile
|
||||
? profileFindings(profile, { engine: 'static-html', phase: 'page', ruleId, target: filePath }, callback)
|
||||
: callback();
|
||||
for (const f of runPageCheck('typography-rules', () => checkStaticPageTypography(document, window))) {
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
}
|
||||
for (const f of runPageCheck('kicker-above-heading', () => checkKickerAboveHeadingFromDoc(document, window))) {
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
}
|
||||
for (const f of runPageCheck('numbered-section-labels', () => checkNumberedSectionLabelsFromDoc(document, window))) {
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
}
|
||||
for (const f of runPageCheck('repeated-container-text', () => checkRepeatedContainerTextFromDoc(document, window))) {
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
}
|
||||
for (const f of runPageCheck('layout-rules', () => checkPageLayout(document, window))) {
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
}
|
||||
for (const f of runPageCheck('cream-palette', () => checkCreamPalette(document, window))) {
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
}
|
||||
for (const f of runPageCheck('skipped-heading', () => checkPageQualityFromDoc(document))) {
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
}
|
||||
// Scoped corpora for the pattern checks (see buildHtmlPatternCorpora in
|
||||
// rules/checks.mjs): CSS-property regexes must not fire on prose ABOUT
|
||||
// css — `<code>background-clip: text</code>` in a changelog is
|
||||
// documentation, not styling. cssText already carries the <style>
|
||||
// blocks and any linked local stylesheets; style/class attributes come
|
||||
// from the parsed document, so escaped code samples never contribute.
|
||||
const styleAttrParts = [];
|
||||
const classAttrParts = [];
|
||||
for (const el of document.querySelectorAll('*')) {
|
||||
const styleAttr = el.getAttribute('style');
|
||||
if (styleAttr) styleAttrParts.push(`style="${styleAttr}"`);
|
||||
const classAttr = el.getAttribute('class');
|
||||
if (classAttr) classAttrParts.push(classAttr);
|
||||
}
|
||||
const patternCorpora = {
|
||||
styleText: [cssText, ...styleAttrParts].join('\n'),
|
||||
classText: classAttrParts.join('\n'),
|
||||
};
|
||||
for (const f of runPageCheck('html-patterns', () => checkHtmlPatterns(html, patternCorpora).filter(item =>
|
||||
item.id !== 'bounce-easing' && item.id !== 'layout-transition'
|
||||
))) {
|
||||
// Selector-backed page findings honor scoped waivers here too, matching
|
||||
// the browser pass: resolve the selector and drop the finding when an
|
||||
// ignoring ancestor covers a match. Unlike the browser, an unmatched
|
||||
// selector keeps the finding — static scans see partial documents.
|
||||
if (f.selector) {
|
||||
let matches = null;
|
||||
try {
|
||||
matches = document.querySelectorAll(String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim());
|
||||
} catch { matches = null; }
|
||||
if (matches && matches.length > 0 && [...matches].every(el => scopedIgnoreActive(el, f.id))) continue;
|
||||
}
|
||||
const item = finding(f.id, filePath, f.snippet);
|
||||
// Position-aware severity promotion: checks may attach a per-finding
|
||||
// severity (e.g. a pulsing dot inside a header/nav landmark) that
|
||||
// overrides the registry default.
|
||||
if (f.severity) item.severity = f.severity;
|
||||
findings.push(deriveAdvisoryFlag(item));
|
||||
}
|
||||
// Text-content analyzers (em-dash overuse, marketing buzzwords,
|
||||
// numbered section markers, aphoristic cadence) live in the regex
|
||||
// engine. Call them from here so .html files get the same coverage
|
||||
// as .css/.tsx files. These are scoped to text content only and
|
||||
// don't overlap with static-html's element/page rules.
|
||||
for (const f of runPageCheck('text-content', () => runTextContentAnalyzers(html, filePath, options))) {
|
||||
findings.push(finding(f.antipattern, filePath, f.snippet));
|
||||
}
|
||||
}
|
||||
|
||||
// Static-HTML findings carry no line number, so only whole-file
|
||||
// `impeccable-disable` directives apply here — exactly the standalone-document
|
||||
// waiver this primitive targets. Bypassed by `--no-config` / `--no-inline-ignores`.
|
||||
return options?.inlineIgnores === false ? findings : applyInlineIgnores(findings, html);
|
||||
}
|
||||
|
||||
export { checkStaticPageTypography, STATIC_ELEMENT_RULES, detectHtml };
|
||||
@@ -0,0 +1,189 @@
|
||||
function sanitizeScreenshotClip(clip, viewport) {
|
||||
if (!clip) return null;
|
||||
const x = Math.max(0, Math.floor(clip.x || 0));
|
||||
const y = Math.max(0, Math.floor(clip.y || 0));
|
||||
const width = Math.min(
|
||||
Math.max(1, Math.ceil(clip.width || 0)),
|
||||
Math.max(1, viewport?.width || 1600),
|
||||
);
|
||||
const height = Math.min(
|
||||
Math.max(1, Math.ceil(clip.height || 0)),
|
||||
320,
|
||||
);
|
||||
if (width < 1 || height < 1) return null;
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
async function compareScreenshotContrast(page, beforeBase64, afterBase64, candidate) {
|
||||
return page.evaluate(async ({ beforeBase64, afterBase64, candidate }) => {
|
||||
const loadImage = (base64) => new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error('Could not decode contrast screenshot'));
|
||||
img.src = `data:image/png;base64,${base64}`;
|
||||
});
|
||||
const [before, after] = await Promise.all([loadImage(beforeBase64), loadImage(afterBase64)]);
|
||||
const width = Math.min(before.width, after.width);
|
||||
const height = Math.min(before.height, after.height);
|
||||
if (width < 1 || height < 1) return null;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx) return null;
|
||||
|
||||
ctx.drawImage(before, 0, 0, width, height);
|
||||
const beforePixels = ctx.getImageData(0, 0, width, height).data;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.drawImage(after, 0, 0, width, height);
|
||||
const afterPixels = ctx.getImageData(0, 0, width, height).data;
|
||||
|
||||
const luminance = ({ r, g, b }) => {
|
||||
const convert = c => {
|
||||
const v = c / 255;
|
||||
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
return 0.2126 * convert(r) + 0.7152 * convert(g) + 0.0722 * convert(b);
|
||||
};
|
||||
const ratio = (a, b) => {
|
||||
const l1 = luminance(a);
|
||||
const l2 = luminance(b);
|
||||
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
||||
};
|
||||
|
||||
const cssTextColor = candidate.textColor && !candidate.preferRenderedForeground
|
||||
? {
|
||||
r: candidate.textColor.r,
|
||||
g: candidate.textColor.g,
|
||||
b: candidate.textColor.b,
|
||||
}
|
||||
: null;
|
||||
const ratios = [];
|
||||
let glyphPixels = 0;
|
||||
let strongestDelta = 0;
|
||||
for (let i = 0; i < beforePixels.length; i += 4) {
|
||||
const delta = Math.abs(beforePixels[i] - afterPixels[i])
|
||||
+ Math.abs(beforePixels[i + 1] - afterPixels[i + 1])
|
||||
+ Math.abs(beforePixels[i + 2] - afterPixels[i + 2])
|
||||
+ Math.abs(beforePixels[i + 3] - afterPixels[i + 3]);
|
||||
strongestDelta = Math.max(strongestDelta, delta);
|
||||
if (delta < 10) continue;
|
||||
glyphPixels++;
|
||||
const fg = cssTextColor || {
|
||||
r: beforePixels[i],
|
||||
g: beforePixels[i + 1],
|
||||
b: beforePixels[i + 2],
|
||||
};
|
||||
const bg = {
|
||||
r: afterPixels[i],
|
||||
g: afterPixels[i + 1],
|
||||
b: afterPixels[i + 2],
|
||||
};
|
||||
ratios.push(ratio(fg, bg));
|
||||
}
|
||||
|
||||
if (ratios.length < 8) {
|
||||
return {
|
||||
glyphPixels,
|
||||
strongestDelta,
|
||||
worstRatio: null,
|
||||
p10Ratio: null,
|
||||
medianRatio: null,
|
||||
};
|
||||
}
|
||||
|
||||
ratios.sort((a, b) => a - b);
|
||||
const pick = pct => ratios[Math.min(ratios.length - 1, Math.max(0, Math.floor((pct / 100) * ratios.length)))];
|
||||
return {
|
||||
glyphPixels,
|
||||
strongestDelta,
|
||||
worstRatio: ratios[0],
|
||||
p10Ratio: pick(10),
|
||||
medianRatio: pick(50),
|
||||
};
|
||||
}, { beforeBase64, afterBase64, candidate });
|
||||
}
|
||||
|
||||
async function captureVisualContrastCandidate(page, candidate, viewport) {
|
||||
const clip = sanitizeScreenshotClip(candidate.clip, viewport);
|
||||
if (!clip) return null;
|
||||
|
||||
const beforeBase64 = await page.screenshot({
|
||||
encoding: 'base64',
|
||||
clip,
|
||||
captureBeyondViewport: true,
|
||||
});
|
||||
const token = `impeccable-contrast-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const applied = await page.evaluate(({ selector, token, backgroundClipText }) => {
|
||||
let el;
|
||||
try {
|
||||
el = document.querySelector(selector);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!el) return false;
|
||||
let style = document.getElementById('impeccable-visual-contrast-hide-style');
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = 'impeccable-visual-contrast-hide-style';
|
||||
style.textContent = [
|
||||
'[data-impeccable-visual-contrast-target] {',
|
||||
' color: transparent !important;',
|
||||
' -webkit-text-fill-color: transparent !important;',
|
||||
' text-shadow: none !important;',
|
||||
'}',
|
||||
'[data-impeccable-visual-contrast-target][data-impeccable-bgclip-text="true"] {',
|
||||
' background-image: none !important;',
|
||||
'}',
|
||||
].join('\n');
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
el.setAttribute('data-impeccable-visual-contrast-target', token);
|
||||
if (backgroundClipText) el.setAttribute('data-impeccable-bgclip-text', 'true');
|
||||
return true;
|
||||
}, {
|
||||
selector: candidate.selector,
|
||||
token,
|
||||
backgroundClipText: candidate.backgroundClipText,
|
||||
});
|
||||
if (!applied) return null;
|
||||
|
||||
let afterBase64;
|
||||
try {
|
||||
afterBase64 = await page.screenshot({
|
||||
encoding: 'base64',
|
||||
clip,
|
||||
captureBeyondViewport: true,
|
||||
});
|
||||
} finally {
|
||||
await page.evaluate(({ selector }) => {
|
||||
try {
|
||||
const el = document.querySelector(selector);
|
||||
if (el) {
|
||||
el.removeAttribute('data-impeccable-visual-contrast-target');
|
||||
el.removeAttribute('data-impeccable-bgclip-text');
|
||||
}
|
||||
} catch {
|
||||
// Ignore invalid or stale selectors during cleanup.
|
||||
}
|
||||
}, { selector: candidate.selector }).catch(() => {});
|
||||
}
|
||||
|
||||
const metrics = await compareScreenshotContrast(page, beforeBase64, afterBase64, candidate);
|
||||
if (!metrics || !Number.isFinite(metrics.p10Ratio) || metrics.glyphPixels < 8) return null;
|
||||
const measuredRatio = metrics.p10Ratio;
|
||||
if (measuredRatio >= candidate.threshold) return null;
|
||||
const textLabel = candidate.text ? ` "${candidate.text}"` : '';
|
||||
const reasonLabel = (candidate.reasons || []).slice(0, 3).join(', ') || 'visual background';
|
||||
return {
|
||||
id: 'low-contrast',
|
||||
snippet: `pixel contrast ${measuredRatio.toFixed(1)}:1 median ${metrics.medianRatio.toFixed(1)}:1 (need ${candidate.threshold}:1) on ${reasonLabel}${textLabel}`,
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
sanitizeScreenshotClip,
|
||||
compareScreenshotContrast,
|
||||
captureVisualContrastCandidate,
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { getAntipattern } from './registry/antipatterns.mjs';
|
||||
|
||||
function getAP(id) {
|
||||
return getAntipattern(id);
|
||||
}
|
||||
|
||||
function deriveAdvisoryFlag(item) {
|
||||
if (item.severity === 'advisory') item.advisory = true;
|
||||
else delete item.advisory;
|
||||
return item;
|
||||
}
|
||||
|
||||
function finding(id, filePath, snippet, line = 0) {
|
||||
const ap = getAP(id);
|
||||
const base = { antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', category: ap.category || null, file: filePath, line, snippet };
|
||||
// Advisory findings are detected but reported separately and never counted as
|
||||
// failures. Carry the flag on the finding so every consumer (CLI, JSON, hook)
|
||||
// can partition without a registry lookup. Only stamped when true to keep the
|
||||
// finding shape stable for the vast majority of rules.
|
||||
return deriveAdvisoryFlag(base);
|
||||
}
|
||||
|
||||
export { getAP, finding, deriveAdvisoryFlag };
|
||||
@@ -0,0 +1,225 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File walker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Hidden directories are skipped wholesale during recursion (below), which
|
||||
// covers .git / .next / .nuxt / .svelte-kit / .turbo / .vercel and — the
|
||||
// issue #303 class — every vendored AI-harness install (.claude, .cursor,
|
||||
// .codex, .agents, .impeccable, ...) whose bundled detector source would
|
||||
// otherwise be reported as findings on a root scan. Only the non-hidden
|
||||
// build/dependency dirs need naming. An explicitly passed hidden target
|
||||
// still scans: walkDir name-checks children, never the root it's given.
|
||||
const SKIP_DIRS = new Set([
|
||||
'node_modules', 'dist', 'build', '__pycache__',
|
||||
]);
|
||||
|
||||
// The exceptions to the hidden-dir rule: hidden directories that
|
||||
// conventionally hold real UI source rather than tooling or vendored code.
|
||||
// VitePress and VuePress keep custom theme components in
|
||||
// .vitepress/theme/*.vue / .vuepress/theme/, and Storybook keeps preview
|
||||
// decorators/styles in .storybook/.
|
||||
const HIDDEN_SOURCE_DIRS = new Set(['.vitepress', '.vuepress', '.storybook']);
|
||||
|
||||
const SCANNABLE_EXTENSIONS = new Set([
|
||||
'.html', '.htm', '.css', '.scss', '.sass', '.less',
|
||||
'.jsx', '.tsx', '.js', '.ts',
|
||||
'.vue', '.svelte', '.astro', '.blade.php',
|
||||
]);
|
||||
|
||||
const HTML_EXTENSIONS = new Set(['.html', '.htm']);
|
||||
|
||||
function hasScannableExtension(filename) {
|
||||
const lower = filename.toLowerCase();
|
||||
if (SCANNABLE_EXTENSIONS.has(path.extname(lower))) return true;
|
||||
for (const ext of SCANNABLE_EXTENSIONS) {
|
||||
if (ext.indexOf('.', 1) !== -1 && lower.endsWith(ext)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const IMPORT_SPECIFIER_PATTERNS = [
|
||||
/import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g,
|
||||
/@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g,
|
||||
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
|
||||
];
|
||||
|
||||
function walkDir(dir, onReadError = null) {
|
||||
const files = [];
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (onReadError) onReadError(dir, error);
|
||||
return files;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) files.push(...walkDir(full, onReadError));
|
||||
else if (hasScannableExtension(entry.name)) files.push(full);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import graph (multi-file awareness)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function resolveImport(specifier, fromDir, fileSet) {
|
||||
if (!/^[./]/.test(specifier)) return null; // skip bare specifiers
|
||||
const base = path.resolve(fromDir, specifier);
|
||||
if (fileSet.has(base)) return base;
|
||||
for (const ext of SCANNABLE_EXTENSIONS) {
|
||||
const withExt = base + ext;
|
||||
if (fileSet.has(withExt)) return withExt;
|
||||
}
|
||||
// index file convention
|
||||
for (const ext of SCANNABLE_EXTENSIONS) {
|
||||
const indexFile = path.join(base, 'index' + ext);
|
||||
if (fileSet.has(indexFile)) return indexFile;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildImportGraph(files, onReadError = null) {
|
||||
const fileSet = new Set(files);
|
||||
const graph = new Map();
|
||||
|
||||
for (const file of files) {
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(file, 'utf-8');
|
||||
} catch (error) {
|
||||
if (!onReadError) throw error;
|
||||
onReadError(file, error);
|
||||
continue;
|
||||
}
|
||||
const dir = path.dirname(file);
|
||||
const imports = new Set();
|
||||
|
||||
for (const pattern of IMPORT_SPECIFIER_PATTERNS) {
|
||||
for (const match of content.matchAll(pattern)) {
|
||||
const resolved = resolveImport(match[1], dir, fileSet);
|
||||
if (resolved) imports.add(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
graph.set(file, imports);
|
||||
}
|
||||
return graph;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Framework dev server detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FRAMEWORK_CONFIGS = [
|
||||
{ name: 'Next.js', files: ['next.config.js', 'next.config.mjs', 'next.config.ts'], defaultPort: 3000,
|
||||
portRe: /port\s*[:=]\s*(\d+)/,
|
||||
fingerprint: { header: 'x-powered-by', value: /next/i } },
|
||||
{ name: 'SvelteKit', files: ['svelte.config.js', 'svelte.config.ts'], defaultPort: 5173,
|
||||
portRe: /port\s*[:=]\s*(\d+)/,
|
||||
fingerprint: { header: 'x-sveltekit-page', value: null } },
|
||||
{ name: 'Nuxt', files: ['nuxt.config.js', 'nuxt.config.ts'], defaultPort: 3000,
|
||||
portRe: /port\s*[:=]\s*(\d+)/,
|
||||
fingerprint: { header: 'x-powered-by', value: /nuxt/i } },
|
||||
{ name: 'Vite', files: ['vite.config.js', 'vite.config.ts', 'vite.config.mjs'], defaultPort: 5173,
|
||||
portRe: /port\s*[:=]\s*(\d+)/,
|
||||
fingerprint: { body: /@vite\/client/ } },
|
||||
{ name: 'Astro', files: ['astro.config.js', 'astro.config.ts', 'astro.config.mjs'], defaultPort: 4321,
|
||||
portRe: /port\s*[:=]\s*(\d+)/,
|
||||
fingerprint: { body: /astro/i } },
|
||||
{ name: 'Angular', files: ['angular.json'], defaultPort: 4200,
|
||||
portRe: /"port"\s*:\s*(\d+)/,
|
||||
fingerprint: { body: /ng-version/i } },
|
||||
{ name: 'Remix', files: ['remix.config.js', 'remix.config.ts'], defaultPort: 3000,
|
||||
portRe: /port\s*[:=]\s*(\d+)/,
|
||||
fingerprint: { header: 'x-powered-by', value: /remix/i } },
|
||||
];
|
||||
|
||||
function detectFrameworkConfig(dir) {
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir); } catch { return null; }
|
||||
const entrySet = new Set(entries);
|
||||
|
||||
for (const cfg of FRAMEWORK_CONFIGS) {
|
||||
const match = cfg.files.find(f => entrySet.has(f));
|
||||
if (!match) continue;
|
||||
|
||||
const configPath = path.join(dir, match);
|
||||
let port = cfg.defaultPort;
|
||||
try {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const portMatch = content.match(cfg.portRe);
|
||||
if (portMatch) port = parseInt(portMatch[1], 10);
|
||||
} catch { /* use default */ }
|
||||
|
||||
return { name: cfg.name, port, configPath, fingerprint: cfg.fingerprint };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a port is listening and optionally verify it matches the expected framework.
|
||||
* Returns { listening: true, matched: true/false } or { listening: false }.
|
||||
*/
|
||||
async function isPortListening(port, fingerprint = null) {
|
||||
if (!fingerprint) {
|
||||
// Simple TCP probe fallback
|
||||
const net = await import('node:net');
|
||||
return new Promise((resolve) => {
|
||||
const sock = net.default.createConnection({ port, host: '127.0.0.1' });
|
||||
sock.setTimeout(500);
|
||||
sock.on('connect', () => { sock.destroy(); resolve({ listening: true, matched: true }); });
|
||||
sock.on('error', () => resolve({ listening: false }));
|
||||
sock.on('timeout', () => { sock.destroy(); resolve({ listening: false }); });
|
||||
});
|
||||
}
|
||||
|
||||
// HTTP probe with fingerprint matching
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 2000);
|
||||
const res = await fetch(`http://localhost:${port}/`, { signal: controller.signal, redirect: 'follow' });
|
||||
clearTimeout(timeout);
|
||||
|
||||
// Check header fingerprint
|
||||
if (fingerprint.header) {
|
||||
const val = res.headers.get(fingerprint.header);
|
||||
if (val && (!fingerprint.value || fingerprint.value.test(val))) {
|
||||
return { listening: true, matched: true };
|
||||
}
|
||||
}
|
||||
|
||||
// Check body fingerprint
|
||||
if (fingerprint.body) {
|
||||
const body = await res.text();
|
||||
if (fingerprint.body.test(body)) {
|
||||
return { listening: true, matched: true };
|
||||
}
|
||||
}
|
||||
|
||||
// Port is listening but doesn't match the expected framework
|
||||
return { listening: true, matched: false };
|
||||
} catch {
|
||||
return { listening: false };
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
SKIP_DIRS,
|
||||
SCANNABLE_EXTENSIONS,
|
||||
HTML_EXTENSIONS,
|
||||
hasScannableExtension,
|
||||
walkDir,
|
||||
resolveImport,
|
||||
buildImportGraph,
|
||||
FRAMEWORK_CONFIGS,
|
||||
detectFrameworkConfig,
|
||||
isPortListening,
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
function profileNow() {
|
||||
return typeof performance !== 'undefined' && performance.now
|
||||
? performance.now()
|
||||
: Date.now();
|
||||
}
|
||||
|
||||
function createDetectorProfile() {
|
||||
return { events: [] };
|
||||
}
|
||||
|
||||
function recordProfileEvent(profile, event) {
|
||||
if (!profile) return;
|
||||
const normalized = {
|
||||
engine: event.engine || 'unknown',
|
||||
phase: event.phase || 'unknown',
|
||||
ruleId: event.ruleId || 'unknown',
|
||||
target: event.target || '',
|
||||
ms: Number.isFinite(event.ms) ? event.ms : 0,
|
||||
findings: Number.isFinite(event.findings) ? event.findings : 0,
|
||||
};
|
||||
if (event.detail) normalized.detail = event.detail;
|
||||
if (Array.isArray(event.findingIds) && event.findingIds.length) {
|
||||
normalized.findingIds = event.findingIds;
|
||||
}
|
||||
if (typeof profile === 'function') {
|
||||
profile(normalized);
|
||||
} else if (typeof profile.record === 'function') {
|
||||
profile.record(normalized);
|
||||
} else if (Array.isArray(profile.events)) {
|
||||
profile.events.push(normalized);
|
||||
} else if (Array.isArray(profile)) {
|
||||
profile.push(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
function extractFindingIds(findings) {
|
||||
if (!Array.isArray(findings) || findings.length === 0) return [];
|
||||
return [...new Set(findings.map(f => f?.id || f?.type || f?.antipattern).filter(Boolean))];
|
||||
}
|
||||
|
||||
function profileFindings(profile, meta, callback) {
|
||||
if (!profile) return callback();
|
||||
const started = profileNow();
|
||||
const findings = callback();
|
||||
recordProfileEvent(profile, {
|
||||
...meta,
|
||||
ms: profileNow() - started,
|
||||
findings: Array.isArray(findings) ? findings.length : 0,
|
||||
findingIds: extractFindingIds(findings),
|
||||
});
|
||||
return findings;
|
||||
}
|
||||
|
||||
function profileStep(profile, meta, callback) {
|
||||
if (!profile) return callback();
|
||||
const started = profileNow();
|
||||
try {
|
||||
return callback();
|
||||
} finally {
|
||||
recordProfileEvent(profile, {
|
||||
...meta,
|
||||
ms: profileNow() - started,
|
||||
findings: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function profileFindingsAsync(profile, meta, callback) {
|
||||
if (!profile) return callback();
|
||||
const started = profileNow();
|
||||
const findings = await callback();
|
||||
recordProfileEvent(profile, {
|
||||
...meta,
|
||||
ms: profileNow() - started,
|
||||
findings: Array.isArray(findings) ? findings.length : 0,
|
||||
findingIds: extractFindingIds(findings),
|
||||
});
|
||||
return findings;
|
||||
}
|
||||
|
||||
async function profileStepAsync(profile, meta, callback) {
|
||||
if (!profile) return callback();
|
||||
const started = profileNow();
|
||||
try {
|
||||
return await callback();
|
||||
} finally {
|
||||
recordProfileEvent(profile, {
|
||||
...meta,
|
||||
ms: profileNow() - started,
|
||||
findings: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function percentile(sortedValues, pct) {
|
||||
if (!sortedValues.length) return 0;
|
||||
const idx = Math.min(
|
||||
sortedValues.length - 1,
|
||||
Math.max(0, Math.ceil((pct / 100) * sortedValues.length) - 1),
|
||||
);
|
||||
return sortedValues[idx];
|
||||
}
|
||||
|
||||
function summarizeDetectorProfile(profile) {
|
||||
const events = Array.isArray(profile)
|
||||
? profile
|
||||
: (Array.isArray(profile?.events) ? profile.events : []);
|
||||
const groups = new Map();
|
||||
for (const event of events) {
|
||||
const key = [
|
||||
event.engine || 'unknown',
|
||||
event.phase || 'unknown',
|
||||
event.ruleId || 'unknown',
|
||||
event.target || '',
|
||||
].join('\u0000');
|
||||
let group = groups.get(key);
|
||||
if (!group) {
|
||||
group = {
|
||||
engine: event.engine || 'unknown',
|
||||
phase: event.phase || 'unknown',
|
||||
ruleId: event.ruleId || 'unknown',
|
||||
target: event.target || '',
|
||||
calls: 0,
|
||||
totalMs: 0,
|
||||
findings: 0,
|
||||
samples: [],
|
||||
};
|
||||
groups.set(key, group);
|
||||
}
|
||||
const ms = Number.isFinite(event.ms) ? event.ms : 0;
|
||||
group.calls += 1;
|
||||
group.totalMs += ms;
|
||||
group.findings += Number.isFinite(event.findings) ? event.findings : 0;
|
||||
group.samples.push(ms);
|
||||
}
|
||||
return [...groups.values()]
|
||||
.map(group => {
|
||||
const samples = group.samples.sort((a, b) => a - b);
|
||||
return {
|
||||
engine: group.engine,
|
||||
phase: group.phase,
|
||||
ruleId: group.ruleId,
|
||||
target: group.target,
|
||||
calls: group.calls,
|
||||
totalMs: Number(group.totalMs.toFixed(3)),
|
||||
avgMs: Number((group.totalMs / group.calls).toFixed(3)),
|
||||
p50: Number(percentile(samples, 50).toFixed(3)),
|
||||
p95: Number(percentile(samples, 95).toFixed(3)),
|
||||
findings: group.findings,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.totalMs - a.totalMs);
|
||||
}
|
||||
|
||||
export {
|
||||
profileNow,
|
||||
createDetectorProfile,
|
||||
recordProfileEvent,
|
||||
extractFindingIds,
|
||||
profileFindings,
|
||||
profileStep,
|
||||
profileFindingsAsync,
|
||||
profileStepAsync,
|
||||
percentile,
|
||||
summarizeDetectorProfile,
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user