mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 22:26:38 +03:00
Fix live accept DOM cleanup after carbonize (#185)
* Add live E2E regression report * Fix live accept DOM cleanup * Fix live browser review findings * Fix stale accepted session cleanup * Remove live regression hunt notes * Fix accept error review findings
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Impeccable Live Variant Mode — Browser Script
|
||||
* Impeccable Live Variant Mode - Browser Script
|
||||
*
|
||||
* Injected into the user's page via <script src="http://localhost:PORT/live.js">.
|
||||
* The server prepends window.__IMPECCABLE_TOKEN__ and window.__IMPECCABLE_PORT__
|
||||
* before this code.
|
||||
*
|
||||
* UI: a single floating bar that morphs between three states —
|
||||
* UI: a single floating bar that morphs between three states -
|
||||
* configure (pick action + go), generating (progressive dots), and cycling
|
||||
* (prev/next + accept/discard). Feels like Spotlight, not a modal.
|
||||
*/
|
||||
@@ -46,7 +46,7 @@
|
||||
mist: 'oklch(90% 0.008 82 / 0.6)', // light hairline
|
||||
white: 'oklch(99% 0 0)',
|
||||
};
|
||||
// Picker bar chrome — mirrors .live-demo-gbar / .live-demo-ctx in kinpaku-kit.css.
|
||||
// Picker bar chrome - mirrors .live-demo-gbar / .live-demo-ctx in kinpaku-kit.css.
|
||||
// Quiet neutral elevation: no gold halo ring (gold is reserved for the brand
|
||||
// mark and the active control, not the container outline).
|
||||
const PICKER_SHADOW =
|
||||
@@ -83,7 +83,7 @@
|
||||
|
||||
// SVG icons stack above each chip label. All strokes use currentColor so the
|
||||
// icon recolors to C.brand when its chip is selected. 20x20 render, 24-viewBox,
|
||||
// 1.5 stroke — visually consistent with the Foundation grid on the homepage.
|
||||
// 1.5 stroke - visually consistent with the Foundation grid on the homepage.
|
||||
const ICON_ATTRS = 'width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="display:block"';
|
||||
const ICONS = {
|
||||
impeccable: `<svg ${ICON_ATTRS}><path d="M4 20l4-1L18 9l-3-3L5 16z"/><path d="M14 7l3 3"/></svg>`,
|
||||
@@ -123,6 +123,7 @@
|
||||
let hoveredElement = null;
|
||||
let selectedElement = null;
|
||||
let currentSessionId = null;
|
||||
let pendingAcceptedSession = null;
|
||||
let expectedVariants = 0;
|
||||
let arrivedVariants = 0;
|
||||
let visibleVariant = 0;
|
||||
@@ -133,7 +134,7 @@
|
||||
const browserOwner = sessionState.owner;
|
||||
let checkpointTimer = null;
|
||||
|
||||
// Scroll lock — holds window.scrollY at a fixed value while the session is
|
||||
// Scroll lock - holds window.scrollY at a fixed value while the session is
|
||||
// active, so HMR DOM patches and variant swaps can't drift the page. See
|
||||
// startScrollLock / stopScrollLock below.
|
||||
let scrollLockObserver = null;
|
||||
@@ -141,7 +142,7 @@
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
// Dedicated key for scroll position — SEPARATE from LS_KEY so that
|
||||
// Dedicated key for scroll position - SEPARATE from LS_KEY so that
|
||||
// saveSession's state updates don't clobber a carefully-captured scrollY.
|
||||
// (Previously: saveSession wrote scrollY alongside state, so every call
|
||||
// during resume overwrote the pre-reload value with whatever the browser
|
||||
@@ -231,7 +232,7 @@
|
||||
// - Stop `focusin` propagation so any focus shifts inside our chrome
|
||||
// don't read as "focus moved outside the dialog" to focus traps.
|
||||
//
|
||||
// Click events still bubble normally — only the early pointer/focus
|
||||
// Click events still bubble normally - only the early pointer/focus
|
||||
// signals that drive outside-interaction detection are silenced.
|
||||
function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) {
|
||||
if (!rootEl) return;
|
||||
@@ -319,7 +320,7 @@
|
||||
// correlate directly with the captured PNG.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DRAG_THRESHOLD = 5; // px — below this, treat pointerup as a click
|
||||
const DRAG_THRESHOLD = 5; // px - below this, treat pointerup as a click
|
||||
const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it
|
||||
let annotOverlayEl = null;
|
||||
let annotSvgEl = null;
|
||||
@@ -423,7 +424,7 @@
|
||||
placeholderResizeDrag = null;
|
||||
if (annotOverlayEl) annotOverlayEl.style.display = 'none';
|
||||
syncPlaceholderResizeHandles();
|
||||
// Drop any in-progress edit without touching annotState — clearAnnotations
|
||||
// Drop any in-progress edit without touching annotState - clearAnnotations
|
||||
// (if the caller is exiting configure mode) handles state reset.
|
||||
annotEditing = null;
|
||||
}
|
||||
@@ -488,7 +489,7 @@
|
||||
function onAnnotDown(e) {
|
||||
if (!annotActive) return;
|
||||
|
||||
// 0) Insert placeholder edge resize — wins over draw / pins.
|
||||
// 0) Insert placeholder edge resize - wins over draw / pins.
|
||||
const resizeEdge = e.target.closest?.('[data-impeccable-placeholder-resize]')?.dataset.impeccablePlaceholderResize;
|
||||
if (resizeEdge && configureKind === 'insert' && placeholderElement) {
|
||||
startPlaceholderEdgeResize(resizeEdge, e);
|
||||
@@ -536,7 +537,7 @@
|
||||
// If editing a different pin, commit that edit before starting here.
|
||||
if (annotEditing && annotEditing.idx !== idx) finalizeEditingPin();
|
||||
// If already editing THIS pin and the user clicked the dot, let the
|
||||
// input keep focus (don't start a drag — the click wasn't meant as one).
|
||||
// input keep focus (don't start a drag - the click wasn't meant as one).
|
||||
if (annotEditing && annotEditing.idx === idx) return;
|
||||
const p = localCoords(e);
|
||||
const pin = annotState.comments[idx];
|
||||
@@ -971,7 +972,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The Bar — one floating element, three modes
|
||||
// The Bar - one floating element, three modes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Contextual-bar palette. Cached at init so every build*Row reads a
|
||||
@@ -1752,7 +1753,7 @@
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
});
|
||||
|
||||
// Action pill — dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill)
|
||||
// Action pill - dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill)
|
||||
const pill = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
||||
padding: '5px 10px', borderRadius: '6px',
|
||||
@@ -1786,7 +1787,7 @@
|
||||
});
|
||||
row.appendChild(pill);
|
||||
|
||||
// Prompt field — same chat-surface chrome as the bottom Steer bar
|
||||
// Prompt field - same chat-surface chrome as the bottom Steer bar
|
||||
const inputWrap = el('div', {
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
flex: '1', minWidth: '0', height: '28px',
|
||||
@@ -2126,7 +2127,7 @@
|
||||
if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
|
||||
row.appendChild(next);
|
||||
|
||||
// Tune chip — only when the visible variant exposes params
|
||||
// Tune chip - only when the visible variant exposes params
|
||||
const visParams = parseVariantParams(getVisibleVariantEl());
|
||||
const hasParams = visParams.length > 0;
|
||||
if (hasParams) {
|
||||
@@ -2173,7 +2174,7 @@
|
||||
// Spacer
|
||||
row.appendChild(el('div', { flex: '1' }));
|
||||
|
||||
// Accept — primary action, kinpaku gold + lacquer-deep (matches demo .live-demo-ctx-accept)
|
||||
// Accept - primary action, kinpaku gold + lacquer-deep (matches demo .live-demo-ctx-accept)
|
||||
const accept = el('button', {
|
||||
padding: '5px 14px', borderRadius: '5px',
|
||||
border: 'none', background: C.brand, color: C.ink,
|
||||
@@ -2266,7 +2267,7 @@
|
||||
const active = i === visibleVariant;
|
||||
// active: solid site-brand kinpaku dot. arrived+inactive: muted neutral.
|
||||
// pending (not yet arrived): faint outline ring. No borders on arrived
|
||||
// dots — the previous "accent ring + ash fill" combo read as noisy
|
||||
// dots - the previous "accent ring + ash fill" combo read as noisy
|
||||
// kinpaku chips, especially when all variants had arrived and every
|
||||
// dot wore an accent ring.
|
||||
const dotBg = active ? C.brand
|
||||
@@ -2456,7 +2457,7 @@
|
||||
let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide)
|
||||
let paramsPanelInner = null; // translating content (carries bg, padding, knobs)
|
||||
let paramsPanelBody = null; // grid holding the knob cells
|
||||
let paramsCurrentValues = {}; // {paramId: value} — mirror of the visible variant's live values
|
||||
let paramsCurrentValues = {}; // {paramId: value} - mirror of the visible variant's live values
|
||||
let tuneOpen = false; // whether the Tune popover is open right now
|
||||
|
||||
// Theme-aware Tune popover. Appears as a drawer that slides out from the
|
||||
@@ -2471,7 +2472,7 @@
|
||||
const P = paramsPanelPalette;
|
||||
|
||||
// Single element, always in the DOM. The slide animation is a CSS mask
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis — no
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis - no
|
||||
// display toggle, no opacity toggle, no transform trickery. The mask
|
||||
// hides everything initially; as it grows, content is revealed from
|
||||
// the bar edge outward.
|
||||
@@ -2494,7 +2495,7 @@
|
||||
transition: 'clip-path 0.44s ' + EASE,
|
||||
|
||||
// Park off-screen until positionParamsPanel places it. These are NOT
|
||||
// in the transition list, so they snap instantly — no fly-in from the
|
||||
// in the transition list, so they snap instantly - no fly-in from the
|
||||
// top-left when first shown.
|
||||
top: '-9999px', left: '-9999px', width: '0',
|
||||
});
|
||||
@@ -2685,7 +2686,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline text editing — makes pure-text descendants of the picked element
|
||||
// Inline text editing - makes pure-text descendants of the picked element
|
||||
// directly contenteditable. Save stages copy edits in the live buffer; the
|
||||
// Apply copy edits dock later asks the AI to apply the staged batch.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -3132,7 +3133,7 @@
|
||||
if (detail.includes('newText cannot contain') || detail.includes('newText cannot be empty')) {
|
||||
showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500);
|
||||
} else {
|
||||
showToast('Save failed — retry or cancel', 4000);
|
||||
showToast('Save failed: retry or cancel', 4000);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3454,19 +3455,19 @@
|
||||
updatePendingCounter(remaining);
|
||||
if (result.failed && result.failed.length > 0) {
|
||||
console.warn('[impeccable] some copy edits failed:', result.failed);
|
||||
showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed — see console', 5000);
|
||||
showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed, see console', 5000);
|
||||
} else {
|
||||
const n = Array.isArray(result.applied) ? result.applied.length : (result.cleared || 0);
|
||||
if (n > 0) {
|
||||
showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500);
|
||||
} else {
|
||||
console.warn('[impeccable] apply returned no verified edits:', result);
|
||||
showToast('No edits applied — see console', 4000);
|
||||
showToast('No edits applied, see console', 4000);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[impeccable] commit failed:', err);
|
||||
showToast('Apply failed — see console', 4000);
|
||||
showToast('Apply failed, see console', 4000);
|
||||
} finally {
|
||||
if (waitForSseCompletion) return;
|
||||
const remainingCount = parseInt(pendingPillEl?.dataset.count || '0', 10) || 0;
|
||||
@@ -3496,7 +3497,7 @@
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[impeccable] discard failed:', err);
|
||||
showToast('Discard failed — see console', 4000);
|
||||
showToast('Discard failed, see console', 4000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3644,7 +3645,7 @@
|
||||
const failedCount = numberOrNull(msg.failedCount) || 0;
|
||||
const appliedCount = numberOrNull(msg.appliedCount) || numberOrNull(msg.cleared) || 0;
|
||||
if (failedCount > 0) {
|
||||
showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed — see console', 5000);
|
||||
showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed, see console', 5000);
|
||||
} else if (appliedCount > 0) {
|
||||
showToast('Applied ' + appliedCount + ' edit' + (appliedCount === 1 ? '' : 's'), 2500);
|
||||
}
|
||||
@@ -3799,7 +3800,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit content badge — floating button at element top-right to enter EDITING mode
|
||||
// Edit content badge - floating button at element top-right to enter EDITING mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initEditBadge() {
|
||||
@@ -3884,7 +3885,7 @@
|
||||
}
|
||||
editBadgeEl.appendChild(btn);
|
||||
} else {
|
||||
// 'editing' — show Cancel + Save separated
|
||||
// 'editing' - show Cancel + Save separated
|
||||
editBadgeEl.innerHTML = '';
|
||||
editBadgeEl.style.gap = '8px';
|
||||
const cancel = document.createElement('button');
|
||||
@@ -4083,7 +4084,7 @@
|
||||
if (!v) continue;
|
||||
setVariantShown(child, v === String(num));
|
||||
}
|
||||
// Unconditional refresh — covers first-reveal (no-op if state isn't
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
// CYCLING yet, the subsequent CYCLING transition triggers its own
|
||||
// refresh) and every cycle step.
|
||||
refreshParamsPanel();
|
||||
@@ -4318,7 +4319,7 @@
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
}, { passive: true, ...sig });
|
||||
|
||||
// Apply target synchronously, not via rAF — racing the browser's
|
||||
// Apply target synchronously, not via rAF - racing the browser's
|
||||
// restore or a smooth-scroll animation means we want to win now.
|
||||
if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) {
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
@@ -4545,18 +4546,34 @@
|
||||
// hidden tab, a route the user navigated away from). The variant
|
||||
// MutationObserver stays armed and auto-transitions to CYCLING
|
||||
// the moment the wrapper actually mounts. Nudge the user toward
|
||||
// that path with a toast — better than the prior force-reload
|
||||
// that path with a toast - better than the prior force-reload
|
||||
// which reset framework state and left the session stuck.
|
||||
setTimeout(() => {
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) return;
|
||||
if (state !== 'GENERATING') return;
|
||||
showToast(
|
||||
"Variants ready. If the picked element isn't visible, retrace the path that revealed it — they'll appear automatically.",
|
||||
"Variants ready. If the picked element isn't visible, retrace the path that revealed it; they'll appear automatically.",
|
||||
15000,
|
||||
);
|
||||
}, 2000);
|
||||
break;
|
||||
case 'complete':
|
||||
case 'accept':
|
||||
if (maybeCompleteAcceptedSession(msg)) break;
|
||||
break;
|
||||
case 'agent_done':
|
||||
// Carbonize accepts are not terminal until live-complete.mjs sends
|
||||
// the final complete event. Keep the browser in its recoverable
|
||||
// saving state while the source cleanup is still in flight.
|
||||
break;
|
||||
case 'error':
|
||||
if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) {
|
||||
pendingAcceptedSession = null;
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
break;
|
||||
}
|
||||
if (maybeCompleteSteer(msg)) break;
|
||||
console.error('[impeccable] Error:', msg.message);
|
||||
showToast('Error: ' + msg.message, 5000);
|
||||
@@ -4779,14 +4796,14 @@
|
||||
|
||||
/**
|
||||
* Surface a brief, non-blocking heads-up when the picked element lives
|
||||
* inside a container whose visibility is gated by ephemeral state — modals,
|
||||
* inside a container whose visibility is gated by ephemeral state - modals,
|
||||
* collapsible panels, popovers, off-screen tab panels. If HMR remounts the
|
||||
* parent during generation (Vite Fast Refresh, SvelteKit page reload), the
|
||||
* variants land in source but stay invisible until the user re-opens the
|
||||
* container. Telling the user upfront is much friendlier than the silent
|
||||
* timeout-then-toast that they'd otherwise hit.
|
||||
*
|
||||
* Heuristic, intentionally narrow — only fires for unambiguous cases so
|
||||
* Heuristic, intentionally narrow - only fires for unambiguous cases so
|
||||
* we don't cry wolf on every nested element.
|
||||
*/
|
||||
function maybeWarnConditionalAncestor(el) {
|
||||
@@ -4804,7 +4821,7 @@
|
||||
showToast('Heads up: this element lives inside an open panel. If state resets during generation, you may need to re-open it.', 6000);
|
||||
return;
|
||||
}
|
||||
// 3. Tab panel — only meaningful when the page also shows ANOTHER
|
||||
// 3. Tab panel - only meaningful when the page also shows ANOTHER
|
||||
// tab as selected. A single tabpanel with no tablist is just a static
|
||||
// section in disguise and isn't conditional.
|
||||
if (node.getAttribute && node.getAttribute('role') === 'tabpanel') {
|
||||
@@ -4833,7 +4850,7 @@
|
||||
// Fire a lightweight prefetch event the first time the user selects an
|
||||
// element on a given route. The agent uses this to Read the underlying file
|
||||
// into context before Go is hit, shaving the read off the critical path.
|
||||
// Dedupe per session by pathname — clicking around on the same page doesn't
|
||||
// Dedupe per session by pathname - clicking around on the same page doesn't
|
||||
// re-fire.
|
||||
//
|
||||
// DISABLED: quick-Go workflows pay an extra harness round trip because
|
||||
@@ -4969,6 +4986,7 @@
|
||||
disableInlineEdit();
|
||||
stripManualEditRuntimeState(selectedElement);
|
||||
|
||||
pendingAcceptedSession = null;
|
||||
currentSessionId = id8();
|
||||
expectedVariants = selectedCount;
|
||||
arrivedVariants = 0;
|
||||
@@ -4976,7 +4994,7 @@
|
||||
|
||||
// Flip to GENERATING immediately so the bar morphs without waiting on
|
||||
// capture + upload. The event is emitted from captureAndEmit() once the
|
||||
// screenshot is uploaded (or capture fails — we still emit, just without
|
||||
// screenshot is uploaded (or capture fails - we still emit, just without
|
||||
// screenshotPath).
|
||||
const elForCapture = selectedElement;
|
||||
const captureRect = elForCapture.getBoundingClientRect();
|
||||
@@ -5002,7 +5020,7 @@
|
||||
state = 'GENERATING';
|
||||
// Disable the Edit badge: starting a manual text edit mid-generation would
|
||||
// conflict with the variant wrap that's about to land in the same DOM
|
||||
// region. Only swap if the badge was visible — picked elements with no
|
||||
// region. Only swap if the badge was visible - picked elements with no
|
||||
// text rows have it hidden already.
|
||||
if (editBadgeEl && editBadgeEl.style.display !== 'none') renderEditBadge('idle-disabled');
|
||||
showBar('generating');
|
||||
@@ -5104,7 +5122,7 @@
|
||||
|
||||
// Collect @font-face rules from every stylesheet on the page. Cross-origin
|
||||
// sheets (Google Fonts, Typekit, etc.) throw SecurityError on .cssRules
|
||||
// access, so modern-screenshot can't embed them on its own — the resulting
|
||||
// access, so modern-screenshot can't embed them on its own - the resulting
|
||||
// SVG falls back to system fonts and text re-wraps + renders with different
|
||||
// weight. We fetch the raw CSS text (CORS-permitted for these providers),
|
||||
// extract @font-face blocks, inline the referenced font files as base64
|
||||
@@ -5188,7 +5206,7 @@
|
||||
// modern-screenshot force-sets `background-color: X !important` on the
|
||||
// cloned root whenever `backgroundColor` is passed, clobbering the
|
||||
// element's own background. So we only pass it when the element is
|
||||
// genuinely transparent (no own color, no own image) — in that case
|
||||
// genuinely transparent (no own color, no own image) - in that case
|
||||
// we resolve up the DOM to the nearest opaque ancestor so the capture
|
||||
// sits on the page's real background instead of rendering black.
|
||||
function resolveCanvasBackground(el) {
|
||||
@@ -5206,7 +5224,7 @@
|
||||
// `getComputedStyle(body).backgroundColor || …` chain is a trap: that
|
||||
// call returns the literal string `"rgba(0, 0, 0, 0)"` for a page that
|
||||
// never set its own bg, which is truthy and short-circuits the chain to
|
||||
// transparent-black — modern-screenshot then renders the capture on a
|
||||
// transparent-black - modern-screenshot then renders the capture on a
|
||||
// black canvas and the shader overlay flashes solid black during load.
|
||||
// The browser canvas defaults to white, so we do too.
|
||||
return '#ffffff';
|
||||
@@ -5248,7 +5266,7 @@
|
||||
// Transparent up to the root. The visible backdrop may still come from an
|
||||
// ancestor's background-image or a covering positioned layer (e.g. a hero
|
||||
// art div) that the color walk can't see. Capture that ancestor and crop
|
||||
// to the element so the real backdrop is embedded — correct for both the
|
||||
// to the element so the real backdrop is embedded - correct for both the
|
||||
// shader and the screenshot sent to the model. Fall back to white only
|
||||
// when nothing is actually painted behind the element.
|
||||
const backdrop = findBackdropAncestor(el);
|
||||
@@ -5289,13 +5307,13 @@
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] capture failed, proceeding without screenshot:', err);
|
||||
}
|
||||
// Light up the shader overlay the moment capture is ready — no reason to
|
||||
// Light up the shader overlay the moment capture is ready - no reason to
|
||||
// wait for the upload to complete before the user sees something alive.
|
||||
if (blob && state === 'GENERATING') {
|
||||
showShaderOverlay(el, blob, rect, paper);
|
||||
}
|
||||
// Only upload + forward the screenshot when annotations (comments/strokes)
|
||||
// are present. Without annotations the image is pure visual anchoring —
|
||||
// are present. Without annotations the image is pure visual anchoring -
|
||||
// it biases the model toward the current rendering and works against the
|
||||
// three-distinct-directions brief.
|
||||
const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0);
|
||||
@@ -5320,7 +5338,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shader overlay — renders the captured screenshot as a WebGL texture and
|
||||
// Shader overlay - renders the captured screenshot as a WebGL texture and
|
||||
// runs an editorial "ink-wash" fragment shader over it during generation.
|
||||
// A single rolling band sweeps top-to-bottom, desaturating + tinting kinpaku
|
||||
// and leaving a soft trail. Makes the wait feel like a letterpress scan
|
||||
@@ -5343,7 +5361,7 @@ uniform vec3 u_accent;
|
||||
uniform vec3 u_paper;
|
||||
varying vec2 v_uv;
|
||||
|
||||
// Asymmetric roller band. Product of two one-sided smoothsteps — peaks at
|
||||
// Asymmetric roller band. Product of two one-sided smoothsteps - peaks at
|
||||
// d=0 with a short sharp leading ramp and a longer soft trailing tail. Clean
|
||||
// outside the [-leadW, trailW] range (no rogue "trail=1 everywhere below"
|
||||
// failure that reversed-edge smoothstep would give).
|
||||
@@ -5369,8 +5387,8 @@ void main() {
|
||||
vec2 sampleCenter = (cellId + 0.5) * cellPx / u_resolution;
|
||||
vec3 cellImg = texture2D(u_texture, sampleCenter).rgb;
|
||||
// Dot size tracks how much the cell DIFFERS from the element's own ground
|
||||
// (u_paper), not absolute darkness. So the content — text, buttons, anything
|
||||
// that deviates from the background — always becomes the dots, on light AND
|
||||
// (u_paper), not absolute darkness. So the content - text, buttons, anything
|
||||
// that deviates from the background - always becomes the dots, on light AND
|
||||
// dark surfaces. A plain darkness curve inverts on dark elements: the dark
|
||||
// background fills with ink and the lighter content punches holes instead.
|
||||
// Capped below the cell half-width so dense content stays separated dots.
|
||||
@@ -5380,8 +5398,8 @@ void main() {
|
||||
// Two-stage dissolve as the roller passes, so the element is rebuilt purely
|
||||
// from dot size (its own halftone) and never bleeds through as raw pixels
|
||||
// behind the dots:
|
||||
// 1. cover — the element flattens to the uniform paper ground first.
|
||||
// 2. dotAmt — kinpaku dots then emerge, sized by each cell's luma.
|
||||
// 1. cover - the element flattens to the uniform paper ground first.
|
||||
// 2. dotAmt - kinpaku dots then emerge, sized by each cell's luma.
|
||||
// A plain mix(base, halftone, band) instead left the raw element visible
|
||||
// through the band's soft core/trail. The paper ground is u_paper (the
|
||||
// element's own bg tone) rather than a fixed white, so the dissolve reads the
|
||||
@@ -5399,7 +5417,7 @@ void main() {
|
||||
|
||||
// Kinpaku gold converted to approximate sRGB 0-1 (matches oklch(84% 0.19 80.46))
|
||||
const SHADER_ACCENT = [1.0, 0.78, 0.31];
|
||||
// Fallback ground when an element and all its ancestors are transparent —
|
||||
// Fallback ground when an element and all its ancestors are transparent -
|
||||
// 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 }
|
||||
@@ -5412,7 +5430,7 @@ void main() {
|
||||
// Rasterize any CSS color (oklch, color(), named, hex, rgb) through a 1x1
|
||||
// canvas and read back the sRGB pixel. String-parsing computed colors is a
|
||||
// trap: Chrome returns backgroundColor as oklch()/color() for oklch inputs,
|
||||
// which a hex/rgb regex misses — every site token would fall back to white.
|
||||
// which a hex/rgb regex misses - every site token would fall back to white.
|
||||
let colorParseCtx = null;
|
||||
function cssColorToRgb01(str) {
|
||||
if (!colorParseCtx) {
|
||||
@@ -5440,7 +5458,7 @@ void main() {
|
||||
|
||||
// When an element is transparent up to the root, its visible backdrop can
|
||||
// still come from an ancestor's background-image or a covering positioned
|
||||
// layer that is a *child* of an ancestor (e.g. a hero's absolute art div) —
|
||||
// layer that is a *child* of an ancestor (e.g. a hero's absolute art div) -
|
||||
// neither of which the ancestor background-COLOR walk can see. Return the
|
||||
// nearest such ancestor so we can capture it and crop, embedding the real
|
||||
// backdrop. Returns null when nothing is actually painted behind the element
|
||||
@@ -5481,7 +5499,7 @@ void main() {
|
||||
|
||||
// Average the backdrop sampled just OUTSIDE an element's rect within a larger
|
||||
// canvas. The ground tone for the dissolve must be the real backdrop, not the
|
||||
// mean of the element's own crop — averaging the crop folds in the element's
|
||||
// mean of the element's own crop - averaging the crop folds in the element's
|
||||
// content (e.g. bright heading text), pulling the ground toward muddy gray.
|
||||
function sampleSurroundingRgb(ctx, sx, sy, sw, sh, W, H) {
|
||||
const pad = Math.max(2, Math.round(Math.min(sw, sh) * 0.12));
|
||||
@@ -5528,11 +5546,31 @@ void main() {
|
||||
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;
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
canvas.remove();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const fallback = document.createElement('div');
|
||||
fallback.id = PREFIX + '-shader';
|
||||
// Copy positioning via cssText. Object.assign across CSSStyleDeclaration
|
||||
// throws in modern Chromium because the source's indexed properties
|
||||
// (style[0], [1], ...) are read-only and the engine forbids writing
|
||||
// them on the destination.
|
||||
fallback.style.cssText = canvas.style.cssText;
|
||||
fallback.style.backgroundImage = 'url("' + objectUrl + '")';
|
||||
fallback.style.backgroundSize = '100% 100%';
|
||||
fallback.style.backgroundRepeat = 'no-repeat';
|
||||
fallback.style.outline = '2px dashed ' + C.brand;
|
||||
fallback.style.outlineOffset = '-2px';
|
||||
document.body.appendChild(fallback);
|
||||
shaderState = { canvas: fallback, gl: null, program: null, texture: null, rafId: 0, startTime: 0, objectUrl };
|
||||
}
|
||||
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
@@ -5553,21 +5591,9 @@ void main() {
|
||||
const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false })
|
||||
|| canvas.getContext('experimental-webgl');
|
||||
if (!gl) {
|
||||
// WebGL unavailable — fall back to a plain <img> overlay so the user
|
||||
// still sees something meaningful during generation.
|
||||
canvas.remove();
|
||||
const img = document.createElement('img');
|
||||
img.src = URL.createObjectURL(blob);
|
||||
img.id = PREFIX + '-shader';
|
||||
// Copy positioning via cssText. Object.assign across CSSStyleDeclaration
|
||||
// throws in modern Chromium because the source's indexed properties
|
||||
// (style[0], [1], ...) are read-only and the engine forbids writing
|
||||
// them on the destination.
|
||||
img.style.cssText = canvas.style.cssText;
|
||||
img.style.outline = '2px dashed ' + C.brand;
|
||||
img.style.outlineOffset = '-2px';
|
||||
document.body.appendChild(img);
|
||||
shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 };
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5609,14 +5635,12 @@ void main() {
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch {
|
||||
// Safari fallback: go via a regular Image
|
||||
const imgUrl = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
img.src = imgUrl;
|
||||
await new Promise((r, rej) => { img.onload = r; img.onerror = rej; });
|
||||
bitmap = img;
|
||||
URL.revokeObjectURL(imgUrl);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
@@ -5670,14 +5694,15 @@ void main() {
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
// The accepted variant is already the only visible child of the wrapper
|
||||
// (all other variants are display:none). HMR from the source rewrite will
|
||||
// replace the wrapper imminently. Don't eagerly replaceChild here — React
|
||||
// reconciliation races with our mutation and throws NotFoundError in Next
|
||||
// 16 / Turbopack. Schedule a fallback that runs the manual swap only if
|
||||
// HMR hasn't cleaned up by then (keeps static-server flows working).
|
||||
const acceptedSessionId = currentSessionId;
|
||||
const acceptedVariant = visibleVariant;
|
||||
const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant);
|
||||
pendingAcceptedSession = {
|
||||
id: acceptedSessionId,
|
||||
variant: String(acceptedVariant),
|
||||
...acceptedSnapshot,
|
||||
finalizing: false,
|
||||
};
|
||||
|
||||
state = 'SAVING';
|
||||
updateBarContent('saving');
|
||||
@@ -5685,53 +5710,130 @@ void main() {
|
||||
sendEvent(acceptPayload, { throwOnError: true })
|
||||
.then(() => {
|
||||
markSessionHandled();
|
||||
confirmAcceptAfterReceipt();
|
||||
})
|
||||
.catch(() => {
|
||||
pendingAcceptedSession = null;
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
}
|
||||
|
||||
function confirmAcceptAfterReceipt() {
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
scheduleAcceptCleanup();
|
||||
function maybeCompleteAcceptedSession(msg) {
|
||||
const pending = pendingAcceptedSession;
|
||||
if (!pending || !msg?.id || msg.id !== pending.id) return false;
|
||||
if (currentSessionId && currentSessionId !== pending.id) {
|
||||
pendingAcceptedSession = null;
|
||||
return false;
|
||||
}
|
||||
if (pending.finalizing) return true;
|
||||
pending.finalizing = true;
|
||||
|
||||
function scheduleAcceptCleanup() {
|
||||
setTimeout(function() {
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
selectedElement = null;
|
||||
currentSessionId = null;
|
||||
selectedAction = 'impeccable';
|
||||
renderEditBadge('hidden');
|
||||
state = 'PICKING';
|
||||
}, 1800);
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
|
||||
// Static-server / no-HMR fallback: if the wrapper is still around 2s after
|
||||
// the cleanup above, swap it out manually. By now React has either moved
|
||||
// on or the app isn't React at all. Preserve the `data-impeccable-variant="N"`
|
||||
// div (with display:contents) so @scope rules anchored to the variant
|
||||
// attribute keep matching until reload replaces it with the carbonize block.
|
||||
// Give framework HMR a short chance to render the now-clean accepted
|
||||
// source. If it misses the update, unwrap the accepted variant after the
|
||||
// source-side completion event so the page is not left empty or stale.
|
||||
setTimeout(function() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + acceptedSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + acceptedVariant + '"]');
|
||||
if (accepted && accepted.firstElementChild) {
|
||||
const parent = wrapper.parentElement;
|
||||
if (!parent) return;
|
||||
accepted.style.display = 'contents';
|
||||
parent.replaceChild(accepted, wrapper);
|
||||
}
|
||||
}, 2000);
|
||||
ensureAcceptedDomClean(pending);
|
||||
cleanupAcceptedSession();
|
||||
}, 1200);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
acceptedHtml: accepted ? accepted.innerHTML : '',
|
||||
acceptedSelector: selectorForAcceptedRoot(root),
|
||||
parentElement: wrapper?.parentElement || null,
|
||||
parentSelector: selectorForAcceptedRoot(wrapper?.parentElement || null),
|
||||
nextSibling: wrapper?.nextSibling || null,
|
||||
};
|
||||
}
|
||||
|
||||
function selectorForAcceptedRoot(root) {
|
||||
if (!root || !root.tagName) return '';
|
||||
const tag = root.tagName.toLowerCase();
|
||||
const classes = [...(root.classList || [])].filter(Boolean);
|
||||
if (classes.length === 0) return tag;
|
||||
return tag + classes.map((cls) => '.' + cssIdent(cls)).join('');
|
||||
}
|
||||
|
||||
function acceptedDomAlreadyClean(pending) {
|
||||
if (!pending?.acceptedSelector) return false;
|
||||
const matches = [...document.querySelectorAll(pending.acceptedSelector)];
|
||||
return matches.some((el) => !el.closest('[data-impeccable-variants],[data-impeccable-variant]'));
|
||||
}
|
||||
|
||||
function ensureAcceptedDomClean(pending) {
|
||||
const sessionId = pending?.id;
|
||||
const variantId = pending?.variant;
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!wrapper) {
|
||||
restoreAcceptedDomFromSnapshot(pending);
|
||||
return;
|
||||
}
|
||||
if (!accepted) {
|
||||
wrapper.remove();
|
||||
restoreAcceptedDomFromSnapshot(pending);
|
||||
return;
|
||||
}
|
||||
const parent = wrapper.parentElement;
|
||||
if (!parent) return;
|
||||
while (accepted.firstChild) {
|
||||
parent.insertBefore(accepted.firstChild, wrapper);
|
||||
}
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
function restoreAcceptedDomFromSnapshot(pending) {
|
||||
if (acceptedDomAlreadyClean(pending)) return;
|
||||
if (!pending?.acceptedHtml) {
|
||||
reloadAfterMissingAcceptedDom(pending);
|
||||
return;
|
||||
}
|
||||
const parent = pending.parentElement?.isConnected
|
||||
? pending.parentElement
|
||||
: (pending.parentSelector ? document.querySelector(pending.parentSelector) : null);
|
||||
if (!parent) {
|
||||
reloadAfterMissingAcceptedDom(pending);
|
||||
return;
|
||||
}
|
||||
const template = document.createElement('template');
|
||||
template.innerHTML = pending.acceptedHtml;
|
||||
const anchor = pending.nextSibling?.isConnected && pending.nextSibling.parentElement === parent
|
||||
? pending.nextSibling
|
||||
: null;
|
||||
parent.insertBefore(template.content, anchor);
|
||||
if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending);
|
||||
}
|
||||
|
||||
function reloadAfterMissingAcceptedDom(pending) {
|
||||
if (acceptedDomAlreadyClean(pending)) return;
|
||||
if (pending?.id && document.querySelector('[data-impeccable-variants="' + pending.id + '"]')) return;
|
||||
location.reload();
|
||||
}
|
||||
|
||||
function cleanupAcceptedSession() {
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
selectedElement = null;
|
||||
currentSessionId = null;
|
||||
selectedAction = 'impeccable';
|
||||
pendingAcceptedSession = null;
|
||||
renderEditBadge('hidden');
|
||||
state = 'PICKING';
|
||||
}
|
||||
|
||||
function handleDiscard() {
|
||||
@@ -5792,7 +5894,7 @@ void main() {
|
||||
|
||||
function cleanup() {
|
||||
// Hide the wrapper immediately so variants disappear. DON'T structurally
|
||||
// mutate the DOM yet — HMR from the agent's source rewrite is on its way,
|
||||
// mutate the DOM yet - HMR from the agent's source rewrite is on its way,
|
||||
// and a manual replaceChild under React causes NotFoundError when the
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
@@ -5838,8 +5940,8 @@ void main() {
|
||||
function showToast(message, duration) {
|
||||
if (toastEl) toastEl.remove();
|
||||
// Stack the toast above the global bar (which sits at bottom:14px) so
|
||||
// the two never overlap. Read the bar's actual rect — its height varies
|
||||
// with hover-expanded labels — and fall back to a sensible default
|
||||
// the two never overlap. Read the bar's actual rect - its height varies
|
||||
// with hover-expanded labels - and fall back to a sensible default
|
||||
// when the bar isn't mounted yet.
|
||||
const barRect = globalBarEl?.getBoundingClientRect();
|
||||
const barTopFromBottom = barRect && barRect.height > 0
|
||||
@@ -6047,7 +6149,7 @@ void main() {
|
||||
let pendingApplyInFlight = false;
|
||||
let firstSaveOfSession = true;
|
||||
|
||||
// Steer — collapsed pill in the global bar; expands while typing for page-level chat.
|
||||
// Steer - collapsed pill in the global bar; expands while typing for page-level chat.
|
||||
let pageChatEl = null;
|
||||
let pageChatInput = null;
|
||||
let pageChatHint = null;
|
||||
@@ -6068,7 +6170,7 @@ void main() {
|
||||
const STEER_AWAIT_TIMEOUT_MS = 120000;
|
||||
const AGENT_STATUS_POLL_MS = 5000;
|
||||
const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)';
|
||||
const AGENT_DISCONNECTED_TIP = 'Agent disconnected — run live-poll.mjs to connect';
|
||||
const AGENT_DISCONNECTED_TIP = 'Agent disconnected: run live-poll.mjs to connect';
|
||||
const GLOBAL_BAR_SECTION_GAP = 8;
|
||||
const GLOBAL_BAR_INNER_GAP = 2;
|
||||
const GLOBAL_BAR_INNER_PAD_LEFT = 2;
|
||||
@@ -6079,7 +6181,7 @@ void main() {
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>';
|
||||
|
||||
// Theme-aware color palette for the global bar. We detect the page's
|
||||
// ambient background and invert — dark bar on light pages, light bar on
|
||||
// ambient background and invert - dark bar on light pages, light bar on
|
||||
// dark pages. This keeps the bar from fighting with the host design.
|
||||
function detectPageTheme() {
|
||||
try {
|
||||
@@ -6092,7 +6194,7 @@ void main() {
|
||||
// Walk body → html, taking the first opaque background. The browser's
|
||||
// default body / html background is `rgba(0, 0, 0, 0)`, which a naive
|
||||
// regex would read as black and mislabel a perfectly white page as
|
||||
// dark. Honoring alpha avoids that — and falling through to <html>
|
||||
// dark. Honoring alpha avoids that - and falling through to <html>
|
||||
// catches the common pattern of a bg only on <html> (or only on body).
|
||||
function readOpaque(el) {
|
||||
if (!el) return null;
|
||||
@@ -6141,7 +6243,7 @@ void main() {
|
||||
exitHover: 'oklch(58% 0.15 35 / 0.18)',
|
||||
shadow: PICKER_SHADOW,
|
||||
chatSurface: 'oklch(22% 0.012 82)',
|
||||
// Verdigris patina — secondary state (see site/styles/kinpaku-tokens.css)
|
||||
// Verdigris patina - secondary state (see site/styles/kinpaku-tokens.css)
|
||||
patina: 'oklch(70% 0.12 188)',
|
||||
patinaPale: 'oklch(82% 0.07 188)',
|
||||
patinaSoft: 'oklch(70% 0.12 188 / 0.28)',
|
||||
@@ -6913,7 +7015,7 @@ void main() {
|
||||
steerFocusLog('page-chat-mounted', {});
|
||||
}
|
||||
|
||||
// Impeccable mark — same paths as site/components/Header.astro + favicon.svg.
|
||||
// Impeccable mark - same paths as site/components/Header.astro + favicon.svg.
|
||||
function brandMarkSvg(color = C.brand, size = 18) {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="${color}" aria-hidden="true">
|
||||
<path d="M5 2.5 L13.5 2.5 L5.5 21.5 L5 21.5 Q2.5 21.5 2.5 19 L2.5 5 Q2.5 2.5 5 2.5 Z"/>
|
||||
@@ -6928,7 +7030,7 @@ void main() {
|
||||
globalBarBrandEl.dataset.agentConnected = connected ? 'true' : 'false';
|
||||
globalBarBrandEl.setAttribute('aria-label', connected
|
||||
? 'Impeccable live mode'
|
||||
: 'Impeccable live mode — agent not polling');
|
||||
: 'Impeccable live mode: agent not polling');
|
||||
globalBarBrandEl.removeAttribute('title');
|
||||
globalBarBrandEl.style.cursor = connected ? 'default' : 'help';
|
||||
const mark = globalBarBrandEl.querySelector('[data-brand-mark]');
|
||||
@@ -7053,7 +7155,7 @@ void main() {
|
||||
globalBarEl.id = PREFIX + '-global-bar';
|
||||
globalBarEl.dataset.theme = theme;
|
||||
|
||||
// Brand mark — kinpaku Impeccable icon (site header / favicon paths).
|
||||
// Brand mark - kinpaku Impeccable icon (site header / favicon paths).
|
||||
const brand = el('span', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
alignSelf: 'stretch', position: 'relative',
|
||||
@@ -7065,7 +7167,7 @@ void main() {
|
||||
brand.id = PREFIX + '-global-bar-brand';
|
||||
brand.dataset.agentConnected = 'false';
|
||||
brand.setAttribute('role', 'img');
|
||||
brand.setAttribute('aria-label', 'Impeccable live mode — agent not polling');
|
||||
brand.setAttribute('aria-label', 'Impeccable live mode: agent not polling');
|
||||
|
||||
const brandMark = el('span', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
@@ -7130,7 +7232,7 @@ void main() {
|
||||
// Per-button hover only changes color (no layout). The label expand/
|
||||
// collapse is driven by the bar-level mouseenter/mouseleave so moving
|
||||
// the mouse between adjacent buttons doesn't trigger per-button width
|
||||
// thrashing — the whole bar grows once and shrinks once.
|
||||
// thrashing - the whole bar grows once and shrinks once.
|
||||
b.addEventListener('mouseenter', () => { if (b.dataset.active !== 'true') b.style.color = P.text; });
|
||||
b.addEventListener('mouseleave', () => { if (b.dataset.active !== 'true') b.style.color = P.textDim; });
|
||||
b.addEventListener('click', onClick);
|
||||
@@ -7139,7 +7241,7 @@ void main() {
|
||||
return b;
|
||||
}
|
||||
|
||||
// Pick toggle — restored from localStorage; both pick and insert may be off.
|
||||
// Pick toggle - restored from localStorage; both pick and insert may be off.
|
||||
const pickBtn = makeIconBtn({
|
||||
id: PREFIX + '-pick-toggle',
|
||||
svg: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><circle cx="12" cy="12" r="10"/><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><line x1="12" y1="6" x2="12" y2="2"/><line x1="12" y1="22" x2="12" y2="18"/></svg>',
|
||||
@@ -7176,7 +7278,7 @@ void main() {
|
||||
detectBtn.appendChild(detectBadge);
|
||||
inner.appendChild(detectBtn);
|
||||
|
||||
// DESIGN.md panel toggle — quartet of color squares as the mark.
|
||||
// DESIGN.md panel toggle - quartet of color squares as the mark.
|
||||
const designBtn = makeIconBtn({
|
||||
id: PREFIX + '-design-toggle',
|
||||
svg: `<span style="display:inline-grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;width:14px;height:14px;border-radius:3px;overflow:hidden;box-shadow:inset 0 0 0 1px oklch(58% 0.065 82 / 0.55);flex-shrink:0">
|
||||
@@ -7378,13 +7480,13 @@ void main() {
|
||||
});
|
||||
inner.appendChild(divider);
|
||||
|
||||
// Exit × on the right — intentionally subtle (textDim at rest, text on
|
||||
// Exit × on the right - intentionally subtle (textDim at rest, text on
|
||||
// hover) so it sits behind the active toggles in visual hierarchy.
|
||||
//
|
||||
// Explicit padding + box-sizing here is load-bearing: a host page like
|
||||
// `button { padding: 0.5rem 1rem; }` (very common in resets) would
|
||||
// otherwise inflate this 24x24 button into 56x40 and push the SVG out
|
||||
// of the visible bar — the X stays invisible even though the styles in
|
||||
// of the visible bar - the X stays invisible even though the styles in
|
||||
// DevTools look fine. Every other chrome button sets padding inline;
|
||||
// this one needed it too.
|
||||
const exitBtn = el('button', {
|
||||
@@ -7472,7 +7574,7 @@ void main() {
|
||||
btn.style.opacity = controlsLocked ? '0.55' : '1';
|
||||
});
|
||||
|
||||
// If the bar is currently under the cursor, keep all labels expanded —
|
||||
// If the bar is currently under the cursor, keep all labels expanded -
|
||||
// otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
|
||||
// would collapse its label while the user's mouse is still on the bar.
|
||||
if (globalBarEl && globalBarEl.matches(':hover')) {
|
||||
@@ -7648,7 +7750,7 @@ void main() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Design System Panel — visualizes the project's .impeccable/design.json sidecar
|
||||
// Design System Panel - visualizes the project's .impeccable/design.json sidecar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DESIGN_PREFS_KEY = 'impeccable-live-design-panel';
|
||||
@@ -7674,7 +7776,7 @@ void main() {
|
||||
};
|
||||
|
||||
function loadDesignPrefs() {
|
||||
// `open` is intentionally NOT persisted — the panel always starts closed
|
||||
// `open` is intentionally NOT persisted - the panel always starts closed
|
||||
// so live mode doesn't auto-slide a big panel over the page on startup.
|
||||
try {
|
||||
const raw = localStorage.getItem(DESIGN_PREFS_KEY);
|
||||
@@ -7731,7 +7833,7 @@ void main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Neutral panel palette — deliberately NOT Impeccable-branded. The panel is
|
||||
// Neutral panel palette - deliberately NOT Impeccable-branded. The panel is
|
||||
// a viewer of the project's design system, not an Impeccable surface.
|
||||
const DP = {
|
||||
canvas: 'oklch(94% 0 0)', // panel background
|
||||
@@ -8025,7 +8127,7 @@ void main() {
|
||||
const root = designShadow.querySelector('.root');
|
||||
root.innerHTML = '';
|
||||
|
||||
// (Panel toggle lives in the global bar — no floating FAB.)
|
||||
// (Panel toggle lives in the global bar - no floating FAB.)
|
||||
// Panel
|
||||
const panel = document.createElement('aside');
|
||||
panel.className = 'panel';
|
||||
@@ -8141,7 +8243,7 @@ void main() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Visual tab — single unified render path.
|
||||
// Visual tab - single unified render path.
|
||||
if (designState.mdNewerThanJson) body.appendChild(renderStaleHint());
|
||||
if (designState.hasMd && !designState.hasSidecar) {
|
||||
body.appendChild(renderParsedMdCta());
|
||||
@@ -8357,7 +8459,7 @@ void main() {
|
||||
specimen.style.fontFamily = fontStack(t);
|
||||
specimen.style.fontWeight = String(t.weight || 400);
|
||||
specimen.style.fontStyle = t.style || 'normal';
|
||||
specimen.style.fontSize = '56px'; // Fixed specimen size — compare faces, not scales.
|
||||
specimen.style.fontSize = '56px'; // Fixed specimen size - compare faces, not scales.
|
||||
specimen.style.letterSpacing = 'normal';
|
||||
specimen.style.textTransform = 'none';
|
||||
tile.appendChild(specimen);
|
||||
@@ -8501,7 +8603,7 @@ void main() {
|
||||
}
|
||||
|
||||
// Single shared description if all items carry the same one; otherwise
|
||||
// skip — per-item descriptions clutter a grouped tile.
|
||||
// skip - per-item descriptions clutter a grouped tile.
|
||||
if (group.length === 1 && group[0].description) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'c-desc';
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Impeccable Live Variant Mode — Browser Script
|
||||
* Impeccable Live Variant Mode - Browser Script
|
||||
*
|
||||
* Injected into the user's page via <script src="http://localhost:PORT/live.js">.
|
||||
* The server prepends window.__IMPECCABLE_TOKEN__ and window.__IMPECCABLE_PORT__
|
||||
* before this code.
|
||||
*
|
||||
* UI: a single floating bar that morphs between three states —
|
||||
* UI: a single floating bar that morphs between three states -
|
||||
* configure (pick action + go), generating (progressive dots), and cycling
|
||||
* (prev/next + accept/discard). Feels like Spotlight, not a modal.
|
||||
*/
|
||||
@@ -46,7 +46,7 @@
|
||||
mist: 'oklch(90% 0.008 82 / 0.6)', // light hairline
|
||||
white: 'oklch(99% 0 0)',
|
||||
};
|
||||
// Picker bar chrome — mirrors .live-demo-gbar / .live-demo-ctx in kinpaku-kit.css.
|
||||
// Picker bar chrome - mirrors .live-demo-gbar / .live-demo-ctx in kinpaku-kit.css.
|
||||
// Quiet neutral elevation: no gold halo ring (gold is reserved for the brand
|
||||
// mark and the active control, not the container outline).
|
||||
const PICKER_SHADOW =
|
||||
@@ -83,7 +83,7 @@
|
||||
|
||||
// SVG icons stack above each chip label. All strokes use currentColor so the
|
||||
// icon recolors to C.brand when its chip is selected. 20x20 render, 24-viewBox,
|
||||
// 1.5 stroke — visually consistent with the Foundation grid on the homepage.
|
||||
// 1.5 stroke - visually consistent with the Foundation grid on the homepage.
|
||||
const ICON_ATTRS = 'width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="display:block"';
|
||||
const ICONS = {
|
||||
impeccable: `<svg ${ICON_ATTRS}><path d="M4 20l4-1L18 9l-3-3L5 16z"/><path d="M14 7l3 3"/></svg>`,
|
||||
@@ -123,6 +123,7 @@
|
||||
let hoveredElement = null;
|
||||
let selectedElement = null;
|
||||
let currentSessionId = null;
|
||||
let pendingAcceptedSession = null;
|
||||
let expectedVariants = 0;
|
||||
let arrivedVariants = 0;
|
||||
let visibleVariant = 0;
|
||||
@@ -133,7 +134,7 @@
|
||||
const browserOwner = sessionState.owner;
|
||||
let checkpointTimer = null;
|
||||
|
||||
// Scroll lock — holds window.scrollY at a fixed value while the session is
|
||||
// Scroll lock - holds window.scrollY at a fixed value while the session is
|
||||
// active, so HMR DOM patches and variant swaps can't drift the page. See
|
||||
// startScrollLock / stopScrollLock below.
|
||||
let scrollLockObserver = null;
|
||||
@@ -141,7 +142,7 @@
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
// Dedicated key for scroll position — SEPARATE from LS_KEY so that
|
||||
// Dedicated key for scroll position - SEPARATE from LS_KEY so that
|
||||
// saveSession's state updates don't clobber a carefully-captured scrollY.
|
||||
// (Previously: saveSession wrote scrollY alongside state, so every call
|
||||
// during resume overwrote the pre-reload value with whatever the browser
|
||||
@@ -231,7 +232,7 @@
|
||||
// - Stop `focusin` propagation so any focus shifts inside our chrome
|
||||
// don't read as "focus moved outside the dialog" to focus traps.
|
||||
//
|
||||
// Click events still bubble normally — only the early pointer/focus
|
||||
// Click events still bubble normally - only the early pointer/focus
|
||||
// signals that drive outside-interaction detection are silenced.
|
||||
function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) {
|
||||
if (!rootEl) return;
|
||||
@@ -319,7 +320,7 @@
|
||||
// correlate directly with the captured PNG.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DRAG_THRESHOLD = 5; // px — below this, treat pointerup as a click
|
||||
const DRAG_THRESHOLD = 5; // px - below this, treat pointerup as a click
|
||||
const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it
|
||||
let annotOverlayEl = null;
|
||||
let annotSvgEl = null;
|
||||
@@ -423,7 +424,7 @@
|
||||
placeholderResizeDrag = null;
|
||||
if (annotOverlayEl) annotOverlayEl.style.display = 'none';
|
||||
syncPlaceholderResizeHandles();
|
||||
// Drop any in-progress edit without touching annotState — clearAnnotations
|
||||
// Drop any in-progress edit without touching annotState - clearAnnotations
|
||||
// (if the caller is exiting configure mode) handles state reset.
|
||||
annotEditing = null;
|
||||
}
|
||||
@@ -488,7 +489,7 @@
|
||||
function onAnnotDown(e) {
|
||||
if (!annotActive) return;
|
||||
|
||||
// 0) Insert placeholder edge resize — wins over draw / pins.
|
||||
// 0) Insert placeholder edge resize - wins over draw / pins.
|
||||
const resizeEdge = e.target.closest?.('[data-impeccable-placeholder-resize]')?.dataset.impeccablePlaceholderResize;
|
||||
if (resizeEdge && configureKind === 'insert' && placeholderElement) {
|
||||
startPlaceholderEdgeResize(resizeEdge, e);
|
||||
@@ -536,7 +537,7 @@
|
||||
// If editing a different pin, commit that edit before starting here.
|
||||
if (annotEditing && annotEditing.idx !== idx) finalizeEditingPin();
|
||||
// If already editing THIS pin and the user clicked the dot, let the
|
||||
// input keep focus (don't start a drag — the click wasn't meant as one).
|
||||
// input keep focus (don't start a drag - the click wasn't meant as one).
|
||||
if (annotEditing && annotEditing.idx === idx) return;
|
||||
const p = localCoords(e);
|
||||
const pin = annotState.comments[idx];
|
||||
@@ -971,7 +972,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The Bar — one floating element, three modes
|
||||
// The Bar - one floating element, three modes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Contextual-bar palette. Cached at init so every build*Row reads a
|
||||
@@ -1752,7 +1753,7 @@
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
});
|
||||
|
||||
// Action pill — dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill)
|
||||
// Action pill - dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill)
|
||||
const pill = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
||||
padding: '5px 10px', borderRadius: '6px',
|
||||
@@ -1786,7 +1787,7 @@
|
||||
});
|
||||
row.appendChild(pill);
|
||||
|
||||
// Prompt field — same chat-surface chrome as the bottom Steer bar
|
||||
// Prompt field - same chat-surface chrome as the bottom Steer bar
|
||||
const inputWrap = el('div', {
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
flex: '1', minWidth: '0', height: '28px',
|
||||
@@ -2126,7 +2127,7 @@
|
||||
if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
|
||||
row.appendChild(next);
|
||||
|
||||
// Tune chip — only when the visible variant exposes params
|
||||
// Tune chip - only when the visible variant exposes params
|
||||
const visParams = parseVariantParams(getVisibleVariantEl());
|
||||
const hasParams = visParams.length > 0;
|
||||
if (hasParams) {
|
||||
@@ -2173,7 +2174,7 @@
|
||||
// Spacer
|
||||
row.appendChild(el('div', { flex: '1' }));
|
||||
|
||||
// Accept — primary action, kinpaku gold + lacquer-deep (matches demo .live-demo-ctx-accept)
|
||||
// Accept - primary action, kinpaku gold + lacquer-deep (matches demo .live-demo-ctx-accept)
|
||||
const accept = el('button', {
|
||||
padding: '5px 14px', borderRadius: '5px',
|
||||
border: 'none', background: C.brand, color: C.ink,
|
||||
@@ -2266,7 +2267,7 @@
|
||||
const active = i === visibleVariant;
|
||||
// active: solid site-brand kinpaku dot. arrived+inactive: muted neutral.
|
||||
// pending (not yet arrived): faint outline ring. No borders on arrived
|
||||
// dots — the previous "accent ring + ash fill" combo read as noisy
|
||||
// dots - the previous "accent ring + ash fill" combo read as noisy
|
||||
// kinpaku chips, especially when all variants had arrived and every
|
||||
// dot wore an accent ring.
|
||||
const dotBg = active ? C.brand
|
||||
@@ -2456,7 +2457,7 @@
|
||||
let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide)
|
||||
let paramsPanelInner = null; // translating content (carries bg, padding, knobs)
|
||||
let paramsPanelBody = null; // grid holding the knob cells
|
||||
let paramsCurrentValues = {}; // {paramId: value} — mirror of the visible variant's live values
|
||||
let paramsCurrentValues = {}; // {paramId: value} - mirror of the visible variant's live values
|
||||
let tuneOpen = false; // whether the Tune popover is open right now
|
||||
|
||||
// Theme-aware Tune popover. Appears as a drawer that slides out from the
|
||||
@@ -2471,7 +2472,7 @@
|
||||
const P = paramsPanelPalette;
|
||||
|
||||
// Single element, always in the DOM. The slide animation is a CSS mask
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis — no
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis - no
|
||||
// display toggle, no opacity toggle, no transform trickery. The mask
|
||||
// hides everything initially; as it grows, content is revealed from
|
||||
// the bar edge outward.
|
||||
@@ -2494,7 +2495,7 @@
|
||||
transition: 'clip-path 0.44s ' + EASE,
|
||||
|
||||
// Park off-screen until positionParamsPanel places it. These are NOT
|
||||
// in the transition list, so they snap instantly — no fly-in from the
|
||||
// in the transition list, so they snap instantly - no fly-in from the
|
||||
// top-left when first shown.
|
||||
top: '-9999px', left: '-9999px', width: '0',
|
||||
});
|
||||
@@ -2685,7 +2686,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline text editing — makes pure-text descendants of the picked element
|
||||
// Inline text editing - makes pure-text descendants of the picked element
|
||||
// directly contenteditable. Save stages copy edits in the live buffer; the
|
||||
// Apply copy edits dock later asks the AI to apply the staged batch.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -3132,7 +3133,7 @@
|
||||
if (detail.includes('newText cannot contain') || detail.includes('newText cannot be empty')) {
|
||||
showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500);
|
||||
} else {
|
||||
showToast('Save failed — retry or cancel', 4000);
|
||||
showToast('Save failed: retry or cancel', 4000);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3454,19 +3455,19 @@
|
||||
updatePendingCounter(remaining);
|
||||
if (result.failed && result.failed.length > 0) {
|
||||
console.warn('[impeccable] some copy edits failed:', result.failed);
|
||||
showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed — see console', 5000);
|
||||
showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed, see console', 5000);
|
||||
} else {
|
||||
const n = Array.isArray(result.applied) ? result.applied.length : (result.cleared || 0);
|
||||
if (n > 0) {
|
||||
showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500);
|
||||
} else {
|
||||
console.warn('[impeccable] apply returned no verified edits:', result);
|
||||
showToast('No edits applied — see console', 4000);
|
||||
showToast('No edits applied, see console', 4000);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[impeccable] commit failed:', err);
|
||||
showToast('Apply failed — see console', 4000);
|
||||
showToast('Apply failed, see console', 4000);
|
||||
} finally {
|
||||
if (waitForSseCompletion) return;
|
||||
const remainingCount = parseInt(pendingPillEl?.dataset.count || '0', 10) || 0;
|
||||
@@ -3496,7 +3497,7 @@
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[impeccable] discard failed:', err);
|
||||
showToast('Discard failed — see console', 4000);
|
||||
showToast('Discard failed, see console', 4000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3644,7 +3645,7 @@
|
||||
const failedCount = numberOrNull(msg.failedCount) || 0;
|
||||
const appliedCount = numberOrNull(msg.appliedCount) || numberOrNull(msg.cleared) || 0;
|
||||
if (failedCount > 0) {
|
||||
showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed — see console', 5000);
|
||||
showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed, see console', 5000);
|
||||
} else if (appliedCount > 0) {
|
||||
showToast('Applied ' + appliedCount + ' edit' + (appliedCount === 1 ? '' : 's'), 2500);
|
||||
}
|
||||
@@ -3799,7 +3800,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit content badge — floating button at element top-right to enter EDITING mode
|
||||
// Edit content badge - floating button at element top-right to enter EDITING mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initEditBadge() {
|
||||
@@ -3884,7 +3885,7 @@
|
||||
}
|
||||
editBadgeEl.appendChild(btn);
|
||||
} else {
|
||||
// 'editing' — show Cancel + Save separated
|
||||
// 'editing' - show Cancel + Save separated
|
||||
editBadgeEl.innerHTML = '';
|
||||
editBadgeEl.style.gap = '8px';
|
||||
const cancel = document.createElement('button');
|
||||
@@ -4083,7 +4084,7 @@
|
||||
if (!v) continue;
|
||||
setVariantShown(child, v === String(num));
|
||||
}
|
||||
// Unconditional refresh — covers first-reveal (no-op if state isn't
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
// CYCLING yet, the subsequent CYCLING transition triggers its own
|
||||
// refresh) and every cycle step.
|
||||
refreshParamsPanel();
|
||||
@@ -4318,7 +4319,7 @@
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
}, { passive: true, ...sig });
|
||||
|
||||
// Apply target synchronously, not via rAF — racing the browser's
|
||||
// Apply target synchronously, not via rAF - racing the browser's
|
||||
// restore or a smooth-scroll animation means we want to win now.
|
||||
if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) {
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
@@ -4545,18 +4546,34 @@
|
||||
// hidden tab, a route the user navigated away from). The variant
|
||||
// MutationObserver stays armed and auto-transitions to CYCLING
|
||||
// the moment the wrapper actually mounts. Nudge the user toward
|
||||
// that path with a toast — better than the prior force-reload
|
||||
// that path with a toast - better than the prior force-reload
|
||||
// which reset framework state and left the session stuck.
|
||||
setTimeout(() => {
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) return;
|
||||
if (state !== 'GENERATING') return;
|
||||
showToast(
|
||||
"Variants ready. If the picked element isn't visible, retrace the path that revealed it — they'll appear automatically.",
|
||||
"Variants ready. If the picked element isn't visible, retrace the path that revealed it; they'll appear automatically.",
|
||||
15000,
|
||||
);
|
||||
}, 2000);
|
||||
break;
|
||||
case 'complete':
|
||||
case 'accept':
|
||||
if (maybeCompleteAcceptedSession(msg)) break;
|
||||
break;
|
||||
case 'agent_done':
|
||||
// Carbonize accepts are not terminal until live-complete.mjs sends
|
||||
// the final complete event. Keep the browser in its recoverable
|
||||
// saving state while the source cleanup is still in flight.
|
||||
break;
|
||||
case 'error':
|
||||
if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) {
|
||||
pendingAcceptedSession = null;
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
break;
|
||||
}
|
||||
if (maybeCompleteSteer(msg)) break;
|
||||
console.error('[impeccable] Error:', msg.message);
|
||||
showToast('Error: ' + msg.message, 5000);
|
||||
@@ -4779,14 +4796,14 @@
|
||||
|
||||
/**
|
||||
* Surface a brief, non-blocking heads-up when the picked element lives
|
||||
* inside a container whose visibility is gated by ephemeral state — modals,
|
||||
* inside a container whose visibility is gated by ephemeral state - modals,
|
||||
* collapsible panels, popovers, off-screen tab panels. If HMR remounts the
|
||||
* parent during generation (Vite Fast Refresh, SvelteKit page reload), the
|
||||
* variants land in source but stay invisible until the user re-opens the
|
||||
* container. Telling the user upfront is much friendlier than the silent
|
||||
* timeout-then-toast that they'd otherwise hit.
|
||||
*
|
||||
* Heuristic, intentionally narrow — only fires for unambiguous cases so
|
||||
* Heuristic, intentionally narrow - only fires for unambiguous cases so
|
||||
* we don't cry wolf on every nested element.
|
||||
*/
|
||||
function maybeWarnConditionalAncestor(el) {
|
||||
@@ -4804,7 +4821,7 @@
|
||||
showToast('Heads up: this element lives inside an open panel. If state resets during generation, you may need to re-open it.', 6000);
|
||||
return;
|
||||
}
|
||||
// 3. Tab panel — only meaningful when the page also shows ANOTHER
|
||||
// 3. Tab panel - only meaningful when the page also shows ANOTHER
|
||||
// tab as selected. A single tabpanel with no tablist is just a static
|
||||
// section in disguise and isn't conditional.
|
||||
if (node.getAttribute && node.getAttribute('role') === 'tabpanel') {
|
||||
@@ -4833,7 +4850,7 @@
|
||||
// Fire a lightweight prefetch event the first time the user selects an
|
||||
// element on a given route. The agent uses this to Read the underlying file
|
||||
// into context before Go is hit, shaving the read off the critical path.
|
||||
// Dedupe per session by pathname — clicking around on the same page doesn't
|
||||
// Dedupe per session by pathname - clicking around on the same page doesn't
|
||||
// re-fire.
|
||||
//
|
||||
// DISABLED: quick-Go workflows pay an extra harness round trip because
|
||||
@@ -4969,6 +4986,7 @@
|
||||
disableInlineEdit();
|
||||
stripManualEditRuntimeState(selectedElement);
|
||||
|
||||
pendingAcceptedSession = null;
|
||||
currentSessionId = id8();
|
||||
expectedVariants = selectedCount;
|
||||
arrivedVariants = 0;
|
||||
@@ -4976,7 +4994,7 @@
|
||||
|
||||
// Flip to GENERATING immediately so the bar morphs without waiting on
|
||||
// capture + upload. The event is emitted from captureAndEmit() once the
|
||||
// screenshot is uploaded (or capture fails — we still emit, just without
|
||||
// screenshot is uploaded (or capture fails - we still emit, just without
|
||||
// screenshotPath).
|
||||
const elForCapture = selectedElement;
|
||||
const captureRect = elForCapture.getBoundingClientRect();
|
||||
@@ -5002,7 +5020,7 @@
|
||||
state = 'GENERATING';
|
||||
// Disable the Edit badge: starting a manual text edit mid-generation would
|
||||
// conflict with the variant wrap that's about to land in the same DOM
|
||||
// region. Only swap if the badge was visible — picked elements with no
|
||||
// region. Only swap if the badge was visible - picked elements with no
|
||||
// text rows have it hidden already.
|
||||
if (editBadgeEl && editBadgeEl.style.display !== 'none') renderEditBadge('idle-disabled');
|
||||
showBar('generating');
|
||||
@@ -5104,7 +5122,7 @@
|
||||
|
||||
// Collect @font-face rules from every stylesheet on the page. Cross-origin
|
||||
// sheets (Google Fonts, Typekit, etc.) throw SecurityError on .cssRules
|
||||
// access, so modern-screenshot can't embed them on its own — the resulting
|
||||
// access, so modern-screenshot can't embed them on its own - the resulting
|
||||
// SVG falls back to system fonts and text re-wraps + renders with different
|
||||
// weight. We fetch the raw CSS text (CORS-permitted for these providers),
|
||||
// extract @font-face blocks, inline the referenced font files as base64
|
||||
@@ -5188,7 +5206,7 @@
|
||||
// modern-screenshot force-sets `background-color: X !important` on the
|
||||
// cloned root whenever `backgroundColor` is passed, clobbering the
|
||||
// element's own background. So we only pass it when the element is
|
||||
// genuinely transparent (no own color, no own image) — in that case
|
||||
// genuinely transparent (no own color, no own image) - in that case
|
||||
// we resolve up the DOM to the nearest opaque ancestor so the capture
|
||||
// sits on the page's real background instead of rendering black.
|
||||
function resolveCanvasBackground(el) {
|
||||
@@ -5206,7 +5224,7 @@
|
||||
// `getComputedStyle(body).backgroundColor || …` chain is a trap: that
|
||||
// call returns the literal string `"rgba(0, 0, 0, 0)"` for a page that
|
||||
// never set its own bg, which is truthy and short-circuits the chain to
|
||||
// transparent-black — modern-screenshot then renders the capture on a
|
||||
// transparent-black - modern-screenshot then renders the capture on a
|
||||
// black canvas and the shader overlay flashes solid black during load.
|
||||
// The browser canvas defaults to white, so we do too.
|
||||
return '#ffffff';
|
||||
@@ -5248,7 +5266,7 @@
|
||||
// Transparent up to the root. The visible backdrop may still come from an
|
||||
// ancestor's background-image or a covering positioned layer (e.g. a hero
|
||||
// art div) that the color walk can't see. Capture that ancestor and crop
|
||||
// to the element so the real backdrop is embedded — correct for both the
|
||||
// to the element so the real backdrop is embedded - correct for both the
|
||||
// shader and the screenshot sent to the model. Fall back to white only
|
||||
// when nothing is actually painted behind the element.
|
||||
const backdrop = findBackdropAncestor(el);
|
||||
@@ -5289,13 +5307,13 @@
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] capture failed, proceeding without screenshot:', err);
|
||||
}
|
||||
// Light up the shader overlay the moment capture is ready — no reason to
|
||||
// Light up the shader overlay the moment capture is ready - no reason to
|
||||
// wait for the upload to complete before the user sees something alive.
|
||||
if (blob && state === 'GENERATING') {
|
||||
showShaderOverlay(el, blob, rect, paper);
|
||||
}
|
||||
// Only upload + forward the screenshot when annotations (comments/strokes)
|
||||
// are present. Without annotations the image is pure visual anchoring —
|
||||
// are present. Without annotations the image is pure visual anchoring -
|
||||
// it biases the model toward the current rendering and works against the
|
||||
// three-distinct-directions brief.
|
||||
const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0);
|
||||
@@ -5320,7 +5338,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shader overlay — renders the captured screenshot as a WebGL texture and
|
||||
// Shader overlay - renders the captured screenshot as a WebGL texture and
|
||||
// runs an editorial "ink-wash" fragment shader over it during generation.
|
||||
// A single rolling band sweeps top-to-bottom, desaturating + tinting kinpaku
|
||||
// and leaving a soft trail. Makes the wait feel like a letterpress scan
|
||||
@@ -5343,7 +5361,7 @@ uniform vec3 u_accent;
|
||||
uniform vec3 u_paper;
|
||||
varying vec2 v_uv;
|
||||
|
||||
// Asymmetric roller band. Product of two one-sided smoothsteps — peaks at
|
||||
// Asymmetric roller band. Product of two one-sided smoothsteps - peaks at
|
||||
// d=0 with a short sharp leading ramp and a longer soft trailing tail. Clean
|
||||
// outside the [-leadW, trailW] range (no rogue "trail=1 everywhere below"
|
||||
// failure that reversed-edge smoothstep would give).
|
||||
@@ -5369,8 +5387,8 @@ void main() {
|
||||
vec2 sampleCenter = (cellId + 0.5) * cellPx / u_resolution;
|
||||
vec3 cellImg = texture2D(u_texture, sampleCenter).rgb;
|
||||
// Dot size tracks how much the cell DIFFERS from the element's own ground
|
||||
// (u_paper), not absolute darkness. So the content — text, buttons, anything
|
||||
// that deviates from the background — always becomes the dots, on light AND
|
||||
// (u_paper), not absolute darkness. So the content - text, buttons, anything
|
||||
// that deviates from the background - always becomes the dots, on light AND
|
||||
// dark surfaces. A plain darkness curve inverts on dark elements: the dark
|
||||
// background fills with ink and the lighter content punches holes instead.
|
||||
// Capped below the cell half-width so dense content stays separated dots.
|
||||
@@ -5380,8 +5398,8 @@ void main() {
|
||||
// Two-stage dissolve as the roller passes, so the element is rebuilt purely
|
||||
// from dot size (its own halftone) and never bleeds through as raw pixels
|
||||
// behind the dots:
|
||||
// 1. cover — the element flattens to the uniform paper ground first.
|
||||
// 2. dotAmt — kinpaku dots then emerge, sized by each cell's luma.
|
||||
// 1. cover - the element flattens to the uniform paper ground first.
|
||||
// 2. dotAmt - kinpaku dots then emerge, sized by each cell's luma.
|
||||
// A plain mix(base, halftone, band) instead left the raw element visible
|
||||
// through the band's soft core/trail. The paper ground is u_paper (the
|
||||
// element's own bg tone) rather than a fixed white, so the dissolve reads the
|
||||
@@ -5399,7 +5417,7 @@ void main() {
|
||||
|
||||
// Kinpaku gold converted to approximate sRGB 0-1 (matches oklch(84% 0.19 80.46))
|
||||
const SHADER_ACCENT = [1.0, 0.78, 0.31];
|
||||
// Fallback ground when an element and all its ancestors are transparent —
|
||||
// Fallback ground when an element and all its ancestors are transparent -
|
||||
// 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 }
|
||||
@@ -5412,7 +5430,7 @@ void main() {
|
||||
// Rasterize any CSS color (oklch, color(), named, hex, rgb) through a 1x1
|
||||
// canvas and read back the sRGB pixel. String-parsing computed colors is a
|
||||
// trap: Chrome returns backgroundColor as oklch()/color() for oklch inputs,
|
||||
// which a hex/rgb regex misses — every site token would fall back to white.
|
||||
// which a hex/rgb regex misses - every site token would fall back to white.
|
||||
let colorParseCtx = null;
|
||||
function cssColorToRgb01(str) {
|
||||
if (!colorParseCtx) {
|
||||
@@ -5440,7 +5458,7 @@ void main() {
|
||||
|
||||
// When an element is transparent up to the root, its visible backdrop can
|
||||
// still come from an ancestor's background-image or a covering positioned
|
||||
// layer that is a *child* of an ancestor (e.g. a hero's absolute art div) —
|
||||
// layer that is a *child* of an ancestor (e.g. a hero's absolute art div) -
|
||||
// neither of which the ancestor background-COLOR walk can see. Return the
|
||||
// nearest such ancestor so we can capture it and crop, embedding the real
|
||||
// backdrop. Returns null when nothing is actually painted behind the element
|
||||
@@ -5481,7 +5499,7 @@ void main() {
|
||||
|
||||
// Average the backdrop sampled just OUTSIDE an element's rect within a larger
|
||||
// canvas. The ground tone for the dissolve must be the real backdrop, not the
|
||||
// mean of the element's own crop — averaging the crop folds in the element's
|
||||
// mean of the element's own crop - averaging the crop folds in the element's
|
||||
// content (e.g. bright heading text), pulling the ground toward muddy gray.
|
||||
function sampleSurroundingRgb(ctx, sx, sy, sw, sh, W, H) {
|
||||
const pad = Math.max(2, Math.round(Math.min(sw, sh) * 0.12));
|
||||
@@ -5528,11 +5546,31 @@ void main() {
|
||||
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;
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
canvas.remove();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const fallback = document.createElement('div');
|
||||
fallback.id = PREFIX + '-shader';
|
||||
// Copy positioning via cssText. Object.assign across CSSStyleDeclaration
|
||||
// throws in modern Chromium because the source's indexed properties
|
||||
// (style[0], [1], ...) are read-only and the engine forbids writing
|
||||
// them on the destination.
|
||||
fallback.style.cssText = canvas.style.cssText;
|
||||
fallback.style.backgroundImage = 'url("' + objectUrl + '")';
|
||||
fallback.style.backgroundSize = '100% 100%';
|
||||
fallback.style.backgroundRepeat = 'no-repeat';
|
||||
fallback.style.outline = '2px dashed ' + C.brand;
|
||||
fallback.style.outlineOffset = '-2px';
|
||||
document.body.appendChild(fallback);
|
||||
shaderState = { canvas: fallback, gl: null, program: null, texture: null, rafId: 0, startTime: 0, objectUrl };
|
||||
}
|
||||
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
@@ -5553,21 +5591,9 @@ void main() {
|
||||
const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false })
|
||||
|| canvas.getContext('experimental-webgl');
|
||||
if (!gl) {
|
||||
// WebGL unavailable — fall back to a plain <img> overlay so the user
|
||||
// still sees something meaningful during generation.
|
||||
canvas.remove();
|
||||
const img = document.createElement('img');
|
||||
img.src = URL.createObjectURL(blob);
|
||||
img.id = PREFIX + '-shader';
|
||||
// Copy positioning via cssText. Object.assign across CSSStyleDeclaration
|
||||
// throws in modern Chromium because the source's indexed properties
|
||||
// (style[0], [1], ...) are read-only and the engine forbids writing
|
||||
// them on the destination.
|
||||
img.style.cssText = canvas.style.cssText;
|
||||
img.style.outline = '2px dashed ' + C.brand;
|
||||
img.style.outlineOffset = '-2px';
|
||||
document.body.appendChild(img);
|
||||
shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 };
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5609,14 +5635,12 @@ void main() {
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch {
|
||||
// Safari fallback: go via a regular Image
|
||||
const imgUrl = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
img.src = imgUrl;
|
||||
await new Promise((r, rej) => { img.onload = r; img.onerror = rej; });
|
||||
bitmap = img;
|
||||
URL.revokeObjectURL(imgUrl);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
@@ -5670,14 +5694,15 @@ void main() {
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
// The accepted variant is already the only visible child of the wrapper
|
||||
// (all other variants are display:none). HMR from the source rewrite will
|
||||
// replace the wrapper imminently. Don't eagerly replaceChild here — React
|
||||
// reconciliation races with our mutation and throws NotFoundError in Next
|
||||
// 16 / Turbopack. Schedule a fallback that runs the manual swap only if
|
||||
// HMR hasn't cleaned up by then (keeps static-server flows working).
|
||||
const acceptedSessionId = currentSessionId;
|
||||
const acceptedVariant = visibleVariant;
|
||||
const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant);
|
||||
pendingAcceptedSession = {
|
||||
id: acceptedSessionId,
|
||||
variant: String(acceptedVariant),
|
||||
...acceptedSnapshot,
|
||||
finalizing: false,
|
||||
};
|
||||
|
||||
state = 'SAVING';
|
||||
updateBarContent('saving');
|
||||
@@ -5685,53 +5710,130 @@ void main() {
|
||||
sendEvent(acceptPayload, { throwOnError: true })
|
||||
.then(() => {
|
||||
markSessionHandled();
|
||||
confirmAcceptAfterReceipt();
|
||||
})
|
||||
.catch(() => {
|
||||
pendingAcceptedSession = null;
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
}
|
||||
|
||||
function confirmAcceptAfterReceipt() {
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
scheduleAcceptCleanup();
|
||||
function maybeCompleteAcceptedSession(msg) {
|
||||
const pending = pendingAcceptedSession;
|
||||
if (!pending || !msg?.id || msg.id !== pending.id) return false;
|
||||
if (currentSessionId && currentSessionId !== pending.id) {
|
||||
pendingAcceptedSession = null;
|
||||
return false;
|
||||
}
|
||||
if (pending.finalizing) return true;
|
||||
pending.finalizing = true;
|
||||
|
||||
function scheduleAcceptCleanup() {
|
||||
setTimeout(function() {
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
selectedElement = null;
|
||||
currentSessionId = null;
|
||||
selectedAction = 'impeccable';
|
||||
renderEditBadge('hidden');
|
||||
state = 'PICKING';
|
||||
}, 1800);
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
|
||||
// Static-server / no-HMR fallback: if the wrapper is still around 2s after
|
||||
// the cleanup above, swap it out manually. By now React has either moved
|
||||
// on or the app isn't React at all. Preserve the `data-impeccable-variant="N"`
|
||||
// div (with display:contents) so @scope rules anchored to the variant
|
||||
// attribute keep matching until reload replaces it with the carbonize block.
|
||||
// Give framework HMR a short chance to render the now-clean accepted
|
||||
// source. If it misses the update, unwrap the accepted variant after the
|
||||
// source-side completion event so the page is not left empty or stale.
|
||||
setTimeout(function() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + acceptedSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + acceptedVariant + '"]');
|
||||
if (accepted && accepted.firstElementChild) {
|
||||
const parent = wrapper.parentElement;
|
||||
if (!parent) return;
|
||||
accepted.style.display = 'contents';
|
||||
parent.replaceChild(accepted, wrapper);
|
||||
}
|
||||
}, 2000);
|
||||
ensureAcceptedDomClean(pending);
|
||||
cleanupAcceptedSession();
|
||||
}, 1200);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
acceptedHtml: accepted ? accepted.innerHTML : '',
|
||||
acceptedSelector: selectorForAcceptedRoot(root),
|
||||
parentElement: wrapper?.parentElement || null,
|
||||
parentSelector: selectorForAcceptedRoot(wrapper?.parentElement || null),
|
||||
nextSibling: wrapper?.nextSibling || null,
|
||||
};
|
||||
}
|
||||
|
||||
function selectorForAcceptedRoot(root) {
|
||||
if (!root || !root.tagName) return '';
|
||||
const tag = root.tagName.toLowerCase();
|
||||
const classes = [...(root.classList || [])].filter(Boolean);
|
||||
if (classes.length === 0) return tag;
|
||||
return tag + classes.map((cls) => '.' + cssIdent(cls)).join('');
|
||||
}
|
||||
|
||||
function acceptedDomAlreadyClean(pending) {
|
||||
if (!pending?.acceptedSelector) return false;
|
||||
const matches = [...document.querySelectorAll(pending.acceptedSelector)];
|
||||
return matches.some((el) => !el.closest('[data-impeccable-variants],[data-impeccable-variant]'));
|
||||
}
|
||||
|
||||
function ensureAcceptedDomClean(pending) {
|
||||
const sessionId = pending?.id;
|
||||
const variantId = pending?.variant;
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!wrapper) {
|
||||
restoreAcceptedDomFromSnapshot(pending);
|
||||
return;
|
||||
}
|
||||
if (!accepted) {
|
||||
wrapper.remove();
|
||||
restoreAcceptedDomFromSnapshot(pending);
|
||||
return;
|
||||
}
|
||||
const parent = wrapper.parentElement;
|
||||
if (!parent) return;
|
||||
while (accepted.firstChild) {
|
||||
parent.insertBefore(accepted.firstChild, wrapper);
|
||||
}
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
function restoreAcceptedDomFromSnapshot(pending) {
|
||||
if (acceptedDomAlreadyClean(pending)) return;
|
||||
if (!pending?.acceptedHtml) {
|
||||
reloadAfterMissingAcceptedDom(pending);
|
||||
return;
|
||||
}
|
||||
const parent = pending.parentElement?.isConnected
|
||||
? pending.parentElement
|
||||
: (pending.parentSelector ? document.querySelector(pending.parentSelector) : null);
|
||||
if (!parent) {
|
||||
reloadAfterMissingAcceptedDom(pending);
|
||||
return;
|
||||
}
|
||||
const template = document.createElement('template');
|
||||
template.innerHTML = pending.acceptedHtml;
|
||||
const anchor = pending.nextSibling?.isConnected && pending.nextSibling.parentElement === parent
|
||||
? pending.nextSibling
|
||||
: null;
|
||||
parent.insertBefore(template.content, anchor);
|
||||
if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending);
|
||||
}
|
||||
|
||||
function reloadAfterMissingAcceptedDom(pending) {
|
||||
if (acceptedDomAlreadyClean(pending)) return;
|
||||
if (pending?.id && document.querySelector('[data-impeccable-variants="' + pending.id + '"]')) return;
|
||||
location.reload();
|
||||
}
|
||||
|
||||
function cleanupAcceptedSession() {
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
selectedElement = null;
|
||||
currentSessionId = null;
|
||||
selectedAction = 'impeccable';
|
||||
pendingAcceptedSession = null;
|
||||
renderEditBadge('hidden');
|
||||
state = 'PICKING';
|
||||
}
|
||||
|
||||
function handleDiscard() {
|
||||
@@ -5792,7 +5894,7 @@ void main() {
|
||||
|
||||
function cleanup() {
|
||||
// Hide the wrapper immediately so variants disappear. DON'T structurally
|
||||
// mutate the DOM yet — HMR from the agent's source rewrite is on its way,
|
||||
// mutate the DOM yet - HMR from the agent's source rewrite is on its way,
|
||||
// and a manual replaceChild under React causes NotFoundError when the
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
@@ -5838,8 +5940,8 @@ void main() {
|
||||
function showToast(message, duration) {
|
||||
if (toastEl) toastEl.remove();
|
||||
// Stack the toast above the global bar (which sits at bottom:14px) so
|
||||
// the two never overlap. Read the bar's actual rect — its height varies
|
||||
// with hover-expanded labels — and fall back to a sensible default
|
||||
// the two never overlap. Read the bar's actual rect - its height varies
|
||||
// with hover-expanded labels - and fall back to a sensible default
|
||||
// when the bar isn't mounted yet.
|
||||
const barRect = globalBarEl?.getBoundingClientRect();
|
||||
const barTopFromBottom = barRect && barRect.height > 0
|
||||
@@ -6047,7 +6149,7 @@ void main() {
|
||||
let pendingApplyInFlight = false;
|
||||
let firstSaveOfSession = true;
|
||||
|
||||
// Steer — collapsed pill in the global bar; expands while typing for page-level chat.
|
||||
// Steer - collapsed pill in the global bar; expands while typing for page-level chat.
|
||||
let pageChatEl = null;
|
||||
let pageChatInput = null;
|
||||
let pageChatHint = null;
|
||||
@@ -6068,7 +6170,7 @@ void main() {
|
||||
const STEER_AWAIT_TIMEOUT_MS = 120000;
|
||||
const AGENT_STATUS_POLL_MS = 5000;
|
||||
const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)';
|
||||
const AGENT_DISCONNECTED_TIP = 'Agent disconnected — run live-poll.mjs to connect';
|
||||
const AGENT_DISCONNECTED_TIP = 'Agent disconnected: run live-poll.mjs to connect';
|
||||
const GLOBAL_BAR_SECTION_GAP = 8;
|
||||
const GLOBAL_BAR_INNER_GAP = 2;
|
||||
const GLOBAL_BAR_INNER_PAD_LEFT = 2;
|
||||
@@ -6079,7 +6181,7 @@ void main() {
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>';
|
||||
|
||||
// Theme-aware color palette for the global bar. We detect the page's
|
||||
// ambient background and invert — dark bar on light pages, light bar on
|
||||
// ambient background and invert - dark bar on light pages, light bar on
|
||||
// dark pages. This keeps the bar from fighting with the host design.
|
||||
function detectPageTheme() {
|
||||
try {
|
||||
@@ -6092,7 +6194,7 @@ void main() {
|
||||
// Walk body → html, taking the first opaque background. The browser's
|
||||
// default body / html background is `rgba(0, 0, 0, 0)`, which a naive
|
||||
// regex would read as black and mislabel a perfectly white page as
|
||||
// dark. Honoring alpha avoids that — and falling through to <html>
|
||||
// dark. Honoring alpha avoids that - and falling through to <html>
|
||||
// catches the common pattern of a bg only on <html> (or only on body).
|
||||
function readOpaque(el) {
|
||||
if (!el) return null;
|
||||
@@ -6141,7 +6243,7 @@ void main() {
|
||||
exitHover: 'oklch(58% 0.15 35 / 0.18)',
|
||||
shadow: PICKER_SHADOW,
|
||||
chatSurface: 'oklch(22% 0.012 82)',
|
||||
// Verdigris patina — secondary state (see site/styles/kinpaku-tokens.css)
|
||||
// Verdigris patina - secondary state (see site/styles/kinpaku-tokens.css)
|
||||
patina: 'oklch(70% 0.12 188)',
|
||||
patinaPale: 'oklch(82% 0.07 188)',
|
||||
patinaSoft: 'oklch(70% 0.12 188 / 0.28)',
|
||||
@@ -6913,7 +7015,7 @@ void main() {
|
||||
steerFocusLog('page-chat-mounted', {});
|
||||
}
|
||||
|
||||
// Impeccable mark — same paths as site/components/Header.astro + favicon.svg.
|
||||
// Impeccable mark - same paths as site/components/Header.astro + favicon.svg.
|
||||
function brandMarkSvg(color = C.brand, size = 18) {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="${color}" aria-hidden="true">
|
||||
<path d="M5 2.5 L13.5 2.5 L5.5 21.5 L5 21.5 Q2.5 21.5 2.5 19 L2.5 5 Q2.5 2.5 5 2.5 Z"/>
|
||||
@@ -6928,7 +7030,7 @@ void main() {
|
||||
globalBarBrandEl.dataset.agentConnected = connected ? 'true' : 'false';
|
||||
globalBarBrandEl.setAttribute('aria-label', connected
|
||||
? 'Impeccable live mode'
|
||||
: 'Impeccable live mode — agent not polling');
|
||||
: 'Impeccable live mode: agent not polling');
|
||||
globalBarBrandEl.removeAttribute('title');
|
||||
globalBarBrandEl.style.cursor = connected ? 'default' : 'help';
|
||||
const mark = globalBarBrandEl.querySelector('[data-brand-mark]');
|
||||
@@ -7053,7 +7155,7 @@ void main() {
|
||||
globalBarEl.id = PREFIX + '-global-bar';
|
||||
globalBarEl.dataset.theme = theme;
|
||||
|
||||
// Brand mark — kinpaku Impeccable icon (site header / favicon paths).
|
||||
// Brand mark - kinpaku Impeccable icon (site header / favicon paths).
|
||||
const brand = el('span', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
alignSelf: 'stretch', position: 'relative',
|
||||
@@ -7065,7 +7167,7 @@ void main() {
|
||||
brand.id = PREFIX + '-global-bar-brand';
|
||||
brand.dataset.agentConnected = 'false';
|
||||
brand.setAttribute('role', 'img');
|
||||
brand.setAttribute('aria-label', 'Impeccable live mode — agent not polling');
|
||||
brand.setAttribute('aria-label', 'Impeccable live mode: agent not polling');
|
||||
|
||||
const brandMark = el('span', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
@@ -7130,7 +7232,7 @@ void main() {
|
||||
// Per-button hover only changes color (no layout). The label expand/
|
||||
// collapse is driven by the bar-level mouseenter/mouseleave so moving
|
||||
// the mouse between adjacent buttons doesn't trigger per-button width
|
||||
// thrashing — the whole bar grows once and shrinks once.
|
||||
// thrashing - the whole bar grows once and shrinks once.
|
||||
b.addEventListener('mouseenter', () => { if (b.dataset.active !== 'true') b.style.color = P.text; });
|
||||
b.addEventListener('mouseleave', () => { if (b.dataset.active !== 'true') b.style.color = P.textDim; });
|
||||
b.addEventListener('click', onClick);
|
||||
@@ -7139,7 +7241,7 @@ void main() {
|
||||
return b;
|
||||
}
|
||||
|
||||
// Pick toggle — restored from localStorage; both pick and insert may be off.
|
||||
// Pick toggle - restored from localStorage; both pick and insert may be off.
|
||||
const pickBtn = makeIconBtn({
|
||||
id: PREFIX + '-pick-toggle',
|
||||
svg: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><circle cx="12" cy="12" r="10"/><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><line x1="12" y1="6" x2="12" y2="2"/><line x1="12" y1="22" x2="12" y2="18"/></svg>',
|
||||
@@ -7176,7 +7278,7 @@ void main() {
|
||||
detectBtn.appendChild(detectBadge);
|
||||
inner.appendChild(detectBtn);
|
||||
|
||||
// DESIGN.md panel toggle — quartet of color squares as the mark.
|
||||
// DESIGN.md panel toggle - quartet of color squares as the mark.
|
||||
const designBtn = makeIconBtn({
|
||||
id: PREFIX + '-design-toggle',
|
||||
svg: `<span style="display:inline-grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;width:14px;height:14px;border-radius:3px;overflow:hidden;box-shadow:inset 0 0 0 1px oklch(58% 0.065 82 / 0.55);flex-shrink:0">
|
||||
@@ -7378,13 +7480,13 @@ void main() {
|
||||
});
|
||||
inner.appendChild(divider);
|
||||
|
||||
// Exit × on the right — intentionally subtle (textDim at rest, text on
|
||||
// Exit × on the right - intentionally subtle (textDim at rest, text on
|
||||
// hover) so it sits behind the active toggles in visual hierarchy.
|
||||
//
|
||||
// Explicit padding + box-sizing here is load-bearing: a host page like
|
||||
// `button { padding: 0.5rem 1rem; }` (very common in resets) would
|
||||
// otherwise inflate this 24x24 button into 56x40 and push the SVG out
|
||||
// of the visible bar — the X stays invisible even though the styles in
|
||||
// of the visible bar - the X stays invisible even though the styles in
|
||||
// DevTools look fine. Every other chrome button sets padding inline;
|
||||
// this one needed it too.
|
||||
const exitBtn = el('button', {
|
||||
@@ -7472,7 +7574,7 @@ void main() {
|
||||
btn.style.opacity = controlsLocked ? '0.55' : '1';
|
||||
});
|
||||
|
||||
// If the bar is currently under the cursor, keep all labels expanded —
|
||||
// If the bar is currently under the cursor, keep all labels expanded -
|
||||
// otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
|
||||
// would collapse its label while the user's mouse is still on the bar.
|
||||
if (globalBarEl && globalBarEl.matches(':hover')) {
|
||||
@@ -7648,7 +7750,7 @@ void main() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Design System Panel — visualizes the project's .impeccable/design.json sidecar
|
||||
// Design System Panel - visualizes the project's .impeccable/design.json sidecar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DESIGN_PREFS_KEY = 'impeccable-live-design-panel';
|
||||
@@ -7674,7 +7776,7 @@ void main() {
|
||||
};
|
||||
|
||||
function loadDesignPrefs() {
|
||||
// `open` is intentionally NOT persisted — the panel always starts closed
|
||||
// `open` is intentionally NOT persisted - the panel always starts closed
|
||||
// so live mode doesn't auto-slide a big panel over the page on startup.
|
||||
try {
|
||||
const raw = localStorage.getItem(DESIGN_PREFS_KEY);
|
||||
@@ -7731,7 +7833,7 @@ void main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Neutral panel palette — deliberately NOT Impeccable-branded. The panel is
|
||||
// Neutral panel palette - deliberately NOT Impeccable-branded. The panel is
|
||||
// a viewer of the project's design system, not an Impeccable surface.
|
||||
const DP = {
|
||||
canvas: 'oklch(94% 0 0)', // panel background
|
||||
@@ -8025,7 +8127,7 @@ void main() {
|
||||
const root = designShadow.querySelector('.root');
|
||||
root.innerHTML = '';
|
||||
|
||||
// (Panel toggle lives in the global bar — no floating FAB.)
|
||||
// (Panel toggle lives in the global bar - no floating FAB.)
|
||||
// Panel
|
||||
const panel = document.createElement('aside');
|
||||
panel.className = 'panel';
|
||||
@@ -8141,7 +8243,7 @@ void main() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Visual tab — single unified render path.
|
||||
// Visual tab - single unified render path.
|
||||
if (designState.mdNewerThanJson) body.appendChild(renderStaleHint());
|
||||
if (designState.hasMd && !designState.hasSidecar) {
|
||||
body.appendChild(renderParsedMdCta());
|
||||
@@ -8357,7 +8459,7 @@ void main() {
|
||||
specimen.style.fontFamily = fontStack(t);
|
||||
specimen.style.fontWeight = String(t.weight || 400);
|
||||
specimen.style.fontStyle = t.style || 'normal';
|
||||
specimen.style.fontSize = '56px'; // Fixed specimen size — compare faces, not scales.
|
||||
specimen.style.fontSize = '56px'; // Fixed specimen size - compare faces, not scales.
|
||||
specimen.style.letterSpacing = 'normal';
|
||||
specimen.style.textTransform = 'none';
|
||||
tile.appendChild(specimen);
|
||||
@@ -8501,7 +8603,7 @@ void main() {
|
||||
}
|
||||
|
||||
// Single shared description if all items carry the same one; otherwise
|
||||
// skip — per-item descriptions clutter a grouped tile.
|
||||
// skip - per-item descriptions clutter a grouped tile.
|
||||
if (group.length === 1 && group[0].description) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'c-desc';
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Impeccable Live Variant Mode — Browser Script
|
||||
* Impeccable Live Variant Mode - Browser Script
|
||||
*
|
||||
* Injected into the user's page via <script src="http://localhost:PORT/live.js">.
|
||||
* The server prepends window.__IMPECCABLE_TOKEN__ and window.__IMPECCABLE_PORT__
|
||||
* before this code.
|
||||
*
|
||||
* UI: a single floating bar that morphs between three states —
|
||||
* UI: a single floating bar that morphs between three states -
|
||||
* configure (pick action + go), generating (progressive dots), and cycling
|
||||
* (prev/next + accept/discard). Feels like Spotlight, not a modal.
|
||||
*/
|
||||
@@ -46,7 +46,7 @@
|
||||
mist: 'oklch(90% 0.008 82 / 0.6)', // light hairline
|
||||
white: 'oklch(99% 0 0)',
|
||||
};
|
||||
// Picker bar chrome — mirrors .live-demo-gbar / .live-demo-ctx in kinpaku-kit.css.
|
||||
// Picker bar chrome - mirrors .live-demo-gbar / .live-demo-ctx in kinpaku-kit.css.
|
||||
// Quiet neutral elevation: no gold halo ring (gold is reserved for the brand
|
||||
// mark and the active control, not the container outline).
|
||||
const PICKER_SHADOW =
|
||||
@@ -83,7 +83,7 @@
|
||||
|
||||
// SVG icons stack above each chip label. All strokes use currentColor so the
|
||||
// icon recolors to C.brand when its chip is selected. 20x20 render, 24-viewBox,
|
||||
// 1.5 stroke — visually consistent with the Foundation grid on the homepage.
|
||||
// 1.5 stroke - visually consistent with the Foundation grid on the homepage.
|
||||
const ICON_ATTRS = 'width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="display:block"';
|
||||
const ICONS = {
|
||||
impeccable: `<svg ${ICON_ATTRS}><path d="M4 20l4-1L18 9l-3-3L5 16z"/><path d="M14 7l3 3"/></svg>`,
|
||||
@@ -123,6 +123,7 @@
|
||||
let hoveredElement = null;
|
||||
let selectedElement = null;
|
||||
let currentSessionId = null;
|
||||
let pendingAcceptedSession = null;
|
||||
let expectedVariants = 0;
|
||||
let arrivedVariants = 0;
|
||||
let visibleVariant = 0;
|
||||
@@ -133,7 +134,7 @@
|
||||
const browserOwner = sessionState.owner;
|
||||
let checkpointTimer = null;
|
||||
|
||||
// Scroll lock — holds window.scrollY at a fixed value while the session is
|
||||
// Scroll lock - holds window.scrollY at a fixed value while the session is
|
||||
// active, so HMR DOM patches and variant swaps can't drift the page. See
|
||||
// startScrollLock / stopScrollLock below.
|
||||
let scrollLockObserver = null;
|
||||
@@ -141,7 +142,7 @@
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
// Dedicated key for scroll position — SEPARATE from LS_KEY so that
|
||||
// Dedicated key for scroll position - SEPARATE from LS_KEY so that
|
||||
// saveSession's state updates don't clobber a carefully-captured scrollY.
|
||||
// (Previously: saveSession wrote scrollY alongside state, so every call
|
||||
// during resume overwrote the pre-reload value with whatever the browser
|
||||
@@ -231,7 +232,7 @@
|
||||
// - Stop `focusin` propagation so any focus shifts inside our chrome
|
||||
// don't read as "focus moved outside the dialog" to focus traps.
|
||||
//
|
||||
// Click events still bubble normally — only the early pointer/focus
|
||||
// Click events still bubble normally - only the early pointer/focus
|
||||
// signals that drive outside-interaction detection are silenced.
|
||||
function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) {
|
||||
if (!rootEl) return;
|
||||
@@ -319,7 +320,7 @@
|
||||
// correlate directly with the captured PNG.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DRAG_THRESHOLD = 5; // px — below this, treat pointerup as a click
|
||||
const DRAG_THRESHOLD = 5; // px - below this, treat pointerup as a click
|
||||
const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it
|
||||
let annotOverlayEl = null;
|
||||
let annotSvgEl = null;
|
||||
@@ -423,7 +424,7 @@
|
||||
placeholderResizeDrag = null;
|
||||
if (annotOverlayEl) annotOverlayEl.style.display = 'none';
|
||||
syncPlaceholderResizeHandles();
|
||||
// Drop any in-progress edit without touching annotState — clearAnnotations
|
||||
// Drop any in-progress edit without touching annotState - clearAnnotations
|
||||
// (if the caller is exiting configure mode) handles state reset.
|
||||
annotEditing = null;
|
||||
}
|
||||
@@ -488,7 +489,7 @@
|
||||
function onAnnotDown(e) {
|
||||
if (!annotActive) return;
|
||||
|
||||
// 0) Insert placeholder edge resize — wins over draw / pins.
|
||||
// 0) Insert placeholder edge resize - wins over draw / pins.
|
||||
const resizeEdge = e.target.closest?.('[data-impeccable-placeholder-resize]')?.dataset.impeccablePlaceholderResize;
|
||||
if (resizeEdge && configureKind === 'insert' && placeholderElement) {
|
||||
startPlaceholderEdgeResize(resizeEdge, e);
|
||||
@@ -536,7 +537,7 @@
|
||||
// If editing a different pin, commit that edit before starting here.
|
||||
if (annotEditing && annotEditing.idx !== idx) finalizeEditingPin();
|
||||
// If already editing THIS pin and the user clicked the dot, let the
|
||||
// input keep focus (don't start a drag — the click wasn't meant as one).
|
||||
// input keep focus (don't start a drag - the click wasn't meant as one).
|
||||
if (annotEditing && annotEditing.idx === idx) return;
|
||||
const p = localCoords(e);
|
||||
const pin = annotState.comments[idx];
|
||||
@@ -971,7 +972,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The Bar — one floating element, three modes
|
||||
// The Bar - one floating element, three modes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Contextual-bar palette. Cached at init so every build*Row reads a
|
||||
@@ -1752,7 +1753,7 @@
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
});
|
||||
|
||||
// Action pill — dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill)
|
||||
// Action pill - dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill)
|
||||
const pill = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
||||
padding: '5px 10px', borderRadius: '6px',
|
||||
@@ -1786,7 +1787,7 @@
|
||||
});
|
||||
row.appendChild(pill);
|
||||
|
||||
// Prompt field — same chat-surface chrome as the bottom Steer bar
|
||||
// Prompt field - same chat-surface chrome as the bottom Steer bar
|
||||
const inputWrap = el('div', {
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
flex: '1', minWidth: '0', height: '28px',
|
||||
@@ -2126,7 +2127,7 @@
|
||||
if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
|
||||
row.appendChild(next);
|
||||
|
||||
// Tune chip — only when the visible variant exposes params
|
||||
// Tune chip - only when the visible variant exposes params
|
||||
const visParams = parseVariantParams(getVisibleVariantEl());
|
||||
const hasParams = visParams.length > 0;
|
||||
if (hasParams) {
|
||||
@@ -2173,7 +2174,7 @@
|
||||
// Spacer
|
||||
row.appendChild(el('div', { flex: '1' }));
|
||||
|
||||
// Accept — primary action, kinpaku gold + lacquer-deep (matches demo .live-demo-ctx-accept)
|
||||
// Accept - primary action, kinpaku gold + lacquer-deep (matches demo .live-demo-ctx-accept)
|
||||
const accept = el('button', {
|
||||
padding: '5px 14px', borderRadius: '5px',
|
||||
border: 'none', background: C.brand, color: C.ink,
|
||||
@@ -2266,7 +2267,7 @@
|
||||
const active = i === visibleVariant;
|
||||
// active: solid site-brand kinpaku dot. arrived+inactive: muted neutral.
|
||||
// pending (not yet arrived): faint outline ring. No borders on arrived
|
||||
// dots — the previous "accent ring + ash fill" combo read as noisy
|
||||
// dots - the previous "accent ring + ash fill" combo read as noisy
|
||||
// kinpaku chips, especially when all variants had arrived and every
|
||||
// dot wore an accent ring.
|
||||
const dotBg = active ? C.brand
|
||||
@@ -2456,7 +2457,7 @@
|
||||
let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide)
|
||||
let paramsPanelInner = null; // translating content (carries bg, padding, knobs)
|
||||
let paramsPanelBody = null; // grid holding the knob cells
|
||||
let paramsCurrentValues = {}; // {paramId: value} — mirror of the visible variant's live values
|
||||
let paramsCurrentValues = {}; // {paramId: value} - mirror of the visible variant's live values
|
||||
let tuneOpen = false; // whether the Tune popover is open right now
|
||||
|
||||
// Theme-aware Tune popover. Appears as a drawer that slides out from the
|
||||
@@ -2471,7 +2472,7 @@
|
||||
const P = paramsPanelPalette;
|
||||
|
||||
// Single element, always in the DOM. The slide animation is a CSS mask
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis — no
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis - no
|
||||
// display toggle, no opacity toggle, no transform trickery. The mask
|
||||
// hides everything initially; as it grows, content is revealed from
|
||||
// the bar edge outward.
|
||||
@@ -2494,7 +2495,7 @@
|
||||
transition: 'clip-path 0.44s ' + EASE,
|
||||
|
||||
// Park off-screen until positionParamsPanel places it. These are NOT
|
||||
// in the transition list, so they snap instantly — no fly-in from the
|
||||
// in the transition list, so they snap instantly - no fly-in from the
|
||||
// top-left when first shown.
|
||||
top: '-9999px', left: '-9999px', width: '0',
|
||||
});
|
||||
@@ -2685,7 +2686,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline text editing — makes pure-text descendants of the picked element
|
||||
// Inline text editing - makes pure-text descendants of the picked element
|
||||
// directly contenteditable. Save stages copy edits in the live buffer; the
|
||||
// Apply copy edits dock later asks the AI to apply the staged batch.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -3132,7 +3133,7 @@
|
||||
if (detail.includes('newText cannot contain') || detail.includes('newText cannot be empty')) {
|
||||
showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500);
|
||||
} else {
|
||||
showToast('Save failed — retry or cancel', 4000);
|
||||
showToast('Save failed: retry or cancel', 4000);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3454,19 +3455,19 @@
|
||||
updatePendingCounter(remaining);
|
||||
if (result.failed && result.failed.length > 0) {
|
||||
console.warn('[impeccable] some copy edits failed:', result.failed);
|
||||
showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed — see console', 5000);
|
||||
showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed, see console', 5000);
|
||||
} else {
|
||||
const n = Array.isArray(result.applied) ? result.applied.length : (result.cleared || 0);
|
||||
if (n > 0) {
|
||||
showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500);
|
||||
} else {
|
||||
console.warn('[impeccable] apply returned no verified edits:', result);
|
||||
showToast('No edits applied — see console', 4000);
|
||||
showToast('No edits applied, see console', 4000);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[impeccable] commit failed:', err);
|
||||
showToast('Apply failed — see console', 4000);
|
||||
showToast('Apply failed, see console', 4000);
|
||||
} finally {
|
||||
if (waitForSseCompletion) return;
|
||||
const remainingCount = parseInt(pendingPillEl?.dataset.count || '0', 10) || 0;
|
||||
@@ -3496,7 +3497,7 @@
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[impeccable] discard failed:', err);
|
||||
showToast('Discard failed — see console', 4000);
|
||||
showToast('Discard failed, see console', 4000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3644,7 +3645,7 @@
|
||||
const failedCount = numberOrNull(msg.failedCount) || 0;
|
||||
const appliedCount = numberOrNull(msg.appliedCount) || numberOrNull(msg.cleared) || 0;
|
||||
if (failedCount > 0) {
|
||||
showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed — see console', 5000);
|
||||
showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed, see console', 5000);
|
||||
} else if (appliedCount > 0) {
|
||||
showToast('Applied ' + appliedCount + ' edit' + (appliedCount === 1 ? '' : 's'), 2500);
|
||||
}
|
||||
@@ -3799,7 +3800,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit content badge — floating button at element top-right to enter EDITING mode
|
||||
// Edit content badge - floating button at element top-right to enter EDITING mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initEditBadge() {
|
||||
@@ -3884,7 +3885,7 @@
|
||||
}
|
||||
editBadgeEl.appendChild(btn);
|
||||
} else {
|
||||
// 'editing' — show Cancel + Save separated
|
||||
// 'editing' - show Cancel + Save separated
|
||||
editBadgeEl.innerHTML = '';
|
||||
editBadgeEl.style.gap = '8px';
|
||||
const cancel = document.createElement('button');
|
||||
@@ -4083,7 +4084,7 @@
|
||||
if (!v) continue;
|
||||
setVariantShown(child, v === String(num));
|
||||
}
|
||||
// Unconditional refresh — covers first-reveal (no-op if state isn't
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
// CYCLING yet, the subsequent CYCLING transition triggers its own
|
||||
// refresh) and every cycle step.
|
||||
refreshParamsPanel();
|
||||
@@ -4318,7 +4319,7 @@
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
}, { passive: true, ...sig });
|
||||
|
||||
// Apply target synchronously, not via rAF — racing the browser's
|
||||
// Apply target synchronously, not via rAF - racing the browser's
|
||||
// restore or a smooth-scroll animation means we want to win now.
|
||||
if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) {
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
@@ -4545,18 +4546,34 @@
|
||||
// hidden tab, a route the user navigated away from). The variant
|
||||
// MutationObserver stays armed and auto-transitions to CYCLING
|
||||
// the moment the wrapper actually mounts. Nudge the user toward
|
||||
// that path with a toast — better than the prior force-reload
|
||||
// that path with a toast - better than the prior force-reload
|
||||
// which reset framework state and left the session stuck.
|
||||
setTimeout(() => {
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) return;
|
||||
if (state !== 'GENERATING') return;
|
||||
showToast(
|
||||
"Variants ready. If the picked element isn't visible, retrace the path that revealed it — they'll appear automatically.",
|
||||
"Variants ready. If the picked element isn't visible, retrace the path that revealed it; they'll appear automatically.",
|
||||
15000,
|
||||
);
|
||||
}, 2000);
|
||||
break;
|
||||
case 'complete':
|
||||
case 'accept':
|
||||
if (maybeCompleteAcceptedSession(msg)) break;
|
||||
break;
|
||||
case 'agent_done':
|
||||
// Carbonize accepts are not terminal until live-complete.mjs sends
|
||||
// the final complete event. Keep the browser in its recoverable
|
||||
// saving state while the source cleanup is still in flight.
|
||||
break;
|
||||
case 'error':
|
||||
if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) {
|
||||
pendingAcceptedSession = null;
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
break;
|
||||
}
|
||||
if (maybeCompleteSteer(msg)) break;
|
||||
console.error('[impeccable] Error:', msg.message);
|
||||
showToast('Error: ' + msg.message, 5000);
|
||||
@@ -4779,14 +4796,14 @@
|
||||
|
||||
/**
|
||||
* Surface a brief, non-blocking heads-up when the picked element lives
|
||||
* inside a container whose visibility is gated by ephemeral state — modals,
|
||||
* inside a container whose visibility is gated by ephemeral state - modals,
|
||||
* collapsible panels, popovers, off-screen tab panels. If HMR remounts the
|
||||
* parent during generation (Vite Fast Refresh, SvelteKit page reload), the
|
||||
* variants land in source but stay invisible until the user re-opens the
|
||||
* container. Telling the user upfront is much friendlier than the silent
|
||||
* timeout-then-toast that they'd otherwise hit.
|
||||
*
|
||||
* Heuristic, intentionally narrow — only fires for unambiguous cases so
|
||||
* Heuristic, intentionally narrow - only fires for unambiguous cases so
|
||||
* we don't cry wolf on every nested element.
|
||||
*/
|
||||
function maybeWarnConditionalAncestor(el) {
|
||||
@@ -4804,7 +4821,7 @@
|
||||
showToast('Heads up: this element lives inside an open panel. If state resets during generation, you may need to re-open it.', 6000);
|
||||
return;
|
||||
}
|
||||
// 3. Tab panel — only meaningful when the page also shows ANOTHER
|
||||
// 3. Tab panel - only meaningful when the page also shows ANOTHER
|
||||
// tab as selected. A single tabpanel with no tablist is just a static
|
||||
// section in disguise and isn't conditional.
|
||||
if (node.getAttribute && node.getAttribute('role') === 'tabpanel') {
|
||||
@@ -4833,7 +4850,7 @@
|
||||
// Fire a lightweight prefetch event the first time the user selects an
|
||||
// element on a given route. The agent uses this to Read the underlying file
|
||||
// into context before Go is hit, shaving the read off the critical path.
|
||||
// Dedupe per session by pathname — clicking around on the same page doesn't
|
||||
// Dedupe per session by pathname - clicking around on the same page doesn't
|
||||
// re-fire.
|
||||
//
|
||||
// DISABLED: quick-Go workflows pay an extra harness round trip because
|
||||
@@ -4969,6 +4986,7 @@
|
||||
disableInlineEdit();
|
||||
stripManualEditRuntimeState(selectedElement);
|
||||
|
||||
pendingAcceptedSession = null;
|
||||
currentSessionId = id8();
|
||||
expectedVariants = selectedCount;
|
||||
arrivedVariants = 0;
|
||||
@@ -4976,7 +4994,7 @@
|
||||
|
||||
// Flip to GENERATING immediately so the bar morphs without waiting on
|
||||
// capture + upload. The event is emitted from captureAndEmit() once the
|
||||
// screenshot is uploaded (or capture fails — we still emit, just without
|
||||
// screenshot is uploaded (or capture fails - we still emit, just without
|
||||
// screenshotPath).
|
||||
const elForCapture = selectedElement;
|
||||
const captureRect = elForCapture.getBoundingClientRect();
|
||||
@@ -5002,7 +5020,7 @@
|
||||
state = 'GENERATING';
|
||||
// Disable the Edit badge: starting a manual text edit mid-generation would
|
||||
// conflict with the variant wrap that's about to land in the same DOM
|
||||
// region. Only swap if the badge was visible — picked elements with no
|
||||
// region. Only swap if the badge was visible - picked elements with no
|
||||
// text rows have it hidden already.
|
||||
if (editBadgeEl && editBadgeEl.style.display !== 'none') renderEditBadge('idle-disabled');
|
||||
showBar('generating');
|
||||
@@ -5104,7 +5122,7 @@
|
||||
|
||||
// Collect @font-face rules from every stylesheet on the page. Cross-origin
|
||||
// sheets (Google Fonts, Typekit, etc.) throw SecurityError on .cssRules
|
||||
// access, so modern-screenshot can't embed them on its own — the resulting
|
||||
// access, so modern-screenshot can't embed them on its own - the resulting
|
||||
// SVG falls back to system fonts and text re-wraps + renders with different
|
||||
// weight. We fetch the raw CSS text (CORS-permitted for these providers),
|
||||
// extract @font-face blocks, inline the referenced font files as base64
|
||||
@@ -5188,7 +5206,7 @@
|
||||
// modern-screenshot force-sets `background-color: X !important` on the
|
||||
// cloned root whenever `backgroundColor` is passed, clobbering the
|
||||
// element's own background. So we only pass it when the element is
|
||||
// genuinely transparent (no own color, no own image) — in that case
|
||||
// genuinely transparent (no own color, no own image) - in that case
|
||||
// we resolve up the DOM to the nearest opaque ancestor so the capture
|
||||
// sits on the page's real background instead of rendering black.
|
||||
function resolveCanvasBackground(el) {
|
||||
@@ -5206,7 +5224,7 @@
|
||||
// `getComputedStyle(body).backgroundColor || …` chain is a trap: that
|
||||
// call returns the literal string `"rgba(0, 0, 0, 0)"` for a page that
|
||||
// never set its own bg, which is truthy and short-circuits the chain to
|
||||
// transparent-black — modern-screenshot then renders the capture on a
|
||||
// transparent-black - modern-screenshot then renders the capture on a
|
||||
// black canvas and the shader overlay flashes solid black during load.
|
||||
// The browser canvas defaults to white, so we do too.
|
||||
return '#ffffff';
|
||||
@@ -5248,7 +5266,7 @@
|
||||
// Transparent up to the root. The visible backdrop may still come from an
|
||||
// ancestor's background-image or a covering positioned layer (e.g. a hero
|
||||
// art div) that the color walk can't see. Capture that ancestor and crop
|
||||
// to the element so the real backdrop is embedded — correct for both the
|
||||
// to the element so the real backdrop is embedded - correct for both the
|
||||
// shader and the screenshot sent to the model. Fall back to white only
|
||||
// when nothing is actually painted behind the element.
|
||||
const backdrop = findBackdropAncestor(el);
|
||||
@@ -5289,13 +5307,13 @@
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] capture failed, proceeding without screenshot:', err);
|
||||
}
|
||||
// Light up the shader overlay the moment capture is ready — no reason to
|
||||
// Light up the shader overlay the moment capture is ready - no reason to
|
||||
// wait for the upload to complete before the user sees something alive.
|
||||
if (blob && state === 'GENERATING') {
|
||||
showShaderOverlay(el, blob, rect, paper);
|
||||
}
|
||||
// Only upload + forward the screenshot when annotations (comments/strokes)
|
||||
// are present. Without annotations the image is pure visual anchoring —
|
||||
// are present. Without annotations the image is pure visual anchoring -
|
||||
// it biases the model toward the current rendering and works against the
|
||||
// three-distinct-directions brief.
|
||||
const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0);
|
||||
@@ -5320,7 +5338,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shader overlay — renders the captured screenshot as a WebGL texture and
|
||||
// Shader overlay - renders the captured screenshot as a WebGL texture and
|
||||
// runs an editorial "ink-wash" fragment shader over it during generation.
|
||||
// A single rolling band sweeps top-to-bottom, desaturating + tinting kinpaku
|
||||
// and leaving a soft trail. Makes the wait feel like a letterpress scan
|
||||
@@ -5343,7 +5361,7 @@ uniform vec3 u_accent;
|
||||
uniform vec3 u_paper;
|
||||
varying vec2 v_uv;
|
||||
|
||||
// Asymmetric roller band. Product of two one-sided smoothsteps — peaks at
|
||||
// Asymmetric roller band. Product of two one-sided smoothsteps - peaks at
|
||||
// d=0 with a short sharp leading ramp and a longer soft trailing tail. Clean
|
||||
// outside the [-leadW, trailW] range (no rogue "trail=1 everywhere below"
|
||||
// failure that reversed-edge smoothstep would give).
|
||||
@@ -5369,8 +5387,8 @@ void main() {
|
||||
vec2 sampleCenter = (cellId + 0.5) * cellPx / u_resolution;
|
||||
vec3 cellImg = texture2D(u_texture, sampleCenter).rgb;
|
||||
// Dot size tracks how much the cell DIFFERS from the element's own ground
|
||||
// (u_paper), not absolute darkness. So the content — text, buttons, anything
|
||||
// that deviates from the background — always becomes the dots, on light AND
|
||||
// (u_paper), not absolute darkness. So the content - text, buttons, anything
|
||||
// that deviates from the background - always becomes the dots, on light AND
|
||||
// dark surfaces. A plain darkness curve inverts on dark elements: the dark
|
||||
// background fills with ink and the lighter content punches holes instead.
|
||||
// Capped below the cell half-width so dense content stays separated dots.
|
||||
@@ -5380,8 +5398,8 @@ void main() {
|
||||
// Two-stage dissolve as the roller passes, so the element is rebuilt purely
|
||||
// from dot size (its own halftone) and never bleeds through as raw pixels
|
||||
// behind the dots:
|
||||
// 1. cover — the element flattens to the uniform paper ground first.
|
||||
// 2. dotAmt — kinpaku dots then emerge, sized by each cell's luma.
|
||||
// 1. cover - the element flattens to the uniform paper ground first.
|
||||
// 2. dotAmt - kinpaku dots then emerge, sized by each cell's luma.
|
||||
// A plain mix(base, halftone, band) instead left the raw element visible
|
||||
// through the band's soft core/trail. The paper ground is u_paper (the
|
||||
// element's own bg tone) rather than a fixed white, so the dissolve reads the
|
||||
@@ -5399,7 +5417,7 @@ void main() {
|
||||
|
||||
// Kinpaku gold converted to approximate sRGB 0-1 (matches oklch(84% 0.19 80.46))
|
||||
const SHADER_ACCENT = [1.0, 0.78, 0.31];
|
||||
// Fallback ground when an element and all its ancestors are transparent —
|
||||
// Fallback ground when an element and all its ancestors are transparent -
|
||||
// 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 }
|
||||
@@ -5412,7 +5430,7 @@ void main() {
|
||||
// Rasterize any CSS color (oklch, color(), named, hex, rgb) through a 1x1
|
||||
// canvas and read back the sRGB pixel. String-parsing computed colors is a
|
||||
// trap: Chrome returns backgroundColor as oklch()/color() for oklch inputs,
|
||||
// which a hex/rgb regex misses — every site token would fall back to white.
|
||||
// which a hex/rgb regex misses - every site token would fall back to white.
|
||||
let colorParseCtx = null;
|
||||
function cssColorToRgb01(str) {
|
||||
if (!colorParseCtx) {
|
||||
@@ -5440,7 +5458,7 @@ void main() {
|
||||
|
||||
// When an element is transparent up to the root, its visible backdrop can
|
||||
// still come from an ancestor's background-image or a covering positioned
|
||||
// layer that is a *child* of an ancestor (e.g. a hero's absolute art div) —
|
||||
// layer that is a *child* of an ancestor (e.g. a hero's absolute art div) -
|
||||
// neither of which the ancestor background-COLOR walk can see. Return the
|
||||
// nearest such ancestor so we can capture it and crop, embedding the real
|
||||
// backdrop. Returns null when nothing is actually painted behind the element
|
||||
@@ -5481,7 +5499,7 @@ void main() {
|
||||
|
||||
// Average the backdrop sampled just OUTSIDE an element's rect within a larger
|
||||
// canvas. The ground tone for the dissolve must be the real backdrop, not the
|
||||
// mean of the element's own crop — averaging the crop folds in the element's
|
||||
// mean of the element's own crop - averaging the crop folds in the element's
|
||||
// content (e.g. bright heading text), pulling the ground toward muddy gray.
|
||||
function sampleSurroundingRgb(ctx, sx, sy, sw, sh, W, H) {
|
||||
const pad = Math.max(2, Math.round(Math.min(sw, sh) * 0.12));
|
||||
@@ -5528,11 +5546,31 @@ void main() {
|
||||
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;
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
canvas.remove();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const fallback = document.createElement('div');
|
||||
fallback.id = PREFIX + '-shader';
|
||||
// Copy positioning via cssText. Object.assign across CSSStyleDeclaration
|
||||
// throws in modern Chromium because the source's indexed properties
|
||||
// (style[0], [1], ...) are read-only and the engine forbids writing
|
||||
// them on the destination.
|
||||
fallback.style.cssText = canvas.style.cssText;
|
||||
fallback.style.backgroundImage = 'url("' + objectUrl + '")';
|
||||
fallback.style.backgroundSize = '100% 100%';
|
||||
fallback.style.backgroundRepeat = 'no-repeat';
|
||||
fallback.style.outline = '2px dashed ' + C.brand;
|
||||
fallback.style.outlineOffset = '-2px';
|
||||
document.body.appendChild(fallback);
|
||||
shaderState = { canvas: fallback, gl: null, program: null, texture: null, rafId: 0, startTime: 0, objectUrl };
|
||||
}
|
||||
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
@@ -5553,21 +5591,9 @@ void main() {
|
||||
const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false })
|
||||
|| canvas.getContext('experimental-webgl');
|
||||
if (!gl) {
|
||||
// WebGL unavailable — fall back to a plain <img> overlay so the user
|
||||
// still sees something meaningful during generation.
|
||||
canvas.remove();
|
||||
const img = document.createElement('img');
|
||||
img.src = URL.createObjectURL(blob);
|
||||
img.id = PREFIX + '-shader';
|
||||
// Copy positioning via cssText. Object.assign across CSSStyleDeclaration
|
||||
// throws in modern Chromium because the source's indexed properties
|
||||
// (style[0], [1], ...) are read-only and the engine forbids writing
|
||||
// them on the destination.
|
||||
img.style.cssText = canvas.style.cssText;
|
||||
img.style.outline = '2px dashed ' + C.brand;
|
||||
img.style.outlineOffset = '-2px';
|
||||
document.body.appendChild(img);
|
||||
shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 };
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5609,14 +5635,12 @@ void main() {
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch {
|
||||
// Safari fallback: go via a regular Image
|
||||
const imgUrl = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
img.src = imgUrl;
|
||||
await new Promise((r, rej) => { img.onload = r; img.onerror = rej; });
|
||||
bitmap = img;
|
||||
URL.revokeObjectURL(imgUrl);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
@@ -5670,14 +5694,15 @@ void main() {
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
// The accepted variant is already the only visible child of the wrapper
|
||||
// (all other variants are display:none). HMR from the source rewrite will
|
||||
// replace the wrapper imminently. Don't eagerly replaceChild here — React
|
||||
// reconciliation races with our mutation and throws NotFoundError in Next
|
||||
// 16 / Turbopack. Schedule a fallback that runs the manual swap only if
|
||||
// HMR hasn't cleaned up by then (keeps static-server flows working).
|
||||
const acceptedSessionId = currentSessionId;
|
||||
const acceptedVariant = visibleVariant;
|
||||
const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant);
|
||||
pendingAcceptedSession = {
|
||||
id: acceptedSessionId,
|
||||
variant: String(acceptedVariant),
|
||||
...acceptedSnapshot,
|
||||
finalizing: false,
|
||||
};
|
||||
|
||||
state = 'SAVING';
|
||||
updateBarContent('saving');
|
||||
@@ -5685,53 +5710,130 @@ void main() {
|
||||
sendEvent(acceptPayload, { throwOnError: true })
|
||||
.then(() => {
|
||||
markSessionHandled();
|
||||
confirmAcceptAfterReceipt();
|
||||
})
|
||||
.catch(() => {
|
||||
pendingAcceptedSession = null;
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
}
|
||||
|
||||
function confirmAcceptAfterReceipt() {
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
scheduleAcceptCleanup();
|
||||
function maybeCompleteAcceptedSession(msg) {
|
||||
const pending = pendingAcceptedSession;
|
||||
if (!pending || !msg?.id || msg.id !== pending.id) return false;
|
||||
if (currentSessionId && currentSessionId !== pending.id) {
|
||||
pendingAcceptedSession = null;
|
||||
return false;
|
||||
}
|
||||
if (pending.finalizing) return true;
|
||||
pending.finalizing = true;
|
||||
|
||||
function scheduleAcceptCleanup() {
|
||||
setTimeout(function() {
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
selectedElement = null;
|
||||
currentSessionId = null;
|
||||
selectedAction = 'impeccable';
|
||||
renderEditBadge('hidden');
|
||||
state = 'PICKING';
|
||||
}, 1800);
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
|
||||
// Static-server / no-HMR fallback: if the wrapper is still around 2s after
|
||||
// the cleanup above, swap it out manually. By now React has either moved
|
||||
// on or the app isn't React at all. Preserve the `data-impeccable-variant="N"`
|
||||
// div (with display:contents) so @scope rules anchored to the variant
|
||||
// attribute keep matching until reload replaces it with the carbonize block.
|
||||
// Give framework HMR a short chance to render the now-clean accepted
|
||||
// source. If it misses the update, unwrap the accepted variant after the
|
||||
// source-side completion event so the page is not left empty or stale.
|
||||
setTimeout(function() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + acceptedSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + acceptedVariant + '"]');
|
||||
if (accepted && accepted.firstElementChild) {
|
||||
const parent = wrapper.parentElement;
|
||||
if (!parent) return;
|
||||
accepted.style.display = 'contents';
|
||||
parent.replaceChild(accepted, wrapper);
|
||||
}
|
||||
}, 2000);
|
||||
ensureAcceptedDomClean(pending);
|
||||
cleanupAcceptedSession();
|
||||
}, 1200);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
acceptedHtml: accepted ? accepted.innerHTML : '',
|
||||
acceptedSelector: selectorForAcceptedRoot(root),
|
||||
parentElement: wrapper?.parentElement || null,
|
||||
parentSelector: selectorForAcceptedRoot(wrapper?.parentElement || null),
|
||||
nextSibling: wrapper?.nextSibling || null,
|
||||
};
|
||||
}
|
||||
|
||||
function selectorForAcceptedRoot(root) {
|
||||
if (!root || !root.tagName) return '';
|
||||
const tag = root.tagName.toLowerCase();
|
||||
const classes = [...(root.classList || [])].filter(Boolean);
|
||||
if (classes.length === 0) return tag;
|
||||
return tag + classes.map((cls) => '.' + cssIdent(cls)).join('');
|
||||
}
|
||||
|
||||
function acceptedDomAlreadyClean(pending) {
|
||||
if (!pending?.acceptedSelector) return false;
|
||||
const matches = [...document.querySelectorAll(pending.acceptedSelector)];
|
||||
return matches.some((el) => !el.closest('[data-impeccable-variants],[data-impeccable-variant]'));
|
||||
}
|
||||
|
||||
function ensureAcceptedDomClean(pending) {
|
||||
const sessionId = pending?.id;
|
||||
const variantId = pending?.variant;
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!wrapper) {
|
||||
restoreAcceptedDomFromSnapshot(pending);
|
||||
return;
|
||||
}
|
||||
if (!accepted) {
|
||||
wrapper.remove();
|
||||
restoreAcceptedDomFromSnapshot(pending);
|
||||
return;
|
||||
}
|
||||
const parent = wrapper.parentElement;
|
||||
if (!parent) return;
|
||||
while (accepted.firstChild) {
|
||||
parent.insertBefore(accepted.firstChild, wrapper);
|
||||
}
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
function restoreAcceptedDomFromSnapshot(pending) {
|
||||
if (acceptedDomAlreadyClean(pending)) return;
|
||||
if (!pending?.acceptedHtml) {
|
||||
reloadAfterMissingAcceptedDom(pending);
|
||||
return;
|
||||
}
|
||||
const parent = pending.parentElement?.isConnected
|
||||
? pending.parentElement
|
||||
: (pending.parentSelector ? document.querySelector(pending.parentSelector) : null);
|
||||
if (!parent) {
|
||||
reloadAfterMissingAcceptedDom(pending);
|
||||
return;
|
||||
}
|
||||
const template = document.createElement('template');
|
||||
template.innerHTML = pending.acceptedHtml;
|
||||
const anchor = pending.nextSibling?.isConnected && pending.nextSibling.parentElement === parent
|
||||
? pending.nextSibling
|
||||
: null;
|
||||
parent.insertBefore(template.content, anchor);
|
||||
if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending);
|
||||
}
|
||||
|
||||
function reloadAfterMissingAcceptedDom(pending) {
|
||||
if (acceptedDomAlreadyClean(pending)) return;
|
||||
if (pending?.id && document.querySelector('[data-impeccable-variants="' + pending.id + '"]')) return;
|
||||
location.reload();
|
||||
}
|
||||
|
||||
function cleanupAcceptedSession() {
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
selectedElement = null;
|
||||
currentSessionId = null;
|
||||
selectedAction = 'impeccable';
|
||||
pendingAcceptedSession = null;
|
||||
renderEditBadge('hidden');
|
||||
state = 'PICKING';
|
||||
}
|
||||
|
||||
function handleDiscard() {
|
||||
@@ -5792,7 +5894,7 @@ void main() {
|
||||
|
||||
function cleanup() {
|
||||
// Hide the wrapper immediately so variants disappear. DON'T structurally
|
||||
// mutate the DOM yet — HMR from the agent's source rewrite is on its way,
|
||||
// mutate the DOM yet - HMR from the agent's source rewrite is on its way,
|
||||
// and a manual replaceChild under React causes NotFoundError when the
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
@@ -5838,8 +5940,8 @@ void main() {
|
||||
function showToast(message, duration) {
|
||||
if (toastEl) toastEl.remove();
|
||||
// Stack the toast above the global bar (which sits at bottom:14px) so
|
||||
// the two never overlap. Read the bar's actual rect — its height varies
|
||||
// with hover-expanded labels — and fall back to a sensible default
|
||||
// the two never overlap. Read the bar's actual rect - its height varies
|
||||
// with hover-expanded labels - and fall back to a sensible default
|
||||
// when the bar isn't mounted yet.
|
||||
const barRect = globalBarEl?.getBoundingClientRect();
|
||||
const barTopFromBottom = barRect && barRect.height > 0
|
||||
@@ -6047,7 +6149,7 @@ void main() {
|
||||
let pendingApplyInFlight = false;
|
||||
let firstSaveOfSession = true;
|
||||
|
||||
// Steer — collapsed pill in the global bar; expands while typing for page-level chat.
|
||||
// Steer - collapsed pill in the global bar; expands while typing for page-level chat.
|
||||
let pageChatEl = null;
|
||||
let pageChatInput = null;
|
||||
let pageChatHint = null;
|
||||
@@ -6068,7 +6170,7 @@ void main() {
|
||||
const STEER_AWAIT_TIMEOUT_MS = 120000;
|
||||
const AGENT_STATUS_POLL_MS = 5000;
|
||||
const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)';
|
||||
const AGENT_DISCONNECTED_TIP = 'Agent disconnected — run live-poll.mjs to connect';
|
||||
const AGENT_DISCONNECTED_TIP = 'Agent disconnected: run live-poll.mjs to connect';
|
||||
const GLOBAL_BAR_SECTION_GAP = 8;
|
||||
const GLOBAL_BAR_INNER_GAP = 2;
|
||||
const GLOBAL_BAR_INNER_PAD_LEFT = 2;
|
||||
@@ -6079,7 +6181,7 @@ void main() {
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>';
|
||||
|
||||
// Theme-aware color palette for the global bar. We detect the page's
|
||||
// ambient background and invert — dark bar on light pages, light bar on
|
||||
// ambient background and invert - dark bar on light pages, light bar on
|
||||
// dark pages. This keeps the bar from fighting with the host design.
|
||||
function detectPageTheme() {
|
||||
try {
|
||||
@@ -6092,7 +6194,7 @@ void main() {
|
||||
// Walk body → html, taking the first opaque background. The browser's
|
||||
// default body / html background is `rgba(0, 0, 0, 0)`, which a naive
|
||||
// regex would read as black and mislabel a perfectly white page as
|
||||
// dark. Honoring alpha avoids that — and falling through to <html>
|
||||
// dark. Honoring alpha avoids that - and falling through to <html>
|
||||
// catches the common pattern of a bg only on <html> (or only on body).
|
||||
function readOpaque(el) {
|
||||
if (!el) return null;
|
||||
@@ -6141,7 +6243,7 @@ void main() {
|
||||
exitHover: 'oklch(58% 0.15 35 / 0.18)',
|
||||
shadow: PICKER_SHADOW,
|
||||
chatSurface: 'oklch(22% 0.012 82)',
|
||||
// Verdigris patina — secondary state (see site/styles/kinpaku-tokens.css)
|
||||
// Verdigris patina - secondary state (see site/styles/kinpaku-tokens.css)
|
||||
patina: 'oklch(70% 0.12 188)',
|
||||
patinaPale: 'oklch(82% 0.07 188)',
|
||||
patinaSoft: 'oklch(70% 0.12 188 / 0.28)',
|
||||
@@ -6913,7 +7015,7 @@ void main() {
|
||||
steerFocusLog('page-chat-mounted', {});
|
||||
}
|
||||
|
||||
// Impeccable mark — same paths as site/components/Header.astro + favicon.svg.
|
||||
// Impeccable mark - same paths as site/components/Header.astro + favicon.svg.
|
||||
function brandMarkSvg(color = C.brand, size = 18) {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="${color}" aria-hidden="true">
|
||||
<path d="M5 2.5 L13.5 2.5 L5.5 21.5 L5 21.5 Q2.5 21.5 2.5 19 L2.5 5 Q2.5 2.5 5 2.5 Z"/>
|
||||
@@ -6928,7 +7030,7 @@ void main() {
|
||||
globalBarBrandEl.dataset.agentConnected = connected ? 'true' : 'false';
|
||||
globalBarBrandEl.setAttribute('aria-label', connected
|
||||
? 'Impeccable live mode'
|
||||
: 'Impeccable live mode — agent not polling');
|
||||
: 'Impeccable live mode: agent not polling');
|
||||
globalBarBrandEl.removeAttribute('title');
|
||||
globalBarBrandEl.style.cursor = connected ? 'default' : 'help';
|
||||
const mark = globalBarBrandEl.querySelector('[data-brand-mark]');
|
||||
@@ -7053,7 +7155,7 @@ void main() {
|
||||
globalBarEl.id = PREFIX + '-global-bar';
|
||||
globalBarEl.dataset.theme = theme;
|
||||
|
||||
// Brand mark — kinpaku Impeccable icon (site header / favicon paths).
|
||||
// Brand mark - kinpaku Impeccable icon (site header / favicon paths).
|
||||
const brand = el('span', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
alignSelf: 'stretch', position: 'relative',
|
||||
@@ -7065,7 +7167,7 @@ void main() {
|
||||
brand.id = PREFIX + '-global-bar-brand';
|
||||
brand.dataset.agentConnected = 'false';
|
||||
brand.setAttribute('role', 'img');
|
||||
brand.setAttribute('aria-label', 'Impeccable live mode — agent not polling');
|
||||
brand.setAttribute('aria-label', 'Impeccable live mode: agent not polling');
|
||||
|
||||
const brandMark = el('span', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
@@ -7130,7 +7232,7 @@ void main() {
|
||||
// Per-button hover only changes color (no layout). The label expand/
|
||||
// collapse is driven by the bar-level mouseenter/mouseleave so moving
|
||||
// the mouse between adjacent buttons doesn't trigger per-button width
|
||||
// thrashing — the whole bar grows once and shrinks once.
|
||||
// thrashing - the whole bar grows once and shrinks once.
|
||||
b.addEventListener('mouseenter', () => { if (b.dataset.active !== 'true') b.style.color = P.text; });
|
||||
b.addEventListener('mouseleave', () => { if (b.dataset.active !== 'true') b.style.color = P.textDim; });
|
||||
b.addEventListener('click', onClick);
|
||||
@@ -7139,7 +7241,7 @@ void main() {
|
||||
return b;
|
||||
}
|
||||
|
||||
// Pick toggle — restored from localStorage; both pick and insert may be off.
|
||||
// Pick toggle - restored from localStorage; both pick and insert may be off.
|
||||
const pickBtn = makeIconBtn({
|
||||
id: PREFIX + '-pick-toggle',
|
||||
svg: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><circle cx="12" cy="12" r="10"/><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><line x1="12" y1="6" x2="12" y2="2"/><line x1="12" y1="22" x2="12" y2="18"/></svg>',
|
||||
@@ -7176,7 +7278,7 @@ void main() {
|
||||
detectBtn.appendChild(detectBadge);
|
||||
inner.appendChild(detectBtn);
|
||||
|
||||
// DESIGN.md panel toggle — quartet of color squares as the mark.
|
||||
// DESIGN.md panel toggle - quartet of color squares as the mark.
|
||||
const designBtn = makeIconBtn({
|
||||
id: PREFIX + '-design-toggle',
|
||||
svg: `<span style="display:inline-grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;width:14px;height:14px;border-radius:3px;overflow:hidden;box-shadow:inset 0 0 0 1px oklch(58% 0.065 82 / 0.55);flex-shrink:0">
|
||||
@@ -7378,13 +7480,13 @@ void main() {
|
||||
});
|
||||
inner.appendChild(divider);
|
||||
|
||||
// Exit × on the right — intentionally subtle (textDim at rest, text on
|
||||
// Exit × on the right - intentionally subtle (textDim at rest, text on
|
||||
// hover) so it sits behind the active toggles in visual hierarchy.
|
||||
//
|
||||
// Explicit padding + box-sizing here is load-bearing: a host page like
|
||||
// `button { padding: 0.5rem 1rem; }` (very common in resets) would
|
||||
// otherwise inflate this 24x24 button into 56x40 and push the SVG out
|
||||
// of the visible bar — the X stays invisible even though the styles in
|
||||
// of the visible bar - the X stays invisible even though the styles in
|
||||
// DevTools look fine. Every other chrome button sets padding inline;
|
||||
// this one needed it too.
|
||||
const exitBtn = el('button', {
|
||||
@@ -7472,7 +7574,7 @@ void main() {
|
||||
btn.style.opacity = controlsLocked ? '0.55' : '1';
|
||||
});
|
||||
|
||||
// If the bar is currently under the cursor, keep all labels expanded —
|
||||
// If the bar is currently under the cursor, keep all labels expanded -
|
||||
// otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
|
||||
// would collapse its label while the user's mouse is still on the bar.
|
||||
if (globalBarEl && globalBarEl.matches(':hover')) {
|
||||
@@ -7648,7 +7750,7 @@ void main() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Design System Panel — visualizes the project's .impeccable/design.json sidecar
|
||||
// Design System Panel - visualizes the project's .impeccable/design.json sidecar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DESIGN_PREFS_KEY = 'impeccable-live-design-panel';
|
||||
@@ -7674,7 +7776,7 @@ void main() {
|
||||
};
|
||||
|
||||
function loadDesignPrefs() {
|
||||
// `open` is intentionally NOT persisted — the panel always starts closed
|
||||
// `open` is intentionally NOT persisted - the panel always starts closed
|
||||
// so live mode doesn't auto-slide a big panel over the page on startup.
|
||||
try {
|
||||
const raw = localStorage.getItem(DESIGN_PREFS_KEY);
|
||||
@@ -7731,7 +7833,7 @@ void main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Neutral panel palette — deliberately NOT Impeccable-branded. The panel is
|
||||
// Neutral panel palette - deliberately NOT Impeccable-branded. The panel is
|
||||
// a viewer of the project's design system, not an Impeccable surface.
|
||||
const DP = {
|
||||
canvas: 'oklch(94% 0 0)', // panel background
|
||||
@@ -8025,7 +8127,7 @@ void main() {
|
||||
const root = designShadow.querySelector('.root');
|
||||
root.innerHTML = '';
|
||||
|
||||
// (Panel toggle lives in the global bar — no floating FAB.)
|
||||
// (Panel toggle lives in the global bar - no floating FAB.)
|
||||
// Panel
|
||||
const panel = document.createElement('aside');
|
||||
panel.className = 'panel';
|
||||
@@ -8141,7 +8243,7 @@ void main() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Visual tab — single unified render path.
|
||||
// Visual tab - single unified render path.
|
||||
if (designState.mdNewerThanJson) body.appendChild(renderStaleHint());
|
||||
if (designState.hasMd && !designState.hasSidecar) {
|
||||
body.appendChild(renderParsedMdCta());
|
||||
@@ -8357,7 +8459,7 @@ void main() {
|
||||
specimen.style.fontFamily = fontStack(t);
|
||||
specimen.style.fontWeight = String(t.weight || 400);
|
||||
specimen.style.fontStyle = t.style || 'normal';
|
||||
specimen.style.fontSize = '56px'; // Fixed specimen size — compare faces, not scales.
|
||||
specimen.style.fontSize = '56px'; // Fixed specimen size - compare faces, not scales.
|
||||
specimen.style.letterSpacing = 'normal';
|
||||
specimen.style.textTransform = 'none';
|
||||
tile.appendChild(specimen);
|
||||
@@ -8501,7 +8603,7 @@ void main() {
|
||||
}
|
||||
|
||||
// Single shared description if all items carry the same one; otherwise
|
||||
// skip — per-item descriptions clutter a grouped tile.
|
||||
// skip - per-item descriptions clutter a grouped tile.
|
||||
if (group.length === 1 && group[0].description) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'c-desc';
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Impeccable Live Variant Mode — Browser Script
|
||||
* Impeccable Live Variant Mode - Browser Script
|
||||
*
|
||||
* Injected into the user's page via <script src="http://localhost:PORT/live.js">.
|
||||
* The server prepends window.__IMPECCABLE_TOKEN__ and window.__IMPECCABLE_PORT__
|
||||
* before this code.
|
||||
*
|
||||
* UI: a single floating bar that morphs between three states —
|
||||
* UI: a single floating bar that morphs between three states -
|
||||
* configure (pick action + go), generating (progressive dots), and cycling
|
||||
* (prev/next + accept/discard). Feels like Spotlight, not a modal.
|
||||
*/
|
||||
@@ -46,7 +46,7 @@
|
||||
mist: 'oklch(90% 0.008 82 / 0.6)', // light hairline
|
||||
white: 'oklch(99% 0 0)',
|
||||
};
|
||||
// Picker bar chrome — mirrors .live-demo-gbar / .live-demo-ctx in kinpaku-kit.css.
|
||||
// Picker bar chrome - mirrors .live-demo-gbar / .live-demo-ctx in kinpaku-kit.css.
|
||||
// Quiet neutral elevation: no gold halo ring (gold is reserved for the brand
|
||||
// mark and the active control, not the container outline).
|
||||
const PICKER_SHADOW =
|
||||
@@ -83,7 +83,7 @@
|
||||
|
||||
// SVG icons stack above each chip label. All strokes use currentColor so the
|
||||
// icon recolors to C.brand when its chip is selected. 20x20 render, 24-viewBox,
|
||||
// 1.5 stroke — visually consistent with the Foundation grid on the homepage.
|
||||
// 1.5 stroke - visually consistent with the Foundation grid on the homepage.
|
||||
const ICON_ATTRS = 'width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="display:block"';
|
||||
const ICONS = {
|
||||
impeccable: `<svg ${ICON_ATTRS}><path d="M4 20l4-1L18 9l-3-3L5 16z"/><path d="M14 7l3 3"/></svg>`,
|
||||
@@ -123,6 +123,7 @@
|
||||
let hoveredElement = null;
|
||||
let selectedElement = null;
|
||||
let currentSessionId = null;
|
||||
let pendingAcceptedSession = null;
|
||||
let expectedVariants = 0;
|
||||
let arrivedVariants = 0;
|
||||
let visibleVariant = 0;
|
||||
@@ -133,7 +134,7 @@
|
||||
const browserOwner = sessionState.owner;
|
||||
let checkpointTimer = null;
|
||||
|
||||
// Scroll lock — holds window.scrollY at a fixed value while the session is
|
||||
// Scroll lock - holds window.scrollY at a fixed value while the session is
|
||||
// active, so HMR DOM patches and variant swaps can't drift the page. See
|
||||
// startScrollLock / stopScrollLock below.
|
||||
let scrollLockObserver = null;
|
||||
@@ -141,7 +142,7 @@
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
// Dedicated key for scroll position — SEPARATE from LS_KEY so that
|
||||
// Dedicated key for scroll position - SEPARATE from LS_KEY so that
|
||||
// saveSession's state updates don't clobber a carefully-captured scrollY.
|
||||
// (Previously: saveSession wrote scrollY alongside state, so every call
|
||||
// during resume overwrote the pre-reload value with whatever the browser
|
||||
@@ -231,7 +232,7 @@
|
||||
// - Stop `focusin` propagation so any focus shifts inside our chrome
|
||||
// don't read as "focus moved outside the dialog" to focus traps.
|
||||
//
|
||||
// Click events still bubble normally — only the early pointer/focus
|
||||
// Click events still bubble normally - only the early pointer/focus
|
||||
// signals that drive outside-interaction detection are silenced.
|
||||
function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) {
|
||||
if (!rootEl) return;
|
||||
@@ -319,7 +320,7 @@
|
||||
// correlate directly with the captured PNG.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DRAG_THRESHOLD = 5; // px — below this, treat pointerup as a click
|
||||
const DRAG_THRESHOLD = 5; // px - below this, treat pointerup as a click
|
||||
const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it
|
||||
let annotOverlayEl = null;
|
||||
let annotSvgEl = null;
|
||||
@@ -423,7 +424,7 @@
|
||||
placeholderResizeDrag = null;
|
||||
if (annotOverlayEl) annotOverlayEl.style.display = 'none';
|
||||
syncPlaceholderResizeHandles();
|
||||
// Drop any in-progress edit without touching annotState — clearAnnotations
|
||||
// Drop any in-progress edit without touching annotState - clearAnnotations
|
||||
// (if the caller is exiting configure mode) handles state reset.
|
||||
annotEditing = null;
|
||||
}
|
||||
@@ -488,7 +489,7 @@
|
||||
function onAnnotDown(e) {
|
||||
if (!annotActive) return;
|
||||
|
||||
// 0) Insert placeholder edge resize — wins over draw / pins.
|
||||
// 0) Insert placeholder edge resize - wins over draw / pins.
|
||||
const resizeEdge = e.target.closest?.('[data-impeccable-placeholder-resize]')?.dataset.impeccablePlaceholderResize;
|
||||
if (resizeEdge && configureKind === 'insert' && placeholderElement) {
|
||||
startPlaceholderEdgeResize(resizeEdge, e);
|
||||
@@ -536,7 +537,7 @@
|
||||
// If editing a different pin, commit that edit before starting here.
|
||||
if (annotEditing && annotEditing.idx !== idx) finalizeEditingPin();
|
||||
// If already editing THIS pin and the user clicked the dot, let the
|
||||
// input keep focus (don't start a drag — the click wasn't meant as one).
|
||||
// input keep focus (don't start a drag - the click wasn't meant as one).
|
||||
if (annotEditing && annotEditing.idx === idx) return;
|
||||
const p = localCoords(e);
|
||||
const pin = annotState.comments[idx];
|
||||
@@ -971,7 +972,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The Bar — one floating element, three modes
|
||||
// The Bar - one floating element, three modes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Contextual-bar palette. Cached at init so every build*Row reads a
|
||||
@@ -1752,7 +1753,7 @@
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
});
|
||||
|
||||
// Action pill — dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill)
|
||||
// Action pill - dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill)
|
||||
const pill = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
||||
padding: '5px 10px', borderRadius: '6px',
|
||||
@@ -1786,7 +1787,7 @@
|
||||
});
|
||||
row.appendChild(pill);
|
||||
|
||||
// Prompt field — same chat-surface chrome as the bottom Steer bar
|
||||
// Prompt field - same chat-surface chrome as the bottom Steer bar
|
||||
const inputWrap = el('div', {
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
flex: '1', minWidth: '0', height: '28px',
|
||||
@@ -2126,7 +2127,7 @@
|
||||
if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
|
||||
row.appendChild(next);
|
||||
|
||||
// Tune chip — only when the visible variant exposes params
|
||||
// Tune chip - only when the visible variant exposes params
|
||||
const visParams = parseVariantParams(getVisibleVariantEl());
|
||||
const hasParams = visParams.length > 0;
|
||||
if (hasParams) {
|
||||
@@ -2173,7 +2174,7 @@
|
||||
// Spacer
|
||||
row.appendChild(el('div', { flex: '1' }));
|
||||
|
||||
// Accept — primary action, kinpaku gold + lacquer-deep (matches demo .live-demo-ctx-accept)
|
||||
// Accept - primary action, kinpaku gold + lacquer-deep (matches demo .live-demo-ctx-accept)
|
||||
const accept = el('button', {
|
||||
padding: '5px 14px', borderRadius: '5px',
|
||||
border: 'none', background: C.brand, color: C.ink,
|
||||
@@ -2266,7 +2267,7 @@
|
||||
const active = i === visibleVariant;
|
||||
// active: solid site-brand kinpaku dot. arrived+inactive: muted neutral.
|
||||
// pending (not yet arrived): faint outline ring. No borders on arrived
|
||||
// dots — the previous "accent ring + ash fill" combo read as noisy
|
||||
// dots - the previous "accent ring + ash fill" combo read as noisy
|
||||
// kinpaku chips, especially when all variants had arrived and every
|
||||
// dot wore an accent ring.
|
||||
const dotBg = active ? C.brand
|
||||
@@ -2456,7 +2457,7 @@
|
||||
let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide)
|
||||
let paramsPanelInner = null; // translating content (carries bg, padding, knobs)
|
||||
let paramsPanelBody = null; // grid holding the knob cells
|
||||
let paramsCurrentValues = {}; // {paramId: value} — mirror of the visible variant's live values
|
||||
let paramsCurrentValues = {}; // {paramId: value} - mirror of the visible variant's live values
|
||||
let tuneOpen = false; // whether the Tune popover is open right now
|
||||
|
||||
// Theme-aware Tune popover. Appears as a drawer that slides out from the
|
||||
@@ -2471,7 +2472,7 @@
|
||||
const P = paramsPanelPalette;
|
||||
|
||||
// Single element, always in the DOM. The slide animation is a CSS mask
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis — no
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis - no
|
||||
// display toggle, no opacity toggle, no transform trickery. The mask
|
||||
// hides everything initially; as it grows, content is revealed from
|
||||
// the bar edge outward.
|
||||
@@ -2494,7 +2495,7 @@
|
||||
transition: 'clip-path 0.44s ' + EASE,
|
||||
|
||||
// Park off-screen until positionParamsPanel places it. These are NOT
|
||||
// in the transition list, so they snap instantly — no fly-in from the
|
||||
// in the transition list, so they snap instantly - no fly-in from the
|
||||
// top-left when first shown.
|
||||
top: '-9999px', left: '-9999px', width: '0',
|
||||
});
|
||||
@@ -2685,7 +2686,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline text editing — makes pure-text descendants of the picked element
|
||||
// Inline text editing - makes pure-text descendants of the picked element
|
||||
// directly contenteditable. Save stages copy edits in the live buffer; the
|
||||
// Apply copy edits dock later asks the AI to apply the staged batch.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -3132,7 +3133,7 @@
|
||||
if (detail.includes('newText cannot contain') || detail.includes('newText cannot be empty')) {
|
||||
showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500);
|
||||
} else {
|
||||
showToast('Save failed — retry or cancel', 4000);
|
||||
showToast('Save failed: retry or cancel', 4000);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3454,19 +3455,19 @@
|
||||
updatePendingCounter(remaining);
|
||||
if (result.failed && result.failed.length > 0) {
|
||||
console.warn('[impeccable] some copy edits failed:', result.failed);
|
||||
showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed — see console', 5000);
|
||||
showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed, see console', 5000);
|
||||
} else {
|
||||
const n = Array.isArray(result.applied) ? result.applied.length : (result.cleared || 0);
|
||||
if (n > 0) {
|
||||
showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500);
|
||||
} else {
|
||||
console.warn('[impeccable] apply returned no verified edits:', result);
|
||||
showToast('No edits applied — see console', 4000);
|
||||
showToast('No edits applied, see console', 4000);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[impeccable] commit failed:', err);
|
||||
showToast('Apply failed — see console', 4000);
|
||||
showToast('Apply failed, see console', 4000);
|
||||
} finally {
|
||||
if (waitForSseCompletion) return;
|
||||
const remainingCount = parseInt(pendingPillEl?.dataset.count || '0', 10) || 0;
|
||||
@@ -3496,7 +3497,7 @@
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[impeccable] discard failed:', err);
|
||||
showToast('Discard failed — see console', 4000);
|
||||
showToast('Discard failed, see console', 4000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3644,7 +3645,7 @@
|
||||
const failedCount = numberOrNull(msg.failedCount) || 0;
|
||||
const appliedCount = numberOrNull(msg.appliedCount) || numberOrNull(msg.cleared) || 0;
|
||||
if (failedCount > 0) {
|
||||
showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed — see console', 5000);
|
||||
showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed, see console', 5000);
|
||||
} else if (appliedCount > 0) {
|
||||
showToast('Applied ' + appliedCount + ' edit' + (appliedCount === 1 ? '' : 's'), 2500);
|
||||
}
|
||||
@@ -3799,7 +3800,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit content badge — floating button at element top-right to enter EDITING mode
|
||||
// Edit content badge - floating button at element top-right to enter EDITING mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initEditBadge() {
|
||||
@@ -3884,7 +3885,7 @@
|
||||
}
|
||||
editBadgeEl.appendChild(btn);
|
||||
} else {
|
||||
// 'editing' — show Cancel + Save separated
|
||||
// 'editing' - show Cancel + Save separated
|
||||
editBadgeEl.innerHTML = '';
|
||||
editBadgeEl.style.gap = '8px';
|
||||
const cancel = document.createElement('button');
|
||||
@@ -4083,7 +4084,7 @@
|
||||
if (!v) continue;
|
||||
setVariantShown(child, v === String(num));
|
||||
}
|
||||
// Unconditional refresh — covers first-reveal (no-op if state isn't
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
// CYCLING yet, the subsequent CYCLING transition triggers its own
|
||||
// refresh) and every cycle step.
|
||||
refreshParamsPanel();
|
||||
@@ -4318,7 +4319,7 @@
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
}, { passive: true, ...sig });
|
||||
|
||||
// Apply target synchronously, not via rAF — racing the browser's
|
||||
// Apply target synchronously, not via rAF - racing the browser's
|
||||
// restore or a smooth-scroll animation means we want to win now.
|
||||
if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) {
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
@@ -4545,18 +4546,34 @@
|
||||
// hidden tab, a route the user navigated away from). The variant
|
||||
// MutationObserver stays armed and auto-transitions to CYCLING
|
||||
// the moment the wrapper actually mounts. Nudge the user toward
|
||||
// that path with a toast — better than the prior force-reload
|
||||
// that path with a toast - better than the prior force-reload
|
||||
// which reset framework state and left the session stuck.
|
||||
setTimeout(() => {
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) return;
|
||||
if (state !== 'GENERATING') return;
|
||||
showToast(
|
||||
"Variants ready. If the picked element isn't visible, retrace the path that revealed it — they'll appear automatically.",
|
||||
"Variants ready. If the picked element isn't visible, retrace the path that revealed it; they'll appear automatically.",
|
||||
15000,
|
||||
);
|
||||
}, 2000);
|
||||
break;
|
||||
case 'complete':
|
||||
case 'accept':
|
||||
if (maybeCompleteAcceptedSession(msg)) break;
|
||||
break;
|
||||
case 'agent_done':
|
||||
// Carbonize accepts are not terminal until live-complete.mjs sends
|
||||
// the final complete event. Keep the browser in its recoverable
|
||||
// saving state while the source cleanup is still in flight.
|
||||
break;
|
||||
case 'error':
|
||||
if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) {
|
||||
pendingAcceptedSession = null;
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
break;
|
||||
}
|
||||
if (maybeCompleteSteer(msg)) break;
|
||||
console.error('[impeccable] Error:', msg.message);
|
||||
showToast('Error: ' + msg.message, 5000);
|
||||
@@ -4779,14 +4796,14 @@
|
||||
|
||||
/**
|
||||
* Surface a brief, non-blocking heads-up when the picked element lives
|
||||
* inside a container whose visibility is gated by ephemeral state — modals,
|
||||
* inside a container whose visibility is gated by ephemeral state - modals,
|
||||
* collapsible panels, popovers, off-screen tab panels. If HMR remounts the
|
||||
* parent during generation (Vite Fast Refresh, SvelteKit page reload), the
|
||||
* variants land in source but stay invisible until the user re-opens the
|
||||
* container. Telling the user upfront is much friendlier than the silent
|
||||
* timeout-then-toast that they'd otherwise hit.
|
||||
*
|
||||
* Heuristic, intentionally narrow — only fires for unambiguous cases so
|
||||
* Heuristic, intentionally narrow - only fires for unambiguous cases so
|
||||
* we don't cry wolf on every nested element.
|
||||
*/
|
||||
function maybeWarnConditionalAncestor(el) {
|
||||
@@ -4804,7 +4821,7 @@
|
||||
showToast('Heads up: this element lives inside an open panel. If state resets during generation, you may need to re-open it.', 6000);
|
||||
return;
|
||||
}
|
||||
// 3. Tab panel — only meaningful when the page also shows ANOTHER
|
||||
// 3. Tab panel - only meaningful when the page also shows ANOTHER
|
||||
// tab as selected. A single tabpanel with no tablist is just a static
|
||||
// section in disguise and isn't conditional.
|
||||
if (node.getAttribute && node.getAttribute('role') === 'tabpanel') {
|
||||
@@ -4833,7 +4850,7 @@
|
||||
// Fire a lightweight prefetch event the first time the user selects an
|
||||
// element on a given route. The agent uses this to Read the underlying file
|
||||
// into context before Go is hit, shaving the read off the critical path.
|
||||
// Dedupe per session by pathname — clicking around on the same page doesn't
|
||||
// Dedupe per session by pathname - clicking around on the same page doesn't
|
||||
// re-fire.
|
||||
//
|
||||
// DISABLED: quick-Go workflows pay an extra harness round trip because
|
||||
@@ -4969,6 +4986,7 @@
|
||||
disableInlineEdit();
|
||||
stripManualEditRuntimeState(selectedElement);
|
||||
|
||||
pendingAcceptedSession = null;
|
||||
currentSessionId = id8();
|
||||
expectedVariants = selectedCount;
|
||||
arrivedVariants = 0;
|
||||
@@ -4976,7 +4994,7 @@
|
||||
|
||||
// Flip to GENERATING immediately so the bar morphs without waiting on
|
||||
// capture + upload. The event is emitted from captureAndEmit() once the
|
||||
// screenshot is uploaded (or capture fails — we still emit, just without
|
||||
// screenshot is uploaded (or capture fails - we still emit, just without
|
||||
// screenshotPath).
|
||||
const elForCapture = selectedElement;
|
||||
const captureRect = elForCapture.getBoundingClientRect();
|
||||
@@ -5002,7 +5020,7 @@
|
||||
state = 'GENERATING';
|
||||
// Disable the Edit badge: starting a manual text edit mid-generation would
|
||||
// conflict with the variant wrap that's about to land in the same DOM
|
||||
// region. Only swap if the badge was visible — picked elements with no
|
||||
// region. Only swap if the badge was visible - picked elements with no
|
||||
// text rows have it hidden already.
|
||||
if (editBadgeEl && editBadgeEl.style.display !== 'none') renderEditBadge('idle-disabled');
|
||||
showBar('generating');
|
||||
@@ -5104,7 +5122,7 @@
|
||||
|
||||
// Collect @font-face rules from every stylesheet on the page. Cross-origin
|
||||
// sheets (Google Fonts, Typekit, etc.) throw SecurityError on .cssRules
|
||||
// access, so modern-screenshot can't embed them on its own — the resulting
|
||||
// access, so modern-screenshot can't embed them on its own - the resulting
|
||||
// SVG falls back to system fonts and text re-wraps + renders with different
|
||||
// weight. We fetch the raw CSS text (CORS-permitted for these providers),
|
||||
// extract @font-face blocks, inline the referenced font files as base64
|
||||
@@ -5188,7 +5206,7 @@
|
||||
// modern-screenshot force-sets `background-color: X !important` on the
|
||||
// cloned root whenever `backgroundColor` is passed, clobbering the
|
||||
// element's own background. So we only pass it when the element is
|
||||
// genuinely transparent (no own color, no own image) — in that case
|
||||
// genuinely transparent (no own color, no own image) - in that case
|
||||
// we resolve up the DOM to the nearest opaque ancestor so the capture
|
||||
// sits on the page's real background instead of rendering black.
|
||||
function resolveCanvasBackground(el) {
|
||||
@@ -5206,7 +5224,7 @@
|
||||
// `getComputedStyle(body).backgroundColor || …` chain is a trap: that
|
||||
// call returns the literal string `"rgba(0, 0, 0, 0)"` for a page that
|
||||
// never set its own bg, which is truthy and short-circuits the chain to
|
||||
// transparent-black — modern-screenshot then renders the capture on a
|
||||
// transparent-black - modern-screenshot then renders the capture on a
|
||||
// black canvas and the shader overlay flashes solid black during load.
|
||||
// The browser canvas defaults to white, so we do too.
|
||||
return '#ffffff';
|
||||
@@ -5248,7 +5266,7 @@
|
||||
// Transparent up to the root. The visible backdrop may still come from an
|
||||
// ancestor's background-image or a covering positioned layer (e.g. a hero
|
||||
// art div) that the color walk can't see. Capture that ancestor and crop
|
||||
// to the element so the real backdrop is embedded — correct for both the
|
||||
// to the element so the real backdrop is embedded - correct for both the
|
||||
// shader and the screenshot sent to the model. Fall back to white only
|
||||
// when nothing is actually painted behind the element.
|
||||
const backdrop = findBackdropAncestor(el);
|
||||
@@ -5289,13 +5307,13 @@
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] capture failed, proceeding without screenshot:', err);
|
||||
}
|
||||
// Light up the shader overlay the moment capture is ready — no reason to
|
||||
// Light up the shader overlay the moment capture is ready - no reason to
|
||||
// wait for the upload to complete before the user sees something alive.
|
||||
if (blob && state === 'GENERATING') {
|
||||
showShaderOverlay(el, blob, rect, paper);
|
||||
}
|
||||
// Only upload + forward the screenshot when annotations (comments/strokes)
|
||||
// are present. Without annotations the image is pure visual anchoring —
|
||||
// are present. Without annotations the image is pure visual anchoring -
|
||||
// it biases the model toward the current rendering and works against the
|
||||
// three-distinct-directions brief.
|
||||
const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0);
|
||||
@@ -5320,7 +5338,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shader overlay — renders the captured screenshot as a WebGL texture and
|
||||
// Shader overlay - renders the captured screenshot as a WebGL texture and
|
||||
// runs an editorial "ink-wash" fragment shader over it during generation.
|
||||
// A single rolling band sweeps top-to-bottom, desaturating + tinting kinpaku
|
||||
// and leaving a soft trail. Makes the wait feel like a letterpress scan
|
||||
@@ -5343,7 +5361,7 @@ uniform vec3 u_accent;
|
||||
uniform vec3 u_paper;
|
||||
varying vec2 v_uv;
|
||||
|
||||
// Asymmetric roller band. Product of two one-sided smoothsteps — peaks at
|
||||
// Asymmetric roller band. Product of two one-sided smoothsteps - peaks at
|
||||
// d=0 with a short sharp leading ramp and a longer soft trailing tail. Clean
|
||||
// outside the [-leadW, trailW] range (no rogue "trail=1 everywhere below"
|
||||
// failure that reversed-edge smoothstep would give).
|
||||
@@ -5369,8 +5387,8 @@ void main() {
|
||||
vec2 sampleCenter = (cellId + 0.5) * cellPx / u_resolution;
|
||||
vec3 cellImg = texture2D(u_texture, sampleCenter).rgb;
|
||||
// Dot size tracks how much the cell DIFFERS from the element's own ground
|
||||
// (u_paper), not absolute darkness. So the content — text, buttons, anything
|
||||
// that deviates from the background — always becomes the dots, on light AND
|
||||
// (u_paper), not absolute darkness. So the content - text, buttons, anything
|
||||
// that deviates from the background - always becomes the dots, on light AND
|
||||
// dark surfaces. A plain darkness curve inverts on dark elements: the dark
|
||||
// background fills with ink and the lighter content punches holes instead.
|
||||
// Capped below the cell half-width so dense content stays separated dots.
|
||||
@@ -5380,8 +5398,8 @@ void main() {
|
||||
// Two-stage dissolve as the roller passes, so the element is rebuilt purely
|
||||
// from dot size (its own halftone) and never bleeds through as raw pixels
|
||||
// behind the dots:
|
||||
// 1. cover — the element flattens to the uniform paper ground first.
|
||||
// 2. dotAmt — kinpaku dots then emerge, sized by each cell's luma.
|
||||
// 1. cover - the element flattens to the uniform paper ground first.
|
||||
// 2. dotAmt - kinpaku dots then emerge, sized by each cell's luma.
|
||||
// A plain mix(base, halftone, band) instead left the raw element visible
|
||||
// through the band's soft core/trail. The paper ground is u_paper (the
|
||||
// element's own bg tone) rather than a fixed white, so the dissolve reads the
|
||||
@@ -5399,7 +5417,7 @@ void main() {
|
||||
|
||||
// Kinpaku gold converted to approximate sRGB 0-1 (matches oklch(84% 0.19 80.46))
|
||||
const SHADER_ACCENT = [1.0, 0.78, 0.31];
|
||||
// Fallback ground when an element and all its ancestors are transparent —
|
||||
// Fallback ground when an element and all its ancestors are transparent -
|
||||
// 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 }
|
||||
@@ -5412,7 +5430,7 @@ void main() {
|
||||
// Rasterize any CSS color (oklch, color(), named, hex, rgb) through a 1x1
|
||||
// canvas and read back the sRGB pixel. String-parsing computed colors is a
|
||||
// trap: Chrome returns backgroundColor as oklch()/color() for oklch inputs,
|
||||
// which a hex/rgb regex misses — every site token would fall back to white.
|
||||
// which a hex/rgb regex misses - every site token would fall back to white.
|
||||
let colorParseCtx = null;
|
||||
function cssColorToRgb01(str) {
|
||||
if (!colorParseCtx) {
|
||||
@@ -5440,7 +5458,7 @@ void main() {
|
||||
|
||||
// When an element is transparent up to the root, its visible backdrop can
|
||||
// still come from an ancestor's background-image or a covering positioned
|
||||
// layer that is a *child* of an ancestor (e.g. a hero's absolute art div) —
|
||||
// layer that is a *child* of an ancestor (e.g. a hero's absolute art div) -
|
||||
// neither of which the ancestor background-COLOR walk can see. Return the
|
||||
// nearest such ancestor so we can capture it and crop, embedding the real
|
||||
// backdrop. Returns null when nothing is actually painted behind the element
|
||||
@@ -5481,7 +5499,7 @@ void main() {
|
||||
|
||||
// Average the backdrop sampled just OUTSIDE an element's rect within a larger
|
||||
// canvas. The ground tone for the dissolve must be the real backdrop, not the
|
||||
// mean of the element's own crop — averaging the crop folds in the element's
|
||||
// mean of the element's own crop - averaging the crop folds in the element's
|
||||
// content (e.g. bright heading text), pulling the ground toward muddy gray.
|
||||
function sampleSurroundingRgb(ctx, sx, sy, sw, sh, W, H) {
|
||||
const pad = Math.max(2, Math.round(Math.min(sw, sh) * 0.12));
|
||||
@@ -5528,11 +5546,31 @@ void main() {
|
||||
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;
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
canvas.remove();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const fallback = document.createElement('div');
|
||||
fallback.id = PREFIX + '-shader';
|
||||
// Copy positioning via cssText. Object.assign across CSSStyleDeclaration
|
||||
// throws in modern Chromium because the source's indexed properties
|
||||
// (style[0], [1], ...) are read-only and the engine forbids writing
|
||||
// them on the destination.
|
||||
fallback.style.cssText = canvas.style.cssText;
|
||||
fallback.style.backgroundImage = 'url("' + objectUrl + '")';
|
||||
fallback.style.backgroundSize = '100% 100%';
|
||||
fallback.style.backgroundRepeat = 'no-repeat';
|
||||
fallback.style.outline = '2px dashed ' + C.brand;
|
||||
fallback.style.outlineOffset = '-2px';
|
||||
document.body.appendChild(fallback);
|
||||
shaderState = { canvas: fallback, gl: null, program: null, texture: null, rafId: 0, startTime: 0, objectUrl };
|
||||
}
|
||||
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
@@ -5553,21 +5591,9 @@ void main() {
|
||||
const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false })
|
||||
|| canvas.getContext('experimental-webgl');
|
||||
if (!gl) {
|
||||
// WebGL unavailable — fall back to a plain <img> overlay so the user
|
||||
// still sees something meaningful during generation.
|
||||
canvas.remove();
|
||||
const img = document.createElement('img');
|
||||
img.src = URL.createObjectURL(blob);
|
||||
img.id = PREFIX + '-shader';
|
||||
// Copy positioning via cssText. Object.assign across CSSStyleDeclaration
|
||||
// throws in modern Chromium because the source's indexed properties
|
||||
// (style[0], [1], ...) are read-only and the engine forbids writing
|
||||
// them on the destination.
|
||||
img.style.cssText = canvas.style.cssText;
|
||||
img.style.outline = '2px dashed ' + C.brand;
|
||||
img.style.outlineOffset = '-2px';
|
||||
document.body.appendChild(img);
|
||||
shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 };
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5609,14 +5635,12 @@ void main() {
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch {
|
||||
// Safari fallback: go via a regular Image
|
||||
const imgUrl = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
img.src = imgUrl;
|
||||
await new Promise((r, rej) => { img.onload = r; img.onerror = rej; });
|
||||
bitmap = img;
|
||||
URL.revokeObjectURL(imgUrl);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
@@ -5670,14 +5694,15 @@ void main() {
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
// The accepted variant is already the only visible child of the wrapper
|
||||
// (all other variants are display:none). HMR from the source rewrite will
|
||||
// replace the wrapper imminently. Don't eagerly replaceChild here — React
|
||||
// reconciliation races with our mutation and throws NotFoundError in Next
|
||||
// 16 / Turbopack. Schedule a fallback that runs the manual swap only if
|
||||
// HMR hasn't cleaned up by then (keeps static-server flows working).
|
||||
const acceptedSessionId = currentSessionId;
|
||||
const acceptedVariant = visibleVariant;
|
||||
const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant);
|
||||
pendingAcceptedSession = {
|
||||
id: acceptedSessionId,
|
||||
variant: String(acceptedVariant),
|
||||
...acceptedSnapshot,
|
||||
finalizing: false,
|
||||
};
|
||||
|
||||
state = 'SAVING';
|
||||
updateBarContent('saving');
|
||||
@@ -5685,53 +5710,130 @@ void main() {
|
||||
sendEvent(acceptPayload, { throwOnError: true })
|
||||
.then(() => {
|
||||
markSessionHandled();
|
||||
confirmAcceptAfterReceipt();
|
||||
})
|
||||
.catch(() => {
|
||||
pendingAcceptedSession = null;
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
}
|
||||
|
||||
function confirmAcceptAfterReceipt() {
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
scheduleAcceptCleanup();
|
||||
function maybeCompleteAcceptedSession(msg) {
|
||||
const pending = pendingAcceptedSession;
|
||||
if (!pending || !msg?.id || msg.id !== pending.id) return false;
|
||||
if (currentSessionId && currentSessionId !== pending.id) {
|
||||
pendingAcceptedSession = null;
|
||||
return false;
|
||||
}
|
||||
if (pending.finalizing) return true;
|
||||
pending.finalizing = true;
|
||||
|
||||
function scheduleAcceptCleanup() {
|
||||
setTimeout(function() {
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
selectedElement = null;
|
||||
currentSessionId = null;
|
||||
selectedAction = 'impeccable';
|
||||
renderEditBadge('hidden');
|
||||
state = 'PICKING';
|
||||
}, 1800);
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
|
||||
// Static-server / no-HMR fallback: if the wrapper is still around 2s after
|
||||
// the cleanup above, swap it out manually. By now React has either moved
|
||||
// on or the app isn't React at all. Preserve the `data-impeccable-variant="N"`
|
||||
// div (with display:contents) so @scope rules anchored to the variant
|
||||
// attribute keep matching until reload replaces it with the carbonize block.
|
||||
// Give framework HMR a short chance to render the now-clean accepted
|
||||
// source. If it misses the update, unwrap the accepted variant after the
|
||||
// source-side completion event so the page is not left empty or stale.
|
||||
setTimeout(function() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + acceptedSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + acceptedVariant + '"]');
|
||||
if (accepted && accepted.firstElementChild) {
|
||||
const parent = wrapper.parentElement;
|
||||
if (!parent) return;
|
||||
accepted.style.display = 'contents';
|
||||
parent.replaceChild(accepted, wrapper);
|
||||
}
|
||||
}, 2000);
|
||||
ensureAcceptedDomClean(pending);
|
||||
cleanupAcceptedSession();
|
||||
}, 1200);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
acceptedHtml: accepted ? accepted.innerHTML : '',
|
||||
acceptedSelector: selectorForAcceptedRoot(root),
|
||||
parentElement: wrapper?.parentElement || null,
|
||||
parentSelector: selectorForAcceptedRoot(wrapper?.parentElement || null),
|
||||
nextSibling: wrapper?.nextSibling || null,
|
||||
};
|
||||
}
|
||||
|
||||
function selectorForAcceptedRoot(root) {
|
||||
if (!root || !root.tagName) return '';
|
||||
const tag = root.tagName.toLowerCase();
|
||||
const classes = [...(root.classList || [])].filter(Boolean);
|
||||
if (classes.length === 0) return tag;
|
||||
return tag + classes.map((cls) => '.' + cssIdent(cls)).join('');
|
||||
}
|
||||
|
||||
function acceptedDomAlreadyClean(pending) {
|
||||
if (!pending?.acceptedSelector) return false;
|
||||
const matches = [...document.querySelectorAll(pending.acceptedSelector)];
|
||||
return matches.some((el) => !el.closest('[data-impeccable-variants],[data-impeccable-variant]'));
|
||||
}
|
||||
|
||||
function ensureAcceptedDomClean(pending) {
|
||||
const sessionId = pending?.id;
|
||||
const variantId = pending?.variant;
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!wrapper) {
|
||||
restoreAcceptedDomFromSnapshot(pending);
|
||||
return;
|
||||
}
|
||||
if (!accepted) {
|
||||
wrapper.remove();
|
||||
restoreAcceptedDomFromSnapshot(pending);
|
||||
return;
|
||||
}
|
||||
const parent = wrapper.parentElement;
|
||||
if (!parent) return;
|
||||
while (accepted.firstChild) {
|
||||
parent.insertBefore(accepted.firstChild, wrapper);
|
||||
}
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
function restoreAcceptedDomFromSnapshot(pending) {
|
||||
if (acceptedDomAlreadyClean(pending)) return;
|
||||
if (!pending?.acceptedHtml) {
|
||||
reloadAfterMissingAcceptedDom(pending);
|
||||
return;
|
||||
}
|
||||
const parent = pending.parentElement?.isConnected
|
||||
? pending.parentElement
|
||||
: (pending.parentSelector ? document.querySelector(pending.parentSelector) : null);
|
||||
if (!parent) {
|
||||
reloadAfterMissingAcceptedDom(pending);
|
||||
return;
|
||||
}
|
||||
const template = document.createElement('template');
|
||||
template.innerHTML = pending.acceptedHtml;
|
||||
const anchor = pending.nextSibling?.isConnected && pending.nextSibling.parentElement === parent
|
||||
? pending.nextSibling
|
||||
: null;
|
||||
parent.insertBefore(template.content, anchor);
|
||||
if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending);
|
||||
}
|
||||
|
||||
function reloadAfterMissingAcceptedDom(pending) {
|
||||
if (acceptedDomAlreadyClean(pending)) return;
|
||||
if (pending?.id && document.querySelector('[data-impeccable-variants="' + pending.id + '"]')) return;
|
||||
location.reload();
|
||||
}
|
||||
|
||||
function cleanupAcceptedSession() {
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
selectedElement = null;
|
||||
currentSessionId = null;
|
||||
selectedAction = 'impeccable';
|
||||
pendingAcceptedSession = null;
|
||||
renderEditBadge('hidden');
|
||||
state = 'PICKING';
|
||||
}
|
||||
|
||||
function handleDiscard() {
|
||||
@@ -5792,7 +5894,7 @@ void main() {
|
||||
|
||||
function cleanup() {
|
||||
// Hide the wrapper immediately so variants disappear. DON'T structurally
|
||||
// mutate the DOM yet — HMR from the agent's source rewrite is on its way,
|
||||
// mutate the DOM yet - HMR from the agent's source rewrite is on its way,
|
||||
// and a manual replaceChild under React causes NotFoundError when the
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
@@ -5838,8 +5940,8 @@ void main() {
|
||||
function showToast(message, duration) {
|
||||
if (toastEl) toastEl.remove();
|
||||
// Stack the toast above the global bar (which sits at bottom:14px) so
|
||||
// the two never overlap. Read the bar's actual rect — its height varies
|
||||
// with hover-expanded labels — and fall back to a sensible default
|
||||
// the two never overlap. Read the bar's actual rect - its height varies
|
||||
// with hover-expanded labels - and fall back to a sensible default
|
||||
// when the bar isn't mounted yet.
|
||||
const barRect = globalBarEl?.getBoundingClientRect();
|
||||
const barTopFromBottom = barRect && barRect.height > 0
|
||||
@@ -6047,7 +6149,7 @@ void main() {
|
||||
let pendingApplyInFlight = false;
|
||||
let firstSaveOfSession = true;
|
||||
|
||||
// Steer — collapsed pill in the global bar; expands while typing for page-level chat.
|
||||
// Steer - collapsed pill in the global bar; expands while typing for page-level chat.
|
||||
let pageChatEl = null;
|
||||
let pageChatInput = null;
|
||||
let pageChatHint = null;
|
||||
@@ -6068,7 +6170,7 @@ void main() {
|
||||
const STEER_AWAIT_TIMEOUT_MS = 120000;
|
||||
const AGENT_STATUS_POLL_MS = 5000;
|
||||
const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)';
|
||||
const AGENT_DISCONNECTED_TIP = 'Agent disconnected — run live-poll.mjs to connect';
|
||||
const AGENT_DISCONNECTED_TIP = 'Agent disconnected: run live-poll.mjs to connect';
|
||||
const GLOBAL_BAR_SECTION_GAP = 8;
|
||||
const GLOBAL_BAR_INNER_GAP = 2;
|
||||
const GLOBAL_BAR_INNER_PAD_LEFT = 2;
|
||||
@@ -6079,7 +6181,7 @@ void main() {
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>';
|
||||
|
||||
// Theme-aware color palette for the global bar. We detect the page's
|
||||
// ambient background and invert — dark bar on light pages, light bar on
|
||||
// ambient background and invert - dark bar on light pages, light bar on
|
||||
// dark pages. This keeps the bar from fighting with the host design.
|
||||
function detectPageTheme() {
|
||||
try {
|
||||
@@ -6092,7 +6194,7 @@ void main() {
|
||||
// Walk body → html, taking the first opaque background. The browser's
|
||||
// default body / html background is `rgba(0, 0, 0, 0)`, which a naive
|
||||
// regex would read as black and mislabel a perfectly white page as
|
||||
// dark. Honoring alpha avoids that — and falling through to <html>
|
||||
// dark. Honoring alpha avoids that - and falling through to <html>
|
||||
// catches the common pattern of a bg only on <html> (or only on body).
|
||||
function readOpaque(el) {
|
||||
if (!el) return null;
|
||||
@@ -6141,7 +6243,7 @@ void main() {
|
||||
exitHover: 'oklch(58% 0.15 35 / 0.18)',
|
||||
shadow: PICKER_SHADOW,
|
||||
chatSurface: 'oklch(22% 0.012 82)',
|
||||
// Verdigris patina — secondary state (see site/styles/kinpaku-tokens.css)
|
||||
// Verdigris patina - secondary state (see site/styles/kinpaku-tokens.css)
|
||||
patina: 'oklch(70% 0.12 188)',
|
||||
patinaPale: 'oklch(82% 0.07 188)',
|
||||
patinaSoft: 'oklch(70% 0.12 188 / 0.28)',
|
||||
@@ -6913,7 +7015,7 @@ void main() {
|
||||
steerFocusLog('page-chat-mounted', {});
|
||||
}
|
||||
|
||||
// Impeccable mark — same paths as site/components/Header.astro + favicon.svg.
|
||||
// Impeccable mark - same paths as site/components/Header.astro + favicon.svg.
|
||||
function brandMarkSvg(color = C.brand, size = 18) {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="${color}" aria-hidden="true">
|
||||
<path d="M5 2.5 L13.5 2.5 L5.5 21.5 L5 21.5 Q2.5 21.5 2.5 19 L2.5 5 Q2.5 2.5 5 2.5 Z"/>
|
||||
@@ -6928,7 +7030,7 @@ void main() {
|
||||
globalBarBrandEl.dataset.agentConnected = connected ? 'true' : 'false';
|
||||
globalBarBrandEl.setAttribute('aria-label', connected
|
||||
? 'Impeccable live mode'
|
||||
: 'Impeccable live mode — agent not polling');
|
||||
: 'Impeccable live mode: agent not polling');
|
||||
globalBarBrandEl.removeAttribute('title');
|
||||
globalBarBrandEl.style.cursor = connected ? 'default' : 'help';
|
||||
const mark = globalBarBrandEl.querySelector('[data-brand-mark]');
|
||||
@@ -7053,7 +7155,7 @@ void main() {
|
||||
globalBarEl.id = PREFIX + '-global-bar';
|
||||
globalBarEl.dataset.theme = theme;
|
||||
|
||||
// Brand mark — kinpaku Impeccable icon (site header / favicon paths).
|
||||
// Brand mark - kinpaku Impeccable icon (site header / favicon paths).
|
||||
const brand = el('span', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
alignSelf: 'stretch', position: 'relative',
|
||||
@@ -7065,7 +7167,7 @@ void main() {
|
||||
brand.id = PREFIX + '-global-bar-brand';
|
||||
brand.dataset.agentConnected = 'false';
|
||||
brand.setAttribute('role', 'img');
|
||||
brand.setAttribute('aria-label', 'Impeccable live mode — agent not polling');
|
||||
brand.setAttribute('aria-label', 'Impeccable live mode: agent not polling');
|
||||
|
||||
const brandMark = el('span', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
@@ -7130,7 +7232,7 @@ void main() {
|
||||
// Per-button hover only changes color (no layout). The label expand/
|
||||
// collapse is driven by the bar-level mouseenter/mouseleave so moving
|
||||
// the mouse between adjacent buttons doesn't trigger per-button width
|
||||
// thrashing — the whole bar grows once and shrinks once.
|
||||
// thrashing - the whole bar grows once and shrinks once.
|
||||
b.addEventListener('mouseenter', () => { if (b.dataset.active !== 'true') b.style.color = P.text; });
|
||||
b.addEventListener('mouseleave', () => { if (b.dataset.active !== 'true') b.style.color = P.textDim; });
|
||||
b.addEventListener('click', onClick);
|
||||
@@ -7139,7 +7241,7 @@ void main() {
|
||||
return b;
|
||||
}
|
||||
|
||||
// Pick toggle — restored from localStorage; both pick and insert may be off.
|
||||
// Pick toggle - restored from localStorage; both pick and insert may be off.
|
||||
const pickBtn = makeIconBtn({
|
||||
id: PREFIX + '-pick-toggle',
|
||||
svg: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><circle cx="12" cy="12" r="10"/><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><line x1="12" y1="6" x2="12" y2="2"/><line x1="12" y1="22" x2="12" y2="18"/></svg>',
|
||||
@@ -7176,7 +7278,7 @@ void main() {
|
||||
detectBtn.appendChild(detectBadge);
|
||||
inner.appendChild(detectBtn);
|
||||
|
||||
// DESIGN.md panel toggle — quartet of color squares as the mark.
|
||||
// DESIGN.md panel toggle - quartet of color squares as the mark.
|
||||
const designBtn = makeIconBtn({
|
||||
id: PREFIX + '-design-toggle',
|
||||
svg: `<span style="display:inline-grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;width:14px;height:14px;border-radius:3px;overflow:hidden;box-shadow:inset 0 0 0 1px oklch(58% 0.065 82 / 0.55);flex-shrink:0">
|
||||
@@ -7378,13 +7480,13 @@ void main() {
|
||||
});
|
||||
inner.appendChild(divider);
|
||||
|
||||
// Exit × on the right — intentionally subtle (textDim at rest, text on
|
||||
// Exit × on the right - intentionally subtle (textDim at rest, text on
|
||||
// hover) so it sits behind the active toggles in visual hierarchy.
|
||||
//
|
||||
// Explicit padding + box-sizing here is load-bearing: a host page like
|
||||
// `button { padding: 0.5rem 1rem; }` (very common in resets) would
|
||||
// otherwise inflate this 24x24 button into 56x40 and push the SVG out
|
||||
// of the visible bar — the X stays invisible even though the styles in
|
||||
// of the visible bar - the X stays invisible even though the styles in
|
||||
// DevTools look fine. Every other chrome button sets padding inline;
|
||||
// this one needed it too.
|
||||
const exitBtn = el('button', {
|
||||
@@ -7472,7 +7574,7 @@ void main() {
|
||||
btn.style.opacity = controlsLocked ? '0.55' : '1';
|
||||
});
|
||||
|
||||
// If the bar is currently under the cursor, keep all labels expanded —
|
||||
// If the bar is currently under the cursor, keep all labels expanded -
|
||||
// otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
|
||||
// would collapse its label while the user's mouse is still on the bar.
|
||||
if (globalBarEl && globalBarEl.matches(':hover')) {
|
||||
@@ -7648,7 +7750,7 @@ void main() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Design System Panel — visualizes the project's .impeccable/design.json sidecar
|
||||
// Design System Panel - visualizes the project's .impeccable/design.json sidecar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DESIGN_PREFS_KEY = 'impeccable-live-design-panel';
|
||||
@@ -7674,7 +7776,7 @@ void main() {
|
||||
};
|
||||
|
||||
function loadDesignPrefs() {
|
||||
// `open` is intentionally NOT persisted — the panel always starts closed
|
||||
// `open` is intentionally NOT persisted - the panel always starts closed
|
||||
// so live mode doesn't auto-slide a big panel over the page on startup.
|
||||
try {
|
||||
const raw = localStorage.getItem(DESIGN_PREFS_KEY);
|
||||
@@ -7731,7 +7833,7 @@ void main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Neutral panel palette — deliberately NOT Impeccable-branded. The panel is
|
||||
// Neutral panel palette - deliberately NOT Impeccable-branded. The panel is
|
||||
// a viewer of the project's design system, not an Impeccable surface.
|
||||
const DP = {
|
||||
canvas: 'oklch(94% 0 0)', // panel background
|
||||
@@ -8025,7 +8127,7 @@ void main() {
|
||||
const root = designShadow.querySelector('.root');
|
||||
root.innerHTML = '';
|
||||
|
||||
// (Panel toggle lives in the global bar — no floating FAB.)
|
||||
// (Panel toggle lives in the global bar - no floating FAB.)
|
||||
// Panel
|
||||
const panel = document.createElement('aside');
|
||||
panel.className = 'panel';
|
||||
@@ -8141,7 +8243,7 @@ void main() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Visual tab — single unified render path.
|
||||
// Visual tab - single unified render path.
|
||||
if (designState.mdNewerThanJson) body.appendChild(renderStaleHint());
|
||||
if (designState.hasMd && !designState.hasSidecar) {
|
||||
body.appendChild(renderParsedMdCta());
|
||||
@@ -8357,7 +8459,7 @@ void main() {
|
||||
specimen.style.fontFamily = fontStack(t);
|
||||
specimen.style.fontWeight = String(t.weight || 400);
|
||||
specimen.style.fontStyle = t.style || 'normal';
|
||||
specimen.style.fontSize = '56px'; // Fixed specimen size — compare faces, not scales.
|
||||
specimen.style.fontSize = '56px'; // Fixed specimen size - compare faces, not scales.
|
||||
specimen.style.letterSpacing = 'normal';
|
||||
specimen.style.textTransform = 'none';
|
||||
tile.appendChild(specimen);
|
||||
@@ -8501,7 +8603,7 @@ void main() {
|
||||
}
|
||||
|
||||
// Single shared description if all items carry the same one; otherwise
|
||||
// skip — per-item descriptions clutter a grouped tile.
|
||||
// skip - per-item descriptions clutter a grouped tile.
|
||||
if (group.length === 1 && group[0].description) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'c-desc';
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Impeccable Live Variant Mode — Browser Script
|
||||
* Impeccable Live Variant Mode - Browser Script
|
||||
*
|
||||
* Injected into the user's page via <script src="http://localhost:PORT/live.js">.
|
||||
* The server prepends window.__IMPECCABLE_TOKEN__ and window.__IMPECCABLE_PORT__
|
||||
* before this code.
|
||||
*
|
||||
* UI: a single floating bar that morphs between three states —
|
||||
* UI: a single floating bar that morphs between three states -
|
||||
* configure (pick action + go), generating (progressive dots), and cycling
|
||||
* (prev/next + accept/discard). Feels like Spotlight, not a modal.
|
||||
*/
|
||||
@@ -46,7 +46,7 @@
|
||||
mist: 'oklch(90% 0.008 82 / 0.6)', // light hairline
|
||||
white: 'oklch(99% 0 0)',
|
||||
};
|
||||
// Picker bar chrome — mirrors .live-demo-gbar / .live-demo-ctx in kinpaku-kit.css.
|
||||
// Picker bar chrome - mirrors .live-demo-gbar / .live-demo-ctx in kinpaku-kit.css.
|
||||
// Quiet neutral elevation: no gold halo ring (gold is reserved for the brand
|
||||
// mark and the active control, not the container outline).
|
||||
const PICKER_SHADOW =
|
||||
@@ -83,7 +83,7 @@
|
||||
|
||||
// SVG icons stack above each chip label. All strokes use currentColor so the
|
||||
// icon recolors to C.brand when its chip is selected. 20x20 render, 24-viewBox,
|
||||
// 1.5 stroke — visually consistent with the Foundation grid on the homepage.
|
||||
// 1.5 stroke - visually consistent with the Foundation grid on the homepage.
|
||||
const ICON_ATTRS = 'width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="display:block"';
|
||||
const ICONS = {
|
||||
impeccable: `<svg ${ICON_ATTRS}><path d="M4 20l4-1L18 9l-3-3L5 16z"/><path d="M14 7l3 3"/></svg>`,
|
||||
@@ -123,6 +123,7 @@
|
||||
let hoveredElement = null;
|
||||
let selectedElement = null;
|
||||
let currentSessionId = null;
|
||||
let pendingAcceptedSession = null;
|
||||
let expectedVariants = 0;
|
||||
let arrivedVariants = 0;
|
||||
let visibleVariant = 0;
|
||||
@@ -133,7 +134,7 @@
|
||||
const browserOwner = sessionState.owner;
|
||||
let checkpointTimer = null;
|
||||
|
||||
// Scroll lock — holds window.scrollY at a fixed value while the session is
|
||||
// Scroll lock - holds window.scrollY at a fixed value while the session is
|
||||
// active, so HMR DOM patches and variant swaps can't drift the page. See
|
||||
// startScrollLock / stopScrollLock below.
|
||||
let scrollLockObserver = null;
|
||||
@@ -141,7 +142,7 @@
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
// Dedicated key for scroll position — SEPARATE from LS_KEY so that
|
||||
// Dedicated key for scroll position - SEPARATE from LS_KEY so that
|
||||
// saveSession's state updates don't clobber a carefully-captured scrollY.
|
||||
// (Previously: saveSession wrote scrollY alongside state, so every call
|
||||
// during resume overwrote the pre-reload value with whatever the browser
|
||||
@@ -231,7 +232,7 @@
|
||||
// - Stop `focusin` propagation so any focus shifts inside our chrome
|
||||
// don't read as "focus moved outside the dialog" to focus traps.
|
||||
//
|
||||
// Click events still bubble normally — only the early pointer/focus
|
||||
// Click events still bubble normally - only the early pointer/focus
|
||||
// signals that drive outside-interaction detection are silenced.
|
||||
function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) {
|
||||
if (!rootEl) return;
|
||||
@@ -319,7 +320,7 @@
|
||||
// correlate directly with the captured PNG.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DRAG_THRESHOLD = 5; // px — below this, treat pointerup as a click
|
||||
const DRAG_THRESHOLD = 5; // px - below this, treat pointerup as a click
|
||||
const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it
|
||||
let annotOverlayEl = null;
|
||||
let annotSvgEl = null;
|
||||
@@ -423,7 +424,7 @@
|
||||
placeholderResizeDrag = null;
|
||||
if (annotOverlayEl) annotOverlayEl.style.display = 'none';
|
||||
syncPlaceholderResizeHandles();
|
||||
// Drop any in-progress edit without touching annotState — clearAnnotations
|
||||
// Drop any in-progress edit without touching annotState - clearAnnotations
|
||||
// (if the caller is exiting configure mode) handles state reset.
|
||||
annotEditing = null;
|
||||
}
|
||||
@@ -488,7 +489,7 @@
|
||||
function onAnnotDown(e) {
|
||||
if (!annotActive) return;
|
||||
|
||||
// 0) Insert placeholder edge resize — wins over draw / pins.
|
||||
// 0) Insert placeholder edge resize - wins over draw / pins.
|
||||
const resizeEdge = e.target.closest?.('[data-impeccable-placeholder-resize]')?.dataset.impeccablePlaceholderResize;
|
||||
if (resizeEdge && configureKind === 'insert' && placeholderElement) {
|
||||
startPlaceholderEdgeResize(resizeEdge, e);
|
||||
@@ -536,7 +537,7 @@
|
||||
// If editing a different pin, commit that edit before starting here.
|
||||
if (annotEditing && annotEditing.idx !== idx) finalizeEditingPin();
|
||||
// If already editing THIS pin and the user clicked the dot, let the
|
||||
// input keep focus (don't start a drag — the click wasn't meant as one).
|
||||
// input keep focus (don't start a drag - the click wasn't meant as one).
|
||||
if (annotEditing && annotEditing.idx === idx) return;
|
||||
const p = localCoords(e);
|
||||
const pin = annotState.comments[idx];
|
||||
@@ -971,7 +972,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The Bar — one floating element, three modes
|
||||
// The Bar - one floating element, three modes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Contextual-bar palette. Cached at init so every build*Row reads a
|
||||
@@ -1752,7 +1753,7 @@
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
});
|
||||
|
||||
// Action pill — dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill)
|
||||
// Action pill - dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill)
|
||||
const pill = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
||||
padding: '5px 10px', borderRadius: '6px',
|
||||
@@ -1786,7 +1787,7 @@
|
||||
});
|
||||
row.appendChild(pill);
|
||||
|
||||
// Prompt field — same chat-surface chrome as the bottom Steer bar
|
||||
// Prompt field - same chat-surface chrome as the bottom Steer bar
|
||||
const inputWrap = el('div', {
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
flex: '1', minWidth: '0', height: '28px',
|
||||
@@ -2126,7 +2127,7 @@
|
||||
if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
|
||||
row.appendChild(next);
|
||||
|
||||
// Tune chip — only when the visible variant exposes params
|
||||
// Tune chip - only when the visible variant exposes params
|
||||
const visParams = parseVariantParams(getVisibleVariantEl());
|
||||
const hasParams = visParams.length > 0;
|
||||
if (hasParams) {
|
||||
@@ -2173,7 +2174,7 @@
|
||||
// Spacer
|
||||
row.appendChild(el('div', { flex: '1' }));
|
||||
|
||||
// Accept — primary action, kinpaku gold + lacquer-deep (matches demo .live-demo-ctx-accept)
|
||||
// Accept - primary action, kinpaku gold + lacquer-deep (matches demo .live-demo-ctx-accept)
|
||||
const accept = el('button', {
|
||||
padding: '5px 14px', borderRadius: '5px',
|
||||
border: 'none', background: C.brand, color: C.ink,
|
||||
@@ -2266,7 +2267,7 @@
|
||||
const active = i === visibleVariant;
|
||||
// active: solid site-brand kinpaku dot. arrived+inactive: muted neutral.
|
||||
// pending (not yet arrived): faint outline ring. No borders on arrived
|
||||
// dots — the previous "accent ring + ash fill" combo read as noisy
|
||||
// dots - the previous "accent ring + ash fill" combo read as noisy
|
||||
// kinpaku chips, especially when all variants had arrived and every
|
||||
// dot wore an accent ring.
|
||||
const dotBg = active ? C.brand
|
||||
@@ -2456,7 +2457,7 @@
|
||||
let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide)
|
||||
let paramsPanelInner = null; // translating content (carries bg, padding, knobs)
|
||||
let paramsPanelBody = null; // grid holding the knob cells
|
||||
let paramsCurrentValues = {}; // {paramId: value} — mirror of the visible variant's live values
|
||||
let paramsCurrentValues = {}; // {paramId: value} - mirror of the visible variant's live values
|
||||
let tuneOpen = false; // whether the Tune popover is open right now
|
||||
|
||||
// Theme-aware Tune popover. Appears as a drawer that slides out from the
|
||||
@@ -2471,7 +2472,7 @@
|
||||
const P = paramsPanelPalette;
|
||||
|
||||
// Single element, always in the DOM. The slide animation is a CSS mask
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis — no
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis - no
|
||||
// display toggle, no opacity toggle, no transform trickery. The mask
|
||||
// hides everything initially; as it grows, content is revealed from
|
||||
// the bar edge outward.
|
||||
@@ -2494,7 +2495,7 @@
|
||||
transition: 'clip-path 0.44s ' + EASE,
|
||||
|
||||
// Park off-screen until positionParamsPanel places it. These are NOT
|
||||
// in the transition list, so they snap instantly — no fly-in from the
|
||||
// in the transition list, so they snap instantly - no fly-in from the
|
||||
// top-left when first shown.
|
||||
top: '-9999px', left: '-9999px', width: '0',
|
||||
});
|
||||
@@ -2685,7 +2686,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline text editing — makes pure-text descendants of the picked element
|
||||
// Inline text editing - makes pure-text descendants of the picked element
|
||||
// directly contenteditable. Save stages copy edits in the live buffer; the
|
||||
// Apply copy edits dock later asks the AI to apply the staged batch.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -3132,7 +3133,7 @@
|
||||
if (detail.includes('newText cannot contain') || detail.includes('newText cannot be empty')) {
|
||||
showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500);
|
||||
} else {
|
||||
showToast('Save failed — retry or cancel', 4000);
|
||||
showToast('Save failed: retry or cancel', 4000);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3454,19 +3455,19 @@
|
||||
updatePendingCounter(remaining);
|
||||
if (result.failed && result.failed.length > 0) {
|
||||
console.warn('[impeccable] some copy edits failed:', result.failed);
|
||||
showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed — see console', 5000);
|
||||
showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed, see console', 5000);
|
||||
} else {
|
||||
const n = Array.isArray(result.applied) ? result.applied.length : (result.cleared || 0);
|
||||
if (n > 0) {
|
||||
showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500);
|
||||
} else {
|
||||
console.warn('[impeccable] apply returned no verified edits:', result);
|
||||
showToast('No edits applied — see console', 4000);
|
||||
showToast('No edits applied, see console', 4000);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[impeccable] commit failed:', err);
|
||||
showToast('Apply failed — see console', 4000);
|
||||
showToast('Apply failed, see console', 4000);
|
||||
} finally {
|
||||
if (waitForSseCompletion) return;
|
||||
const remainingCount = parseInt(pendingPillEl?.dataset.count || '0', 10) || 0;
|
||||
@@ -3496,7 +3497,7 @@
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[impeccable] discard failed:', err);
|
||||
showToast('Discard failed — see console', 4000);
|
||||
showToast('Discard failed, see console', 4000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3644,7 +3645,7 @@
|
||||
const failedCount = numberOrNull(msg.failedCount) || 0;
|
||||
const appliedCount = numberOrNull(msg.appliedCount) || numberOrNull(msg.cleared) || 0;
|
||||
if (failedCount > 0) {
|
||||
showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed — see console', 5000);
|
||||
showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed, see console', 5000);
|
||||
} else if (appliedCount > 0) {
|
||||
showToast('Applied ' + appliedCount + ' edit' + (appliedCount === 1 ? '' : 's'), 2500);
|
||||
}
|
||||
@@ -3799,7 +3800,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit content badge — floating button at element top-right to enter EDITING mode
|
||||
// Edit content badge - floating button at element top-right to enter EDITING mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initEditBadge() {
|
||||
@@ -3884,7 +3885,7 @@
|
||||
}
|
||||
editBadgeEl.appendChild(btn);
|
||||
} else {
|
||||
// 'editing' — show Cancel + Save separated
|
||||
// 'editing' - show Cancel + Save separated
|
||||
editBadgeEl.innerHTML = '';
|
||||
editBadgeEl.style.gap = '8px';
|
||||
const cancel = document.createElement('button');
|
||||
@@ -4083,7 +4084,7 @@
|
||||
if (!v) continue;
|
||||
setVariantShown(child, v === String(num));
|
||||
}
|
||||
// Unconditional refresh — covers first-reveal (no-op if state isn't
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
// CYCLING yet, the subsequent CYCLING transition triggers its own
|
||||
// refresh) and every cycle step.
|
||||
refreshParamsPanel();
|
||||
@@ -4318,7 +4319,7 @@
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
}, { passive: true, ...sig });
|
||||
|
||||
// Apply target synchronously, not via rAF — racing the browser's
|
||||
// Apply target synchronously, not via rAF - racing the browser's
|
||||
// restore or a smooth-scroll animation means we want to win now.
|
||||
if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) {
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
@@ -4545,18 +4546,34 @@
|
||||
// hidden tab, a route the user navigated away from). The variant
|
||||
// MutationObserver stays armed and auto-transitions to CYCLING
|
||||
// the moment the wrapper actually mounts. Nudge the user toward
|
||||
// that path with a toast — better than the prior force-reload
|
||||
// that path with a toast - better than the prior force-reload
|
||||
// which reset framework state and left the session stuck.
|
||||
setTimeout(() => {
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) return;
|
||||
if (state !== 'GENERATING') return;
|
||||
showToast(
|
||||
"Variants ready. If the picked element isn't visible, retrace the path that revealed it — they'll appear automatically.",
|
||||
"Variants ready. If the picked element isn't visible, retrace the path that revealed it; they'll appear automatically.",
|
||||
15000,
|
||||
);
|
||||
}, 2000);
|
||||
break;
|
||||
case 'complete':
|
||||
case 'accept':
|
||||
if (maybeCompleteAcceptedSession(msg)) break;
|
||||
break;
|
||||
case 'agent_done':
|
||||
// Carbonize accepts are not terminal until live-complete.mjs sends
|
||||
// the final complete event. Keep the browser in its recoverable
|
||||
// saving state while the source cleanup is still in flight.
|
||||
break;
|
||||
case 'error':
|
||||
if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) {
|
||||
pendingAcceptedSession = null;
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
break;
|
||||
}
|
||||
if (maybeCompleteSteer(msg)) break;
|
||||
console.error('[impeccable] Error:', msg.message);
|
||||
showToast('Error: ' + msg.message, 5000);
|
||||
@@ -4779,14 +4796,14 @@
|
||||
|
||||
/**
|
||||
* Surface a brief, non-blocking heads-up when the picked element lives
|
||||
* inside a container whose visibility is gated by ephemeral state — modals,
|
||||
* inside a container whose visibility is gated by ephemeral state - modals,
|
||||
* collapsible panels, popovers, off-screen tab panels. If HMR remounts the
|
||||
* parent during generation (Vite Fast Refresh, SvelteKit page reload), the
|
||||
* variants land in source but stay invisible until the user re-opens the
|
||||
* container. Telling the user upfront is much friendlier than the silent
|
||||
* timeout-then-toast that they'd otherwise hit.
|
||||
*
|
||||
* Heuristic, intentionally narrow — only fires for unambiguous cases so
|
||||
* Heuristic, intentionally narrow - only fires for unambiguous cases so
|
||||
* we don't cry wolf on every nested element.
|
||||
*/
|
||||
function maybeWarnConditionalAncestor(el) {
|
||||
@@ -4804,7 +4821,7 @@
|
||||
showToast('Heads up: this element lives inside an open panel. If state resets during generation, you may need to re-open it.', 6000);
|
||||
return;
|
||||
}
|
||||
// 3. Tab panel — only meaningful when the page also shows ANOTHER
|
||||
// 3. Tab panel - only meaningful when the page also shows ANOTHER
|
||||
// tab as selected. A single tabpanel with no tablist is just a static
|
||||
// section in disguise and isn't conditional.
|
||||
if (node.getAttribute && node.getAttribute('role') === 'tabpanel') {
|
||||
@@ -4833,7 +4850,7 @@
|
||||
// Fire a lightweight prefetch event the first time the user selects an
|
||||
// element on a given route. The agent uses this to Read the underlying file
|
||||
// into context before Go is hit, shaving the read off the critical path.
|
||||
// Dedupe per session by pathname — clicking around on the same page doesn't
|
||||
// Dedupe per session by pathname - clicking around on the same page doesn't
|
||||
// re-fire.
|
||||
//
|
||||
// DISABLED: quick-Go workflows pay an extra harness round trip because
|
||||
@@ -4969,6 +4986,7 @@
|
||||
disableInlineEdit();
|
||||
stripManualEditRuntimeState(selectedElement);
|
||||
|
||||
pendingAcceptedSession = null;
|
||||
currentSessionId = id8();
|
||||
expectedVariants = selectedCount;
|
||||
arrivedVariants = 0;
|
||||
@@ -4976,7 +4994,7 @@
|
||||
|
||||
// Flip to GENERATING immediately so the bar morphs without waiting on
|
||||
// capture + upload. The event is emitted from captureAndEmit() once the
|
||||
// screenshot is uploaded (or capture fails — we still emit, just without
|
||||
// screenshot is uploaded (or capture fails - we still emit, just without
|
||||
// screenshotPath).
|
||||
const elForCapture = selectedElement;
|
||||
const captureRect = elForCapture.getBoundingClientRect();
|
||||
@@ -5002,7 +5020,7 @@
|
||||
state = 'GENERATING';
|
||||
// Disable the Edit badge: starting a manual text edit mid-generation would
|
||||
// conflict with the variant wrap that's about to land in the same DOM
|
||||
// region. Only swap if the badge was visible — picked elements with no
|
||||
// region. Only swap if the badge was visible - picked elements with no
|
||||
// text rows have it hidden already.
|
||||
if (editBadgeEl && editBadgeEl.style.display !== 'none') renderEditBadge('idle-disabled');
|
||||
showBar('generating');
|
||||
@@ -5104,7 +5122,7 @@
|
||||
|
||||
// Collect @font-face rules from every stylesheet on the page. Cross-origin
|
||||
// sheets (Google Fonts, Typekit, etc.) throw SecurityError on .cssRules
|
||||
// access, so modern-screenshot can't embed them on its own — the resulting
|
||||
// access, so modern-screenshot can't embed them on its own - the resulting
|
||||
// SVG falls back to system fonts and text re-wraps + renders with different
|
||||
// weight. We fetch the raw CSS text (CORS-permitted for these providers),
|
||||
// extract @font-face blocks, inline the referenced font files as base64
|
||||
@@ -5188,7 +5206,7 @@
|
||||
// modern-screenshot force-sets `background-color: X !important` on the
|
||||
// cloned root whenever `backgroundColor` is passed, clobbering the
|
||||
// element's own background. So we only pass it when the element is
|
||||
// genuinely transparent (no own color, no own image) — in that case
|
||||
// genuinely transparent (no own color, no own image) - in that case
|
||||
// we resolve up the DOM to the nearest opaque ancestor so the capture
|
||||
// sits on the page's real background instead of rendering black.
|
||||
function resolveCanvasBackground(el) {
|
||||
@@ -5206,7 +5224,7 @@
|
||||
// `getComputedStyle(body).backgroundColor || …` chain is a trap: that
|
||||
// call returns the literal string `"rgba(0, 0, 0, 0)"` for a page that
|
||||
// never set its own bg, which is truthy and short-circuits the chain to
|
||||
// transparent-black — modern-screenshot then renders the capture on a
|
||||
// transparent-black - modern-screenshot then renders the capture on a
|
||||
// black canvas and the shader overlay flashes solid black during load.
|
||||
// The browser canvas defaults to white, so we do too.
|
||||
return '#ffffff';
|
||||
@@ -5248,7 +5266,7 @@
|
||||
// Transparent up to the root. The visible backdrop may still come from an
|
||||
// ancestor's background-image or a covering positioned layer (e.g. a hero
|
||||
// art div) that the color walk can't see. Capture that ancestor and crop
|
||||
// to the element so the real backdrop is embedded — correct for both the
|
||||
// to the element so the real backdrop is embedded - correct for both the
|
||||
// shader and the screenshot sent to the model. Fall back to white only
|
||||
// when nothing is actually painted behind the element.
|
||||
const backdrop = findBackdropAncestor(el);
|
||||
@@ -5289,13 +5307,13 @@
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] capture failed, proceeding without screenshot:', err);
|
||||
}
|
||||
// Light up the shader overlay the moment capture is ready — no reason to
|
||||
// Light up the shader overlay the moment capture is ready - no reason to
|
||||
// wait for the upload to complete before the user sees something alive.
|
||||
if (blob && state === 'GENERATING') {
|
||||
showShaderOverlay(el, blob, rect, paper);
|
||||
}
|
||||
// Only upload + forward the screenshot when annotations (comments/strokes)
|
||||
// are present. Without annotations the image is pure visual anchoring —
|
||||
// are present. Without annotations the image is pure visual anchoring -
|
||||
// it biases the model toward the current rendering and works against the
|
||||
// three-distinct-directions brief.
|
||||
const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0);
|
||||
@@ -5320,7 +5338,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shader overlay — renders the captured screenshot as a WebGL texture and
|
||||
// Shader overlay - renders the captured screenshot as a WebGL texture and
|
||||
// runs an editorial "ink-wash" fragment shader over it during generation.
|
||||
// A single rolling band sweeps top-to-bottom, desaturating + tinting kinpaku
|
||||
// and leaving a soft trail. Makes the wait feel like a letterpress scan
|
||||
@@ -5343,7 +5361,7 @@ uniform vec3 u_accent;
|
||||
uniform vec3 u_paper;
|
||||
varying vec2 v_uv;
|
||||
|
||||
// Asymmetric roller band. Product of two one-sided smoothsteps — peaks at
|
||||
// Asymmetric roller band. Product of two one-sided smoothsteps - peaks at
|
||||
// d=0 with a short sharp leading ramp and a longer soft trailing tail. Clean
|
||||
// outside the [-leadW, trailW] range (no rogue "trail=1 everywhere below"
|
||||
// failure that reversed-edge smoothstep would give).
|
||||
@@ -5369,8 +5387,8 @@ void main() {
|
||||
vec2 sampleCenter = (cellId + 0.5) * cellPx / u_resolution;
|
||||
vec3 cellImg = texture2D(u_texture, sampleCenter).rgb;
|
||||
// Dot size tracks how much the cell DIFFERS from the element's own ground
|
||||
// (u_paper), not absolute darkness. So the content — text, buttons, anything
|
||||
// that deviates from the background — always becomes the dots, on light AND
|
||||
// (u_paper), not absolute darkness. So the content - text, buttons, anything
|
||||
// that deviates from the background - always becomes the dots, on light AND
|
||||
// dark surfaces. A plain darkness curve inverts on dark elements: the dark
|
||||
// background fills with ink and the lighter content punches holes instead.
|
||||
// Capped below the cell half-width so dense content stays separated dots.
|
||||
@@ -5380,8 +5398,8 @@ void main() {
|
||||
// Two-stage dissolve as the roller passes, so the element is rebuilt purely
|
||||
// from dot size (its own halftone) and never bleeds through as raw pixels
|
||||
// behind the dots:
|
||||
// 1. cover — the element flattens to the uniform paper ground first.
|
||||
// 2. dotAmt — kinpaku dots then emerge, sized by each cell's luma.
|
||||
// 1. cover - the element flattens to the uniform paper ground first.
|
||||
// 2. dotAmt - kinpaku dots then emerge, sized by each cell's luma.
|
||||
// A plain mix(base, halftone, band) instead left the raw element visible
|
||||
// through the band's soft core/trail. The paper ground is u_paper (the
|
||||
// element's own bg tone) rather than a fixed white, so the dissolve reads the
|
||||
@@ -5399,7 +5417,7 @@ void main() {
|
||||
|
||||
// Kinpaku gold converted to approximate sRGB 0-1 (matches oklch(84% 0.19 80.46))
|
||||
const SHADER_ACCENT = [1.0, 0.78, 0.31];
|
||||
// Fallback ground when an element and all its ancestors are transparent —
|
||||
// Fallback ground when an element and all its ancestors are transparent -
|
||||
// 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 }
|
||||
@@ -5412,7 +5430,7 @@ void main() {
|
||||
// Rasterize any CSS color (oklch, color(), named, hex, rgb) through a 1x1
|
||||
// canvas and read back the sRGB pixel. String-parsing computed colors is a
|
||||
// trap: Chrome returns backgroundColor as oklch()/color() for oklch inputs,
|
||||
// which a hex/rgb regex misses — every site token would fall back to white.
|
||||
// which a hex/rgb regex misses - every site token would fall back to white.
|
||||
let colorParseCtx = null;
|
||||
function cssColorToRgb01(str) {
|
||||
if (!colorParseCtx) {
|
||||
@@ -5440,7 +5458,7 @@ void main() {
|
||||
|
||||
// When an element is transparent up to the root, its visible backdrop can
|
||||
// still come from an ancestor's background-image or a covering positioned
|
||||
// layer that is a *child* of an ancestor (e.g. a hero's absolute art div) —
|
||||
// layer that is a *child* of an ancestor (e.g. a hero's absolute art div) -
|
||||
// neither of which the ancestor background-COLOR walk can see. Return the
|
||||
// nearest such ancestor so we can capture it and crop, embedding the real
|
||||
// backdrop. Returns null when nothing is actually painted behind the element
|
||||
@@ -5481,7 +5499,7 @@ void main() {
|
||||
|
||||
// Average the backdrop sampled just OUTSIDE an element's rect within a larger
|
||||
// canvas. The ground tone for the dissolve must be the real backdrop, not the
|
||||
// mean of the element's own crop — averaging the crop folds in the element's
|
||||
// mean of the element's own crop - averaging the crop folds in the element's
|
||||
// content (e.g. bright heading text), pulling the ground toward muddy gray.
|
||||
function sampleSurroundingRgb(ctx, sx, sy, sw, sh, W, H) {
|
||||
const pad = Math.max(2, Math.round(Math.min(sw, sh) * 0.12));
|
||||
@@ -5528,11 +5546,31 @@ void main() {
|
||||
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;
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
canvas.remove();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const fallback = document.createElement('div');
|
||||
fallback.id = PREFIX + '-shader';
|
||||
// Copy positioning via cssText. Object.assign across CSSStyleDeclaration
|
||||
// throws in modern Chromium because the source's indexed properties
|
||||
// (style[0], [1], ...) are read-only and the engine forbids writing
|
||||
// them on the destination.
|
||||
fallback.style.cssText = canvas.style.cssText;
|
||||
fallback.style.backgroundImage = 'url("' + objectUrl + '")';
|
||||
fallback.style.backgroundSize = '100% 100%';
|
||||
fallback.style.backgroundRepeat = 'no-repeat';
|
||||
fallback.style.outline = '2px dashed ' + C.brand;
|
||||
fallback.style.outlineOffset = '-2px';
|
||||
document.body.appendChild(fallback);
|
||||
shaderState = { canvas: fallback, gl: null, program: null, texture: null, rafId: 0, startTime: 0, objectUrl };
|
||||
}
|
||||
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
@@ -5553,21 +5591,9 @@ void main() {
|
||||
const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false })
|
||||
|| canvas.getContext('experimental-webgl');
|
||||
if (!gl) {
|
||||
// WebGL unavailable — fall back to a plain <img> overlay so the user
|
||||
// still sees something meaningful during generation.
|
||||
canvas.remove();
|
||||
const img = document.createElement('img');
|
||||
img.src = URL.createObjectURL(blob);
|
||||
img.id = PREFIX + '-shader';
|
||||
// Copy positioning via cssText. Object.assign across CSSStyleDeclaration
|
||||
// throws in modern Chromium because the source's indexed properties
|
||||
// (style[0], [1], ...) are read-only and the engine forbids writing
|
||||
// them on the destination.
|
||||
img.style.cssText = canvas.style.cssText;
|
||||
img.style.outline = '2px dashed ' + C.brand;
|
||||
img.style.outlineOffset = '-2px';
|
||||
document.body.appendChild(img);
|
||||
shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 };
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5609,14 +5635,12 @@ void main() {
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch {
|
||||
// Safari fallback: go via a regular Image
|
||||
const imgUrl = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
img.src = imgUrl;
|
||||
await new Promise((r, rej) => { img.onload = r; img.onerror = rej; });
|
||||
bitmap = img;
|
||||
URL.revokeObjectURL(imgUrl);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
@@ -5670,14 +5694,15 @@ void main() {
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
// The accepted variant is already the only visible child of the wrapper
|
||||
// (all other variants are display:none). HMR from the source rewrite will
|
||||
// replace the wrapper imminently. Don't eagerly replaceChild here — React
|
||||
// reconciliation races with our mutation and throws NotFoundError in Next
|
||||
// 16 / Turbopack. Schedule a fallback that runs the manual swap only if
|
||||
// HMR hasn't cleaned up by then (keeps static-server flows working).
|
||||
const acceptedSessionId = currentSessionId;
|
||||
const acceptedVariant = visibleVariant;
|
||||
const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant);
|
||||
pendingAcceptedSession = {
|
||||
id: acceptedSessionId,
|
||||
variant: String(acceptedVariant),
|
||||
...acceptedSnapshot,
|
||||
finalizing: false,
|
||||
};
|
||||
|
||||
state = 'SAVING';
|
||||
updateBarContent('saving');
|
||||
@@ -5685,53 +5710,130 @@ void main() {
|
||||
sendEvent(acceptPayload, { throwOnError: true })
|
||||
.then(() => {
|
||||
markSessionHandled();
|
||||
confirmAcceptAfterReceipt();
|
||||
})
|
||||
.catch(() => {
|
||||
pendingAcceptedSession = null;
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
}
|
||||
|
||||
function confirmAcceptAfterReceipt() {
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
scheduleAcceptCleanup();
|
||||
function maybeCompleteAcceptedSession(msg) {
|
||||
const pending = pendingAcceptedSession;
|
||||
if (!pending || !msg?.id || msg.id !== pending.id) return false;
|
||||
if (currentSessionId && currentSessionId !== pending.id) {
|
||||
pendingAcceptedSession = null;
|
||||
return false;
|
||||
}
|
||||
if (pending.finalizing) return true;
|
||||
pending.finalizing = true;
|
||||
|
||||
function scheduleAcceptCleanup() {
|
||||
setTimeout(function() {
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
selectedElement = null;
|
||||
currentSessionId = null;
|
||||
selectedAction = 'impeccable';
|
||||
renderEditBadge('hidden');
|
||||
state = 'PICKING';
|
||||
}, 1800);
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
|
||||
// Static-server / no-HMR fallback: if the wrapper is still around 2s after
|
||||
// the cleanup above, swap it out manually. By now React has either moved
|
||||
// on or the app isn't React at all. Preserve the `data-impeccable-variant="N"`
|
||||
// div (with display:contents) so @scope rules anchored to the variant
|
||||
// attribute keep matching until reload replaces it with the carbonize block.
|
||||
// Give framework HMR a short chance to render the now-clean accepted
|
||||
// source. If it misses the update, unwrap the accepted variant after the
|
||||
// source-side completion event so the page is not left empty or stale.
|
||||
setTimeout(function() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + acceptedSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + acceptedVariant + '"]');
|
||||
if (accepted && accepted.firstElementChild) {
|
||||
const parent = wrapper.parentElement;
|
||||
if (!parent) return;
|
||||
accepted.style.display = 'contents';
|
||||
parent.replaceChild(accepted, wrapper);
|
||||
}
|
||||
}, 2000);
|
||||
ensureAcceptedDomClean(pending);
|
||||
cleanupAcceptedSession();
|
||||
}, 1200);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
acceptedHtml: accepted ? accepted.innerHTML : '',
|
||||
acceptedSelector: selectorForAcceptedRoot(root),
|
||||
parentElement: wrapper?.parentElement || null,
|
||||
parentSelector: selectorForAcceptedRoot(wrapper?.parentElement || null),
|
||||
nextSibling: wrapper?.nextSibling || null,
|
||||
};
|
||||
}
|
||||
|
||||
function selectorForAcceptedRoot(root) {
|
||||
if (!root || !root.tagName) return '';
|
||||
const tag = root.tagName.toLowerCase();
|
||||
const classes = [...(root.classList || [])].filter(Boolean);
|
||||
if (classes.length === 0) return tag;
|
||||
return tag + classes.map((cls) => '.' + cssIdent(cls)).join('');
|
||||
}
|
||||
|
||||
function acceptedDomAlreadyClean(pending) {
|
||||
if (!pending?.acceptedSelector) return false;
|
||||
const matches = [...document.querySelectorAll(pending.acceptedSelector)];
|
||||
return matches.some((el) => !el.closest('[data-impeccable-variants],[data-impeccable-variant]'));
|
||||
}
|
||||
|
||||
function ensureAcceptedDomClean(pending) {
|
||||
const sessionId = pending?.id;
|
||||
const variantId = pending?.variant;
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!wrapper) {
|
||||
restoreAcceptedDomFromSnapshot(pending);
|
||||
return;
|
||||
}
|
||||
if (!accepted) {
|
||||
wrapper.remove();
|
||||
restoreAcceptedDomFromSnapshot(pending);
|
||||
return;
|
||||
}
|
||||
const parent = wrapper.parentElement;
|
||||
if (!parent) return;
|
||||
while (accepted.firstChild) {
|
||||
parent.insertBefore(accepted.firstChild, wrapper);
|
||||
}
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
function restoreAcceptedDomFromSnapshot(pending) {
|
||||
if (acceptedDomAlreadyClean(pending)) return;
|
||||
if (!pending?.acceptedHtml) {
|
||||
reloadAfterMissingAcceptedDom(pending);
|
||||
return;
|
||||
}
|
||||
const parent = pending.parentElement?.isConnected
|
||||
? pending.parentElement
|
||||
: (pending.parentSelector ? document.querySelector(pending.parentSelector) : null);
|
||||
if (!parent) {
|
||||
reloadAfterMissingAcceptedDom(pending);
|
||||
return;
|
||||
}
|
||||
const template = document.createElement('template');
|
||||
template.innerHTML = pending.acceptedHtml;
|
||||
const anchor = pending.nextSibling?.isConnected && pending.nextSibling.parentElement === parent
|
||||
? pending.nextSibling
|
||||
: null;
|
||||
parent.insertBefore(template.content, anchor);
|
||||
if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending);
|
||||
}
|
||||
|
||||
function reloadAfterMissingAcceptedDom(pending) {
|
||||
if (acceptedDomAlreadyClean(pending)) return;
|
||||
if (pending?.id && document.querySelector('[data-impeccable-variants="' + pending.id + '"]')) return;
|
||||
location.reload();
|
||||
}
|
||||
|
||||
function cleanupAcceptedSession() {
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
selectedElement = null;
|
||||
currentSessionId = null;
|
||||
selectedAction = 'impeccable';
|
||||
pendingAcceptedSession = null;
|
||||
renderEditBadge('hidden');
|
||||
state = 'PICKING';
|
||||
}
|
||||
|
||||
function handleDiscard() {
|
||||
@@ -5792,7 +5894,7 @@ void main() {
|
||||
|
||||
function cleanup() {
|
||||
// Hide the wrapper immediately so variants disappear. DON'T structurally
|
||||
// mutate the DOM yet — HMR from the agent's source rewrite is on its way,
|
||||
// mutate the DOM yet - HMR from the agent's source rewrite is on its way,
|
||||
// and a manual replaceChild under React causes NotFoundError when the
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
@@ -5838,8 +5940,8 @@ void main() {
|
||||
function showToast(message, duration) {
|
||||
if (toastEl) toastEl.remove();
|
||||
// Stack the toast above the global bar (which sits at bottom:14px) so
|
||||
// the two never overlap. Read the bar's actual rect — its height varies
|
||||
// with hover-expanded labels — and fall back to a sensible default
|
||||
// the two never overlap. Read the bar's actual rect - its height varies
|
||||
// with hover-expanded labels - and fall back to a sensible default
|
||||
// when the bar isn't mounted yet.
|
||||
const barRect = globalBarEl?.getBoundingClientRect();
|
||||
const barTopFromBottom = barRect && barRect.height > 0
|
||||
@@ -6047,7 +6149,7 @@ void main() {
|
||||
let pendingApplyInFlight = false;
|
||||
let firstSaveOfSession = true;
|
||||
|
||||
// Steer — collapsed pill in the global bar; expands while typing for page-level chat.
|
||||
// Steer - collapsed pill in the global bar; expands while typing for page-level chat.
|
||||
let pageChatEl = null;
|
||||
let pageChatInput = null;
|
||||
let pageChatHint = null;
|
||||
@@ -6068,7 +6170,7 @@ void main() {
|
||||
const STEER_AWAIT_TIMEOUT_MS = 120000;
|
||||
const AGENT_STATUS_POLL_MS = 5000;
|
||||
const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)';
|
||||
const AGENT_DISCONNECTED_TIP = 'Agent disconnected — run live-poll.mjs to connect';
|
||||
const AGENT_DISCONNECTED_TIP = 'Agent disconnected: run live-poll.mjs to connect';
|
||||
const GLOBAL_BAR_SECTION_GAP = 8;
|
||||
const GLOBAL_BAR_INNER_GAP = 2;
|
||||
const GLOBAL_BAR_INNER_PAD_LEFT = 2;
|
||||
@@ -6079,7 +6181,7 @@ void main() {
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>';
|
||||
|
||||
// Theme-aware color palette for the global bar. We detect the page's
|
||||
// ambient background and invert — dark bar on light pages, light bar on
|
||||
// ambient background and invert - dark bar on light pages, light bar on
|
||||
// dark pages. This keeps the bar from fighting with the host design.
|
||||
function detectPageTheme() {
|
||||
try {
|
||||
@@ -6092,7 +6194,7 @@ void main() {
|
||||
// Walk body → html, taking the first opaque background. The browser's
|
||||
// default body / html background is `rgba(0, 0, 0, 0)`, which a naive
|
||||
// regex would read as black and mislabel a perfectly white page as
|
||||
// dark. Honoring alpha avoids that — and falling through to <html>
|
||||
// dark. Honoring alpha avoids that - and falling through to <html>
|
||||
// catches the common pattern of a bg only on <html> (or only on body).
|
||||
function readOpaque(el) {
|
||||
if (!el) return null;
|
||||
@@ -6141,7 +6243,7 @@ void main() {
|
||||
exitHover: 'oklch(58% 0.15 35 / 0.18)',
|
||||
shadow: PICKER_SHADOW,
|
||||
chatSurface: 'oklch(22% 0.012 82)',
|
||||
// Verdigris patina — secondary state (see site/styles/kinpaku-tokens.css)
|
||||
// Verdigris patina - secondary state (see site/styles/kinpaku-tokens.css)
|
||||
patina: 'oklch(70% 0.12 188)',
|
||||
patinaPale: 'oklch(82% 0.07 188)',
|
||||
patinaSoft: 'oklch(70% 0.12 188 / 0.28)',
|
||||
@@ -6913,7 +7015,7 @@ void main() {
|
||||
steerFocusLog('page-chat-mounted', {});
|
||||
}
|
||||
|
||||
// Impeccable mark — same paths as site/components/Header.astro + favicon.svg.
|
||||
// Impeccable mark - same paths as site/components/Header.astro + favicon.svg.
|
||||
function brandMarkSvg(color = C.brand, size = 18) {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="${color}" aria-hidden="true">
|
||||
<path d="M5 2.5 L13.5 2.5 L5.5 21.5 L5 21.5 Q2.5 21.5 2.5 19 L2.5 5 Q2.5 2.5 5 2.5 Z"/>
|
||||
@@ -6928,7 +7030,7 @@ void main() {
|
||||
globalBarBrandEl.dataset.agentConnected = connected ? 'true' : 'false';
|
||||
globalBarBrandEl.setAttribute('aria-label', connected
|
||||
? 'Impeccable live mode'
|
||||
: 'Impeccable live mode — agent not polling');
|
||||
: 'Impeccable live mode: agent not polling');
|
||||
globalBarBrandEl.removeAttribute('title');
|
||||
globalBarBrandEl.style.cursor = connected ? 'default' : 'help';
|
||||
const mark = globalBarBrandEl.querySelector('[data-brand-mark]');
|
||||
@@ -7053,7 +7155,7 @@ void main() {
|
||||
globalBarEl.id = PREFIX + '-global-bar';
|
||||
globalBarEl.dataset.theme = theme;
|
||||
|
||||
// Brand mark — kinpaku Impeccable icon (site header / favicon paths).
|
||||
// Brand mark - kinpaku Impeccable icon (site header / favicon paths).
|
||||
const brand = el('span', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
alignSelf: 'stretch', position: 'relative',
|
||||
@@ -7065,7 +7167,7 @@ void main() {
|
||||
brand.id = PREFIX + '-global-bar-brand';
|
||||
brand.dataset.agentConnected = 'false';
|
||||
brand.setAttribute('role', 'img');
|
||||
brand.setAttribute('aria-label', 'Impeccable live mode — agent not polling');
|
||||
brand.setAttribute('aria-label', 'Impeccable live mode: agent not polling');
|
||||
|
||||
const brandMark = el('span', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
@@ -7130,7 +7232,7 @@ void main() {
|
||||
// Per-button hover only changes color (no layout). The label expand/
|
||||
// collapse is driven by the bar-level mouseenter/mouseleave so moving
|
||||
// the mouse between adjacent buttons doesn't trigger per-button width
|
||||
// thrashing — the whole bar grows once and shrinks once.
|
||||
// thrashing - the whole bar grows once and shrinks once.
|
||||
b.addEventListener('mouseenter', () => { if (b.dataset.active !== 'true') b.style.color = P.text; });
|
||||
b.addEventListener('mouseleave', () => { if (b.dataset.active !== 'true') b.style.color = P.textDim; });
|
||||
b.addEventListener('click', onClick);
|
||||
@@ -7139,7 +7241,7 @@ void main() {
|
||||
return b;
|
||||
}
|
||||
|
||||
// Pick toggle — restored from localStorage; both pick and insert may be off.
|
||||
// Pick toggle - restored from localStorage; both pick and insert may be off.
|
||||
const pickBtn = makeIconBtn({
|
||||
id: PREFIX + '-pick-toggle',
|
||||
svg: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><circle cx="12" cy="12" r="10"/><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><line x1="12" y1="6" x2="12" y2="2"/><line x1="12" y1="22" x2="12" y2="18"/></svg>',
|
||||
@@ -7176,7 +7278,7 @@ void main() {
|
||||
detectBtn.appendChild(detectBadge);
|
||||
inner.appendChild(detectBtn);
|
||||
|
||||
// DESIGN.md panel toggle — quartet of color squares as the mark.
|
||||
// DESIGN.md panel toggle - quartet of color squares as the mark.
|
||||
const designBtn = makeIconBtn({
|
||||
id: PREFIX + '-design-toggle',
|
||||
svg: `<span style="display:inline-grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;width:14px;height:14px;border-radius:3px;overflow:hidden;box-shadow:inset 0 0 0 1px oklch(58% 0.065 82 / 0.55);flex-shrink:0">
|
||||
@@ -7378,13 +7480,13 @@ void main() {
|
||||
});
|
||||
inner.appendChild(divider);
|
||||
|
||||
// Exit × on the right — intentionally subtle (textDim at rest, text on
|
||||
// Exit × on the right - intentionally subtle (textDim at rest, text on
|
||||
// hover) so it sits behind the active toggles in visual hierarchy.
|
||||
//
|
||||
// Explicit padding + box-sizing here is load-bearing: a host page like
|
||||
// `button { padding: 0.5rem 1rem; }` (very common in resets) would
|
||||
// otherwise inflate this 24x24 button into 56x40 and push the SVG out
|
||||
// of the visible bar — the X stays invisible even though the styles in
|
||||
// of the visible bar - the X stays invisible even though the styles in
|
||||
// DevTools look fine. Every other chrome button sets padding inline;
|
||||
// this one needed it too.
|
||||
const exitBtn = el('button', {
|
||||
@@ -7472,7 +7574,7 @@ void main() {
|
||||
btn.style.opacity = controlsLocked ? '0.55' : '1';
|
||||
});
|
||||
|
||||
// If the bar is currently under the cursor, keep all labels expanded —
|
||||
// If the bar is currently under the cursor, keep all labels expanded -
|
||||
// otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
|
||||
// would collapse its label while the user's mouse is still on the bar.
|
||||
if (globalBarEl && globalBarEl.matches(':hover')) {
|
||||
@@ -7648,7 +7750,7 @@ void main() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Design System Panel — visualizes the project's .impeccable/design.json sidecar
|
||||
// Design System Panel - visualizes the project's .impeccable/design.json sidecar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DESIGN_PREFS_KEY = 'impeccable-live-design-panel';
|
||||
@@ -7674,7 +7776,7 @@ void main() {
|
||||
};
|
||||
|
||||
function loadDesignPrefs() {
|
||||
// `open` is intentionally NOT persisted — the panel always starts closed
|
||||
// `open` is intentionally NOT persisted - the panel always starts closed
|
||||
// so live mode doesn't auto-slide a big panel over the page on startup.
|
||||
try {
|
||||
const raw = localStorage.getItem(DESIGN_PREFS_KEY);
|
||||
@@ -7731,7 +7833,7 @@ void main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Neutral panel palette — deliberately NOT Impeccable-branded. The panel is
|
||||
// Neutral panel palette - deliberately NOT Impeccable-branded. The panel is
|
||||
// a viewer of the project's design system, not an Impeccable surface.
|
||||
const DP = {
|
||||
canvas: 'oklch(94% 0 0)', // panel background
|
||||
@@ -8025,7 +8127,7 @@ void main() {
|
||||
const root = designShadow.querySelector('.root');
|
||||
root.innerHTML = '';
|
||||
|
||||
// (Panel toggle lives in the global bar — no floating FAB.)
|
||||
// (Panel toggle lives in the global bar - no floating FAB.)
|
||||
// Panel
|
||||
const panel = document.createElement('aside');
|
||||
panel.className = 'panel';
|
||||
@@ -8141,7 +8243,7 @@ void main() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Visual tab — single unified render path.
|
||||
// Visual tab - single unified render path.
|
||||
if (designState.mdNewerThanJson) body.appendChild(renderStaleHint());
|
||||
if (designState.hasMd && !designState.hasSidecar) {
|
||||
body.appendChild(renderParsedMdCta());
|
||||
@@ -8357,7 +8459,7 @@ void main() {
|
||||
specimen.style.fontFamily = fontStack(t);
|
||||
specimen.style.fontWeight = String(t.weight || 400);
|
||||
specimen.style.fontStyle = t.style || 'normal';
|
||||
specimen.style.fontSize = '56px'; // Fixed specimen size — compare faces, not scales.
|
||||
specimen.style.fontSize = '56px'; // Fixed specimen size - compare faces, not scales.
|
||||
specimen.style.letterSpacing = 'normal';
|
||||
specimen.style.textTransform = 'none';
|
||||
tile.appendChild(specimen);
|
||||
@@ -8501,7 +8603,7 @@ void main() {
|
||||
}
|
||||
|
||||
// Single shared description if all items carry the same one; otherwise
|
||||
// skip — per-item descriptions clutter a grouped tile.
|
||||
// skip - per-item descriptions clutter a grouped tile.
|
||||
if (group.length === 1 && group[0].description) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'c-desc';
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Impeccable Live Variant Mode — Browser Script
|
||||
* Impeccable Live Variant Mode - Browser Script
|
||||
*
|
||||
* Injected into the user's page via <script src="http://localhost:PORT/live.js">.
|
||||
* The server prepends window.__IMPECCABLE_TOKEN__ and window.__IMPECCABLE_PORT__
|
||||
* before this code.
|
||||
*
|
||||
* UI: a single floating bar that morphs between three states —
|
||||
* UI: a single floating bar that morphs between three states -
|
||||
* configure (pick action + go), generating (progressive dots), and cycling
|
||||
* (prev/next + accept/discard). Feels like Spotlight, not a modal.
|
||||
*/
|
||||
@@ -46,7 +46,7 @@
|
||||
mist: 'oklch(90% 0.008 82 / 0.6)', // light hairline
|
||||
white: 'oklch(99% 0 0)',
|
||||
};
|
||||
// Picker bar chrome — mirrors .live-demo-gbar / .live-demo-ctx in kinpaku-kit.css.
|
||||
// Picker bar chrome - mirrors .live-demo-gbar / .live-demo-ctx in kinpaku-kit.css.
|
||||
// Quiet neutral elevation: no gold halo ring (gold is reserved for the brand
|
||||
// mark and the active control, not the container outline).
|
||||
const PICKER_SHADOW =
|
||||
@@ -83,7 +83,7 @@
|
||||
|
||||
// SVG icons stack above each chip label. All strokes use currentColor so the
|
||||
// icon recolors to C.brand when its chip is selected. 20x20 render, 24-viewBox,
|
||||
// 1.5 stroke — visually consistent with the Foundation grid on the homepage.
|
||||
// 1.5 stroke - visually consistent with the Foundation grid on the homepage.
|
||||
const ICON_ATTRS = 'width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="display:block"';
|
||||
const ICONS = {
|
||||
impeccable: `<svg ${ICON_ATTRS}><path d="M4 20l4-1L18 9l-3-3L5 16z"/><path d="M14 7l3 3"/></svg>`,
|
||||
@@ -123,6 +123,7 @@
|
||||
let hoveredElement = null;
|
||||
let selectedElement = null;
|
||||
let currentSessionId = null;
|
||||
let pendingAcceptedSession = null;
|
||||
let expectedVariants = 0;
|
||||
let arrivedVariants = 0;
|
||||
let visibleVariant = 0;
|
||||
@@ -133,7 +134,7 @@
|
||||
const browserOwner = sessionState.owner;
|
||||
let checkpointTimer = null;
|
||||
|
||||
// Scroll lock — holds window.scrollY at a fixed value while the session is
|
||||
// Scroll lock - holds window.scrollY at a fixed value while the session is
|
||||
// active, so HMR DOM patches and variant swaps can't drift the page. See
|
||||
// startScrollLock / stopScrollLock below.
|
||||
let scrollLockObserver = null;
|
||||
@@ -141,7 +142,7 @@
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
// Dedicated key for scroll position — SEPARATE from LS_KEY so that
|
||||
// Dedicated key for scroll position - SEPARATE from LS_KEY so that
|
||||
// saveSession's state updates don't clobber a carefully-captured scrollY.
|
||||
// (Previously: saveSession wrote scrollY alongside state, so every call
|
||||
// during resume overwrote the pre-reload value with whatever the browser
|
||||
@@ -231,7 +232,7 @@
|
||||
// - Stop `focusin` propagation so any focus shifts inside our chrome
|
||||
// don't read as "focus moved outside the dialog" to focus traps.
|
||||
//
|
||||
// Click events still bubble normally — only the early pointer/focus
|
||||
// Click events still bubble normally - only the early pointer/focus
|
||||
// signals that drive outside-interaction detection are silenced.
|
||||
function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) {
|
||||
if (!rootEl) return;
|
||||
@@ -319,7 +320,7 @@
|
||||
// correlate directly with the captured PNG.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DRAG_THRESHOLD = 5; // px — below this, treat pointerup as a click
|
||||
const DRAG_THRESHOLD = 5; // px - below this, treat pointerup as a click
|
||||
const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it
|
||||
let annotOverlayEl = null;
|
||||
let annotSvgEl = null;
|
||||
@@ -423,7 +424,7 @@
|
||||
placeholderResizeDrag = null;
|
||||
if (annotOverlayEl) annotOverlayEl.style.display = 'none';
|
||||
syncPlaceholderResizeHandles();
|
||||
// Drop any in-progress edit without touching annotState — clearAnnotations
|
||||
// Drop any in-progress edit without touching annotState - clearAnnotations
|
||||
// (if the caller is exiting configure mode) handles state reset.
|
||||
annotEditing = null;
|
||||
}
|
||||
@@ -488,7 +489,7 @@
|
||||
function onAnnotDown(e) {
|
||||
if (!annotActive) return;
|
||||
|
||||
// 0) Insert placeholder edge resize — wins over draw / pins.
|
||||
// 0) Insert placeholder edge resize - wins over draw / pins.
|
||||
const resizeEdge = e.target.closest?.('[data-impeccable-placeholder-resize]')?.dataset.impeccablePlaceholderResize;
|
||||
if (resizeEdge && configureKind === 'insert' && placeholderElement) {
|
||||
startPlaceholderEdgeResize(resizeEdge, e);
|
||||
@@ -536,7 +537,7 @@
|
||||
// If editing a different pin, commit that edit before starting here.
|
||||
if (annotEditing && annotEditing.idx !== idx) finalizeEditingPin();
|
||||
// If already editing THIS pin and the user clicked the dot, let the
|
||||
// input keep focus (don't start a drag — the click wasn't meant as one).
|
||||
// input keep focus (don't start a drag - the click wasn't meant as one).
|
||||
if (annotEditing && annotEditing.idx === idx) return;
|
||||
const p = localCoords(e);
|
||||
const pin = annotState.comments[idx];
|
||||
@@ -971,7 +972,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The Bar — one floating element, three modes
|
||||
// The Bar - one floating element, three modes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Contextual-bar palette. Cached at init so every build*Row reads a
|
||||
@@ -1752,7 +1753,7 @@
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
});
|
||||
|
||||
// Action pill — dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill)
|
||||
// Action pill - dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill)
|
||||
const pill = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
||||
padding: '5px 10px', borderRadius: '6px',
|
||||
@@ -1786,7 +1787,7 @@
|
||||
});
|
||||
row.appendChild(pill);
|
||||
|
||||
// Prompt field — same chat-surface chrome as the bottom Steer bar
|
||||
// Prompt field - same chat-surface chrome as the bottom Steer bar
|
||||
const inputWrap = el('div', {
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
flex: '1', minWidth: '0', height: '28px',
|
||||
@@ -2126,7 +2127,7 @@
|
||||
if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
|
||||
row.appendChild(next);
|
||||
|
||||
// Tune chip — only when the visible variant exposes params
|
||||
// Tune chip - only when the visible variant exposes params
|
||||
const visParams = parseVariantParams(getVisibleVariantEl());
|
||||
const hasParams = visParams.length > 0;
|
||||
if (hasParams) {
|
||||
@@ -2173,7 +2174,7 @@
|
||||
// Spacer
|
||||
row.appendChild(el('div', { flex: '1' }));
|
||||
|
||||
// Accept — primary action, kinpaku gold + lacquer-deep (matches demo .live-demo-ctx-accept)
|
||||
// Accept - primary action, kinpaku gold + lacquer-deep (matches demo .live-demo-ctx-accept)
|
||||
const accept = el('button', {
|
||||
padding: '5px 14px', borderRadius: '5px',
|
||||
border: 'none', background: C.brand, color: C.ink,
|
||||
@@ -2266,7 +2267,7 @@
|
||||
const active = i === visibleVariant;
|
||||
// active: solid site-brand kinpaku dot. arrived+inactive: muted neutral.
|
||||
// pending (not yet arrived): faint outline ring. No borders on arrived
|
||||
// dots — the previous "accent ring + ash fill" combo read as noisy
|
||||
// dots - the previous "accent ring + ash fill" combo read as noisy
|
||||
// kinpaku chips, especially when all variants had arrived and every
|
||||
// dot wore an accent ring.
|
||||
const dotBg = active ? C.brand
|
||||
@@ -2456,7 +2457,7 @@
|
||||
let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide)
|
||||
let paramsPanelInner = null; // translating content (carries bg, padding, knobs)
|
||||
let paramsPanelBody = null; // grid holding the knob cells
|
||||
let paramsCurrentValues = {}; // {paramId: value} — mirror of the visible variant's live values
|
||||
let paramsCurrentValues = {}; // {paramId: value} - mirror of the visible variant's live values
|
||||
let tuneOpen = false; // whether the Tune popover is open right now
|
||||
|
||||
// Theme-aware Tune popover. Appears as a drawer that slides out from the
|
||||
@@ -2471,7 +2472,7 @@
|
||||
const P = paramsPanelPalette;
|
||||
|
||||
// Single element, always in the DOM. The slide animation is a CSS mask
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis — no
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis - no
|
||||
// display toggle, no opacity toggle, no transform trickery. The mask
|
||||
// hides everything initially; as it grows, content is revealed from
|
||||
// the bar edge outward.
|
||||
@@ -2494,7 +2495,7 @@
|
||||
transition: 'clip-path 0.44s ' + EASE,
|
||||
|
||||
// Park off-screen until positionParamsPanel places it. These are NOT
|
||||
// in the transition list, so they snap instantly — no fly-in from the
|
||||
// in the transition list, so they snap instantly - no fly-in from the
|
||||
// top-left when first shown.
|
||||
top: '-9999px', left: '-9999px', width: '0',
|
||||
});
|
||||
@@ -2685,7 +2686,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline text editing — makes pure-text descendants of the picked element
|
||||
// Inline text editing - makes pure-text descendants of the picked element
|
||||
// directly contenteditable. Save stages copy edits in the live buffer; the
|
||||
// Apply copy edits dock later asks the AI to apply the staged batch.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -3132,7 +3133,7 @@
|
||||
if (detail.includes('newText cannot contain') || detail.includes('newText cannot be empty')) {
|
||||
showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500);
|
||||
} else {
|
||||
showToast('Save failed — retry or cancel', 4000);
|
||||
showToast('Save failed: retry or cancel', 4000);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3454,19 +3455,19 @@
|
||||
updatePendingCounter(remaining);
|
||||
if (result.failed && result.failed.length > 0) {
|
||||
console.warn('[impeccable] some copy edits failed:', result.failed);
|
||||
showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed — see console', 5000);
|
||||
showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed, see console', 5000);
|
||||
} else {
|
||||
const n = Array.isArray(result.applied) ? result.applied.length : (result.cleared || 0);
|
||||
if (n > 0) {
|
||||
showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500);
|
||||
} else {
|
||||
console.warn('[impeccable] apply returned no verified edits:', result);
|
||||
showToast('No edits applied — see console', 4000);
|
||||
showToast('No edits applied, see console', 4000);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[impeccable] commit failed:', err);
|
||||
showToast('Apply failed — see console', 4000);
|
||||
showToast('Apply failed, see console', 4000);
|
||||
} finally {
|
||||
if (waitForSseCompletion) return;
|
||||
const remainingCount = parseInt(pendingPillEl?.dataset.count || '0', 10) || 0;
|
||||
@@ -3496,7 +3497,7 @@
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[impeccable] discard failed:', err);
|
||||
showToast('Discard failed — see console', 4000);
|
||||
showToast('Discard failed, see console', 4000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3644,7 +3645,7 @@
|
||||
const failedCount = numberOrNull(msg.failedCount) || 0;
|
||||
const appliedCount = numberOrNull(msg.appliedCount) || numberOrNull(msg.cleared) || 0;
|
||||
if (failedCount > 0) {
|
||||
showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed — see console', 5000);
|
||||
showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed, see console', 5000);
|
||||
} else if (appliedCount > 0) {
|
||||
showToast('Applied ' + appliedCount + ' edit' + (appliedCount === 1 ? '' : 's'), 2500);
|
||||
}
|
||||
@@ -3799,7 +3800,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit content badge — floating button at element top-right to enter EDITING mode
|
||||
// Edit content badge - floating button at element top-right to enter EDITING mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initEditBadge() {
|
||||
@@ -3884,7 +3885,7 @@
|
||||
}
|
||||
editBadgeEl.appendChild(btn);
|
||||
} else {
|
||||
// 'editing' — show Cancel + Save separated
|
||||
// 'editing' - show Cancel + Save separated
|
||||
editBadgeEl.innerHTML = '';
|
||||
editBadgeEl.style.gap = '8px';
|
||||
const cancel = document.createElement('button');
|
||||
@@ -4083,7 +4084,7 @@
|
||||
if (!v) continue;
|
||||
setVariantShown(child, v === String(num));
|
||||
}
|
||||
// Unconditional refresh — covers first-reveal (no-op if state isn't
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
// CYCLING yet, the subsequent CYCLING transition triggers its own
|
||||
// refresh) and every cycle step.
|
||||
refreshParamsPanel();
|
||||
@@ -4318,7 +4319,7 @@
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
}, { passive: true, ...sig });
|
||||
|
||||
// Apply target synchronously, not via rAF — racing the browser's
|
||||
// Apply target synchronously, not via rAF - racing the browser's
|
||||
// restore or a smooth-scroll animation means we want to win now.
|
||||
if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) {
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
@@ -4545,18 +4546,34 @@
|
||||
// hidden tab, a route the user navigated away from). The variant
|
||||
// MutationObserver stays armed and auto-transitions to CYCLING
|
||||
// the moment the wrapper actually mounts. Nudge the user toward
|
||||
// that path with a toast — better than the prior force-reload
|
||||
// that path with a toast - better than the prior force-reload
|
||||
// which reset framework state and left the session stuck.
|
||||
setTimeout(() => {
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) return;
|
||||
if (state !== 'GENERATING') return;
|
||||
showToast(
|
||||
"Variants ready. If the picked element isn't visible, retrace the path that revealed it — they'll appear automatically.",
|
||||
"Variants ready. If the picked element isn't visible, retrace the path that revealed it; they'll appear automatically.",
|
||||
15000,
|
||||
);
|
||||
}, 2000);
|
||||
break;
|
||||
case 'complete':
|
||||
case 'accept':
|
||||
if (maybeCompleteAcceptedSession(msg)) break;
|
||||
break;
|
||||
case 'agent_done':
|
||||
// Carbonize accepts are not terminal until live-complete.mjs sends
|
||||
// the final complete event. Keep the browser in its recoverable
|
||||
// saving state while the source cleanup is still in flight.
|
||||
break;
|
||||
case 'error':
|
||||
if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) {
|
||||
pendingAcceptedSession = null;
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
break;
|
||||
}
|
||||
if (maybeCompleteSteer(msg)) break;
|
||||
console.error('[impeccable] Error:', msg.message);
|
||||
showToast('Error: ' + msg.message, 5000);
|
||||
@@ -4779,14 +4796,14 @@
|
||||
|
||||
/**
|
||||
* Surface a brief, non-blocking heads-up when the picked element lives
|
||||
* inside a container whose visibility is gated by ephemeral state — modals,
|
||||
* inside a container whose visibility is gated by ephemeral state - modals,
|
||||
* collapsible panels, popovers, off-screen tab panels. If HMR remounts the
|
||||
* parent during generation (Vite Fast Refresh, SvelteKit page reload), the
|
||||
* variants land in source but stay invisible until the user re-opens the
|
||||
* container. Telling the user upfront is much friendlier than the silent
|
||||
* timeout-then-toast that they'd otherwise hit.
|
||||
*
|
||||
* Heuristic, intentionally narrow — only fires for unambiguous cases so
|
||||
* Heuristic, intentionally narrow - only fires for unambiguous cases so
|
||||
* we don't cry wolf on every nested element.
|
||||
*/
|
||||
function maybeWarnConditionalAncestor(el) {
|
||||
@@ -4804,7 +4821,7 @@
|
||||
showToast('Heads up: this element lives inside an open panel. If state resets during generation, you may need to re-open it.', 6000);
|
||||
return;
|
||||
}
|
||||
// 3. Tab panel — only meaningful when the page also shows ANOTHER
|
||||
// 3. Tab panel - only meaningful when the page also shows ANOTHER
|
||||
// tab as selected. A single tabpanel with no tablist is just a static
|
||||
// section in disguise and isn't conditional.
|
||||
if (node.getAttribute && node.getAttribute('role') === 'tabpanel') {
|
||||
@@ -4833,7 +4850,7 @@
|
||||
// Fire a lightweight prefetch event the first time the user selects an
|
||||
// element on a given route. The agent uses this to Read the underlying file
|
||||
// into context before Go is hit, shaving the read off the critical path.
|
||||
// Dedupe per session by pathname — clicking around on the same page doesn't
|
||||
// Dedupe per session by pathname - clicking around on the same page doesn't
|
||||
// re-fire.
|
||||
//
|
||||
// DISABLED: quick-Go workflows pay an extra harness round trip because
|
||||
@@ -4969,6 +4986,7 @@
|
||||
disableInlineEdit();
|
||||
stripManualEditRuntimeState(selectedElement);
|
||||
|
||||
pendingAcceptedSession = null;
|
||||
currentSessionId = id8();
|
||||
expectedVariants = selectedCount;
|
||||
arrivedVariants = 0;
|
||||
@@ -4976,7 +4994,7 @@
|
||||
|
||||
// Flip to GENERATING immediately so the bar morphs without waiting on
|
||||
// capture + upload. The event is emitted from captureAndEmit() once the
|
||||
// screenshot is uploaded (or capture fails — we still emit, just without
|
||||
// screenshot is uploaded (or capture fails - we still emit, just without
|
||||
// screenshotPath).
|
||||
const elForCapture = selectedElement;
|
||||
const captureRect = elForCapture.getBoundingClientRect();
|
||||
@@ -5002,7 +5020,7 @@
|
||||
state = 'GENERATING';
|
||||
// Disable the Edit badge: starting a manual text edit mid-generation would
|
||||
// conflict with the variant wrap that's about to land in the same DOM
|
||||
// region. Only swap if the badge was visible — picked elements with no
|
||||
// region. Only swap if the badge was visible - picked elements with no
|
||||
// text rows have it hidden already.
|
||||
if (editBadgeEl && editBadgeEl.style.display !== 'none') renderEditBadge('idle-disabled');
|
||||
showBar('generating');
|
||||
@@ -5104,7 +5122,7 @@
|
||||
|
||||
// Collect @font-face rules from every stylesheet on the page. Cross-origin
|
||||
// sheets (Google Fonts, Typekit, etc.) throw SecurityError on .cssRules
|
||||
// access, so modern-screenshot can't embed them on its own — the resulting
|
||||
// access, so modern-screenshot can't embed them on its own - the resulting
|
||||
// SVG falls back to system fonts and text re-wraps + renders with different
|
||||
// weight. We fetch the raw CSS text (CORS-permitted for these providers),
|
||||
// extract @font-face blocks, inline the referenced font files as base64
|
||||
@@ -5188,7 +5206,7 @@
|
||||
// modern-screenshot force-sets `background-color: X !important` on the
|
||||
// cloned root whenever `backgroundColor` is passed, clobbering the
|
||||
// element's own background. So we only pass it when the element is
|
||||
// genuinely transparent (no own color, no own image) — in that case
|
||||
// genuinely transparent (no own color, no own image) - in that case
|
||||
// we resolve up the DOM to the nearest opaque ancestor so the capture
|
||||
// sits on the page's real background instead of rendering black.
|
||||
function resolveCanvasBackground(el) {
|
||||
@@ -5206,7 +5224,7 @@
|
||||
// `getComputedStyle(body).backgroundColor || …` chain is a trap: that
|
||||
// call returns the literal string `"rgba(0, 0, 0, 0)"` for a page that
|
||||
// never set its own bg, which is truthy and short-circuits the chain to
|
||||
// transparent-black — modern-screenshot then renders the capture on a
|
||||
// transparent-black - modern-screenshot then renders the capture on a
|
||||
// black canvas and the shader overlay flashes solid black during load.
|
||||
// The browser canvas defaults to white, so we do too.
|
||||
return '#ffffff';
|
||||
@@ -5248,7 +5266,7 @@
|
||||
// Transparent up to the root. The visible backdrop may still come from an
|
||||
// ancestor's background-image or a covering positioned layer (e.g. a hero
|
||||
// art div) that the color walk can't see. Capture that ancestor and crop
|
||||
// to the element so the real backdrop is embedded — correct for both the
|
||||
// to the element so the real backdrop is embedded - correct for both the
|
||||
// shader and the screenshot sent to the model. Fall back to white only
|
||||
// when nothing is actually painted behind the element.
|
||||
const backdrop = findBackdropAncestor(el);
|
||||
@@ -5289,13 +5307,13 @@
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] capture failed, proceeding without screenshot:', err);
|
||||
}
|
||||
// Light up the shader overlay the moment capture is ready — no reason to
|
||||
// Light up the shader overlay the moment capture is ready - no reason to
|
||||
// wait for the upload to complete before the user sees something alive.
|
||||
if (blob && state === 'GENERATING') {
|
||||
showShaderOverlay(el, blob, rect, paper);
|
||||
}
|
||||
// Only upload + forward the screenshot when annotations (comments/strokes)
|
||||
// are present. Without annotations the image is pure visual anchoring —
|
||||
// are present. Without annotations the image is pure visual anchoring -
|
||||
// it biases the model toward the current rendering and works against the
|
||||
// three-distinct-directions brief.
|
||||
const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0);
|
||||
@@ -5320,7 +5338,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shader overlay — renders the captured screenshot as a WebGL texture and
|
||||
// Shader overlay - renders the captured screenshot as a WebGL texture and
|
||||
// runs an editorial "ink-wash" fragment shader over it during generation.
|
||||
// A single rolling band sweeps top-to-bottom, desaturating + tinting kinpaku
|
||||
// and leaving a soft trail. Makes the wait feel like a letterpress scan
|
||||
@@ -5343,7 +5361,7 @@ uniform vec3 u_accent;
|
||||
uniform vec3 u_paper;
|
||||
varying vec2 v_uv;
|
||||
|
||||
// Asymmetric roller band. Product of two one-sided smoothsteps — peaks at
|
||||
// Asymmetric roller band. Product of two one-sided smoothsteps - peaks at
|
||||
// d=0 with a short sharp leading ramp and a longer soft trailing tail. Clean
|
||||
// outside the [-leadW, trailW] range (no rogue "trail=1 everywhere below"
|
||||
// failure that reversed-edge smoothstep would give).
|
||||
@@ -5369,8 +5387,8 @@ void main() {
|
||||
vec2 sampleCenter = (cellId + 0.5) * cellPx / u_resolution;
|
||||
vec3 cellImg = texture2D(u_texture, sampleCenter).rgb;
|
||||
// Dot size tracks how much the cell DIFFERS from the element's own ground
|
||||
// (u_paper), not absolute darkness. So the content — text, buttons, anything
|
||||
// that deviates from the background — always becomes the dots, on light AND
|
||||
// (u_paper), not absolute darkness. So the content - text, buttons, anything
|
||||
// that deviates from the background - always becomes the dots, on light AND
|
||||
// dark surfaces. A plain darkness curve inverts on dark elements: the dark
|
||||
// background fills with ink and the lighter content punches holes instead.
|
||||
// Capped below the cell half-width so dense content stays separated dots.
|
||||
@@ -5380,8 +5398,8 @@ void main() {
|
||||
// Two-stage dissolve as the roller passes, so the element is rebuilt purely
|
||||
// from dot size (its own halftone) and never bleeds through as raw pixels
|
||||
// behind the dots:
|
||||
// 1. cover — the element flattens to the uniform paper ground first.
|
||||
// 2. dotAmt — kinpaku dots then emerge, sized by each cell's luma.
|
||||
// 1. cover - the element flattens to the uniform paper ground first.
|
||||
// 2. dotAmt - kinpaku dots then emerge, sized by each cell's luma.
|
||||
// A plain mix(base, halftone, band) instead left the raw element visible
|
||||
// through the band's soft core/trail. The paper ground is u_paper (the
|
||||
// element's own bg tone) rather than a fixed white, so the dissolve reads the
|
||||
@@ -5399,7 +5417,7 @@ void main() {
|
||||
|
||||
// Kinpaku gold converted to approximate sRGB 0-1 (matches oklch(84% 0.19 80.46))
|
||||
const SHADER_ACCENT = [1.0, 0.78, 0.31];
|
||||
// Fallback ground when an element and all its ancestors are transparent —
|
||||
// Fallback ground when an element and all its ancestors are transparent -
|
||||
// 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 }
|
||||
@@ -5412,7 +5430,7 @@ void main() {
|
||||
// Rasterize any CSS color (oklch, color(), named, hex, rgb) through a 1x1
|
||||
// canvas and read back the sRGB pixel. String-parsing computed colors is a
|
||||
// trap: Chrome returns backgroundColor as oklch()/color() for oklch inputs,
|
||||
// which a hex/rgb regex misses — every site token would fall back to white.
|
||||
// which a hex/rgb regex misses - every site token would fall back to white.
|
||||
let colorParseCtx = null;
|
||||
function cssColorToRgb01(str) {
|
||||
if (!colorParseCtx) {
|
||||
@@ -5440,7 +5458,7 @@ void main() {
|
||||
|
||||
// When an element is transparent up to the root, its visible backdrop can
|
||||
// still come from an ancestor's background-image or a covering positioned
|
||||
// layer that is a *child* of an ancestor (e.g. a hero's absolute art div) —
|
||||
// layer that is a *child* of an ancestor (e.g. a hero's absolute art div) -
|
||||
// neither of which the ancestor background-COLOR walk can see. Return the
|
||||
// nearest such ancestor so we can capture it and crop, embedding the real
|
||||
// backdrop. Returns null when nothing is actually painted behind the element
|
||||
@@ -5481,7 +5499,7 @@ void main() {
|
||||
|
||||
// Average the backdrop sampled just OUTSIDE an element's rect within a larger
|
||||
// canvas. The ground tone for the dissolve must be the real backdrop, not the
|
||||
// mean of the element's own crop — averaging the crop folds in the element's
|
||||
// mean of the element's own crop - averaging the crop folds in the element's
|
||||
// content (e.g. bright heading text), pulling the ground toward muddy gray.
|
||||
function sampleSurroundingRgb(ctx, sx, sy, sw, sh, W, H) {
|
||||
const pad = Math.max(2, Math.round(Math.min(sw, sh) * 0.12));
|
||||
@@ -5528,11 +5546,31 @@ void main() {
|
||||
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;
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
canvas.remove();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const fallback = document.createElement('div');
|
||||
fallback.id = PREFIX + '-shader';
|
||||
// Copy positioning via cssText. Object.assign across CSSStyleDeclaration
|
||||
// throws in modern Chromium because the source's indexed properties
|
||||
// (style[0], [1], ...) are read-only and the engine forbids writing
|
||||
// them on the destination.
|
||||
fallback.style.cssText = canvas.style.cssText;
|
||||
fallback.style.backgroundImage = 'url("' + objectUrl + '")';
|
||||
fallback.style.backgroundSize = '100% 100%';
|
||||
fallback.style.backgroundRepeat = 'no-repeat';
|
||||
fallback.style.outline = '2px dashed ' + C.brand;
|
||||
fallback.style.outlineOffset = '-2px';
|
||||
document.body.appendChild(fallback);
|
||||
shaderState = { canvas: fallback, gl: null, program: null, texture: null, rafId: 0, startTime: 0, objectUrl };
|
||||
}
|
||||
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
@@ -5553,21 +5591,9 @@ void main() {
|
||||
const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false })
|
||||
|| canvas.getContext('experimental-webgl');
|
||||
if (!gl) {
|
||||
// WebGL unavailable — fall back to a plain <img> overlay so the user
|
||||
// still sees something meaningful during generation.
|
||||
canvas.remove();
|
||||
const img = document.createElement('img');
|
||||
img.src = URL.createObjectURL(blob);
|
||||
img.id = PREFIX + '-shader';
|
||||
// Copy positioning via cssText. Object.assign across CSSStyleDeclaration
|
||||
// throws in modern Chromium because the source's indexed properties
|
||||
// (style[0], [1], ...) are read-only and the engine forbids writing
|
||||
// them on the destination.
|
||||
img.style.cssText = canvas.style.cssText;
|
||||
img.style.outline = '2px dashed ' + C.brand;
|
||||
img.style.outlineOffset = '-2px';
|
||||
document.body.appendChild(img);
|
||||
shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 };
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5609,14 +5635,12 @@ void main() {
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch {
|
||||
// Safari fallback: go via a regular Image
|
||||
const imgUrl = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
img.src = imgUrl;
|
||||
await new Promise((r, rej) => { img.onload = r; img.onerror = rej; });
|
||||
bitmap = img;
|
||||
URL.revokeObjectURL(imgUrl);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
@@ -5670,14 +5694,15 @@ void main() {
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
// The accepted variant is already the only visible child of the wrapper
|
||||
// (all other variants are display:none). HMR from the source rewrite will
|
||||
// replace the wrapper imminently. Don't eagerly replaceChild here — React
|
||||
// reconciliation races with our mutation and throws NotFoundError in Next
|
||||
// 16 / Turbopack. Schedule a fallback that runs the manual swap only if
|
||||
// HMR hasn't cleaned up by then (keeps static-server flows working).
|
||||
const acceptedSessionId = currentSessionId;
|
||||
const acceptedVariant = visibleVariant;
|
||||
const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant);
|
||||
pendingAcceptedSession = {
|
||||
id: acceptedSessionId,
|
||||
variant: String(acceptedVariant),
|
||||
...acceptedSnapshot,
|
||||
finalizing: false,
|
||||
};
|
||||
|
||||
state = 'SAVING';
|
||||
updateBarContent('saving');
|
||||
@@ -5685,53 +5710,130 @@ void main() {
|
||||
sendEvent(acceptPayload, { throwOnError: true })
|
||||
.then(() => {
|
||||
markSessionHandled();
|
||||
confirmAcceptAfterReceipt();
|
||||
})
|
||||
.catch(() => {
|
||||
pendingAcceptedSession = null;
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
}
|
||||
|
||||
function confirmAcceptAfterReceipt() {
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
scheduleAcceptCleanup();
|
||||
function maybeCompleteAcceptedSession(msg) {
|
||||
const pending = pendingAcceptedSession;
|
||||
if (!pending || !msg?.id || msg.id !== pending.id) return false;
|
||||
if (currentSessionId && currentSessionId !== pending.id) {
|
||||
pendingAcceptedSession = null;
|
||||
return false;
|
||||
}
|
||||
if (pending.finalizing) return true;
|
||||
pending.finalizing = true;
|
||||
|
||||
function scheduleAcceptCleanup() {
|
||||
setTimeout(function() {
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
selectedElement = null;
|
||||
currentSessionId = null;
|
||||
selectedAction = 'impeccable';
|
||||
renderEditBadge('hidden');
|
||||
state = 'PICKING';
|
||||
}, 1800);
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
|
||||
// Static-server / no-HMR fallback: if the wrapper is still around 2s after
|
||||
// the cleanup above, swap it out manually. By now React has either moved
|
||||
// on or the app isn't React at all. Preserve the `data-impeccable-variant="N"`
|
||||
// div (with display:contents) so @scope rules anchored to the variant
|
||||
// attribute keep matching until reload replaces it with the carbonize block.
|
||||
// Give framework HMR a short chance to render the now-clean accepted
|
||||
// source. If it misses the update, unwrap the accepted variant after the
|
||||
// source-side completion event so the page is not left empty or stale.
|
||||
setTimeout(function() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + acceptedSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + acceptedVariant + '"]');
|
||||
if (accepted && accepted.firstElementChild) {
|
||||
const parent = wrapper.parentElement;
|
||||
if (!parent) return;
|
||||
accepted.style.display = 'contents';
|
||||
parent.replaceChild(accepted, wrapper);
|
||||
}
|
||||
}, 2000);
|
||||
ensureAcceptedDomClean(pending);
|
||||
cleanupAcceptedSession();
|
||||
}, 1200);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
acceptedHtml: accepted ? accepted.innerHTML : '',
|
||||
acceptedSelector: selectorForAcceptedRoot(root),
|
||||
parentElement: wrapper?.parentElement || null,
|
||||
parentSelector: selectorForAcceptedRoot(wrapper?.parentElement || null),
|
||||
nextSibling: wrapper?.nextSibling || null,
|
||||
};
|
||||
}
|
||||
|
||||
function selectorForAcceptedRoot(root) {
|
||||
if (!root || !root.tagName) return '';
|
||||
const tag = root.tagName.toLowerCase();
|
||||
const classes = [...(root.classList || [])].filter(Boolean);
|
||||
if (classes.length === 0) return tag;
|
||||
return tag + classes.map((cls) => '.' + cssIdent(cls)).join('');
|
||||
}
|
||||
|
||||
function acceptedDomAlreadyClean(pending) {
|
||||
if (!pending?.acceptedSelector) return false;
|
||||
const matches = [...document.querySelectorAll(pending.acceptedSelector)];
|
||||
return matches.some((el) => !el.closest('[data-impeccable-variants],[data-impeccable-variant]'));
|
||||
}
|
||||
|
||||
function ensureAcceptedDomClean(pending) {
|
||||
const sessionId = pending?.id;
|
||||
const variantId = pending?.variant;
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!wrapper) {
|
||||
restoreAcceptedDomFromSnapshot(pending);
|
||||
return;
|
||||
}
|
||||
if (!accepted) {
|
||||
wrapper.remove();
|
||||
restoreAcceptedDomFromSnapshot(pending);
|
||||
return;
|
||||
}
|
||||
const parent = wrapper.parentElement;
|
||||
if (!parent) return;
|
||||
while (accepted.firstChild) {
|
||||
parent.insertBefore(accepted.firstChild, wrapper);
|
||||
}
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
function restoreAcceptedDomFromSnapshot(pending) {
|
||||
if (acceptedDomAlreadyClean(pending)) return;
|
||||
if (!pending?.acceptedHtml) {
|
||||
reloadAfterMissingAcceptedDom(pending);
|
||||
return;
|
||||
}
|
||||
const parent = pending.parentElement?.isConnected
|
||||
? pending.parentElement
|
||||
: (pending.parentSelector ? document.querySelector(pending.parentSelector) : null);
|
||||
if (!parent) {
|
||||
reloadAfterMissingAcceptedDom(pending);
|
||||
return;
|
||||
}
|
||||
const template = document.createElement('template');
|
||||
template.innerHTML = pending.acceptedHtml;
|
||||
const anchor = pending.nextSibling?.isConnected && pending.nextSibling.parentElement === parent
|
||||
? pending.nextSibling
|
||||
: null;
|
||||
parent.insertBefore(template.content, anchor);
|
||||
if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending);
|
||||
}
|
||||
|
||||
function reloadAfterMissingAcceptedDom(pending) {
|
||||
if (acceptedDomAlreadyClean(pending)) return;
|
||||
if (pending?.id && document.querySelector('[data-impeccable-variants="' + pending.id + '"]')) return;
|
||||
location.reload();
|
||||
}
|
||||
|
||||
function cleanupAcceptedSession() {
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
selectedElement = null;
|
||||
currentSessionId = null;
|
||||
selectedAction = 'impeccable';
|
||||
pendingAcceptedSession = null;
|
||||
renderEditBadge('hidden');
|
||||
state = 'PICKING';
|
||||
}
|
||||
|
||||
function handleDiscard() {
|
||||
@@ -5792,7 +5894,7 @@ void main() {
|
||||
|
||||
function cleanup() {
|
||||
// Hide the wrapper immediately so variants disappear. DON'T structurally
|
||||
// mutate the DOM yet — HMR from the agent's source rewrite is on its way,
|
||||
// mutate the DOM yet - HMR from the agent's source rewrite is on its way,
|
||||
// and a manual replaceChild under React causes NotFoundError when the
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
@@ -5838,8 +5940,8 @@ void main() {
|
||||
function showToast(message, duration) {
|
||||
if (toastEl) toastEl.remove();
|
||||
// Stack the toast above the global bar (which sits at bottom:14px) so
|
||||
// the two never overlap. Read the bar's actual rect — its height varies
|
||||
// with hover-expanded labels — and fall back to a sensible default
|
||||
// the two never overlap. Read the bar's actual rect - its height varies
|
||||
// with hover-expanded labels - and fall back to a sensible default
|
||||
// when the bar isn't mounted yet.
|
||||
const barRect = globalBarEl?.getBoundingClientRect();
|
||||
const barTopFromBottom = barRect && barRect.height > 0
|
||||
@@ -6047,7 +6149,7 @@ void main() {
|
||||
let pendingApplyInFlight = false;
|
||||
let firstSaveOfSession = true;
|
||||
|
||||
// Steer — collapsed pill in the global bar; expands while typing for page-level chat.
|
||||
// Steer - collapsed pill in the global bar; expands while typing for page-level chat.
|
||||
let pageChatEl = null;
|
||||
let pageChatInput = null;
|
||||
let pageChatHint = null;
|
||||
@@ -6068,7 +6170,7 @@ void main() {
|
||||
const STEER_AWAIT_TIMEOUT_MS = 120000;
|
||||
const AGENT_STATUS_POLL_MS = 5000;
|
||||
const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)';
|
||||
const AGENT_DISCONNECTED_TIP = 'Agent disconnected — run live-poll.mjs to connect';
|
||||
const AGENT_DISCONNECTED_TIP = 'Agent disconnected: run live-poll.mjs to connect';
|
||||
const GLOBAL_BAR_SECTION_GAP = 8;
|
||||
const GLOBAL_BAR_INNER_GAP = 2;
|
||||
const GLOBAL_BAR_INNER_PAD_LEFT = 2;
|
||||
@@ -6079,7 +6181,7 @@ void main() {
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>';
|
||||
|
||||
// Theme-aware color palette for the global bar. We detect the page's
|
||||
// ambient background and invert — dark bar on light pages, light bar on
|
||||
// ambient background and invert - dark bar on light pages, light bar on
|
||||
// dark pages. This keeps the bar from fighting with the host design.
|
||||
function detectPageTheme() {
|
||||
try {
|
||||
@@ -6092,7 +6194,7 @@ void main() {
|
||||
// Walk body → html, taking the first opaque background. The browser's
|
||||
// default body / html background is `rgba(0, 0, 0, 0)`, which a naive
|
||||
// regex would read as black and mislabel a perfectly white page as
|
||||
// dark. Honoring alpha avoids that — and falling through to <html>
|
||||
// dark. Honoring alpha avoids that - and falling through to <html>
|
||||
// catches the common pattern of a bg only on <html> (or only on body).
|
||||
function readOpaque(el) {
|
||||
if (!el) return null;
|
||||
@@ -6141,7 +6243,7 @@ void main() {
|
||||
exitHover: 'oklch(58% 0.15 35 / 0.18)',
|
||||
shadow: PICKER_SHADOW,
|
||||
chatSurface: 'oklch(22% 0.012 82)',
|
||||
// Verdigris patina — secondary state (see site/styles/kinpaku-tokens.css)
|
||||
// Verdigris patina - secondary state (see site/styles/kinpaku-tokens.css)
|
||||
patina: 'oklch(70% 0.12 188)',
|
||||
patinaPale: 'oklch(82% 0.07 188)',
|
||||
patinaSoft: 'oklch(70% 0.12 188 / 0.28)',
|
||||
@@ -6913,7 +7015,7 @@ void main() {
|
||||
steerFocusLog('page-chat-mounted', {});
|
||||
}
|
||||
|
||||
// Impeccable mark — same paths as site/components/Header.astro + favicon.svg.
|
||||
// Impeccable mark - same paths as site/components/Header.astro + favicon.svg.
|
||||
function brandMarkSvg(color = C.brand, size = 18) {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="${color}" aria-hidden="true">
|
||||
<path d="M5 2.5 L13.5 2.5 L5.5 21.5 L5 21.5 Q2.5 21.5 2.5 19 L2.5 5 Q2.5 2.5 5 2.5 Z"/>
|
||||
@@ -6928,7 +7030,7 @@ void main() {
|
||||
globalBarBrandEl.dataset.agentConnected = connected ? 'true' : 'false';
|
||||
globalBarBrandEl.setAttribute('aria-label', connected
|
||||
? 'Impeccable live mode'
|
||||
: 'Impeccable live mode — agent not polling');
|
||||
: 'Impeccable live mode: agent not polling');
|
||||
globalBarBrandEl.removeAttribute('title');
|
||||
globalBarBrandEl.style.cursor = connected ? 'default' : 'help';
|
||||
const mark = globalBarBrandEl.querySelector('[data-brand-mark]');
|
||||
@@ -7053,7 +7155,7 @@ void main() {
|
||||
globalBarEl.id = PREFIX + '-global-bar';
|
||||
globalBarEl.dataset.theme = theme;
|
||||
|
||||
// Brand mark — kinpaku Impeccable icon (site header / favicon paths).
|
||||
// Brand mark - kinpaku Impeccable icon (site header / favicon paths).
|
||||
const brand = el('span', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
alignSelf: 'stretch', position: 'relative',
|
||||
@@ -7065,7 +7167,7 @@ void main() {
|
||||
brand.id = PREFIX + '-global-bar-brand';
|
||||
brand.dataset.agentConnected = 'false';
|
||||
brand.setAttribute('role', 'img');
|
||||
brand.setAttribute('aria-label', 'Impeccable live mode — agent not polling');
|
||||
brand.setAttribute('aria-label', 'Impeccable live mode: agent not polling');
|
||||
|
||||
const brandMark = el('span', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
@@ -7130,7 +7232,7 @@ void main() {
|
||||
// Per-button hover only changes color (no layout). The label expand/
|
||||
// collapse is driven by the bar-level mouseenter/mouseleave so moving
|
||||
// the mouse between adjacent buttons doesn't trigger per-button width
|
||||
// thrashing — the whole bar grows once and shrinks once.
|
||||
// thrashing - the whole bar grows once and shrinks once.
|
||||
b.addEventListener('mouseenter', () => { if (b.dataset.active !== 'true') b.style.color = P.text; });
|
||||
b.addEventListener('mouseleave', () => { if (b.dataset.active !== 'true') b.style.color = P.textDim; });
|
||||
b.addEventListener('click', onClick);
|
||||
@@ -7139,7 +7241,7 @@ void main() {
|
||||
return b;
|
||||
}
|
||||
|
||||
// Pick toggle — restored from localStorage; both pick and insert may be off.
|
||||
// Pick toggle - restored from localStorage; both pick and insert may be off.
|
||||
const pickBtn = makeIconBtn({
|
||||
id: PREFIX + '-pick-toggle',
|
||||
svg: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><circle cx="12" cy="12" r="10"/><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><line x1="12" y1="6" x2="12" y2="2"/><line x1="12" y1="22" x2="12" y2="18"/></svg>',
|
||||
@@ -7176,7 +7278,7 @@ void main() {
|
||||
detectBtn.appendChild(detectBadge);
|
||||
inner.appendChild(detectBtn);
|
||||
|
||||
// DESIGN.md panel toggle — quartet of color squares as the mark.
|
||||
// DESIGN.md panel toggle - quartet of color squares as the mark.
|
||||
const designBtn = makeIconBtn({
|
||||
id: PREFIX + '-design-toggle',
|
||||
svg: `<span style="display:inline-grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;width:14px;height:14px;border-radius:3px;overflow:hidden;box-shadow:inset 0 0 0 1px oklch(58% 0.065 82 / 0.55);flex-shrink:0">
|
||||
@@ -7378,13 +7480,13 @@ void main() {
|
||||
});
|
||||
inner.appendChild(divider);
|
||||
|
||||
// Exit × on the right — intentionally subtle (textDim at rest, text on
|
||||
// Exit × on the right - intentionally subtle (textDim at rest, text on
|
||||
// hover) so it sits behind the active toggles in visual hierarchy.
|
||||
//
|
||||
// Explicit padding + box-sizing here is load-bearing: a host page like
|
||||
// `button { padding: 0.5rem 1rem; }` (very common in resets) would
|
||||
// otherwise inflate this 24x24 button into 56x40 and push the SVG out
|
||||
// of the visible bar — the X stays invisible even though the styles in
|
||||
// of the visible bar - the X stays invisible even though the styles in
|
||||
// DevTools look fine. Every other chrome button sets padding inline;
|
||||
// this one needed it too.
|
||||
const exitBtn = el('button', {
|
||||
@@ -7472,7 +7574,7 @@ void main() {
|
||||
btn.style.opacity = controlsLocked ? '0.55' : '1';
|
||||
});
|
||||
|
||||
// If the bar is currently under the cursor, keep all labels expanded —
|
||||
// If the bar is currently under the cursor, keep all labels expanded -
|
||||
// otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
|
||||
// would collapse its label while the user's mouse is still on the bar.
|
||||
if (globalBarEl && globalBarEl.matches(':hover')) {
|
||||
@@ -7648,7 +7750,7 @@ void main() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Design System Panel — visualizes the project's .impeccable/design.json sidecar
|
||||
// Design System Panel - visualizes the project's .impeccable/design.json sidecar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DESIGN_PREFS_KEY = 'impeccable-live-design-panel';
|
||||
@@ -7674,7 +7776,7 @@ void main() {
|
||||
};
|
||||
|
||||
function loadDesignPrefs() {
|
||||
// `open` is intentionally NOT persisted — the panel always starts closed
|
||||
// `open` is intentionally NOT persisted - the panel always starts closed
|
||||
// so live mode doesn't auto-slide a big panel over the page on startup.
|
||||
try {
|
||||
const raw = localStorage.getItem(DESIGN_PREFS_KEY);
|
||||
@@ -7731,7 +7833,7 @@ void main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Neutral panel palette — deliberately NOT Impeccable-branded. The panel is
|
||||
// Neutral panel palette - deliberately NOT Impeccable-branded. The panel is
|
||||
// a viewer of the project's design system, not an Impeccable surface.
|
||||
const DP = {
|
||||
canvas: 'oklch(94% 0 0)', // panel background
|
||||
@@ -8025,7 +8127,7 @@ void main() {
|
||||
const root = designShadow.querySelector('.root');
|
||||
root.innerHTML = '';
|
||||
|
||||
// (Panel toggle lives in the global bar — no floating FAB.)
|
||||
// (Panel toggle lives in the global bar - no floating FAB.)
|
||||
// Panel
|
||||
const panel = document.createElement('aside');
|
||||
panel.className = 'panel';
|
||||
@@ -8141,7 +8243,7 @@ void main() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Visual tab — single unified render path.
|
||||
// Visual tab - single unified render path.
|
||||
if (designState.mdNewerThanJson) body.appendChild(renderStaleHint());
|
||||
if (designState.hasMd && !designState.hasSidecar) {
|
||||
body.appendChild(renderParsedMdCta());
|
||||
@@ -8357,7 +8459,7 @@ void main() {
|
||||
specimen.style.fontFamily = fontStack(t);
|
||||
specimen.style.fontWeight = String(t.weight || 400);
|
||||
specimen.style.fontStyle = t.style || 'normal';
|
||||
specimen.style.fontSize = '56px'; // Fixed specimen size — compare faces, not scales.
|
||||
specimen.style.fontSize = '56px'; // Fixed specimen size - compare faces, not scales.
|
||||
specimen.style.letterSpacing = 'normal';
|
||||
specimen.style.textTransform = 'none';
|
||||
tile.appendChild(specimen);
|
||||
@@ -8501,7 +8603,7 @@ void main() {
|
||||
}
|
||||
|
||||
// Single shared description if all items carry the same one; otherwise
|
||||
// skip — per-item descriptions clutter a grouped tile.
|
||||
// skip - per-item descriptions clutter a grouped tile.
|
||||
if (group.length === 1 && group[0].description) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'c-desc';
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Impeccable Live Variant Mode — Browser Script
|
||||
* Impeccable Live Variant Mode - Browser Script
|
||||
*
|
||||
* Injected into the user's page via <script src="http://localhost:PORT/live.js">.
|
||||
* The server prepends window.__IMPECCABLE_TOKEN__ and window.__IMPECCABLE_PORT__
|
||||
* before this code.
|
||||
*
|
||||
* UI: a single floating bar that morphs between three states —
|
||||
* UI: a single floating bar that morphs between three states -
|
||||
* configure (pick action + go), generating (progressive dots), and cycling
|
||||
* (prev/next + accept/discard). Feels like Spotlight, not a modal.
|
||||
*/
|
||||
@@ -46,7 +46,7 @@
|
||||
mist: 'oklch(90% 0.008 82 / 0.6)', // light hairline
|
||||
white: 'oklch(99% 0 0)',
|
||||
};
|
||||
// Picker bar chrome — mirrors .live-demo-gbar / .live-demo-ctx in kinpaku-kit.css.
|
||||
// Picker bar chrome - mirrors .live-demo-gbar / .live-demo-ctx in kinpaku-kit.css.
|
||||
// Quiet neutral elevation: no gold halo ring (gold is reserved for the brand
|
||||
// mark and the active control, not the container outline).
|
||||
const PICKER_SHADOW =
|
||||
@@ -83,7 +83,7 @@
|
||||
|
||||
// SVG icons stack above each chip label. All strokes use currentColor so the
|
||||
// icon recolors to C.brand when its chip is selected. 20x20 render, 24-viewBox,
|
||||
// 1.5 stroke — visually consistent with the Foundation grid on the homepage.
|
||||
// 1.5 stroke - visually consistent with the Foundation grid on the homepage.
|
||||
const ICON_ATTRS = 'width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="display:block"';
|
||||
const ICONS = {
|
||||
impeccable: `<svg ${ICON_ATTRS}><path d="M4 20l4-1L18 9l-3-3L5 16z"/><path d="M14 7l3 3"/></svg>`,
|
||||
@@ -123,6 +123,7 @@
|
||||
let hoveredElement = null;
|
||||
let selectedElement = null;
|
||||
let currentSessionId = null;
|
||||
let pendingAcceptedSession = null;
|
||||
let expectedVariants = 0;
|
||||
let arrivedVariants = 0;
|
||||
let visibleVariant = 0;
|
||||
@@ -133,7 +134,7 @@
|
||||
const browserOwner = sessionState.owner;
|
||||
let checkpointTimer = null;
|
||||
|
||||
// Scroll lock — holds window.scrollY at a fixed value while the session is
|
||||
// Scroll lock - holds window.scrollY at a fixed value while the session is
|
||||
// active, so HMR DOM patches and variant swaps can't drift the page. See
|
||||
// startScrollLock / stopScrollLock below.
|
||||
let scrollLockObserver = null;
|
||||
@@ -141,7 +142,7 @@
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
// Dedicated key for scroll position — SEPARATE from LS_KEY so that
|
||||
// Dedicated key for scroll position - SEPARATE from LS_KEY so that
|
||||
// saveSession's state updates don't clobber a carefully-captured scrollY.
|
||||
// (Previously: saveSession wrote scrollY alongside state, so every call
|
||||
// during resume overwrote the pre-reload value with whatever the browser
|
||||
@@ -231,7 +232,7 @@
|
||||
// - Stop `focusin` propagation so any focus shifts inside our chrome
|
||||
// don't read as "focus moved outside the dialog" to focus traps.
|
||||
//
|
||||
// Click events still bubble normally — only the early pointer/focus
|
||||
// Click events still bubble normally - only the early pointer/focus
|
||||
// signals that drive outside-interaction detection are silenced.
|
||||
function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) {
|
||||
if (!rootEl) return;
|
||||
@@ -319,7 +320,7 @@
|
||||
// correlate directly with the captured PNG.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DRAG_THRESHOLD = 5; // px — below this, treat pointerup as a click
|
||||
const DRAG_THRESHOLD = 5; // px - below this, treat pointerup as a click
|
||||
const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it
|
||||
let annotOverlayEl = null;
|
||||
let annotSvgEl = null;
|
||||
@@ -423,7 +424,7 @@
|
||||
placeholderResizeDrag = null;
|
||||
if (annotOverlayEl) annotOverlayEl.style.display = 'none';
|
||||
syncPlaceholderResizeHandles();
|
||||
// Drop any in-progress edit without touching annotState — clearAnnotations
|
||||
// Drop any in-progress edit without touching annotState - clearAnnotations
|
||||
// (if the caller is exiting configure mode) handles state reset.
|
||||
annotEditing = null;
|
||||
}
|
||||
@@ -488,7 +489,7 @@
|
||||
function onAnnotDown(e) {
|
||||
if (!annotActive) return;
|
||||
|
||||
// 0) Insert placeholder edge resize — wins over draw / pins.
|
||||
// 0) Insert placeholder edge resize - wins over draw / pins.
|
||||
const resizeEdge = e.target.closest?.('[data-impeccable-placeholder-resize]')?.dataset.impeccablePlaceholderResize;
|
||||
if (resizeEdge && configureKind === 'insert' && placeholderElement) {
|
||||
startPlaceholderEdgeResize(resizeEdge, e);
|
||||
@@ -536,7 +537,7 @@
|
||||
// If editing a different pin, commit that edit before starting here.
|
||||
if (annotEditing && annotEditing.idx !== idx) finalizeEditingPin();
|
||||
// If already editing THIS pin and the user clicked the dot, let the
|
||||
// input keep focus (don't start a drag — the click wasn't meant as one).
|
||||
// input keep focus (don't start a drag - the click wasn't meant as one).
|
||||
if (annotEditing && annotEditing.idx === idx) return;
|
||||
const p = localCoords(e);
|
||||
const pin = annotState.comments[idx];
|
||||
@@ -971,7 +972,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The Bar — one floating element, three modes
|
||||
// The Bar - one floating element, three modes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Contextual-bar palette. Cached at init so every build*Row reads a
|
||||
@@ -1752,7 +1753,7 @@
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
});
|
||||
|
||||
// Action pill — dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill)
|
||||
// Action pill - dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill)
|
||||
const pill = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
||||
padding: '5px 10px', borderRadius: '6px',
|
||||
@@ -1786,7 +1787,7 @@
|
||||
});
|
||||
row.appendChild(pill);
|
||||
|
||||
// Prompt field — same chat-surface chrome as the bottom Steer bar
|
||||
// Prompt field - same chat-surface chrome as the bottom Steer bar
|
||||
const inputWrap = el('div', {
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
flex: '1', minWidth: '0', height: '28px',
|
||||
@@ -2126,7 +2127,7 @@
|
||||
if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
|
||||
row.appendChild(next);
|
||||
|
||||
// Tune chip — only when the visible variant exposes params
|
||||
// Tune chip - only when the visible variant exposes params
|
||||
const visParams = parseVariantParams(getVisibleVariantEl());
|
||||
const hasParams = visParams.length > 0;
|
||||
if (hasParams) {
|
||||
@@ -2173,7 +2174,7 @@
|
||||
// Spacer
|
||||
row.appendChild(el('div', { flex: '1' }));
|
||||
|
||||
// Accept — primary action, kinpaku gold + lacquer-deep (matches demo .live-demo-ctx-accept)
|
||||
// Accept - primary action, kinpaku gold + lacquer-deep (matches demo .live-demo-ctx-accept)
|
||||
const accept = el('button', {
|
||||
padding: '5px 14px', borderRadius: '5px',
|
||||
border: 'none', background: C.brand, color: C.ink,
|
||||
@@ -2266,7 +2267,7 @@
|
||||
const active = i === visibleVariant;
|
||||
// active: solid site-brand kinpaku dot. arrived+inactive: muted neutral.
|
||||
// pending (not yet arrived): faint outline ring. No borders on arrived
|
||||
// dots — the previous "accent ring + ash fill" combo read as noisy
|
||||
// dots - the previous "accent ring + ash fill" combo read as noisy
|
||||
// kinpaku chips, especially when all variants had arrived and every
|
||||
// dot wore an accent ring.
|
||||
const dotBg = active ? C.brand
|
||||
@@ -2456,7 +2457,7 @@
|
||||
let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide)
|
||||
let paramsPanelInner = null; // translating content (carries bg, padding, knobs)
|
||||
let paramsPanelBody = null; // grid holding the knob cells
|
||||
let paramsCurrentValues = {}; // {paramId: value} — mirror of the visible variant's live values
|
||||
let paramsCurrentValues = {}; // {paramId: value} - mirror of the visible variant's live values
|
||||
let tuneOpen = false; // whether the Tune popover is open right now
|
||||
|
||||
// Theme-aware Tune popover. Appears as a drawer that slides out from the
|
||||
@@ -2471,7 +2472,7 @@
|
||||
const P = paramsPanelPalette;
|
||||
|
||||
// Single element, always in the DOM. The slide animation is a CSS mask
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis — no
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis - no
|
||||
// display toggle, no opacity toggle, no transform trickery. The mask
|
||||
// hides everything initially; as it grows, content is revealed from
|
||||
// the bar edge outward.
|
||||
@@ -2494,7 +2495,7 @@
|
||||
transition: 'clip-path 0.44s ' + EASE,
|
||||
|
||||
// Park off-screen until positionParamsPanel places it. These are NOT
|
||||
// in the transition list, so they snap instantly — no fly-in from the
|
||||
// in the transition list, so they snap instantly - no fly-in from the
|
||||
// top-left when first shown.
|
||||
top: '-9999px', left: '-9999px', width: '0',
|
||||
});
|
||||
@@ -2685,7 +2686,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline text editing — makes pure-text descendants of the picked element
|
||||
// Inline text editing - makes pure-text descendants of the picked element
|
||||
// directly contenteditable. Save stages copy edits in the live buffer; the
|
||||
// Apply copy edits dock later asks the AI to apply the staged batch.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -3132,7 +3133,7 @@
|
||||
if (detail.includes('newText cannot contain') || detail.includes('newText cannot be empty')) {
|
||||
showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500);
|
||||
} else {
|
||||
showToast('Save failed — retry or cancel', 4000);
|
||||
showToast('Save failed: retry or cancel', 4000);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3454,19 +3455,19 @@
|
||||
updatePendingCounter(remaining);
|
||||
if (result.failed && result.failed.length > 0) {
|
||||
console.warn('[impeccable] some copy edits failed:', result.failed);
|
||||
showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed — see console', 5000);
|
||||
showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed, see console', 5000);
|
||||
} else {
|
||||
const n = Array.isArray(result.applied) ? result.applied.length : (result.cleared || 0);
|
||||
if (n > 0) {
|
||||
showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500);
|
||||
} else {
|
||||
console.warn('[impeccable] apply returned no verified edits:', result);
|
||||
showToast('No edits applied — see console', 4000);
|
||||
showToast('No edits applied, see console', 4000);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[impeccable] commit failed:', err);
|
||||
showToast('Apply failed — see console', 4000);
|
||||
showToast('Apply failed, see console', 4000);
|
||||
} finally {
|
||||
if (waitForSseCompletion) return;
|
||||
const remainingCount = parseInt(pendingPillEl?.dataset.count || '0', 10) || 0;
|
||||
@@ -3496,7 +3497,7 @@
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[impeccable] discard failed:', err);
|
||||
showToast('Discard failed — see console', 4000);
|
||||
showToast('Discard failed, see console', 4000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3644,7 +3645,7 @@
|
||||
const failedCount = numberOrNull(msg.failedCount) || 0;
|
||||
const appliedCount = numberOrNull(msg.appliedCount) || numberOrNull(msg.cleared) || 0;
|
||||
if (failedCount > 0) {
|
||||
showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed — see console', 5000);
|
||||
showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed, see console', 5000);
|
||||
} else if (appliedCount > 0) {
|
||||
showToast('Applied ' + appliedCount + ' edit' + (appliedCount === 1 ? '' : 's'), 2500);
|
||||
}
|
||||
@@ -3799,7 +3800,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit content badge — floating button at element top-right to enter EDITING mode
|
||||
// Edit content badge - floating button at element top-right to enter EDITING mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initEditBadge() {
|
||||
@@ -3884,7 +3885,7 @@
|
||||
}
|
||||
editBadgeEl.appendChild(btn);
|
||||
} else {
|
||||
// 'editing' — show Cancel + Save separated
|
||||
// 'editing' - show Cancel + Save separated
|
||||
editBadgeEl.innerHTML = '';
|
||||
editBadgeEl.style.gap = '8px';
|
||||
const cancel = document.createElement('button');
|
||||
@@ -4083,7 +4084,7 @@
|
||||
if (!v) continue;
|
||||
setVariantShown(child, v === String(num));
|
||||
}
|
||||
// Unconditional refresh — covers first-reveal (no-op if state isn't
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
// CYCLING yet, the subsequent CYCLING transition triggers its own
|
||||
// refresh) and every cycle step.
|
||||
refreshParamsPanel();
|
||||
@@ -4318,7 +4319,7 @@
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
}, { passive: true, ...sig });
|
||||
|
||||
// Apply target synchronously, not via rAF — racing the browser's
|
||||
// Apply target synchronously, not via rAF - racing the browser's
|
||||
// restore or a smooth-scroll animation means we want to win now.
|
||||
if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) {
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
@@ -4545,18 +4546,34 @@
|
||||
// hidden tab, a route the user navigated away from). The variant
|
||||
// MutationObserver stays armed and auto-transitions to CYCLING
|
||||
// the moment the wrapper actually mounts. Nudge the user toward
|
||||
// that path with a toast — better than the prior force-reload
|
||||
// that path with a toast - better than the prior force-reload
|
||||
// which reset framework state and left the session stuck.
|
||||
setTimeout(() => {
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) return;
|
||||
if (state !== 'GENERATING') return;
|
||||
showToast(
|
||||
"Variants ready. If the picked element isn't visible, retrace the path that revealed it — they'll appear automatically.",
|
||||
"Variants ready. If the picked element isn't visible, retrace the path that revealed it; they'll appear automatically.",
|
||||
15000,
|
||||
);
|
||||
}, 2000);
|
||||
break;
|
||||
case 'complete':
|
||||
case 'accept':
|
||||
if (maybeCompleteAcceptedSession(msg)) break;
|
||||
break;
|
||||
case 'agent_done':
|
||||
// Carbonize accepts are not terminal until live-complete.mjs sends
|
||||
// the final complete event. Keep the browser in its recoverable
|
||||
// saving state while the source cleanup is still in flight.
|
||||
break;
|
||||
case 'error':
|
||||
if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) {
|
||||
pendingAcceptedSession = null;
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
break;
|
||||
}
|
||||
if (maybeCompleteSteer(msg)) break;
|
||||
console.error('[impeccable] Error:', msg.message);
|
||||
showToast('Error: ' + msg.message, 5000);
|
||||
@@ -4779,14 +4796,14 @@
|
||||
|
||||
/**
|
||||
* Surface a brief, non-blocking heads-up when the picked element lives
|
||||
* inside a container whose visibility is gated by ephemeral state — modals,
|
||||
* inside a container whose visibility is gated by ephemeral state - modals,
|
||||
* collapsible panels, popovers, off-screen tab panels. If HMR remounts the
|
||||
* parent during generation (Vite Fast Refresh, SvelteKit page reload), the
|
||||
* variants land in source but stay invisible until the user re-opens the
|
||||
* container. Telling the user upfront is much friendlier than the silent
|
||||
* timeout-then-toast that they'd otherwise hit.
|
||||
*
|
||||
* Heuristic, intentionally narrow — only fires for unambiguous cases so
|
||||
* Heuristic, intentionally narrow - only fires for unambiguous cases so
|
||||
* we don't cry wolf on every nested element.
|
||||
*/
|
||||
function maybeWarnConditionalAncestor(el) {
|
||||
@@ -4804,7 +4821,7 @@
|
||||
showToast('Heads up: this element lives inside an open panel. If state resets during generation, you may need to re-open it.', 6000);
|
||||
return;
|
||||
}
|
||||
// 3. Tab panel — only meaningful when the page also shows ANOTHER
|
||||
// 3. Tab panel - only meaningful when the page also shows ANOTHER
|
||||
// tab as selected. A single tabpanel with no tablist is just a static
|
||||
// section in disguise and isn't conditional.
|
||||
if (node.getAttribute && node.getAttribute('role') === 'tabpanel') {
|
||||
@@ -4833,7 +4850,7 @@
|
||||
// Fire a lightweight prefetch event the first time the user selects an
|
||||
// element on a given route. The agent uses this to Read the underlying file
|
||||
// into context before Go is hit, shaving the read off the critical path.
|
||||
// Dedupe per session by pathname — clicking around on the same page doesn't
|
||||
// Dedupe per session by pathname - clicking around on the same page doesn't
|
||||
// re-fire.
|
||||
//
|
||||
// DISABLED: quick-Go workflows pay an extra harness round trip because
|
||||
@@ -4969,6 +4986,7 @@
|
||||
disableInlineEdit();
|
||||
stripManualEditRuntimeState(selectedElement);
|
||||
|
||||
pendingAcceptedSession = null;
|
||||
currentSessionId = id8();
|
||||
expectedVariants = selectedCount;
|
||||
arrivedVariants = 0;
|
||||
@@ -4976,7 +4994,7 @@
|
||||
|
||||
// Flip to GENERATING immediately so the bar morphs without waiting on
|
||||
// capture + upload. The event is emitted from captureAndEmit() once the
|
||||
// screenshot is uploaded (or capture fails — we still emit, just without
|
||||
// screenshot is uploaded (or capture fails - we still emit, just without
|
||||
// screenshotPath).
|
||||
const elForCapture = selectedElement;
|
||||
const captureRect = elForCapture.getBoundingClientRect();
|
||||
@@ -5002,7 +5020,7 @@
|
||||
state = 'GENERATING';
|
||||
// Disable the Edit badge: starting a manual text edit mid-generation would
|
||||
// conflict with the variant wrap that's about to land in the same DOM
|
||||
// region. Only swap if the badge was visible — picked elements with no
|
||||
// region. Only swap if the badge was visible - picked elements with no
|
||||
// text rows have it hidden already.
|
||||
if (editBadgeEl && editBadgeEl.style.display !== 'none') renderEditBadge('idle-disabled');
|
||||
showBar('generating');
|
||||
@@ -5104,7 +5122,7 @@
|
||||
|
||||
// Collect @font-face rules from every stylesheet on the page. Cross-origin
|
||||
// sheets (Google Fonts, Typekit, etc.) throw SecurityError on .cssRules
|
||||
// access, so modern-screenshot can't embed them on its own — the resulting
|
||||
// access, so modern-screenshot can't embed them on its own - the resulting
|
||||
// SVG falls back to system fonts and text re-wraps + renders with different
|
||||
// weight. We fetch the raw CSS text (CORS-permitted for these providers),
|
||||
// extract @font-face blocks, inline the referenced font files as base64
|
||||
@@ -5188,7 +5206,7 @@
|
||||
// modern-screenshot force-sets `background-color: X !important` on the
|
||||
// cloned root whenever `backgroundColor` is passed, clobbering the
|
||||
// element's own background. So we only pass it when the element is
|
||||
// genuinely transparent (no own color, no own image) — in that case
|
||||
// genuinely transparent (no own color, no own image) - in that case
|
||||
// we resolve up the DOM to the nearest opaque ancestor so the capture
|
||||
// sits on the page's real background instead of rendering black.
|
||||
function resolveCanvasBackground(el) {
|
||||
@@ -5206,7 +5224,7 @@
|
||||
// `getComputedStyle(body).backgroundColor || …` chain is a trap: that
|
||||
// call returns the literal string `"rgba(0, 0, 0, 0)"` for a page that
|
||||
// never set its own bg, which is truthy and short-circuits the chain to
|
||||
// transparent-black — modern-screenshot then renders the capture on a
|
||||
// transparent-black - modern-screenshot then renders the capture on a
|
||||
// black canvas and the shader overlay flashes solid black during load.
|
||||
// The browser canvas defaults to white, so we do too.
|
||||
return '#ffffff';
|
||||
@@ -5248,7 +5266,7 @@
|
||||
// Transparent up to the root. The visible backdrop may still come from an
|
||||
// ancestor's background-image or a covering positioned layer (e.g. a hero
|
||||
// art div) that the color walk can't see. Capture that ancestor and crop
|
||||
// to the element so the real backdrop is embedded — correct for both the
|
||||
// to the element so the real backdrop is embedded - correct for both the
|
||||
// shader and the screenshot sent to the model. Fall back to white only
|
||||
// when nothing is actually painted behind the element.
|
||||
const backdrop = findBackdropAncestor(el);
|
||||
@@ -5289,13 +5307,13 @@
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] capture failed, proceeding without screenshot:', err);
|
||||
}
|
||||
// Light up the shader overlay the moment capture is ready — no reason to
|
||||
// Light up the shader overlay the moment capture is ready - no reason to
|
||||
// wait for the upload to complete before the user sees something alive.
|
||||
if (blob && state === 'GENERATING') {
|
||||
showShaderOverlay(el, blob, rect, paper);
|
||||
}
|
||||
// Only upload + forward the screenshot when annotations (comments/strokes)
|
||||
// are present. Without annotations the image is pure visual anchoring —
|
||||
// are present. Without annotations the image is pure visual anchoring -
|
||||
// it biases the model toward the current rendering and works against the
|
||||
// three-distinct-directions brief.
|
||||
const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0);
|
||||
@@ -5320,7 +5338,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shader overlay — renders the captured screenshot as a WebGL texture and
|
||||
// Shader overlay - renders the captured screenshot as a WebGL texture and
|
||||
// runs an editorial "ink-wash" fragment shader over it during generation.
|
||||
// A single rolling band sweeps top-to-bottom, desaturating + tinting kinpaku
|
||||
// and leaving a soft trail. Makes the wait feel like a letterpress scan
|
||||
@@ -5343,7 +5361,7 @@ uniform vec3 u_accent;
|
||||
uniform vec3 u_paper;
|
||||
varying vec2 v_uv;
|
||||
|
||||
// Asymmetric roller band. Product of two one-sided smoothsteps — peaks at
|
||||
// Asymmetric roller band. Product of two one-sided smoothsteps - peaks at
|
||||
// d=0 with a short sharp leading ramp and a longer soft trailing tail. Clean
|
||||
// outside the [-leadW, trailW] range (no rogue "trail=1 everywhere below"
|
||||
// failure that reversed-edge smoothstep would give).
|
||||
@@ -5369,8 +5387,8 @@ void main() {
|
||||
vec2 sampleCenter = (cellId + 0.5) * cellPx / u_resolution;
|
||||
vec3 cellImg = texture2D(u_texture, sampleCenter).rgb;
|
||||
// Dot size tracks how much the cell DIFFERS from the element's own ground
|
||||
// (u_paper), not absolute darkness. So the content — text, buttons, anything
|
||||
// that deviates from the background — always becomes the dots, on light AND
|
||||
// (u_paper), not absolute darkness. So the content - text, buttons, anything
|
||||
// that deviates from the background - always becomes the dots, on light AND
|
||||
// dark surfaces. A plain darkness curve inverts on dark elements: the dark
|
||||
// background fills with ink and the lighter content punches holes instead.
|
||||
// Capped below the cell half-width so dense content stays separated dots.
|
||||
@@ -5380,8 +5398,8 @@ void main() {
|
||||
// Two-stage dissolve as the roller passes, so the element is rebuilt purely
|
||||
// from dot size (its own halftone) and never bleeds through as raw pixels
|
||||
// behind the dots:
|
||||
// 1. cover — the element flattens to the uniform paper ground first.
|
||||
// 2. dotAmt — kinpaku dots then emerge, sized by each cell's luma.
|
||||
// 1. cover - the element flattens to the uniform paper ground first.
|
||||
// 2. dotAmt - kinpaku dots then emerge, sized by each cell's luma.
|
||||
// A plain mix(base, halftone, band) instead left the raw element visible
|
||||
// through the band's soft core/trail. The paper ground is u_paper (the
|
||||
// element's own bg tone) rather than a fixed white, so the dissolve reads the
|
||||
@@ -5399,7 +5417,7 @@ void main() {
|
||||
|
||||
// Kinpaku gold converted to approximate sRGB 0-1 (matches oklch(84% 0.19 80.46))
|
||||
const SHADER_ACCENT = [1.0, 0.78, 0.31];
|
||||
// Fallback ground when an element and all its ancestors are transparent —
|
||||
// Fallback ground when an element and all its ancestors are transparent -
|
||||
// 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 }
|
||||
@@ -5412,7 +5430,7 @@ void main() {
|
||||
// Rasterize any CSS color (oklch, color(), named, hex, rgb) through a 1x1
|
||||
// canvas and read back the sRGB pixel. String-parsing computed colors is a
|
||||
// trap: Chrome returns backgroundColor as oklch()/color() for oklch inputs,
|
||||
// which a hex/rgb regex misses — every site token would fall back to white.
|
||||
// which a hex/rgb regex misses - every site token would fall back to white.
|
||||
let colorParseCtx = null;
|
||||
function cssColorToRgb01(str) {
|
||||
if (!colorParseCtx) {
|
||||
@@ -5440,7 +5458,7 @@ void main() {
|
||||
|
||||
// When an element is transparent up to the root, its visible backdrop can
|
||||
// still come from an ancestor's background-image or a covering positioned
|
||||
// layer that is a *child* of an ancestor (e.g. a hero's absolute art div) —
|
||||
// layer that is a *child* of an ancestor (e.g. a hero's absolute art div) -
|
||||
// neither of which the ancestor background-COLOR walk can see. Return the
|
||||
// nearest such ancestor so we can capture it and crop, embedding the real
|
||||
// backdrop. Returns null when nothing is actually painted behind the element
|
||||
@@ -5481,7 +5499,7 @@ void main() {
|
||||
|
||||
// Average the backdrop sampled just OUTSIDE an element's rect within a larger
|
||||
// canvas. The ground tone for the dissolve must be the real backdrop, not the
|
||||
// mean of the element's own crop — averaging the crop folds in the element's
|
||||
// mean of the element's own crop - averaging the crop folds in the element's
|
||||
// content (e.g. bright heading text), pulling the ground toward muddy gray.
|
||||
function sampleSurroundingRgb(ctx, sx, sy, sw, sh, W, H) {
|
||||
const pad = Math.max(2, Math.round(Math.min(sw, sh) * 0.12));
|
||||
@@ -5528,11 +5546,31 @@ void main() {
|
||||
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;
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
canvas.remove();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const fallback = document.createElement('div');
|
||||
fallback.id = PREFIX + '-shader';
|
||||
// Copy positioning via cssText. Object.assign across CSSStyleDeclaration
|
||||
// throws in modern Chromium because the source's indexed properties
|
||||
// (style[0], [1], ...) are read-only and the engine forbids writing
|
||||
// them on the destination.
|
||||
fallback.style.cssText = canvas.style.cssText;
|
||||
fallback.style.backgroundImage = 'url("' + objectUrl + '")';
|
||||
fallback.style.backgroundSize = '100% 100%';
|
||||
fallback.style.backgroundRepeat = 'no-repeat';
|
||||
fallback.style.outline = '2px dashed ' + C.brand;
|
||||
fallback.style.outlineOffset = '-2px';
|
||||
document.body.appendChild(fallback);
|
||||
shaderState = { canvas: fallback, gl: null, program: null, texture: null, rafId: 0, startTime: 0, objectUrl };
|
||||
}
|
||||
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
@@ -5553,21 +5591,9 @@ void main() {
|
||||
const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false })
|
||||
|| canvas.getContext('experimental-webgl');
|
||||
if (!gl) {
|
||||
// WebGL unavailable — fall back to a plain <img> overlay so the user
|
||||
// still sees something meaningful during generation.
|
||||
canvas.remove();
|
||||
const img = document.createElement('img');
|
||||
img.src = URL.createObjectURL(blob);
|
||||
img.id = PREFIX + '-shader';
|
||||
// Copy positioning via cssText. Object.assign across CSSStyleDeclaration
|
||||
// throws in modern Chromium because the source's indexed properties
|
||||
// (style[0], [1], ...) are read-only and the engine forbids writing
|
||||
// them on the destination.
|
||||
img.style.cssText = canvas.style.cssText;
|
||||
img.style.outline = '2px dashed ' + C.brand;
|
||||
img.style.outlineOffset = '-2px';
|
||||
document.body.appendChild(img);
|
||||
shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 };
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5609,14 +5635,12 @@ void main() {
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch {
|
||||
// Safari fallback: go via a regular Image
|
||||
const imgUrl = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
img.src = imgUrl;
|
||||
await new Promise((r, rej) => { img.onload = r; img.onerror = rej; });
|
||||
bitmap = img;
|
||||
URL.revokeObjectURL(imgUrl);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
@@ -5670,14 +5694,15 @@ void main() {
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
// The accepted variant is already the only visible child of the wrapper
|
||||
// (all other variants are display:none). HMR from the source rewrite will
|
||||
// replace the wrapper imminently. Don't eagerly replaceChild here — React
|
||||
// reconciliation races with our mutation and throws NotFoundError in Next
|
||||
// 16 / Turbopack. Schedule a fallback that runs the manual swap only if
|
||||
// HMR hasn't cleaned up by then (keeps static-server flows working).
|
||||
const acceptedSessionId = currentSessionId;
|
||||
const acceptedVariant = visibleVariant;
|
||||
const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant);
|
||||
pendingAcceptedSession = {
|
||||
id: acceptedSessionId,
|
||||
variant: String(acceptedVariant),
|
||||
...acceptedSnapshot,
|
||||
finalizing: false,
|
||||
};
|
||||
|
||||
state = 'SAVING';
|
||||
updateBarContent('saving');
|
||||
@@ -5685,53 +5710,130 @@ void main() {
|
||||
sendEvent(acceptPayload, { throwOnError: true })
|
||||
.then(() => {
|
||||
markSessionHandled();
|
||||
confirmAcceptAfterReceipt();
|
||||
})
|
||||
.catch(() => {
|
||||
pendingAcceptedSession = null;
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
}
|
||||
|
||||
function confirmAcceptAfterReceipt() {
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
scheduleAcceptCleanup();
|
||||
function maybeCompleteAcceptedSession(msg) {
|
||||
const pending = pendingAcceptedSession;
|
||||
if (!pending || !msg?.id || msg.id !== pending.id) return false;
|
||||
if (currentSessionId && currentSessionId !== pending.id) {
|
||||
pendingAcceptedSession = null;
|
||||
return false;
|
||||
}
|
||||
if (pending.finalizing) return true;
|
||||
pending.finalizing = true;
|
||||
|
||||
function scheduleAcceptCleanup() {
|
||||
setTimeout(function() {
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
selectedElement = null;
|
||||
currentSessionId = null;
|
||||
selectedAction = 'impeccable';
|
||||
renderEditBadge('hidden');
|
||||
state = 'PICKING';
|
||||
}, 1800);
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
|
||||
// Static-server / no-HMR fallback: if the wrapper is still around 2s after
|
||||
// the cleanup above, swap it out manually. By now React has either moved
|
||||
// on or the app isn't React at all. Preserve the `data-impeccable-variant="N"`
|
||||
// div (with display:contents) so @scope rules anchored to the variant
|
||||
// attribute keep matching until reload replaces it with the carbonize block.
|
||||
// Give framework HMR a short chance to render the now-clean accepted
|
||||
// source. If it misses the update, unwrap the accepted variant after the
|
||||
// source-side completion event so the page is not left empty or stale.
|
||||
setTimeout(function() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + acceptedSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + acceptedVariant + '"]');
|
||||
if (accepted && accepted.firstElementChild) {
|
||||
const parent = wrapper.parentElement;
|
||||
if (!parent) return;
|
||||
accepted.style.display = 'contents';
|
||||
parent.replaceChild(accepted, wrapper);
|
||||
}
|
||||
}, 2000);
|
||||
ensureAcceptedDomClean(pending);
|
||||
cleanupAcceptedSession();
|
||||
}, 1200);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
acceptedHtml: accepted ? accepted.innerHTML : '',
|
||||
acceptedSelector: selectorForAcceptedRoot(root),
|
||||
parentElement: wrapper?.parentElement || null,
|
||||
parentSelector: selectorForAcceptedRoot(wrapper?.parentElement || null),
|
||||
nextSibling: wrapper?.nextSibling || null,
|
||||
};
|
||||
}
|
||||
|
||||
function selectorForAcceptedRoot(root) {
|
||||
if (!root || !root.tagName) return '';
|
||||
const tag = root.tagName.toLowerCase();
|
||||
const classes = [...(root.classList || [])].filter(Boolean);
|
||||
if (classes.length === 0) return tag;
|
||||
return tag + classes.map((cls) => '.' + cssIdent(cls)).join('');
|
||||
}
|
||||
|
||||
function acceptedDomAlreadyClean(pending) {
|
||||
if (!pending?.acceptedSelector) return false;
|
||||
const matches = [...document.querySelectorAll(pending.acceptedSelector)];
|
||||
return matches.some((el) => !el.closest('[data-impeccable-variants],[data-impeccable-variant]'));
|
||||
}
|
||||
|
||||
function ensureAcceptedDomClean(pending) {
|
||||
const sessionId = pending?.id;
|
||||
const variantId = pending?.variant;
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!wrapper) {
|
||||
restoreAcceptedDomFromSnapshot(pending);
|
||||
return;
|
||||
}
|
||||
if (!accepted) {
|
||||
wrapper.remove();
|
||||
restoreAcceptedDomFromSnapshot(pending);
|
||||
return;
|
||||
}
|
||||
const parent = wrapper.parentElement;
|
||||
if (!parent) return;
|
||||
while (accepted.firstChild) {
|
||||
parent.insertBefore(accepted.firstChild, wrapper);
|
||||
}
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
function restoreAcceptedDomFromSnapshot(pending) {
|
||||
if (acceptedDomAlreadyClean(pending)) return;
|
||||
if (!pending?.acceptedHtml) {
|
||||
reloadAfterMissingAcceptedDom(pending);
|
||||
return;
|
||||
}
|
||||
const parent = pending.parentElement?.isConnected
|
||||
? pending.parentElement
|
||||
: (pending.parentSelector ? document.querySelector(pending.parentSelector) : null);
|
||||
if (!parent) {
|
||||
reloadAfterMissingAcceptedDom(pending);
|
||||
return;
|
||||
}
|
||||
const template = document.createElement('template');
|
||||
template.innerHTML = pending.acceptedHtml;
|
||||
const anchor = pending.nextSibling?.isConnected && pending.nextSibling.parentElement === parent
|
||||
? pending.nextSibling
|
||||
: null;
|
||||
parent.insertBefore(template.content, anchor);
|
||||
if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending);
|
||||
}
|
||||
|
||||
function reloadAfterMissingAcceptedDom(pending) {
|
||||
if (acceptedDomAlreadyClean(pending)) return;
|
||||
if (pending?.id && document.querySelector('[data-impeccable-variants="' + pending.id + '"]')) return;
|
||||
location.reload();
|
||||
}
|
||||
|
||||
function cleanupAcceptedSession() {
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
selectedElement = null;
|
||||
currentSessionId = null;
|
||||
selectedAction = 'impeccable';
|
||||
pendingAcceptedSession = null;
|
||||
renderEditBadge('hidden');
|
||||
state = 'PICKING';
|
||||
}
|
||||
|
||||
function handleDiscard() {
|
||||
@@ -5792,7 +5894,7 @@ void main() {
|
||||
|
||||
function cleanup() {
|
||||
// Hide the wrapper immediately so variants disappear. DON'T structurally
|
||||
// mutate the DOM yet — HMR from the agent's source rewrite is on its way,
|
||||
// mutate the DOM yet - HMR from the agent's source rewrite is on its way,
|
||||
// and a manual replaceChild under React causes NotFoundError when the
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
@@ -5838,8 +5940,8 @@ void main() {
|
||||
function showToast(message, duration) {
|
||||
if (toastEl) toastEl.remove();
|
||||
// Stack the toast above the global bar (which sits at bottom:14px) so
|
||||
// the two never overlap. Read the bar's actual rect — its height varies
|
||||
// with hover-expanded labels — and fall back to a sensible default
|
||||
// the two never overlap. Read the bar's actual rect - its height varies
|
||||
// with hover-expanded labels - and fall back to a sensible default
|
||||
// when the bar isn't mounted yet.
|
||||
const barRect = globalBarEl?.getBoundingClientRect();
|
||||
const barTopFromBottom = barRect && barRect.height > 0
|
||||
@@ -6047,7 +6149,7 @@ void main() {
|
||||
let pendingApplyInFlight = false;
|
||||
let firstSaveOfSession = true;
|
||||
|
||||
// Steer — collapsed pill in the global bar; expands while typing for page-level chat.
|
||||
// Steer - collapsed pill in the global bar; expands while typing for page-level chat.
|
||||
let pageChatEl = null;
|
||||
let pageChatInput = null;
|
||||
let pageChatHint = null;
|
||||
@@ -6068,7 +6170,7 @@ void main() {
|
||||
const STEER_AWAIT_TIMEOUT_MS = 120000;
|
||||
const AGENT_STATUS_POLL_MS = 5000;
|
||||
const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)';
|
||||
const AGENT_DISCONNECTED_TIP = 'Agent disconnected — run live-poll.mjs to connect';
|
||||
const AGENT_DISCONNECTED_TIP = 'Agent disconnected: run live-poll.mjs to connect';
|
||||
const GLOBAL_BAR_SECTION_GAP = 8;
|
||||
const GLOBAL_BAR_INNER_GAP = 2;
|
||||
const GLOBAL_BAR_INNER_PAD_LEFT = 2;
|
||||
@@ -6079,7 +6181,7 @@ void main() {
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>';
|
||||
|
||||
// Theme-aware color palette for the global bar. We detect the page's
|
||||
// ambient background and invert — dark bar on light pages, light bar on
|
||||
// ambient background and invert - dark bar on light pages, light bar on
|
||||
// dark pages. This keeps the bar from fighting with the host design.
|
||||
function detectPageTheme() {
|
||||
try {
|
||||
@@ -6092,7 +6194,7 @@ void main() {
|
||||
// Walk body → html, taking the first opaque background. The browser's
|
||||
// default body / html background is `rgba(0, 0, 0, 0)`, which a naive
|
||||
// regex would read as black and mislabel a perfectly white page as
|
||||
// dark. Honoring alpha avoids that — and falling through to <html>
|
||||
// dark. Honoring alpha avoids that - and falling through to <html>
|
||||
// catches the common pattern of a bg only on <html> (or only on body).
|
||||
function readOpaque(el) {
|
||||
if (!el) return null;
|
||||
@@ -6141,7 +6243,7 @@ void main() {
|
||||
exitHover: 'oklch(58% 0.15 35 / 0.18)',
|
||||
shadow: PICKER_SHADOW,
|
||||
chatSurface: 'oklch(22% 0.012 82)',
|
||||
// Verdigris patina — secondary state (see site/styles/kinpaku-tokens.css)
|
||||
// Verdigris patina - secondary state (see site/styles/kinpaku-tokens.css)
|
||||
patina: 'oklch(70% 0.12 188)',
|
||||
patinaPale: 'oklch(82% 0.07 188)',
|
||||
patinaSoft: 'oklch(70% 0.12 188 / 0.28)',
|
||||
@@ -6913,7 +7015,7 @@ void main() {
|
||||
steerFocusLog('page-chat-mounted', {});
|
||||
}
|
||||
|
||||
// Impeccable mark — same paths as site/components/Header.astro + favicon.svg.
|
||||
// Impeccable mark - same paths as site/components/Header.astro + favicon.svg.
|
||||
function brandMarkSvg(color = C.brand, size = 18) {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="${color}" aria-hidden="true">
|
||||
<path d="M5 2.5 L13.5 2.5 L5.5 21.5 L5 21.5 Q2.5 21.5 2.5 19 L2.5 5 Q2.5 2.5 5 2.5 Z"/>
|
||||
@@ -6928,7 +7030,7 @@ void main() {
|
||||
globalBarBrandEl.dataset.agentConnected = connected ? 'true' : 'false';
|
||||
globalBarBrandEl.setAttribute('aria-label', connected
|
||||
? 'Impeccable live mode'
|
||||
: 'Impeccable live mode — agent not polling');
|
||||
: 'Impeccable live mode: agent not polling');
|
||||
globalBarBrandEl.removeAttribute('title');
|
||||
globalBarBrandEl.style.cursor = connected ? 'default' : 'help';
|
||||
const mark = globalBarBrandEl.querySelector('[data-brand-mark]');
|
||||
@@ -7053,7 +7155,7 @@ void main() {
|
||||
globalBarEl.id = PREFIX + '-global-bar';
|
||||
globalBarEl.dataset.theme = theme;
|
||||
|
||||
// Brand mark — kinpaku Impeccable icon (site header / favicon paths).
|
||||
// Brand mark - kinpaku Impeccable icon (site header / favicon paths).
|
||||
const brand = el('span', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
alignSelf: 'stretch', position: 'relative',
|
||||
@@ -7065,7 +7167,7 @@ void main() {
|
||||
brand.id = PREFIX + '-global-bar-brand';
|
||||
brand.dataset.agentConnected = 'false';
|
||||
brand.setAttribute('role', 'img');
|
||||
brand.setAttribute('aria-label', 'Impeccable live mode — agent not polling');
|
||||
brand.setAttribute('aria-label', 'Impeccable live mode: agent not polling');
|
||||
|
||||
const brandMark = el('span', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
@@ -7130,7 +7232,7 @@ void main() {
|
||||
// Per-button hover only changes color (no layout). The label expand/
|
||||
// collapse is driven by the bar-level mouseenter/mouseleave so moving
|
||||
// the mouse between adjacent buttons doesn't trigger per-button width
|
||||
// thrashing — the whole bar grows once and shrinks once.
|
||||
// thrashing - the whole bar grows once and shrinks once.
|
||||
b.addEventListener('mouseenter', () => { if (b.dataset.active !== 'true') b.style.color = P.text; });
|
||||
b.addEventListener('mouseleave', () => { if (b.dataset.active !== 'true') b.style.color = P.textDim; });
|
||||
b.addEventListener('click', onClick);
|
||||
@@ -7139,7 +7241,7 @@ void main() {
|
||||
return b;
|
||||
}
|
||||
|
||||
// Pick toggle — restored from localStorage; both pick and insert may be off.
|
||||
// Pick toggle - restored from localStorage; both pick and insert may be off.
|
||||
const pickBtn = makeIconBtn({
|
||||
id: PREFIX + '-pick-toggle',
|
||||
svg: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><circle cx="12" cy="12" r="10"/><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><line x1="12" y1="6" x2="12" y2="2"/><line x1="12" y1="22" x2="12" y2="18"/></svg>',
|
||||
@@ -7176,7 +7278,7 @@ void main() {
|
||||
detectBtn.appendChild(detectBadge);
|
||||
inner.appendChild(detectBtn);
|
||||
|
||||
// DESIGN.md panel toggle — quartet of color squares as the mark.
|
||||
// DESIGN.md panel toggle - quartet of color squares as the mark.
|
||||
const designBtn = makeIconBtn({
|
||||
id: PREFIX + '-design-toggle',
|
||||
svg: `<span style="display:inline-grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;width:14px;height:14px;border-radius:3px;overflow:hidden;box-shadow:inset 0 0 0 1px oklch(58% 0.065 82 / 0.55);flex-shrink:0">
|
||||
@@ -7378,13 +7480,13 @@ void main() {
|
||||
});
|
||||
inner.appendChild(divider);
|
||||
|
||||
// Exit × on the right — intentionally subtle (textDim at rest, text on
|
||||
// Exit × on the right - intentionally subtle (textDim at rest, text on
|
||||
// hover) so it sits behind the active toggles in visual hierarchy.
|
||||
//
|
||||
// Explicit padding + box-sizing here is load-bearing: a host page like
|
||||
// `button { padding: 0.5rem 1rem; }` (very common in resets) would
|
||||
// otherwise inflate this 24x24 button into 56x40 and push the SVG out
|
||||
// of the visible bar — the X stays invisible even though the styles in
|
||||
// of the visible bar - the X stays invisible even though the styles in
|
||||
// DevTools look fine. Every other chrome button sets padding inline;
|
||||
// this one needed it too.
|
||||
const exitBtn = el('button', {
|
||||
@@ -7472,7 +7574,7 @@ void main() {
|
||||
btn.style.opacity = controlsLocked ? '0.55' : '1';
|
||||
});
|
||||
|
||||
// If the bar is currently under the cursor, keep all labels expanded —
|
||||
// If the bar is currently under the cursor, keep all labels expanded -
|
||||
// otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
|
||||
// would collapse its label while the user's mouse is still on the bar.
|
||||
if (globalBarEl && globalBarEl.matches(':hover')) {
|
||||
@@ -7648,7 +7750,7 @@ void main() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Design System Panel — visualizes the project's .impeccable/design.json sidecar
|
||||
// Design System Panel - visualizes the project's .impeccable/design.json sidecar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DESIGN_PREFS_KEY = 'impeccable-live-design-panel';
|
||||
@@ -7674,7 +7776,7 @@ void main() {
|
||||
};
|
||||
|
||||
function loadDesignPrefs() {
|
||||
// `open` is intentionally NOT persisted — the panel always starts closed
|
||||
// `open` is intentionally NOT persisted - the panel always starts closed
|
||||
// so live mode doesn't auto-slide a big panel over the page on startup.
|
||||
try {
|
||||
const raw = localStorage.getItem(DESIGN_PREFS_KEY);
|
||||
@@ -7731,7 +7833,7 @@ void main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Neutral panel palette — deliberately NOT Impeccable-branded. The panel is
|
||||
// Neutral panel palette - deliberately NOT Impeccable-branded. The panel is
|
||||
// a viewer of the project's design system, not an Impeccable surface.
|
||||
const DP = {
|
||||
canvas: 'oklch(94% 0 0)', // panel background
|
||||
@@ -8025,7 +8127,7 @@ void main() {
|
||||
const root = designShadow.querySelector('.root');
|
||||
root.innerHTML = '';
|
||||
|
||||
// (Panel toggle lives in the global bar — no floating FAB.)
|
||||
// (Panel toggle lives in the global bar - no floating FAB.)
|
||||
// Panel
|
||||
const panel = document.createElement('aside');
|
||||
panel.className = 'panel';
|
||||
@@ -8141,7 +8243,7 @@ void main() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Visual tab — single unified render path.
|
||||
// Visual tab - single unified render path.
|
||||
if (designState.mdNewerThanJson) body.appendChild(renderStaleHint());
|
||||
if (designState.hasMd && !designState.hasSidecar) {
|
||||
body.appendChild(renderParsedMdCta());
|
||||
@@ -8357,7 +8459,7 @@ void main() {
|
||||
specimen.style.fontFamily = fontStack(t);
|
||||
specimen.style.fontWeight = String(t.weight || 400);
|
||||
specimen.style.fontStyle = t.style || 'normal';
|
||||
specimen.style.fontSize = '56px'; // Fixed specimen size — compare faces, not scales.
|
||||
specimen.style.fontSize = '56px'; // Fixed specimen size - compare faces, not scales.
|
||||
specimen.style.letterSpacing = 'normal';
|
||||
specimen.style.textTransform = 'none';
|
||||
tile.appendChild(specimen);
|
||||
@@ -8501,7 +8603,7 @@ void main() {
|
||||
}
|
||||
|
||||
// Single shared description if all items carry the same one; otherwise
|
||||
// skip — per-item descriptions clutter a grouped tile.
|
||||
// skip - per-item descriptions clutter a grouped tile.
|
||||
if (group.length === 1 && group[0].description) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'c-desc';
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Impeccable Live Variant Mode — Browser Script
|
||||
* Impeccable Live Variant Mode - Browser Script
|
||||
*
|
||||
* Injected into the user's page via <script src="http://localhost:PORT/live.js">.
|
||||
* The server prepends window.__IMPECCABLE_TOKEN__ and window.__IMPECCABLE_PORT__
|
||||
* before this code.
|
||||
*
|
||||
* UI: a single floating bar that morphs between three states —
|
||||
* UI: a single floating bar that morphs between three states -
|
||||
* configure (pick action + go), generating (progressive dots), and cycling
|
||||
* (prev/next + accept/discard). Feels like Spotlight, not a modal.
|
||||
*/
|
||||
@@ -46,7 +46,7 @@
|
||||
mist: 'oklch(90% 0.008 82 / 0.6)', // light hairline
|
||||
white: 'oklch(99% 0 0)',
|
||||
};
|
||||
// Picker bar chrome — mirrors .live-demo-gbar / .live-demo-ctx in kinpaku-kit.css.
|
||||
// Picker bar chrome - mirrors .live-demo-gbar / .live-demo-ctx in kinpaku-kit.css.
|
||||
// Quiet neutral elevation: no gold halo ring (gold is reserved for the brand
|
||||
// mark and the active control, not the container outline).
|
||||
const PICKER_SHADOW =
|
||||
@@ -83,7 +83,7 @@
|
||||
|
||||
// SVG icons stack above each chip label. All strokes use currentColor so the
|
||||
// icon recolors to C.brand when its chip is selected. 20x20 render, 24-viewBox,
|
||||
// 1.5 stroke — visually consistent with the Foundation grid on the homepage.
|
||||
// 1.5 stroke - visually consistent with the Foundation grid on the homepage.
|
||||
const ICON_ATTRS = 'width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="display:block"';
|
||||
const ICONS = {
|
||||
impeccable: `<svg ${ICON_ATTRS}><path d="M4 20l4-1L18 9l-3-3L5 16z"/><path d="M14 7l3 3"/></svg>`,
|
||||
@@ -123,6 +123,7 @@
|
||||
let hoveredElement = null;
|
||||
let selectedElement = null;
|
||||
let currentSessionId = null;
|
||||
let pendingAcceptedSession = null;
|
||||
let expectedVariants = 0;
|
||||
let arrivedVariants = 0;
|
||||
let visibleVariant = 0;
|
||||
@@ -133,7 +134,7 @@
|
||||
const browserOwner = sessionState.owner;
|
||||
let checkpointTimer = null;
|
||||
|
||||
// Scroll lock — holds window.scrollY at a fixed value while the session is
|
||||
// Scroll lock - holds window.scrollY at a fixed value while the session is
|
||||
// active, so HMR DOM patches and variant swaps can't drift the page. See
|
||||
// startScrollLock / stopScrollLock below.
|
||||
let scrollLockObserver = null;
|
||||
@@ -141,7 +142,7 @@
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
// Dedicated key for scroll position — SEPARATE from LS_KEY so that
|
||||
// Dedicated key for scroll position - SEPARATE from LS_KEY so that
|
||||
// saveSession's state updates don't clobber a carefully-captured scrollY.
|
||||
// (Previously: saveSession wrote scrollY alongside state, so every call
|
||||
// during resume overwrote the pre-reload value with whatever the browser
|
||||
@@ -231,7 +232,7 @@
|
||||
// - Stop `focusin` propagation so any focus shifts inside our chrome
|
||||
// don't read as "focus moved outside the dialog" to focus traps.
|
||||
//
|
||||
// Click events still bubble normally — only the early pointer/focus
|
||||
// Click events still bubble normally - only the early pointer/focus
|
||||
// signals that drive outside-interaction detection are silenced.
|
||||
function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) {
|
||||
if (!rootEl) return;
|
||||
@@ -319,7 +320,7 @@
|
||||
// correlate directly with the captured PNG.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DRAG_THRESHOLD = 5; // px — below this, treat pointerup as a click
|
||||
const DRAG_THRESHOLD = 5; // px - below this, treat pointerup as a click
|
||||
const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it
|
||||
let annotOverlayEl = null;
|
||||
let annotSvgEl = null;
|
||||
@@ -423,7 +424,7 @@
|
||||
placeholderResizeDrag = null;
|
||||
if (annotOverlayEl) annotOverlayEl.style.display = 'none';
|
||||
syncPlaceholderResizeHandles();
|
||||
// Drop any in-progress edit without touching annotState — clearAnnotations
|
||||
// Drop any in-progress edit without touching annotState - clearAnnotations
|
||||
// (if the caller is exiting configure mode) handles state reset.
|
||||
annotEditing = null;
|
||||
}
|
||||
@@ -488,7 +489,7 @@
|
||||
function onAnnotDown(e) {
|
||||
if (!annotActive) return;
|
||||
|
||||
// 0) Insert placeholder edge resize — wins over draw / pins.
|
||||
// 0) Insert placeholder edge resize - wins over draw / pins.
|
||||
const resizeEdge = e.target.closest?.('[data-impeccable-placeholder-resize]')?.dataset.impeccablePlaceholderResize;
|
||||
if (resizeEdge && configureKind === 'insert' && placeholderElement) {
|
||||
startPlaceholderEdgeResize(resizeEdge, e);
|
||||
@@ -536,7 +537,7 @@
|
||||
// If editing a different pin, commit that edit before starting here.
|
||||
if (annotEditing && annotEditing.idx !== idx) finalizeEditingPin();
|
||||
// If already editing THIS pin and the user clicked the dot, let the
|
||||
// input keep focus (don't start a drag — the click wasn't meant as one).
|
||||
// input keep focus (don't start a drag - the click wasn't meant as one).
|
||||
if (annotEditing && annotEditing.idx === idx) return;
|
||||
const p = localCoords(e);
|
||||
const pin = annotState.comments[idx];
|
||||
@@ -971,7 +972,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The Bar — one floating element, three modes
|
||||
// The Bar - one floating element, three modes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Contextual-bar palette. Cached at init so every build*Row reads a
|
||||
@@ -1752,7 +1753,7 @@
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
});
|
||||
|
||||
// Action pill — dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill)
|
||||
// Action pill - dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill)
|
||||
const pill = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
||||
padding: '5px 10px', borderRadius: '6px',
|
||||
@@ -1786,7 +1787,7 @@
|
||||
});
|
||||
row.appendChild(pill);
|
||||
|
||||
// Prompt field — same chat-surface chrome as the bottom Steer bar
|
||||
// Prompt field - same chat-surface chrome as the bottom Steer bar
|
||||
const inputWrap = el('div', {
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
flex: '1', minWidth: '0', height: '28px',
|
||||
@@ -2126,7 +2127,7 @@
|
||||
if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
|
||||
row.appendChild(next);
|
||||
|
||||
// Tune chip — only when the visible variant exposes params
|
||||
// Tune chip - only when the visible variant exposes params
|
||||
const visParams = parseVariantParams(getVisibleVariantEl());
|
||||
const hasParams = visParams.length > 0;
|
||||
if (hasParams) {
|
||||
@@ -2173,7 +2174,7 @@
|
||||
// Spacer
|
||||
row.appendChild(el('div', { flex: '1' }));
|
||||
|
||||
// Accept — primary action, kinpaku gold + lacquer-deep (matches demo .live-demo-ctx-accept)
|
||||
// Accept - primary action, kinpaku gold + lacquer-deep (matches demo .live-demo-ctx-accept)
|
||||
const accept = el('button', {
|
||||
padding: '5px 14px', borderRadius: '5px',
|
||||
border: 'none', background: C.brand, color: C.ink,
|
||||
@@ -2266,7 +2267,7 @@
|
||||
const active = i === visibleVariant;
|
||||
// active: solid site-brand kinpaku dot. arrived+inactive: muted neutral.
|
||||
// pending (not yet arrived): faint outline ring. No borders on arrived
|
||||
// dots — the previous "accent ring + ash fill" combo read as noisy
|
||||
// dots - the previous "accent ring + ash fill" combo read as noisy
|
||||
// kinpaku chips, especially when all variants had arrived and every
|
||||
// dot wore an accent ring.
|
||||
const dotBg = active ? C.brand
|
||||
@@ -2456,7 +2457,7 @@
|
||||
let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide)
|
||||
let paramsPanelInner = null; // translating content (carries bg, padding, knobs)
|
||||
let paramsPanelBody = null; // grid holding the knob cells
|
||||
let paramsCurrentValues = {}; // {paramId: value} — mirror of the visible variant's live values
|
||||
let paramsCurrentValues = {}; // {paramId: value} - mirror of the visible variant's live values
|
||||
let tuneOpen = false; // whether the Tune popover is open right now
|
||||
|
||||
// Theme-aware Tune popover. Appears as a drawer that slides out from the
|
||||
@@ -2471,7 +2472,7 @@
|
||||
const P = paramsPanelPalette;
|
||||
|
||||
// Single element, always in the DOM. The slide animation is a CSS mask
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis — no
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis - no
|
||||
// display toggle, no opacity toggle, no transform trickery. The mask
|
||||
// hides everything initially; as it grows, content is revealed from
|
||||
// the bar edge outward.
|
||||
@@ -2494,7 +2495,7 @@
|
||||
transition: 'clip-path 0.44s ' + EASE,
|
||||
|
||||
// Park off-screen until positionParamsPanel places it. These are NOT
|
||||
// in the transition list, so they snap instantly — no fly-in from the
|
||||
// in the transition list, so they snap instantly - no fly-in from the
|
||||
// top-left when first shown.
|
||||
top: '-9999px', left: '-9999px', width: '0',
|
||||
});
|
||||
@@ -2685,7 +2686,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline text editing — makes pure-text descendants of the picked element
|
||||
// Inline text editing - makes pure-text descendants of the picked element
|
||||
// directly contenteditable. Save stages copy edits in the live buffer; the
|
||||
// Apply copy edits dock later asks the AI to apply the staged batch.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -3132,7 +3133,7 @@
|
||||
if (detail.includes('newText cannot contain') || detail.includes('newText cannot be empty')) {
|
||||
showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500);
|
||||
} else {
|
||||
showToast('Save failed — retry or cancel', 4000);
|
||||
showToast('Save failed: retry or cancel', 4000);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3454,19 +3455,19 @@
|
||||
updatePendingCounter(remaining);
|
||||
if (result.failed && result.failed.length > 0) {
|
||||
console.warn('[impeccable] some copy edits failed:', result.failed);
|
||||
showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed — see console', 5000);
|
||||
showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed, see console', 5000);
|
||||
} else {
|
||||
const n = Array.isArray(result.applied) ? result.applied.length : (result.cleared || 0);
|
||||
if (n > 0) {
|
||||
showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500);
|
||||
} else {
|
||||
console.warn('[impeccable] apply returned no verified edits:', result);
|
||||
showToast('No edits applied — see console', 4000);
|
||||
showToast('No edits applied, see console', 4000);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[impeccable] commit failed:', err);
|
||||
showToast('Apply failed — see console', 4000);
|
||||
showToast('Apply failed, see console', 4000);
|
||||
} finally {
|
||||
if (waitForSseCompletion) return;
|
||||
const remainingCount = parseInt(pendingPillEl?.dataset.count || '0', 10) || 0;
|
||||
@@ -3496,7 +3497,7 @@
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[impeccable] discard failed:', err);
|
||||
showToast('Discard failed — see console', 4000);
|
||||
showToast('Discard failed, see console', 4000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3644,7 +3645,7 @@
|
||||
const failedCount = numberOrNull(msg.failedCount) || 0;
|
||||
const appliedCount = numberOrNull(msg.appliedCount) || numberOrNull(msg.cleared) || 0;
|
||||
if (failedCount > 0) {
|
||||
showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed — see console', 5000);
|
||||
showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed, see console', 5000);
|
||||
} else if (appliedCount > 0) {
|
||||
showToast('Applied ' + appliedCount + ' edit' + (appliedCount === 1 ? '' : 's'), 2500);
|
||||
}
|
||||
@@ -3799,7 +3800,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit content badge — floating button at element top-right to enter EDITING mode
|
||||
// Edit content badge - floating button at element top-right to enter EDITING mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initEditBadge() {
|
||||
@@ -3884,7 +3885,7 @@
|
||||
}
|
||||
editBadgeEl.appendChild(btn);
|
||||
} else {
|
||||
// 'editing' — show Cancel + Save separated
|
||||
// 'editing' - show Cancel + Save separated
|
||||
editBadgeEl.innerHTML = '';
|
||||
editBadgeEl.style.gap = '8px';
|
||||
const cancel = document.createElement('button');
|
||||
@@ -4083,7 +4084,7 @@
|
||||
if (!v) continue;
|
||||
setVariantShown(child, v === String(num));
|
||||
}
|
||||
// Unconditional refresh — covers first-reveal (no-op if state isn't
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
// CYCLING yet, the subsequent CYCLING transition triggers its own
|
||||
// refresh) and every cycle step.
|
||||
refreshParamsPanel();
|
||||
@@ -4318,7 +4319,7 @@
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
}, { passive: true, ...sig });
|
||||
|
||||
// Apply target synchronously, not via rAF — racing the browser's
|
||||
// Apply target synchronously, not via rAF - racing the browser's
|
||||
// restore or a smooth-scroll animation means we want to win now.
|
||||
if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) {
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
@@ -4545,18 +4546,34 @@
|
||||
// hidden tab, a route the user navigated away from). The variant
|
||||
// MutationObserver stays armed and auto-transitions to CYCLING
|
||||
// the moment the wrapper actually mounts. Nudge the user toward
|
||||
// that path with a toast — better than the prior force-reload
|
||||
// that path with a toast - better than the prior force-reload
|
||||
// which reset framework state and left the session stuck.
|
||||
setTimeout(() => {
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) return;
|
||||
if (state !== 'GENERATING') return;
|
||||
showToast(
|
||||
"Variants ready. If the picked element isn't visible, retrace the path that revealed it — they'll appear automatically.",
|
||||
"Variants ready. If the picked element isn't visible, retrace the path that revealed it; they'll appear automatically.",
|
||||
15000,
|
||||
);
|
||||
}, 2000);
|
||||
break;
|
||||
case 'complete':
|
||||
case 'accept':
|
||||
if (maybeCompleteAcceptedSession(msg)) break;
|
||||
break;
|
||||
case 'agent_done':
|
||||
// Carbonize accepts are not terminal until live-complete.mjs sends
|
||||
// the final complete event. Keep the browser in its recoverable
|
||||
// saving state while the source cleanup is still in flight.
|
||||
break;
|
||||
case 'error':
|
||||
if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) {
|
||||
pendingAcceptedSession = null;
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
break;
|
||||
}
|
||||
if (maybeCompleteSteer(msg)) break;
|
||||
console.error('[impeccable] Error:', msg.message);
|
||||
showToast('Error: ' + msg.message, 5000);
|
||||
@@ -4779,14 +4796,14 @@
|
||||
|
||||
/**
|
||||
* Surface a brief, non-blocking heads-up when the picked element lives
|
||||
* inside a container whose visibility is gated by ephemeral state — modals,
|
||||
* inside a container whose visibility is gated by ephemeral state - modals,
|
||||
* collapsible panels, popovers, off-screen tab panels. If HMR remounts the
|
||||
* parent during generation (Vite Fast Refresh, SvelteKit page reload), the
|
||||
* variants land in source but stay invisible until the user re-opens the
|
||||
* container. Telling the user upfront is much friendlier than the silent
|
||||
* timeout-then-toast that they'd otherwise hit.
|
||||
*
|
||||
* Heuristic, intentionally narrow — only fires for unambiguous cases so
|
||||
* Heuristic, intentionally narrow - only fires for unambiguous cases so
|
||||
* we don't cry wolf on every nested element.
|
||||
*/
|
||||
function maybeWarnConditionalAncestor(el) {
|
||||
@@ -4804,7 +4821,7 @@
|
||||
showToast('Heads up: this element lives inside an open panel. If state resets during generation, you may need to re-open it.', 6000);
|
||||
return;
|
||||
}
|
||||
// 3. Tab panel — only meaningful when the page also shows ANOTHER
|
||||
// 3. Tab panel - only meaningful when the page also shows ANOTHER
|
||||
// tab as selected. A single tabpanel with no tablist is just a static
|
||||
// section in disguise and isn't conditional.
|
||||
if (node.getAttribute && node.getAttribute('role') === 'tabpanel') {
|
||||
@@ -4833,7 +4850,7 @@
|
||||
// Fire a lightweight prefetch event the first time the user selects an
|
||||
// element on a given route. The agent uses this to Read the underlying file
|
||||
// into context before Go is hit, shaving the read off the critical path.
|
||||
// Dedupe per session by pathname — clicking around on the same page doesn't
|
||||
// Dedupe per session by pathname - clicking around on the same page doesn't
|
||||
// re-fire.
|
||||
//
|
||||
// DISABLED: quick-Go workflows pay an extra harness round trip because
|
||||
@@ -4969,6 +4986,7 @@
|
||||
disableInlineEdit();
|
||||
stripManualEditRuntimeState(selectedElement);
|
||||
|
||||
pendingAcceptedSession = null;
|
||||
currentSessionId = id8();
|
||||
expectedVariants = selectedCount;
|
||||
arrivedVariants = 0;
|
||||
@@ -4976,7 +4994,7 @@
|
||||
|
||||
// Flip to GENERATING immediately so the bar morphs without waiting on
|
||||
// capture + upload. The event is emitted from captureAndEmit() once the
|
||||
// screenshot is uploaded (or capture fails — we still emit, just without
|
||||
// screenshot is uploaded (or capture fails - we still emit, just without
|
||||
// screenshotPath).
|
||||
const elForCapture = selectedElement;
|
||||
const captureRect = elForCapture.getBoundingClientRect();
|
||||
@@ -5002,7 +5020,7 @@
|
||||
state = 'GENERATING';
|
||||
// Disable the Edit badge: starting a manual text edit mid-generation would
|
||||
// conflict with the variant wrap that's about to land in the same DOM
|
||||
// region. Only swap if the badge was visible — picked elements with no
|
||||
// region. Only swap if the badge was visible - picked elements with no
|
||||
// text rows have it hidden already.
|
||||
if (editBadgeEl && editBadgeEl.style.display !== 'none') renderEditBadge('idle-disabled');
|
||||
showBar('generating');
|
||||
@@ -5104,7 +5122,7 @@
|
||||
|
||||
// Collect @font-face rules from every stylesheet on the page. Cross-origin
|
||||
// sheets (Google Fonts, Typekit, etc.) throw SecurityError on .cssRules
|
||||
// access, so modern-screenshot can't embed them on its own — the resulting
|
||||
// access, so modern-screenshot can't embed them on its own - the resulting
|
||||
// SVG falls back to system fonts and text re-wraps + renders with different
|
||||
// weight. We fetch the raw CSS text (CORS-permitted for these providers),
|
||||
// extract @font-face blocks, inline the referenced font files as base64
|
||||
@@ -5188,7 +5206,7 @@
|
||||
// modern-screenshot force-sets `background-color: X !important` on the
|
||||
// cloned root whenever `backgroundColor` is passed, clobbering the
|
||||
// element's own background. So we only pass it when the element is
|
||||
// genuinely transparent (no own color, no own image) — in that case
|
||||
// genuinely transparent (no own color, no own image) - in that case
|
||||
// we resolve up the DOM to the nearest opaque ancestor so the capture
|
||||
// sits on the page's real background instead of rendering black.
|
||||
function resolveCanvasBackground(el) {
|
||||
@@ -5206,7 +5224,7 @@
|
||||
// `getComputedStyle(body).backgroundColor || …` chain is a trap: that
|
||||
// call returns the literal string `"rgba(0, 0, 0, 0)"` for a page that
|
||||
// never set its own bg, which is truthy and short-circuits the chain to
|
||||
// transparent-black — modern-screenshot then renders the capture on a
|
||||
// transparent-black - modern-screenshot then renders the capture on a
|
||||
// black canvas and the shader overlay flashes solid black during load.
|
||||
// The browser canvas defaults to white, so we do too.
|
||||
return '#ffffff';
|
||||
@@ -5248,7 +5266,7 @@
|
||||
// Transparent up to the root. The visible backdrop may still come from an
|
||||
// ancestor's background-image or a covering positioned layer (e.g. a hero
|
||||
// art div) that the color walk can't see. Capture that ancestor and crop
|
||||
// to the element so the real backdrop is embedded — correct for both the
|
||||
// to the element so the real backdrop is embedded - correct for both the
|
||||
// shader and the screenshot sent to the model. Fall back to white only
|
||||
// when nothing is actually painted behind the element.
|
||||
const backdrop = findBackdropAncestor(el);
|
||||
@@ -5289,13 +5307,13 @@
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] capture failed, proceeding without screenshot:', err);
|
||||
}
|
||||
// Light up the shader overlay the moment capture is ready — no reason to
|
||||
// Light up the shader overlay the moment capture is ready - no reason to
|
||||
// wait for the upload to complete before the user sees something alive.
|
||||
if (blob && state === 'GENERATING') {
|
||||
showShaderOverlay(el, blob, rect, paper);
|
||||
}
|
||||
// Only upload + forward the screenshot when annotations (comments/strokes)
|
||||
// are present. Without annotations the image is pure visual anchoring —
|
||||
// are present. Without annotations the image is pure visual anchoring -
|
||||
// it biases the model toward the current rendering and works against the
|
||||
// three-distinct-directions brief.
|
||||
const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0);
|
||||
@@ -5320,7 +5338,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shader overlay — renders the captured screenshot as a WebGL texture and
|
||||
// Shader overlay - renders the captured screenshot as a WebGL texture and
|
||||
// runs an editorial "ink-wash" fragment shader over it during generation.
|
||||
// A single rolling band sweeps top-to-bottom, desaturating + tinting kinpaku
|
||||
// and leaving a soft trail. Makes the wait feel like a letterpress scan
|
||||
@@ -5343,7 +5361,7 @@ uniform vec3 u_accent;
|
||||
uniform vec3 u_paper;
|
||||
varying vec2 v_uv;
|
||||
|
||||
// Asymmetric roller band. Product of two one-sided smoothsteps — peaks at
|
||||
// Asymmetric roller band. Product of two one-sided smoothsteps - peaks at
|
||||
// d=0 with a short sharp leading ramp and a longer soft trailing tail. Clean
|
||||
// outside the [-leadW, trailW] range (no rogue "trail=1 everywhere below"
|
||||
// failure that reversed-edge smoothstep would give).
|
||||
@@ -5369,8 +5387,8 @@ void main() {
|
||||
vec2 sampleCenter = (cellId + 0.5) * cellPx / u_resolution;
|
||||
vec3 cellImg = texture2D(u_texture, sampleCenter).rgb;
|
||||
// Dot size tracks how much the cell DIFFERS from the element's own ground
|
||||
// (u_paper), not absolute darkness. So the content — text, buttons, anything
|
||||
// that deviates from the background — always becomes the dots, on light AND
|
||||
// (u_paper), not absolute darkness. So the content - text, buttons, anything
|
||||
// that deviates from the background - always becomes the dots, on light AND
|
||||
// dark surfaces. A plain darkness curve inverts on dark elements: the dark
|
||||
// background fills with ink and the lighter content punches holes instead.
|
||||
// Capped below the cell half-width so dense content stays separated dots.
|
||||
@@ -5380,8 +5398,8 @@ void main() {
|
||||
// Two-stage dissolve as the roller passes, so the element is rebuilt purely
|
||||
// from dot size (its own halftone) and never bleeds through as raw pixels
|
||||
// behind the dots:
|
||||
// 1. cover — the element flattens to the uniform paper ground first.
|
||||
// 2. dotAmt — kinpaku dots then emerge, sized by each cell's luma.
|
||||
// 1. cover - the element flattens to the uniform paper ground first.
|
||||
// 2. dotAmt - kinpaku dots then emerge, sized by each cell's luma.
|
||||
// A plain mix(base, halftone, band) instead left the raw element visible
|
||||
// through the band's soft core/trail. The paper ground is u_paper (the
|
||||
// element's own bg tone) rather than a fixed white, so the dissolve reads the
|
||||
@@ -5399,7 +5417,7 @@ void main() {
|
||||
|
||||
// Kinpaku gold converted to approximate sRGB 0-1 (matches oklch(84% 0.19 80.46))
|
||||
const SHADER_ACCENT = [1.0, 0.78, 0.31];
|
||||
// Fallback ground when an element and all its ancestors are transparent —
|
||||
// Fallback ground when an element and all its ancestors are transparent -
|
||||
// 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 }
|
||||
@@ -5412,7 +5430,7 @@ void main() {
|
||||
// Rasterize any CSS color (oklch, color(), named, hex, rgb) through a 1x1
|
||||
// canvas and read back the sRGB pixel. String-parsing computed colors is a
|
||||
// trap: Chrome returns backgroundColor as oklch()/color() for oklch inputs,
|
||||
// which a hex/rgb regex misses — every site token would fall back to white.
|
||||
// which a hex/rgb regex misses - every site token would fall back to white.
|
||||
let colorParseCtx = null;
|
||||
function cssColorToRgb01(str) {
|
||||
if (!colorParseCtx) {
|
||||
@@ -5440,7 +5458,7 @@ void main() {
|
||||
|
||||
// When an element is transparent up to the root, its visible backdrop can
|
||||
// still come from an ancestor's background-image or a covering positioned
|
||||
// layer that is a *child* of an ancestor (e.g. a hero's absolute art div) —
|
||||
// layer that is a *child* of an ancestor (e.g. a hero's absolute art div) -
|
||||
// neither of which the ancestor background-COLOR walk can see. Return the
|
||||
// nearest such ancestor so we can capture it and crop, embedding the real
|
||||
// backdrop. Returns null when nothing is actually painted behind the element
|
||||
@@ -5481,7 +5499,7 @@ void main() {
|
||||
|
||||
// Average the backdrop sampled just OUTSIDE an element's rect within a larger
|
||||
// canvas. The ground tone for the dissolve must be the real backdrop, not the
|
||||
// mean of the element's own crop — averaging the crop folds in the element's
|
||||
// mean of the element's own crop - averaging the crop folds in the element's
|
||||
// content (e.g. bright heading text), pulling the ground toward muddy gray.
|
||||
function sampleSurroundingRgb(ctx, sx, sy, sw, sh, W, H) {
|
||||
const pad = Math.max(2, Math.round(Math.min(sw, sh) * 0.12));
|
||||
@@ -5528,11 +5546,31 @@ void main() {
|
||||
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;
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
canvas.remove();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const fallback = document.createElement('div');
|
||||
fallback.id = PREFIX + '-shader';
|
||||
// Copy positioning via cssText. Object.assign across CSSStyleDeclaration
|
||||
// throws in modern Chromium because the source's indexed properties
|
||||
// (style[0], [1], ...) are read-only and the engine forbids writing
|
||||
// them on the destination.
|
||||
fallback.style.cssText = canvas.style.cssText;
|
||||
fallback.style.backgroundImage = 'url("' + objectUrl + '")';
|
||||
fallback.style.backgroundSize = '100% 100%';
|
||||
fallback.style.backgroundRepeat = 'no-repeat';
|
||||
fallback.style.outline = '2px dashed ' + C.brand;
|
||||
fallback.style.outlineOffset = '-2px';
|
||||
document.body.appendChild(fallback);
|
||||
shaderState = { canvas: fallback, gl: null, program: null, texture: null, rafId: 0, startTime: 0, objectUrl };
|
||||
}
|
||||
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
@@ -5553,21 +5591,9 @@ void main() {
|
||||
const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false })
|
||||
|| canvas.getContext('experimental-webgl');
|
||||
if (!gl) {
|
||||
// WebGL unavailable — fall back to a plain <img> overlay so the user
|
||||
// still sees something meaningful during generation.
|
||||
canvas.remove();
|
||||
const img = document.createElement('img');
|
||||
img.src = URL.createObjectURL(blob);
|
||||
img.id = PREFIX + '-shader';
|
||||
// Copy positioning via cssText. Object.assign across CSSStyleDeclaration
|
||||
// throws in modern Chromium because the source's indexed properties
|
||||
// (style[0], [1], ...) are read-only and the engine forbids writing
|
||||
// them on the destination.
|
||||
img.style.cssText = canvas.style.cssText;
|
||||
img.style.outline = '2px dashed ' + C.brand;
|
||||
img.style.outlineOffset = '-2px';
|
||||
document.body.appendChild(img);
|
||||
shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 };
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5609,14 +5635,12 @@ void main() {
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch {
|
||||
// Safari fallback: go via a regular Image
|
||||
const imgUrl = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
img.src = imgUrl;
|
||||
await new Promise((r, rej) => { img.onload = r; img.onerror = rej; });
|
||||
bitmap = img;
|
||||
URL.revokeObjectURL(imgUrl);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
@@ -5670,14 +5694,15 @@ void main() {
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
// The accepted variant is already the only visible child of the wrapper
|
||||
// (all other variants are display:none). HMR from the source rewrite will
|
||||
// replace the wrapper imminently. Don't eagerly replaceChild here — React
|
||||
// reconciliation races with our mutation and throws NotFoundError in Next
|
||||
// 16 / Turbopack. Schedule a fallback that runs the manual swap only if
|
||||
// HMR hasn't cleaned up by then (keeps static-server flows working).
|
||||
const acceptedSessionId = currentSessionId;
|
||||
const acceptedVariant = visibleVariant;
|
||||
const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant);
|
||||
pendingAcceptedSession = {
|
||||
id: acceptedSessionId,
|
||||
variant: String(acceptedVariant),
|
||||
...acceptedSnapshot,
|
||||
finalizing: false,
|
||||
};
|
||||
|
||||
state = 'SAVING';
|
||||
updateBarContent('saving');
|
||||
@@ -5685,53 +5710,130 @@ void main() {
|
||||
sendEvent(acceptPayload, { throwOnError: true })
|
||||
.then(() => {
|
||||
markSessionHandled();
|
||||
confirmAcceptAfterReceipt();
|
||||
})
|
||||
.catch(() => {
|
||||
pendingAcceptedSession = null;
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
}
|
||||
|
||||
function confirmAcceptAfterReceipt() {
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
scheduleAcceptCleanup();
|
||||
function maybeCompleteAcceptedSession(msg) {
|
||||
const pending = pendingAcceptedSession;
|
||||
if (!pending || !msg?.id || msg.id !== pending.id) return false;
|
||||
if (currentSessionId && currentSessionId !== pending.id) {
|
||||
pendingAcceptedSession = null;
|
||||
return false;
|
||||
}
|
||||
if (pending.finalizing) return true;
|
||||
pending.finalizing = true;
|
||||
|
||||
function scheduleAcceptCleanup() {
|
||||
setTimeout(function() {
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
selectedElement = null;
|
||||
currentSessionId = null;
|
||||
selectedAction = 'impeccable';
|
||||
renderEditBadge('hidden');
|
||||
state = 'PICKING';
|
||||
}, 1800);
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
|
||||
// Static-server / no-HMR fallback: if the wrapper is still around 2s after
|
||||
// the cleanup above, swap it out manually. By now React has either moved
|
||||
// on or the app isn't React at all. Preserve the `data-impeccable-variant="N"`
|
||||
// div (with display:contents) so @scope rules anchored to the variant
|
||||
// attribute keep matching until reload replaces it with the carbonize block.
|
||||
// Give framework HMR a short chance to render the now-clean accepted
|
||||
// source. If it misses the update, unwrap the accepted variant after the
|
||||
// source-side completion event so the page is not left empty or stale.
|
||||
setTimeout(function() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + acceptedSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + acceptedVariant + '"]');
|
||||
if (accepted && accepted.firstElementChild) {
|
||||
const parent = wrapper.parentElement;
|
||||
if (!parent) return;
|
||||
accepted.style.display = 'contents';
|
||||
parent.replaceChild(accepted, wrapper);
|
||||
}
|
||||
}, 2000);
|
||||
ensureAcceptedDomClean(pending);
|
||||
cleanupAcceptedSession();
|
||||
}, 1200);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
acceptedHtml: accepted ? accepted.innerHTML : '',
|
||||
acceptedSelector: selectorForAcceptedRoot(root),
|
||||
parentElement: wrapper?.parentElement || null,
|
||||
parentSelector: selectorForAcceptedRoot(wrapper?.parentElement || null),
|
||||
nextSibling: wrapper?.nextSibling || null,
|
||||
};
|
||||
}
|
||||
|
||||
function selectorForAcceptedRoot(root) {
|
||||
if (!root || !root.tagName) return '';
|
||||
const tag = root.tagName.toLowerCase();
|
||||
const classes = [...(root.classList || [])].filter(Boolean);
|
||||
if (classes.length === 0) return tag;
|
||||
return tag + classes.map((cls) => '.' + cssIdent(cls)).join('');
|
||||
}
|
||||
|
||||
function acceptedDomAlreadyClean(pending) {
|
||||
if (!pending?.acceptedSelector) return false;
|
||||
const matches = [...document.querySelectorAll(pending.acceptedSelector)];
|
||||
return matches.some((el) => !el.closest('[data-impeccable-variants],[data-impeccable-variant]'));
|
||||
}
|
||||
|
||||
function ensureAcceptedDomClean(pending) {
|
||||
const sessionId = pending?.id;
|
||||
const variantId = pending?.variant;
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!wrapper) {
|
||||
restoreAcceptedDomFromSnapshot(pending);
|
||||
return;
|
||||
}
|
||||
if (!accepted) {
|
||||
wrapper.remove();
|
||||
restoreAcceptedDomFromSnapshot(pending);
|
||||
return;
|
||||
}
|
||||
const parent = wrapper.parentElement;
|
||||
if (!parent) return;
|
||||
while (accepted.firstChild) {
|
||||
parent.insertBefore(accepted.firstChild, wrapper);
|
||||
}
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
function restoreAcceptedDomFromSnapshot(pending) {
|
||||
if (acceptedDomAlreadyClean(pending)) return;
|
||||
if (!pending?.acceptedHtml) {
|
||||
reloadAfterMissingAcceptedDom(pending);
|
||||
return;
|
||||
}
|
||||
const parent = pending.parentElement?.isConnected
|
||||
? pending.parentElement
|
||||
: (pending.parentSelector ? document.querySelector(pending.parentSelector) : null);
|
||||
if (!parent) {
|
||||
reloadAfterMissingAcceptedDom(pending);
|
||||
return;
|
||||
}
|
||||
const template = document.createElement('template');
|
||||
template.innerHTML = pending.acceptedHtml;
|
||||
const anchor = pending.nextSibling?.isConnected && pending.nextSibling.parentElement === parent
|
||||
? pending.nextSibling
|
||||
: null;
|
||||
parent.insertBefore(template.content, anchor);
|
||||
if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending);
|
||||
}
|
||||
|
||||
function reloadAfterMissingAcceptedDom(pending) {
|
||||
if (acceptedDomAlreadyClean(pending)) return;
|
||||
if (pending?.id && document.querySelector('[data-impeccable-variants="' + pending.id + '"]')) return;
|
||||
location.reload();
|
||||
}
|
||||
|
||||
function cleanupAcceptedSession() {
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
selectedElement = null;
|
||||
currentSessionId = null;
|
||||
selectedAction = 'impeccable';
|
||||
pendingAcceptedSession = null;
|
||||
renderEditBadge('hidden');
|
||||
state = 'PICKING';
|
||||
}
|
||||
|
||||
function handleDiscard() {
|
||||
@@ -5792,7 +5894,7 @@ void main() {
|
||||
|
||||
function cleanup() {
|
||||
// Hide the wrapper immediately so variants disappear. DON'T structurally
|
||||
// mutate the DOM yet — HMR from the agent's source rewrite is on its way,
|
||||
// mutate the DOM yet - HMR from the agent's source rewrite is on its way,
|
||||
// and a manual replaceChild under React causes NotFoundError when the
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
@@ -5838,8 +5940,8 @@ void main() {
|
||||
function showToast(message, duration) {
|
||||
if (toastEl) toastEl.remove();
|
||||
// Stack the toast above the global bar (which sits at bottom:14px) so
|
||||
// the two never overlap. Read the bar's actual rect — its height varies
|
||||
// with hover-expanded labels — and fall back to a sensible default
|
||||
// the two never overlap. Read the bar's actual rect - its height varies
|
||||
// with hover-expanded labels - and fall back to a sensible default
|
||||
// when the bar isn't mounted yet.
|
||||
const barRect = globalBarEl?.getBoundingClientRect();
|
||||
const barTopFromBottom = barRect && barRect.height > 0
|
||||
@@ -6047,7 +6149,7 @@ void main() {
|
||||
let pendingApplyInFlight = false;
|
||||
let firstSaveOfSession = true;
|
||||
|
||||
// Steer — collapsed pill in the global bar; expands while typing for page-level chat.
|
||||
// Steer - collapsed pill in the global bar; expands while typing for page-level chat.
|
||||
let pageChatEl = null;
|
||||
let pageChatInput = null;
|
||||
let pageChatHint = null;
|
||||
@@ -6068,7 +6170,7 @@ void main() {
|
||||
const STEER_AWAIT_TIMEOUT_MS = 120000;
|
||||
const AGENT_STATUS_POLL_MS = 5000;
|
||||
const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)';
|
||||
const AGENT_DISCONNECTED_TIP = 'Agent disconnected — run live-poll.mjs to connect';
|
||||
const AGENT_DISCONNECTED_TIP = 'Agent disconnected: run live-poll.mjs to connect';
|
||||
const GLOBAL_BAR_SECTION_GAP = 8;
|
||||
const GLOBAL_BAR_INNER_GAP = 2;
|
||||
const GLOBAL_BAR_INNER_PAD_LEFT = 2;
|
||||
@@ -6079,7 +6181,7 @@ void main() {
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>';
|
||||
|
||||
// Theme-aware color palette for the global bar. We detect the page's
|
||||
// ambient background and invert — dark bar on light pages, light bar on
|
||||
// ambient background and invert - dark bar on light pages, light bar on
|
||||
// dark pages. This keeps the bar from fighting with the host design.
|
||||
function detectPageTheme() {
|
||||
try {
|
||||
@@ -6092,7 +6194,7 @@ void main() {
|
||||
// Walk body → html, taking the first opaque background. The browser's
|
||||
// default body / html background is `rgba(0, 0, 0, 0)`, which a naive
|
||||
// regex would read as black and mislabel a perfectly white page as
|
||||
// dark. Honoring alpha avoids that — and falling through to <html>
|
||||
// dark. Honoring alpha avoids that - and falling through to <html>
|
||||
// catches the common pattern of a bg only on <html> (or only on body).
|
||||
function readOpaque(el) {
|
||||
if (!el) return null;
|
||||
@@ -6141,7 +6243,7 @@ void main() {
|
||||
exitHover: 'oklch(58% 0.15 35 / 0.18)',
|
||||
shadow: PICKER_SHADOW,
|
||||
chatSurface: 'oklch(22% 0.012 82)',
|
||||
// Verdigris patina — secondary state (see site/styles/kinpaku-tokens.css)
|
||||
// Verdigris patina - secondary state (see site/styles/kinpaku-tokens.css)
|
||||
patina: 'oklch(70% 0.12 188)',
|
||||
patinaPale: 'oklch(82% 0.07 188)',
|
||||
patinaSoft: 'oklch(70% 0.12 188 / 0.28)',
|
||||
@@ -6913,7 +7015,7 @@ void main() {
|
||||
steerFocusLog('page-chat-mounted', {});
|
||||
}
|
||||
|
||||
// Impeccable mark — same paths as site/components/Header.astro + favicon.svg.
|
||||
// Impeccable mark - same paths as site/components/Header.astro + favicon.svg.
|
||||
function brandMarkSvg(color = C.brand, size = 18) {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="${color}" aria-hidden="true">
|
||||
<path d="M5 2.5 L13.5 2.5 L5.5 21.5 L5 21.5 Q2.5 21.5 2.5 19 L2.5 5 Q2.5 2.5 5 2.5 Z"/>
|
||||
@@ -6928,7 +7030,7 @@ void main() {
|
||||
globalBarBrandEl.dataset.agentConnected = connected ? 'true' : 'false';
|
||||
globalBarBrandEl.setAttribute('aria-label', connected
|
||||
? 'Impeccable live mode'
|
||||
: 'Impeccable live mode — agent not polling');
|
||||
: 'Impeccable live mode: agent not polling');
|
||||
globalBarBrandEl.removeAttribute('title');
|
||||
globalBarBrandEl.style.cursor = connected ? 'default' : 'help';
|
||||
const mark = globalBarBrandEl.querySelector('[data-brand-mark]');
|
||||
@@ -7053,7 +7155,7 @@ void main() {
|
||||
globalBarEl.id = PREFIX + '-global-bar';
|
||||
globalBarEl.dataset.theme = theme;
|
||||
|
||||
// Brand mark — kinpaku Impeccable icon (site header / favicon paths).
|
||||
// Brand mark - kinpaku Impeccable icon (site header / favicon paths).
|
||||
const brand = el('span', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
alignSelf: 'stretch', position: 'relative',
|
||||
@@ -7065,7 +7167,7 @@ void main() {
|
||||
brand.id = PREFIX + '-global-bar-brand';
|
||||
brand.dataset.agentConnected = 'false';
|
||||
brand.setAttribute('role', 'img');
|
||||
brand.setAttribute('aria-label', 'Impeccable live mode — agent not polling');
|
||||
brand.setAttribute('aria-label', 'Impeccable live mode: agent not polling');
|
||||
|
||||
const brandMark = el('span', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
@@ -7130,7 +7232,7 @@ void main() {
|
||||
// Per-button hover only changes color (no layout). The label expand/
|
||||
// collapse is driven by the bar-level mouseenter/mouseleave so moving
|
||||
// the mouse between adjacent buttons doesn't trigger per-button width
|
||||
// thrashing — the whole bar grows once and shrinks once.
|
||||
// thrashing - the whole bar grows once and shrinks once.
|
||||
b.addEventListener('mouseenter', () => { if (b.dataset.active !== 'true') b.style.color = P.text; });
|
||||
b.addEventListener('mouseleave', () => { if (b.dataset.active !== 'true') b.style.color = P.textDim; });
|
||||
b.addEventListener('click', onClick);
|
||||
@@ -7139,7 +7241,7 @@ void main() {
|
||||
return b;
|
||||
}
|
||||
|
||||
// Pick toggle — restored from localStorage; both pick and insert may be off.
|
||||
// Pick toggle - restored from localStorage; both pick and insert may be off.
|
||||
const pickBtn = makeIconBtn({
|
||||
id: PREFIX + '-pick-toggle',
|
||||
svg: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><circle cx="12" cy="12" r="10"/><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><line x1="12" y1="6" x2="12" y2="2"/><line x1="12" y1="22" x2="12" y2="18"/></svg>',
|
||||
@@ -7176,7 +7278,7 @@ void main() {
|
||||
detectBtn.appendChild(detectBadge);
|
||||
inner.appendChild(detectBtn);
|
||||
|
||||
// DESIGN.md panel toggle — quartet of color squares as the mark.
|
||||
// DESIGN.md panel toggle - quartet of color squares as the mark.
|
||||
const designBtn = makeIconBtn({
|
||||
id: PREFIX + '-design-toggle',
|
||||
svg: `<span style="display:inline-grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;width:14px;height:14px;border-radius:3px;overflow:hidden;box-shadow:inset 0 0 0 1px oklch(58% 0.065 82 / 0.55);flex-shrink:0">
|
||||
@@ -7378,13 +7480,13 @@ void main() {
|
||||
});
|
||||
inner.appendChild(divider);
|
||||
|
||||
// Exit × on the right — intentionally subtle (textDim at rest, text on
|
||||
// Exit × on the right - intentionally subtle (textDim at rest, text on
|
||||
// hover) so it sits behind the active toggles in visual hierarchy.
|
||||
//
|
||||
// Explicit padding + box-sizing here is load-bearing: a host page like
|
||||
// `button { padding: 0.5rem 1rem; }` (very common in resets) would
|
||||
// otherwise inflate this 24x24 button into 56x40 and push the SVG out
|
||||
// of the visible bar — the X stays invisible even though the styles in
|
||||
// of the visible bar - the X stays invisible even though the styles in
|
||||
// DevTools look fine. Every other chrome button sets padding inline;
|
||||
// this one needed it too.
|
||||
const exitBtn = el('button', {
|
||||
@@ -7472,7 +7574,7 @@ void main() {
|
||||
btn.style.opacity = controlsLocked ? '0.55' : '1';
|
||||
});
|
||||
|
||||
// If the bar is currently under the cursor, keep all labels expanded —
|
||||
// If the bar is currently under the cursor, keep all labels expanded -
|
||||
// otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
|
||||
// would collapse its label while the user's mouse is still on the bar.
|
||||
if (globalBarEl && globalBarEl.matches(':hover')) {
|
||||
@@ -7648,7 +7750,7 @@ void main() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Design System Panel — visualizes the project's .impeccable/design.json sidecar
|
||||
// Design System Panel - visualizes the project's .impeccable/design.json sidecar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DESIGN_PREFS_KEY = 'impeccable-live-design-panel';
|
||||
@@ -7674,7 +7776,7 @@ void main() {
|
||||
};
|
||||
|
||||
function loadDesignPrefs() {
|
||||
// `open` is intentionally NOT persisted — the panel always starts closed
|
||||
// `open` is intentionally NOT persisted - the panel always starts closed
|
||||
// so live mode doesn't auto-slide a big panel over the page on startup.
|
||||
try {
|
||||
const raw = localStorage.getItem(DESIGN_PREFS_KEY);
|
||||
@@ -7731,7 +7833,7 @@ void main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Neutral panel palette — deliberately NOT Impeccable-branded. The panel is
|
||||
// Neutral panel palette - deliberately NOT Impeccable-branded. The panel is
|
||||
// a viewer of the project's design system, not an Impeccable surface.
|
||||
const DP = {
|
||||
canvas: 'oklch(94% 0 0)', // panel background
|
||||
@@ -8025,7 +8127,7 @@ void main() {
|
||||
const root = designShadow.querySelector('.root');
|
||||
root.innerHTML = '';
|
||||
|
||||
// (Panel toggle lives in the global bar — no floating FAB.)
|
||||
// (Panel toggle lives in the global bar - no floating FAB.)
|
||||
// Panel
|
||||
const panel = document.createElement('aside');
|
||||
panel.className = 'panel';
|
||||
@@ -8141,7 +8243,7 @@ void main() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Visual tab — single unified render path.
|
||||
// Visual tab - single unified render path.
|
||||
if (designState.mdNewerThanJson) body.appendChild(renderStaleHint());
|
||||
if (designState.hasMd && !designState.hasSidecar) {
|
||||
body.appendChild(renderParsedMdCta());
|
||||
@@ -8357,7 +8459,7 @@ void main() {
|
||||
specimen.style.fontFamily = fontStack(t);
|
||||
specimen.style.fontWeight = String(t.weight || 400);
|
||||
specimen.style.fontStyle = t.style || 'normal';
|
||||
specimen.style.fontSize = '56px'; // Fixed specimen size — compare faces, not scales.
|
||||
specimen.style.fontSize = '56px'; // Fixed specimen size - compare faces, not scales.
|
||||
specimen.style.letterSpacing = 'normal';
|
||||
specimen.style.textTransform = 'none';
|
||||
tile.appendChild(specimen);
|
||||
@@ -8501,7 +8603,7 @@ void main() {
|
||||
}
|
||||
|
||||
// Single shared description if all items carry the same one; otherwise
|
||||
// skip — per-item descriptions clutter a grouped tile.
|
||||
// skip - per-item descriptions clutter a grouped tile.
|
||||
if (group.length === 1 && group[0].description) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'c-desc';
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Impeccable Live Variant Mode — Browser Script
|
||||
* Impeccable Live Variant Mode - Browser Script
|
||||
*
|
||||
* Injected into the user's page via <script src="http://localhost:PORT/live.js">.
|
||||
* The server prepends window.__IMPECCABLE_TOKEN__ and window.__IMPECCABLE_PORT__
|
||||
* before this code.
|
||||
*
|
||||
* UI: a single floating bar that morphs between three states —
|
||||
* UI: a single floating bar that morphs between three states -
|
||||
* configure (pick action + go), generating (progressive dots), and cycling
|
||||
* (prev/next + accept/discard). Feels like Spotlight, not a modal.
|
||||
*/
|
||||
@@ -46,7 +46,7 @@
|
||||
mist: 'oklch(90% 0.008 82 / 0.6)', // light hairline
|
||||
white: 'oklch(99% 0 0)',
|
||||
};
|
||||
// Picker bar chrome — mirrors .live-demo-gbar / .live-demo-ctx in kinpaku-kit.css.
|
||||
// Picker bar chrome - mirrors .live-demo-gbar / .live-demo-ctx in kinpaku-kit.css.
|
||||
// Quiet neutral elevation: no gold halo ring (gold is reserved for the brand
|
||||
// mark and the active control, not the container outline).
|
||||
const PICKER_SHADOW =
|
||||
@@ -83,7 +83,7 @@
|
||||
|
||||
// SVG icons stack above each chip label. All strokes use currentColor so the
|
||||
// icon recolors to C.brand when its chip is selected. 20x20 render, 24-viewBox,
|
||||
// 1.5 stroke — visually consistent with the Foundation grid on the homepage.
|
||||
// 1.5 stroke - visually consistent with the Foundation grid on the homepage.
|
||||
const ICON_ATTRS = 'width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="display:block"';
|
||||
const ICONS = {
|
||||
impeccable: `<svg ${ICON_ATTRS}><path d="M4 20l4-1L18 9l-3-3L5 16z"/><path d="M14 7l3 3"/></svg>`,
|
||||
@@ -123,6 +123,7 @@
|
||||
let hoveredElement = null;
|
||||
let selectedElement = null;
|
||||
let currentSessionId = null;
|
||||
let pendingAcceptedSession = null;
|
||||
let expectedVariants = 0;
|
||||
let arrivedVariants = 0;
|
||||
let visibleVariant = 0;
|
||||
@@ -133,7 +134,7 @@
|
||||
const browserOwner = sessionState.owner;
|
||||
let checkpointTimer = null;
|
||||
|
||||
// Scroll lock — holds window.scrollY at a fixed value while the session is
|
||||
// Scroll lock - holds window.scrollY at a fixed value while the session is
|
||||
// active, so HMR DOM patches and variant swaps can't drift the page. See
|
||||
// startScrollLock / stopScrollLock below.
|
||||
let scrollLockObserver = null;
|
||||
@@ -141,7 +142,7 @@
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
// Dedicated key for scroll position — SEPARATE from LS_KEY so that
|
||||
// Dedicated key for scroll position - SEPARATE from LS_KEY so that
|
||||
// saveSession's state updates don't clobber a carefully-captured scrollY.
|
||||
// (Previously: saveSession wrote scrollY alongside state, so every call
|
||||
// during resume overwrote the pre-reload value with whatever the browser
|
||||
@@ -231,7 +232,7 @@
|
||||
// - Stop `focusin` propagation so any focus shifts inside our chrome
|
||||
// don't read as "focus moved outside the dialog" to focus traps.
|
||||
//
|
||||
// Click events still bubble normally — only the early pointer/focus
|
||||
// Click events still bubble normally - only the early pointer/focus
|
||||
// signals that drive outside-interaction detection are silenced.
|
||||
function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) {
|
||||
if (!rootEl) return;
|
||||
@@ -319,7 +320,7 @@
|
||||
// correlate directly with the captured PNG.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DRAG_THRESHOLD = 5; // px — below this, treat pointerup as a click
|
||||
const DRAG_THRESHOLD = 5; // px - below this, treat pointerup as a click
|
||||
const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it
|
||||
let annotOverlayEl = null;
|
||||
let annotSvgEl = null;
|
||||
@@ -423,7 +424,7 @@
|
||||
placeholderResizeDrag = null;
|
||||
if (annotOverlayEl) annotOverlayEl.style.display = 'none';
|
||||
syncPlaceholderResizeHandles();
|
||||
// Drop any in-progress edit without touching annotState — clearAnnotations
|
||||
// Drop any in-progress edit without touching annotState - clearAnnotations
|
||||
// (if the caller is exiting configure mode) handles state reset.
|
||||
annotEditing = null;
|
||||
}
|
||||
@@ -488,7 +489,7 @@
|
||||
function onAnnotDown(e) {
|
||||
if (!annotActive) return;
|
||||
|
||||
// 0) Insert placeholder edge resize — wins over draw / pins.
|
||||
// 0) Insert placeholder edge resize - wins over draw / pins.
|
||||
const resizeEdge = e.target.closest?.('[data-impeccable-placeholder-resize]')?.dataset.impeccablePlaceholderResize;
|
||||
if (resizeEdge && configureKind === 'insert' && placeholderElement) {
|
||||
startPlaceholderEdgeResize(resizeEdge, e);
|
||||
@@ -536,7 +537,7 @@
|
||||
// If editing a different pin, commit that edit before starting here.
|
||||
if (annotEditing && annotEditing.idx !== idx) finalizeEditingPin();
|
||||
// If already editing THIS pin and the user clicked the dot, let the
|
||||
// input keep focus (don't start a drag — the click wasn't meant as one).
|
||||
// input keep focus (don't start a drag - the click wasn't meant as one).
|
||||
if (annotEditing && annotEditing.idx === idx) return;
|
||||
const p = localCoords(e);
|
||||
const pin = annotState.comments[idx];
|
||||
@@ -971,7 +972,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The Bar — one floating element, three modes
|
||||
// The Bar - one floating element, three modes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Contextual-bar palette. Cached at init so every build*Row reads a
|
||||
@@ -1752,7 +1753,7 @@
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
});
|
||||
|
||||
// Action pill — dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill)
|
||||
// Action pill - dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill)
|
||||
const pill = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
||||
padding: '5px 10px', borderRadius: '6px',
|
||||
@@ -1786,7 +1787,7 @@
|
||||
});
|
||||
row.appendChild(pill);
|
||||
|
||||
// Prompt field — same chat-surface chrome as the bottom Steer bar
|
||||
// Prompt field - same chat-surface chrome as the bottom Steer bar
|
||||
const inputWrap = el('div', {
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
flex: '1', minWidth: '0', height: '28px',
|
||||
@@ -2126,7 +2127,7 @@
|
||||
if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
|
||||
row.appendChild(next);
|
||||
|
||||
// Tune chip — only when the visible variant exposes params
|
||||
// Tune chip - only when the visible variant exposes params
|
||||
const visParams = parseVariantParams(getVisibleVariantEl());
|
||||
const hasParams = visParams.length > 0;
|
||||
if (hasParams) {
|
||||
@@ -2173,7 +2174,7 @@
|
||||
// Spacer
|
||||
row.appendChild(el('div', { flex: '1' }));
|
||||
|
||||
// Accept — primary action, kinpaku gold + lacquer-deep (matches demo .live-demo-ctx-accept)
|
||||
// Accept - primary action, kinpaku gold + lacquer-deep (matches demo .live-demo-ctx-accept)
|
||||
const accept = el('button', {
|
||||
padding: '5px 14px', borderRadius: '5px',
|
||||
border: 'none', background: C.brand, color: C.ink,
|
||||
@@ -2266,7 +2267,7 @@
|
||||
const active = i === visibleVariant;
|
||||
// active: solid site-brand kinpaku dot. arrived+inactive: muted neutral.
|
||||
// pending (not yet arrived): faint outline ring. No borders on arrived
|
||||
// dots — the previous "accent ring + ash fill" combo read as noisy
|
||||
// dots - the previous "accent ring + ash fill" combo read as noisy
|
||||
// kinpaku chips, especially when all variants had arrived and every
|
||||
// dot wore an accent ring.
|
||||
const dotBg = active ? C.brand
|
||||
@@ -2456,7 +2457,7 @@
|
||||
let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide)
|
||||
let paramsPanelInner = null; // translating content (carries bg, padding, knobs)
|
||||
let paramsPanelBody = null; // grid holding the knob cells
|
||||
let paramsCurrentValues = {}; // {paramId: value} — mirror of the visible variant's live values
|
||||
let paramsCurrentValues = {}; // {paramId: value} - mirror of the visible variant's live values
|
||||
let tuneOpen = false; // whether the Tune popover is open right now
|
||||
|
||||
// Theme-aware Tune popover. Appears as a drawer that slides out from the
|
||||
@@ -2471,7 +2472,7 @@
|
||||
const P = paramsPanelPalette;
|
||||
|
||||
// Single element, always in the DOM. The slide animation is a CSS mask
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis — no
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis - no
|
||||
// display toggle, no opacity toggle, no transform trickery. The mask
|
||||
// hides everything initially; as it grows, content is revealed from
|
||||
// the bar edge outward.
|
||||
@@ -2494,7 +2495,7 @@
|
||||
transition: 'clip-path 0.44s ' + EASE,
|
||||
|
||||
// Park off-screen until positionParamsPanel places it. These are NOT
|
||||
// in the transition list, so they snap instantly — no fly-in from the
|
||||
// in the transition list, so they snap instantly - no fly-in from the
|
||||
// top-left when first shown.
|
||||
top: '-9999px', left: '-9999px', width: '0',
|
||||
});
|
||||
@@ -2685,7 +2686,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline text editing — makes pure-text descendants of the picked element
|
||||
// Inline text editing - makes pure-text descendants of the picked element
|
||||
// directly contenteditable. Save stages copy edits in the live buffer; the
|
||||
// Apply copy edits dock later asks the AI to apply the staged batch.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -3132,7 +3133,7 @@
|
||||
if (detail.includes('newText cannot contain') || detail.includes('newText cannot be empty')) {
|
||||
showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500);
|
||||
} else {
|
||||
showToast('Save failed — retry or cancel', 4000);
|
||||
showToast('Save failed: retry or cancel', 4000);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3454,19 +3455,19 @@
|
||||
updatePendingCounter(remaining);
|
||||
if (result.failed && result.failed.length > 0) {
|
||||
console.warn('[impeccable] some copy edits failed:', result.failed);
|
||||
showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed — see console', 5000);
|
||||
showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed, see console', 5000);
|
||||
} else {
|
||||
const n = Array.isArray(result.applied) ? result.applied.length : (result.cleared || 0);
|
||||
if (n > 0) {
|
||||
showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500);
|
||||
} else {
|
||||
console.warn('[impeccable] apply returned no verified edits:', result);
|
||||
showToast('No edits applied — see console', 4000);
|
||||
showToast('No edits applied, see console', 4000);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[impeccable] commit failed:', err);
|
||||
showToast('Apply failed — see console', 4000);
|
||||
showToast('Apply failed, see console', 4000);
|
||||
} finally {
|
||||
if (waitForSseCompletion) return;
|
||||
const remainingCount = parseInt(pendingPillEl?.dataset.count || '0', 10) || 0;
|
||||
@@ -3496,7 +3497,7 @@
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[impeccable] discard failed:', err);
|
||||
showToast('Discard failed — see console', 4000);
|
||||
showToast('Discard failed, see console', 4000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3644,7 +3645,7 @@
|
||||
const failedCount = numberOrNull(msg.failedCount) || 0;
|
||||
const appliedCount = numberOrNull(msg.appliedCount) || numberOrNull(msg.cleared) || 0;
|
||||
if (failedCount > 0) {
|
||||
showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed — see console', 5000);
|
||||
showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed, see console', 5000);
|
||||
} else if (appliedCount > 0) {
|
||||
showToast('Applied ' + appliedCount + ' edit' + (appliedCount === 1 ? '' : 's'), 2500);
|
||||
}
|
||||
@@ -3799,7 +3800,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit content badge — floating button at element top-right to enter EDITING mode
|
||||
// Edit content badge - floating button at element top-right to enter EDITING mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initEditBadge() {
|
||||
@@ -3884,7 +3885,7 @@
|
||||
}
|
||||
editBadgeEl.appendChild(btn);
|
||||
} else {
|
||||
// 'editing' — show Cancel + Save separated
|
||||
// 'editing' - show Cancel + Save separated
|
||||
editBadgeEl.innerHTML = '';
|
||||
editBadgeEl.style.gap = '8px';
|
||||
const cancel = document.createElement('button');
|
||||
@@ -4083,7 +4084,7 @@
|
||||
if (!v) continue;
|
||||
setVariantShown(child, v === String(num));
|
||||
}
|
||||
// Unconditional refresh — covers first-reveal (no-op if state isn't
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
// CYCLING yet, the subsequent CYCLING transition triggers its own
|
||||
// refresh) and every cycle step.
|
||||
refreshParamsPanel();
|
||||
@@ -4318,7 +4319,7 @@
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
}, { passive: true, ...sig });
|
||||
|
||||
// Apply target synchronously, not via rAF — racing the browser's
|
||||
// Apply target synchronously, not via rAF - racing the browser's
|
||||
// restore or a smooth-scroll animation means we want to win now.
|
||||
if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) {
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
@@ -4545,18 +4546,34 @@
|
||||
// hidden tab, a route the user navigated away from). The variant
|
||||
// MutationObserver stays armed and auto-transitions to CYCLING
|
||||
// the moment the wrapper actually mounts. Nudge the user toward
|
||||
// that path with a toast — better than the prior force-reload
|
||||
// that path with a toast - better than the prior force-reload
|
||||
// which reset framework state and left the session stuck.
|
||||
setTimeout(() => {
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) return;
|
||||
if (state !== 'GENERATING') return;
|
||||
showToast(
|
||||
"Variants ready. If the picked element isn't visible, retrace the path that revealed it — they'll appear automatically.",
|
||||
"Variants ready. If the picked element isn't visible, retrace the path that revealed it; they'll appear automatically.",
|
||||
15000,
|
||||
);
|
||||
}, 2000);
|
||||
break;
|
||||
case 'complete':
|
||||
case 'accept':
|
||||
if (maybeCompleteAcceptedSession(msg)) break;
|
||||
break;
|
||||
case 'agent_done':
|
||||
// Carbonize accepts are not terminal until live-complete.mjs sends
|
||||
// the final complete event. Keep the browser in its recoverable
|
||||
// saving state while the source cleanup is still in flight.
|
||||
break;
|
||||
case 'error':
|
||||
if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) {
|
||||
pendingAcceptedSession = null;
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
break;
|
||||
}
|
||||
if (maybeCompleteSteer(msg)) break;
|
||||
console.error('[impeccable] Error:', msg.message);
|
||||
showToast('Error: ' + msg.message, 5000);
|
||||
@@ -4779,14 +4796,14 @@
|
||||
|
||||
/**
|
||||
* Surface a brief, non-blocking heads-up when the picked element lives
|
||||
* inside a container whose visibility is gated by ephemeral state — modals,
|
||||
* inside a container whose visibility is gated by ephemeral state - modals,
|
||||
* collapsible panels, popovers, off-screen tab panels. If HMR remounts the
|
||||
* parent during generation (Vite Fast Refresh, SvelteKit page reload), the
|
||||
* variants land in source but stay invisible until the user re-opens the
|
||||
* container. Telling the user upfront is much friendlier than the silent
|
||||
* timeout-then-toast that they'd otherwise hit.
|
||||
*
|
||||
* Heuristic, intentionally narrow — only fires for unambiguous cases so
|
||||
* Heuristic, intentionally narrow - only fires for unambiguous cases so
|
||||
* we don't cry wolf on every nested element.
|
||||
*/
|
||||
function maybeWarnConditionalAncestor(el) {
|
||||
@@ -4804,7 +4821,7 @@
|
||||
showToast('Heads up: this element lives inside an open panel. If state resets during generation, you may need to re-open it.', 6000);
|
||||
return;
|
||||
}
|
||||
// 3. Tab panel — only meaningful when the page also shows ANOTHER
|
||||
// 3. Tab panel - only meaningful when the page also shows ANOTHER
|
||||
// tab as selected. A single tabpanel with no tablist is just a static
|
||||
// section in disguise and isn't conditional.
|
||||
if (node.getAttribute && node.getAttribute('role') === 'tabpanel') {
|
||||
@@ -4833,7 +4850,7 @@
|
||||
// Fire a lightweight prefetch event the first time the user selects an
|
||||
// element on a given route. The agent uses this to Read the underlying file
|
||||
// into context before Go is hit, shaving the read off the critical path.
|
||||
// Dedupe per session by pathname — clicking around on the same page doesn't
|
||||
// Dedupe per session by pathname - clicking around on the same page doesn't
|
||||
// re-fire.
|
||||
//
|
||||
// DISABLED: quick-Go workflows pay an extra harness round trip because
|
||||
@@ -4969,6 +4986,7 @@
|
||||
disableInlineEdit();
|
||||
stripManualEditRuntimeState(selectedElement);
|
||||
|
||||
pendingAcceptedSession = null;
|
||||
currentSessionId = id8();
|
||||
expectedVariants = selectedCount;
|
||||
arrivedVariants = 0;
|
||||
@@ -4976,7 +4994,7 @@
|
||||
|
||||
// Flip to GENERATING immediately so the bar morphs without waiting on
|
||||
// capture + upload. The event is emitted from captureAndEmit() once the
|
||||
// screenshot is uploaded (or capture fails — we still emit, just without
|
||||
// screenshot is uploaded (or capture fails - we still emit, just without
|
||||
// screenshotPath).
|
||||
const elForCapture = selectedElement;
|
||||
const captureRect = elForCapture.getBoundingClientRect();
|
||||
@@ -5002,7 +5020,7 @@
|
||||
state = 'GENERATING';
|
||||
// Disable the Edit badge: starting a manual text edit mid-generation would
|
||||
// conflict with the variant wrap that's about to land in the same DOM
|
||||
// region. Only swap if the badge was visible — picked elements with no
|
||||
// region. Only swap if the badge was visible - picked elements with no
|
||||
// text rows have it hidden already.
|
||||
if (editBadgeEl && editBadgeEl.style.display !== 'none') renderEditBadge('idle-disabled');
|
||||
showBar('generating');
|
||||
@@ -5104,7 +5122,7 @@
|
||||
|
||||
// Collect @font-face rules from every stylesheet on the page. Cross-origin
|
||||
// sheets (Google Fonts, Typekit, etc.) throw SecurityError on .cssRules
|
||||
// access, so modern-screenshot can't embed them on its own — the resulting
|
||||
// access, so modern-screenshot can't embed them on its own - the resulting
|
||||
// SVG falls back to system fonts and text re-wraps + renders with different
|
||||
// weight. We fetch the raw CSS text (CORS-permitted for these providers),
|
||||
// extract @font-face blocks, inline the referenced font files as base64
|
||||
@@ -5188,7 +5206,7 @@
|
||||
// modern-screenshot force-sets `background-color: X !important` on the
|
||||
// cloned root whenever `backgroundColor` is passed, clobbering the
|
||||
// element's own background. So we only pass it when the element is
|
||||
// genuinely transparent (no own color, no own image) — in that case
|
||||
// genuinely transparent (no own color, no own image) - in that case
|
||||
// we resolve up the DOM to the nearest opaque ancestor so the capture
|
||||
// sits on the page's real background instead of rendering black.
|
||||
function resolveCanvasBackground(el) {
|
||||
@@ -5206,7 +5224,7 @@
|
||||
// `getComputedStyle(body).backgroundColor || …` chain is a trap: that
|
||||
// call returns the literal string `"rgba(0, 0, 0, 0)"` for a page that
|
||||
// never set its own bg, which is truthy and short-circuits the chain to
|
||||
// transparent-black — modern-screenshot then renders the capture on a
|
||||
// transparent-black - modern-screenshot then renders the capture on a
|
||||
// black canvas and the shader overlay flashes solid black during load.
|
||||
// The browser canvas defaults to white, so we do too.
|
||||
return '#ffffff';
|
||||
@@ -5248,7 +5266,7 @@
|
||||
// Transparent up to the root. The visible backdrop may still come from an
|
||||
// ancestor's background-image or a covering positioned layer (e.g. a hero
|
||||
// art div) that the color walk can't see. Capture that ancestor and crop
|
||||
// to the element so the real backdrop is embedded — correct for both the
|
||||
// to the element so the real backdrop is embedded - correct for both the
|
||||
// shader and the screenshot sent to the model. Fall back to white only
|
||||
// when nothing is actually painted behind the element.
|
||||
const backdrop = findBackdropAncestor(el);
|
||||
@@ -5289,13 +5307,13 @@
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] capture failed, proceeding without screenshot:', err);
|
||||
}
|
||||
// Light up the shader overlay the moment capture is ready — no reason to
|
||||
// Light up the shader overlay the moment capture is ready - no reason to
|
||||
// wait for the upload to complete before the user sees something alive.
|
||||
if (blob && state === 'GENERATING') {
|
||||
showShaderOverlay(el, blob, rect, paper);
|
||||
}
|
||||
// Only upload + forward the screenshot when annotations (comments/strokes)
|
||||
// are present. Without annotations the image is pure visual anchoring —
|
||||
// are present. Without annotations the image is pure visual anchoring -
|
||||
// it biases the model toward the current rendering and works against the
|
||||
// three-distinct-directions brief.
|
||||
const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0);
|
||||
@@ -5320,7 +5338,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shader overlay — renders the captured screenshot as a WebGL texture and
|
||||
// Shader overlay - renders the captured screenshot as a WebGL texture and
|
||||
// runs an editorial "ink-wash" fragment shader over it during generation.
|
||||
// A single rolling band sweeps top-to-bottom, desaturating + tinting kinpaku
|
||||
// and leaving a soft trail. Makes the wait feel like a letterpress scan
|
||||
@@ -5343,7 +5361,7 @@ uniform vec3 u_accent;
|
||||
uniform vec3 u_paper;
|
||||
varying vec2 v_uv;
|
||||
|
||||
// Asymmetric roller band. Product of two one-sided smoothsteps — peaks at
|
||||
// Asymmetric roller band. Product of two one-sided smoothsteps - peaks at
|
||||
// d=0 with a short sharp leading ramp and a longer soft trailing tail. Clean
|
||||
// outside the [-leadW, trailW] range (no rogue "trail=1 everywhere below"
|
||||
// failure that reversed-edge smoothstep would give).
|
||||
@@ -5369,8 +5387,8 @@ void main() {
|
||||
vec2 sampleCenter = (cellId + 0.5) * cellPx / u_resolution;
|
||||
vec3 cellImg = texture2D(u_texture, sampleCenter).rgb;
|
||||
// Dot size tracks how much the cell DIFFERS from the element's own ground
|
||||
// (u_paper), not absolute darkness. So the content — text, buttons, anything
|
||||
// that deviates from the background — always becomes the dots, on light AND
|
||||
// (u_paper), not absolute darkness. So the content - text, buttons, anything
|
||||
// that deviates from the background - always becomes the dots, on light AND
|
||||
// dark surfaces. A plain darkness curve inverts on dark elements: the dark
|
||||
// background fills with ink and the lighter content punches holes instead.
|
||||
// Capped below the cell half-width so dense content stays separated dots.
|
||||
@@ -5380,8 +5398,8 @@ void main() {
|
||||
// Two-stage dissolve as the roller passes, so the element is rebuilt purely
|
||||
// from dot size (its own halftone) and never bleeds through as raw pixels
|
||||
// behind the dots:
|
||||
// 1. cover — the element flattens to the uniform paper ground first.
|
||||
// 2. dotAmt — kinpaku dots then emerge, sized by each cell's luma.
|
||||
// 1. cover - the element flattens to the uniform paper ground first.
|
||||
// 2. dotAmt - kinpaku dots then emerge, sized by each cell's luma.
|
||||
// A plain mix(base, halftone, band) instead left the raw element visible
|
||||
// through the band's soft core/trail. The paper ground is u_paper (the
|
||||
// element's own bg tone) rather than a fixed white, so the dissolve reads the
|
||||
@@ -5399,7 +5417,7 @@ void main() {
|
||||
|
||||
// Kinpaku gold converted to approximate sRGB 0-1 (matches oklch(84% 0.19 80.46))
|
||||
const SHADER_ACCENT = [1.0, 0.78, 0.31];
|
||||
// Fallback ground when an element and all its ancestors are transparent —
|
||||
// Fallback ground when an element and all its ancestors are transparent -
|
||||
// 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 }
|
||||
@@ -5412,7 +5430,7 @@ void main() {
|
||||
// Rasterize any CSS color (oklch, color(), named, hex, rgb) through a 1x1
|
||||
// canvas and read back the sRGB pixel. String-parsing computed colors is a
|
||||
// trap: Chrome returns backgroundColor as oklch()/color() for oklch inputs,
|
||||
// which a hex/rgb regex misses — every site token would fall back to white.
|
||||
// which a hex/rgb regex misses - every site token would fall back to white.
|
||||
let colorParseCtx = null;
|
||||
function cssColorToRgb01(str) {
|
||||
if (!colorParseCtx) {
|
||||
@@ -5440,7 +5458,7 @@ void main() {
|
||||
|
||||
// When an element is transparent up to the root, its visible backdrop can
|
||||
// still come from an ancestor's background-image or a covering positioned
|
||||
// layer that is a *child* of an ancestor (e.g. a hero's absolute art div) —
|
||||
// layer that is a *child* of an ancestor (e.g. a hero's absolute art div) -
|
||||
// neither of which the ancestor background-COLOR walk can see. Return the
|
||||
// nearest such ancestor so we can capture it and crop, embedding the real
|
||||
// backdrop. Returns null when nothing is actually painted behind the element
|
||||
@@ -5481,7 +5499,7 @@ void main() {
|
||||
|
||||
// Average the backdrop sampled just OUTSIDE an element's rect within a larger
|
||||
// canvas. The ground tone for the dissolve must be the real backdrop, not the
|
||||
// mean of the element's own crop — averaging the crop folds in the element's
|
||||
// mean of the element's own crop - averaging the crop folds in the element's
|
||||
// content (e.g. bright heading text), pulling the ground toward muddy gray.
|
||||
function sampleSurroundingRgb(ctx, sx, sy, sw, sh, W, H) {
|
||||
const pad = Math.max(2, Math.round(Math.min(sw, sh) * 0.12));
|
||||
@@ -5528,11 +5546,31 @@ void main() {
|
||||
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;
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
canvas.remove();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const fallback = document.createElement('div');
|
||||
fallback.id = PREFIX + '-shader';
|
||||
// Copy positioning via cssText. Object.assign across CSSStyleDeclaration
|
||||
// throws in modern Chromium because the source's indexed properties
|
||||
// (style[0], [1], ...) are read-only and the engine forbids writing
|
||||
// them on the destination.
|
||||
fallback.style.cssText = canvas.style.cssText;
|
||||
fallback.style.backgroundImage = 'url("' + objectUrl + '")';
|
||||
fallback.style.backgroundSize = '100% 100%';
|
||||
fallback.style.backgroundRepeat = 'no-repeat';
|
||||
fallback.style.outline = '2px dashed ' + C.brand;
|
||||
fallback.style.outlineOffset = '-2px';
|
||||
document.body.appendChild(fallback);
|
||||
shaderState = { canvas: fallback, gl: null, program: null, texture: null, rafId: 0, startTime: 0, objectUrl };
|
||||
}
|
||||
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
@@ -5553,21 +5591,9 @@ void main() {
|
||||
const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false })
|
||||
|| canvas.getContext('experimental-webgl');
|
||||
if (!gl) {
|
||||
// WebGL unavailable — fall back to a plain <img> overlay so the user
|
||||
// still sees something meaningful during generation.
|
||||
canvas.remove();
|
||||
const img = document.createElement('img');
|
||||
img.src = URL.createObjectURL(blob);
|
||||
img.id = PREFIX + '-shader';
|
||||
// Copy positioning via cssText. Object.assign across CSSStyleDeclaration
|
||||
// throws in modern Chromium because the source's indexed properties
|
||||
// (style[0], [1], ...) are read-only and the engine forbids writing
|
||||
// them on the destination.
|
||||
img.style.cssText = canvas.style.cssText;
|
||||
img.style.outline = '2px dashed ' + C.brand;
|
||||
img.style.outlineOffset = '-2px';
|
||||
document.body.appendChild(img);
|
||||
shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 };
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5609,14 +5635,12 @@ void main() {
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch {
|
||||
// Safari fallback: go via a regular Image
|
||||
const imgUrl = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
img.src = imgUrl;
|
||||
await new Promise((r, rej) => { img.onload = r; img.onerror = rej; });
|
||||
bitmap = img;
|
||||
URL.revokeObjectURL(imgUrl);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
@@ -5670,14 +5694,15 @@ void main() {
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
// The accepted variant is already the only visible child of the wrapper
|
||||
// (all other variants are display:none). HMR from the source rewrite will
|
||||
// replace the wrapper imminently. Don't eagerly replaceChild here — React
|
||||
// reconciliation races with our mutation and throws NotFoundError in Next
|
||||
// 16 / Turbopack. Schedule a fallback that runs the manual swap only if
|
||||
// HMR hasn't cleaned up by then (keeps static-server flows working).
|
||||
const acceptedSessionId = currentSessionId;
|
||||
const acceptedVariant = visibleVariant;
|
||||
const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant);
|
||||
pendingAcceptedSession = {
|
||||
id: acceptedSessionId,
|
||||
variant: String(acceptedVariant),
|
||||
...acceptedSnapshot,
|
||||
finalizing: false,
|
||||
};
|
||||
|
||||
state = 'SAVING';
|
||||
updateBarContent('saving');
|
||||
@@ -5685,53 +5710,130 @@ void main() {
|
||||
sendEvent(acceptPayload, { throwOnError: true })
|
||||
.then(() => {
|
||||
markSessionHandled();
|
||||
confirmAcceptAfterReceipt();
|
||||
})
|
||||
.catch(() => {
|
||||
pendingAcceptedSession = null;
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
}
|
||||
|
||||
function confirmAcceptAfterReceipt() {
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
scheduleAcceptCleanup();
|
||||
function maybeCompleteAcceptedSession(msg) {
|
||||
const pending = pendingAcceptedSession;
|
||||
if (!pending || !msg?.id || msg.id !== pending.id) return false;
|
||||
if (currentSessionId && currentSessionId !== pending.id) {
|
||||
pendingAcceptedSession = null;
|
||||
return false;
|
||||
}
|
||||
if (pending.finalizing) return true;
|
||||
pending.finalizing = true;
|
||||
|
||||
function scheduleAcceptCleanup() {
|
||||
setTimeout(function() {
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
selectedElement = null;
|
||||
currentSessionId = null;
|
||||
selectedAction = 'impeccable';
|
||||
renderEditBadge('hidden');
|
||||
state = 'PICKING';
|
||||
}, 1800);
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
|
||||
// Static-server / no-HMR fallback: if the wrapper is still around 2s after
|
||||
// the cleanup above, swap it out manually. By now React has either moved
|
||||
// on or the app isn't React at all. Preserve the `data-impeccable-variant="N"`
|
||||
// div (with display:contents) so @scope rules anchored to the variant
|
||||
// attribute keep matching until reload replaces it with the carbonize block.
|
||||
// Give framework HMR a short chance to render the now-clean accepted
|
||||
// source. If it misses the update, unwrap the accepted variant after the
|
||||
// source-side completion event so the page is not left empty or stale.
|
||||
setTimeout(function() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + acceptedSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + acceptedVariant + '"]');
|
||||
if (accepted && accepted.firstElementChild) {
|
||||
const parent = wrapper.parentElement;
|
||||
if (!parent) return;
|
||||
accepted.style.display = 'contents';
|
||||
parent.replaceChild(accepted, wrapper);
|
||||
}
|
||||
}, 2000);
|
||||
ensureAcceptedDomClean(pending);
|
||||
cleanupAcceptedSession();
|
||||
}, 1200);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
acceptedHtml: accepted ? accepted.innerHTML : '',
|
||||
acceptedSelector: selectorForAcceptedRoot(root),
|
||||
parentElement: wrapper?.parentElement || null,
|
||||
parentSelector: selectorForAcceptedRoot(wrapper?.parentElement || null),
|
||||
nextSibling: wrapper?.nextSibling || null,
|
||||
};
|
||||
}
|
||||
|
||||
function selectorForAcceptedRoot(root) {
|
||||
if (!root || !root.tagName) return '';
|
||||
const tag = root.tagName.toLowerCase();
|
||||
const classes = [...(root.classList || [])].filter(Boolean);
|
||||
if (classes.length === 0) return tag;
|
||||
return tag + classes.map((cls) => '.' + cssIdent(cls)).join('');
|
||||
}
|
||||
|
||||
function acceptedDomAlreadyClean(pending) {
|
||||
if (!pending?.acceptedSelector) return false;
|
||||
const matches = [...document.querySelectorAll(pending.acceptedSelector)];
|
||||
return matches.some((el) => !el.closest('[data-impeccable-variants],[data-impeccable-variant]'));
|
||||
}
|
||||
|
||||
function ensureAcceptedDomClean(pending) {
|
||||
const sessionId = pending?.id;
|
||||
const variantId = pending?.variant;
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!wrapper) {
|
||||
restoreAcceptedDomFromSnapshot(pending);
|
||||
return;
|
||||
}
|
||||
if (!accepted) {
|
||||
wrapper.remove();
|
||||
restoreAcceptedDomFromSnapshot(pending);
|
||||
return;
|
||||
}
|
||||
const parent = wrapper.parentElement;
|
||||
if (!parent) return;
|
||||
while (accepted.firstChild) {
|
||||
parent.insertBefore(accepted.firstChild, wrapper);
|
||||
}
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
function restoreAcceptedDomFromSnapshot(pending) {
|
||||
if (acceptedDomAlreadyClean(pending)) return;
|
||||
if (!pending?.acceptedHtml) {
|
||||
reloadAfterMissingAcceptedDom(pending);
|
||||
return;
|
||||
}
|
||||
const parent = pending.parentElement?.isConnected
|
||||
? pending.parentElement
|
||||
: (pending.parentSelector ? document.querySelector(pending.parentSelector) : null);
|
||||
if (!parent) {
|
||||
reloadAfterMissingAcceptedDom(pending);
|
||||
return;
|
||||
}
|
||||
const template = document.createElement('template');
|
||||
template.innerHTML = pending.acceptedHtml;
|
||||
const anchor = pending.nextSibling?.isConnected && pending.nextSibling.parentElement === parent
|
||||
? pending.nextSibling
|
||||
: null;
|
||||
parent.insertBefore(template.content, anchor);
|
||||
if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending);
|
||||
}
|
||||
|
||||
function reloadAfterMissingAcceptedDom(pending) {
|
||||
if (acceptedDomAlreadyClean(pending)) return;
|
||||
if (pending?.id && document.querySelector('[data-impeccable-variants="' + pending.id + '"]')) return;
|
||||
location.reload();
|
||||
}
|
||||
|
||||
function cleanupAcceptedSession() {
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
selectedElement = null;
|
||||
currentSessionId = null;
|
||||
selectedAction = 'impeccable';
|
||||
pendingAcceptedSession = null;
|
||||
renderEditBadge('hidden');
|
||||
state = 'PICKING';
|
||||
}
|
||||
|
||||
function handleDiscard() {
|
||||
@@ -5792,7 +5894,7 @@ void main() {
|
||||
|
||||
function cleanup() {
|
||||
// Hide the wrapper immediately so variants disappear. DON'T structurally
|
||||
// mutate the DOM yet — HMR from the agent's source rewrite is on its way,
|
||||
// mutate the DOM yet - HMR from the agent's source rewrite is on its way,
|
||||
// and a manual replaceChild under React causes NotFoundError when the
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
@@ -5838,8 +5940,8 @@ void main() {
|
||||
function showToast(message, duration) {
|
||||
if (toastEl) toastEl.remove();
|
||||
// Stack the toast above the global bar (which sits at bottom:14px) so
|
||||
// the two never overlap. Read the bar's actual rect — its height varies
|
||||
// with hover-expanded labels — and fall back to a sensible default
|
||||
// the two never overlap. Read the bar's actual rect - its height varies
|
||||
// with hover-expanded labels - and fall back to a sensible default
|
||||
// when the bar isn't mounted yet.
|
||||
const barRect = globalBarEl?.getBoundingClientRect();
|
||||
const barTopFromBottom = barRect && barRect.height > 0
|
||||
@@ -6047,7 +6149,7 @@ void main() {
|
||||
let pendingApplyInFlight = false;
|
||||
let firstSaveOfSession = true;
|
||||
|
||||
// Steer — collapsed pill in the global bar; expands while typing for page-level chat.
|
||||
// Steer - collapsed pill in the global bar; expands while typing for page-level chat.
|
||||
let pageChatEl = null;
|
||||
let pageChatInput = null;
|
||||
let pageChatHint = null;
|
||||
@@ -6068,7 +6170,7 @@ void main() {
|
||||
const STEER_AWAIT_TIMEOUT_MS = 120000;
|
||||
const AGENT_STATUS_POLL_MS = 5000;
|
||||
const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)';
|
||||
const AGENT_DISCONNECTED_TIP = 'Agent disconnected — run live-poll.mjs to connect';
|
||||
const AGENT_DISCONNECTED_TIP = 'Agent disconnected: run live-poll.mjs to connect';
|
||||
const GLOBAL_BAR_SECTION_GAP = 8;
|
||||
const GLOBAL_BAR_INNER_GAP = 2;
|
||||
const GLOBAL_BAR_INNER_PAD_LEFT = 2;
|
||||
@@ -6079,7 +6181,7 @@ void main() {
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>';
|
||||
|
||||
// Theme-aware color palette for the global bar. We detect the page's
|
||||
// ambient background and invert — dark bar on light pages, light bar on
|
||||
// ambient background and invert - dark bar on light pages, light bar on
|
||||
// dark pages. This keeps the bar from fighting with the host design.
|
||||
function detectPageTheme() {
|
||||
try {
|
||||
@@ -6092,7 +6194,7 @@ void main() {
|
||||
// Walk body → html, taking the first opaque background. The browser's
|
||||
// default body / html background is `rgba(0, 0, 0, 0)`, which a naive
|
||||
// regex would read as black and mislabel a perfectly white page as
|
||||
// dark. Honoring alpha avoids that — and falling through to <html>
|
||||
// dark. Honoring alpha avoids that - and falling through to <html>
|
||||
// catches the common pattern of a bg only on <html> (or only on body).
|
||||
function readOpaque(el) {
|
||||
if (!el) return null;
|
||||
@@ -6141,7 +6243,7 @@ void main() {
|
||||
exitHover: 'oklch(58% 0.15 35 / 0.18)',
|
||||
shadow: PICKER_SHADOW,
|
||||
chatSurface: 'oklch(22% 0.012 82)',
|
||||
// Verdigris patina — secondary state (see site/styles/kinpaku-tokens.css)
|
||||
// Verdigris patina - secondary state (see site/styles/kinpaku-tokens.css)
|
||||
patina: 'oklch(70% 0.12 188)',
|
||||
patinaPale: 'oklch(82% 0.07 188)',
|
||||
patinaSoft: 'oklch(70% 0.12 188 / 0.28)',
|
||||
@@ -6913,7 +7015,7 @@ void main() {
|
||||
steerFocusLog('page-chat-mounted', {});
|
||||
}
|
||||
|
||||
// Impeccable mark — same paths as site/components/Header.astro + favicon.svg.
|
||||
// Impeccable mark - same paths as site/components/Header.astro + favicon.svg.
|
||||
function brandMarkSvg(color = C.brand, size = 18) {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="${color}" aria-hidden="true">
|
||||
<path d="M5 2.5 L13.5 2.5 L5.5 21.5 L5 21.5 Q2.5 21.5 2.5 19 L2.5 5 Q2.5 2.5 5 2.5 Z"/>
|
||||
@@ -6928,7 +7030,7 @@ void main() {
|
||||
globalBarBrandEl.dataset.agentConnected = connected ? 'true' : 'false';
|
||||
globalBarBrandEl.setAttribute('aria-label', connected
|
||||
? 'Impeccable live mode'
|
||||
: 'Impeccable live mode — agent not polling');
|
||||
: 'Impeccable live mode: agent not polling');
|
||||
globalBarBrandEl.removeAttribute('title');
|
||||
globalBarBrandEl.style.cursor = connected ? 'default' : 'help';
|
||||
const mark = globalBarBrandEl.querySelector('[data-brand-mark]');
|
||||
@@ -7053,7 +7155,7 @@ void main() {
|
||||
globalBarEl.id = PREFIX + '-global-bar';
|
||||
globalBarEl.dataset.theme = theme;
|
||||
|
||||
// Brand mark — kinpaku Impeccable icon (site header / favicon paths).
|
||||
// Brand mark - kinpaku Impeccable icon (site header / favicon paths).
|
||||
const brand = el('span', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
alignSelf: 'stretch', position: 'relative',
|
||||
@@ -7065,7 +7167,7 @@ void main() {
|
||||
brand.id = PREFIX + '-global-bar-brand';
|
||||
brand.dataset.agentConnected = 'false';
|
||||
brand.setAttribute('role', 'img');
|
||||
brand.setAttribute('aria-label', 'Impeccable live mode — agent not polling');
|
||||
brand.setAttribute('aria-label', 'Impeccable live mode: agent not polling');
|
||||
|
||||
const brandMark = el('span', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
@@ -7130,7 +7232,7 @@ void main() {
|
||||
// Per-button hover only changes color (no layout). The label expand/
|
||||
// collapse is driven by the bar-level mouseenter/mouseleave so moving
|
||||
// the mouse between adjacent buttons doesn't trigger per-button width
|
||||
// thrashing — the whole bar grows once and shrinks once.
|
||||
// thrashing - the whole bar grows once and shrinks once.
|
||||
b.addEventListener('mouseenter', () => { if (b.dataset.active !== 'true') b.style.color = P.text; });
|
||||
b.addEventListener('mouseleave', () => { if (b.dataset.active !== 'true') b.style.color = P.textDim; });
|
||||
b.addEventListener('click', onClick);
|
||||
@@ -7139,7 +7241,7 @@ void main() {
|
||||
return b;
|
||||
}
|
||||
|
||||
// Pick toggle — restored from localStorage; both pick and insert may be off.
|
||||
// Pick toggle - restored from localStorage; both pick and insert may be off.
|
||||
const pickBtn = makeIconBtn({
|
||||
id: PREFIX + '-pick-toggle',
|
||||
svg: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><circle cx="12" cy="12" r="10"/><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><line x1="12" y1="6" x2="12" y2="2"/><line x1="12" y1="22" x2="12" y2="18"/></svg>',
|
||||
@@ -7176,7 +7278,7 @@ void main() {
|
||||
detectBtn.appendChild(detectBadge);
|
||||
inner.appendChild(detectBtn);
|
||||
|
||||
// DESIGN.md panel toggle — quartet of color squares as the mark.
|
||||
// DESIGN.md panel toggle - quartet of color squares as the mark.
|
||||
const designBtn = makeIconBtn({
|
||||
id: PREFIX + '-design-toggle',
|
||||
svg: `<span style="display:inline-grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;width:14px;height:14px;border-radius:3px;overflow:hidden;box-shadow:inset 0 0 0 1px oklch(58% 0.065 82 / 0.55);flex-shrink:0">
|
||||
@@ -7378,13 +7480,13 @@ void main() {
|
||||
});
|
||||
inner.appendChild(divider);
|
||||
|
||||
// Exit × on the right — intentionally subtle (textDim at rest, text on
|
||||
// Exit × on the right - intentionally subtle (textDim at rest, text on
|
||||
// hover) so it sits behind the active toggles in visual hierarchy.
|
||||
//
|
||||
// Explicit padding + box-sizing here is load-bearing: a host page like
|
||||
// `button { padding: 0.5rem 1rem; }` (very common in resets) would
|
||||
// otherwise inflate this 24x24 button into 56x40 and push the SVG out
|
||||
// of the visible bar — the X stays invisible even though the styles in
|
||||
// of the visible bar - the X stays invisible even though the styles in
|
||||
// DevTools look fine. Every other chrome button sets padding inline;
|
||||
// this one needed it too.
|
||||
const exitBtn = el('button', {
|
||||
@@ -7472,7 +7574,7 @@ void main() {
|
||||
btn.style.opacity = controlsLocked ? '0.55' : '1';
|
||||
});
|
||||
|
||||
// If the bar is currently under the cursor, keep all labels expanded —
|
||||
// If the bar is currently under the cursor, keep all labels expanded -
|
||||
// otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
|
||||
// would collapse its label while the user's mouse is still on the bar.
|
||||
if (globalBarEl && globalBarEl.matches(':hover')) {
|
||||
@@ -7648,7 +7750,7 @@ void main() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Design System Panel — visualizes the project's .impeccable/design.json sidecar
|
||||
// Design System Panel - visualizes the project's .impeccable/design.json sidecar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DESIGN_PREFS_KEY = 'impeccable-live-design-panel';
|
||||
@@ -7674,7 +7776,7 @@ void main() {
|
||||
};
|
||||
|
||||
function loadDesignPrefs() {
|
||||
// `open` is intentionally NOT persisted — the panel always starts closed
|
||||
// `open` is intentionally NOT persisted - the panel always starts closed
|
||||
// so live mode doesn't auto-slide a big panel over the page on startup.
|
||||
try {
|
||||
const raw = localStorage.getItem(DESIGN_PREFS_KEY);
|
||||
@@ -7731,7 +7833,7 @@ void main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Neutral panel palette — deliberately NOT Impeccable-branded. The panel is
|
||||
// Neutral panel palette - deliberately NOT Impeccable-branded. The panel is
|
||||
// a viewer of the project's design system, not an Impeccable surface.
|
||||
const DP = {
|
||||
canvas: 'oklch(94% 0 0)', // panel background
|
||||
@@ -8025,7 +8127,7 @@ void main() {
|
||||
const root = designShadow.querySelector('.root');
|
||||
root.innerHTML = '';
|
||||
|
||||
// (Panel toggle lives in the global bar — no floating FAB.)
|
||||
// (Panel toggle lives in the global bar - no floating FAB.)
|
||||
// Panel
|
||||
const panel = document.createElement('aside');
|
||||
panel.className = 'panel';
|
||||
@@ -8141,7 +8243,7 @@ void main() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Visual tab — single unified render path.
|
||||
// Visual tab - single unified render path.
|
||||
if (designState.mdNewerThanJson) body.appendChild(renderStaleHint());
|
||||
if (designState.hasMd && !designState.hasSidecar) {
|
||||
body.appendChild(renderParsedMdCta());
|
||||
@@ -8357,7 +8459,7 @@ void main() {
|
||||
specimen.style.fontFamily = fontStack(t);
|
||||
specimen.style.fontWeight = String(t.weight || 400);
|
||||
specimen.style.fontStyle = t.style || 'normal';
|
||||
specimen.style.fontSize = '56px'; // Fixed specimen size — compare faces, not scales.
|
||||
specimen.style.fontSize = '56px'; // Fixed specimen size - compare faces, not scales.
|
||||
specimen.style.letterSpacing = 'normal';
|
||||
specimen.style.textTransform = 'none';
|
||||
tile.appendChild(specimen);
|
||||
@@ -8501,7 +8603,7 @@ void main() {
|
||||
}
|
||||
|
||||
// Single shared description if all items carry the same one; otherwise
|
||||
// skip — per-item descriptions clutter a grouped tile.
|
||||
// skip - per-item descriptions clutter a grouped tile.
|
||||
if (group.length === 1 && group[0].description) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'c-desc';
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Impeccable Live Variant Mode — Browser Script
|
||||
* Impeccable Live Variant Mode - Browser Script
|
||||
*
|
||||
* Injected into the user's page via <script src="http://localhost:PORT/live.js">.
|
||||
* The server prepends window.__IMPECCABLE_TOKEN__ and window.__IMPECCABLE_PORT__
|
||||
* before this code.
|
||||
*
|
||||
* UI: a single floating bar that morphs between three states —
|
||||
* UI: a single floating bar that morphs between three states -
|
||||
* configure (pick action + go), generating (progressive dots), and cycling
|
||||
* (prev/next + accept/discard). Feels like Spotlight, not a modal.
|
||||
*/
|
||||
@@ -46,7 +46,7 @@
|
||||
mist: 'oklch(90% 0.008 82 / 0.6)', // light hairline
|
||||
white: 'oklch(99% 0 0)',
|
||||
};
|
||||
// Picker bar chrome — mirrors .live-demo-gbar / .live-demo-ctx in kinpaku-kit.css.
|
||||
// Picker bar chrome - mirrors .live-demo-gbar / .live-demo-ctx in kinpaku-kit.css.
|
||||
// Quiet neutral elevation: no gold halo ring (gold is reserved for the brand
|
||||
// mark and the active control, not the container outline).
|
||||
const PICKER_SHADOW =
|
||||
@@ -83,7 +83,7 @@
|
||||
|
||||
// SVG icons stack above each chip label. All strokes use currentColor so the
|
||||
// icon recolors to C.brand when its chip is selected. 20x20 render, 24-viewBox,
|
||||
// 1.5 stroke — visually consistent with the Foundation grid on the homepage.
|
||||
// 1.5 stroke - visually consistent with the Foundation grid on the homepage.
|
||||
const ICON_ATTRS = 'width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="display:block"';
|
||||
const ICONS = {
|
||||
impeccable: `<svg ${ICON_ATTRS}><path d="M4 20l4-1L18 9l-3-3L5 16z"/><path d="M14 7l3 3"/></svg>`,
|
||||
@@ -123,6 +123,7 @@
|
||||
let hoveredElement = null;
|
||||
let selectedElement = null;
|
||||
let currentSessionId = null;
|
||||
let pendingAcceptedSession = null;
|
||||
let expectedVariants = 0;
|
||||
let arrivedVariants = 0;
|
||||
let visibleVariant = 0;
|
||||
@@ -133,7 +134,7 @@
|
||||
const browserOwner = sessionState.owner;
|
||||
let checkpointTimer = null;
|
||||
|
||||
// Scroll lock — holds window.scrollY at a fixed value while the session is
|
||||
// Scroll lock - holds window.scrollY at a fixed value while the session is
|
||||
// active, so HMR DOM patches and variant swaps can't drift the page. See
|
||||
// startScrollLock / stopScrollLock below.
|
||||
let scrollLockObserver = null;
|
||||
@@ -141,7 +142,7 @@
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
// Dedicated key for scroll position — SEPARATE from LS_KEY so that
|
||||
// Dedicated key for scroll position - SEPARATE from LS_KEY so that
|
||||
// saveSession's state updates don't clobber a carefully-captured scrollY.
|
||||
// (Previously: saveSession wrote scrollY alongside state, so every call
|
||||
// during resume overwrote the pre-reload value with whatever the browser
|
||||
@@ -231,7 +232,7 @@
|
||||
// - Stop `focusin` propagation so any focus shifts inside our chrome
|
||||
// don't read as "focus moved outside the dialog" to focus traps.
|
||||
//
|
||||
// Click events still bubble normally — only the early pointer/focus
|
||||
// Click events still bubble normally - only the early pointer/focus
|
||||
// signals that drive outside-interaction detection are silenced.
|
||||
function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) {
|
||||
if (!rootEl) return;
|
||||
@@ -319,7 +320,7 @@
|
||||
// correlate directly with the captured PNG.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DRAG_THRESHOLD = 5; // px — below this, treat pointerup as a click
|
||||
const DRAG_THRESHOLD = 5; // px - below this, treat pointerup as a click
|
||||
const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it
|
||||
let annotOverlayEl = null;
|
||||
let annotSvgEl = null;
|
||||
@@ -423,7 +424,7 @@
|
||||
placeholderResizeDrag = null;
|
||||
if (annotOverlayEl) annotOverlayEl.style.display = 'none';
|
||||
syncPlaceholderResizeHandles();
|
||||
// Drop any in-progress edit without touching annotState — clearAnnotations
|
||||
// Drop any in-progress edit without touching annotState - clearAnnotations
|
||||
// (if the caller is exiting configure mode) handles state reset.
|
||||
annotEditing = null;
|
||||
}
|
||||
@@ -488,7 +489,7 @@
|
||||
function onAnnotDown(e) {
|
||||
if (!annotActive) return;
|
||||
|
||||
// 0) Insert placeholder edge resize — wins over draw / pins.
|
||||
// 0) Insert placeholder edge resize - wins over draw / pins.
|
||||
const resizeEdge = e.target.closest?.('[data-impeccable-placeholder-resize]')?.dataset.impeccablePlaceholderResize;
|
||||
if (resizeEdge && configureKind === 'insert' && placeholderElement) {
|
||||
startPlaceholderEdgeResize(resizeEdge, e);
|
||||
@@ -536,7 +537,7 @@
|
||||
// If editing a different pin, commit that edit before starting here.
|
||||
if (annotEditing && annotEditing.idx !== idx) finalizeEditingPin();
|
||||
// If already editing THIS pin and the user clicked the dot, let the
|
||||
// input keep focus (don't start a drag — the click wasn't meant as one).
|
||||
// input keep focus (don't start a drag - the click wasn't meant as one).
|
||||
if (annotEditing && annotEditing.idx === idx) return;
|
||||
const p = localCoords(e);
|
||||
const pin = annotState.comments[idx];
|
||||
@@ -971,7 +972,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The Bar — one floating element, three modes
|
||||
// The Bar - one floating element, three modes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Contextual-bar palette. Cached at init so every build*Row reads a
|
||||
@@ -1752,7 +1753,7 @@
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
});
|
||||
|
||||
// Action pill — dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill)
|
||||
// Action pill - dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill)
|
||||
const pill = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
||||
padding: '5px 10px', borderRadius: '6px',
|
||||
@@ -1786,7 +1787,7 @@
|
||||
});
|
||||
row.appendChild(pill);
|
||||
|
||||
// Prompt field — same chat-surface chrome as the bottom Steer bar
|
||||
// Prompt field - same chat-surface chrome as the bottom Steer bar
|
||||
const inputWrap = el('div', {
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
flex: '1', minWidth: '0', height: '28px',
|
||||
@@ -2126,7 +2127,7 @@
|
||||
if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
|
||||
row.appendChild(next);
|
||||
|
||||
// Tune chip — only when the visible variant exposes params
|
||||
// Tune chip - only when the visible variant exposes params
|
||||
const visParams = parseVariantParams(getVisibleVariantEl());
|
||||
const hasParams = visParams.length > 0;
|
||||
if (hasParams) {
|
||||
@@ -2173,7 +2174,7 @@
|
||||
// Spacer
|
||||
row.appendChild(el('div', { flex: '1' }));
|
||||
|
||||
// Accept — primary action, kinpaku gold + lacquer-deep (matches demo .live-demo-ctx-accept)
|
||||
// Accept - primary action, kinpaku gold + lacquer-deep (matches demo .live-demo-ctx-accept)
|
||||
const accept = el('button', {
|
||||
padding: '5px 14px', borderRadius: '5px',
|
||||
border: 'none', background: C.brand, color: C.ink,
|
||||
@@ -2266,7 +2267,7 @@
|
||||
const active = i === visibleVariant;
|
||||
// active: solid site-brand kinpaku dot. arrived+inactive: muted neutral.
|
||||
// pending (not yet arrived): faint outline ring. No borders on arrived
|
||||
// dots — the previous "accent ring + ash fill" combo read as noisy
|
||||
// dots - the previous "accent ring + ash fill" combo read as noisy
|
||||
// kinpaku chips, especially when all variants had arrived and every
|
||||
// dot wore an accent ring.
|
||||
const dotBg = active ? C.brand
|
||||
@@ -2456,7 +2457,7 @@
|
||||
let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide)
|
||||
let paramsPanelInner = null; // translating content (carries bg, padding, knobs)
|
||||
let paramsPanelBody = null; // grid holding the knob cells
|
||||
let paramsCurrentValues = {}; // {paramId: value} — mirror of the visible variant's live values
|
||||
let paramsCurrentValues = {}; // {paramId: value} - mirror of the visible variant's live values
|
||||
let tuneOpen = false; // whether the Tune popover is open right now
|
||||
|
||||
// Theme-aware Tune popover. Appears as a drawer that slides out from the
|
||||
@@ -2471,7 +2472,7 @@
|
||||
const P = paramsPanelPalette;
|
||||
|
||||
// Single element, always in the DOM. The slide animation is a CSS mask
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis — no
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis - no
|
||||
// display toggle, no opacity toggle, no transform trickery. The mask
|
||||
// hides everything initially; as it grows, content is revealed from
|
||||
// the bar edge outward.
|
||||
@@ -2494,7 +2495,7 @@
|
||||
transition: 'clip-path 0.44s ' + EASE,
|
||||
|
||||
// Park off-screen until positionParamsPanel places it. These are NOT
|
||||
// in the transition list, so they snap instantly — no fly-in from the
|
||||
// in the transition list, so they snap instantly - no fly-in from the
|
||||
// top-left when first shown.
|
||||
top: '-9999px', left: '-9999px', width: '0',
|
||||
});
|
||||
@@ -2685,7 +2686,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline text editing — makes pure-text descendants of the picked element
|
||||
// Inline text editing - makes pure-text descendants of the picked element
|
||||
// directly contenteditable. Save stages copy edits in the live buffer; the
|
||||
// Apply copy edits dock later asks the AI to apply the staged batch.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -3132,7 +3133,7 @@
|
||||
if (detail.includes('newText cannot contain') || detail.includes('newText cannot be empty')) {
|
||||
showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500);
|
||||
} else {
|
||||
showToast('Save failed — retry or cancel', 4000);
|
||||
showToast('Save failed: retry or cancel', 4000);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3454,19 +3455,19 @@
|
||||
updatePendingCounter(remaining);
|
||||
if (result.failed && result.failed.length > 0) {
|
||||
console.warn('[impeccable] some copy edits failed:', result.failed);
|
||||
showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed — see console', 5000);
|
||||
showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed, see console', 5000);
|
||||
} else {
|
||||
const n = Array.isArray(result.applied) ? result.applied.length : (result.cleared || 0);
|
||||
if (n > 0) {
|
||||
showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500);
|
||||
} else {
|
||||
console.warn('[impeccable] apply returned no verified edits:', result);
|
||||
showToast('No edits applied — see console', 4000);
|
||||
showToast('No edits applied, see console', 4000);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[impeccable] commit failed:', err);
|
||||
showToast('Apply failed — see console', 4000);
|
||||
showToast('Apply failed, see console', 4000);
|
||||
} finally {
|
||||
if (waitForSseCompletion) return;
|
||||
const remainingCount = parseInt(pendingPillEl?.dataset.count || '0', 10) || 0;
|
||||
@@ -3496,7 +3497,7 @@
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[impeccable] discard failed:', err);
|
||||
showToast('Discard failed — see console', 4000);
|
||||
showToast('Discard failed, see console', 4000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3644,7 +3645,7 @@
|
||||
const failedCount = numberOrNull(msg.failedCount) || 0;
|
||||
const appliedCount = numberOrNull(msg.appliedCount) || numberOrNull(msg.cleared) || 0;
|
||||
if (failedCount > 0) {
|
||||
showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed — see console', 5000);
|
||||
showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed, see console', 5000);
|
||||
} else if (appliedCount > 0) {
|
||||
showToast('Applied ' + appliedCount + ' edit' + (appliedCount === 1 ? '' : 's'), 2500);
|
||||
}
|
||||
@@ -3799,7 +3800,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit content badge — floating button at element top-right to enter EDITING mode
|
||||
// Edit content badge - floating button at element top-right to enter EDITING mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initEditBadge() {
|
||||
@@ -3884,7 +3885,7 @@
|
||||
}
|
||||
editBadgeEl.appendChild(btn);
|
||||
} else {
|
||||
// 'editing' — show Cancel + Save separated
|
||||
// 'editing' - show Cancel + Save separated
|
||||
editBadgeEl.innerHTML = '';
|
||||
editBadgeEl.style.gap = '8px';
|
||||
const cancel = document.createElement('button');
|
||||
@@ -4083,7 +4084,7 @@
|
||||
if (!v) continue;
|
||||
setVariantShown(child, v === String(num));
|
||||
}
|
||||
// Unconditional refresh — covers first-reveal (no-op if state isn't
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
// CYCLING yet, the subsequent CYCLING transition triggers its own
|
||||
// refresh) and every cycle step.
|
||||
refreshParamsPanel();
|
||||
@@ -4318,7 +4319,7 @@
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
}, { passive: true, ...sig });
|
||||
|
||||
// Apply target synchronously, not via rAF — racing the browser's
|
||||
// Apply target synchronously, not via rAF - racing the browser's
|
||||
// restore or a smooth-scroll animation means we want to win now.
|
||||
if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) {
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
@@ -4545,18 +4546,34 @@
|
||||
// hidden tab, a route the user navigated away from). The variant
|
||||
// MutationObserver stays armed and auto-transitions to CYCLING
|
||||
// the moment the wrapper actually mounts. Nudge the user toward
|
||||
// that path with a toast — better than the prior force-reload
|
||||
// that path with a toast - better than the prior force-reload
|
||||
// which reset framework state and left the session stuck.
|
||||
setTimeout(() => {
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) return;
|
||||
if (state !== 'GENERATING') return;
|
||||
showToast(
|
||||
"Variants ready. If the picked element isn't visible, retrace the path that revealed it — they'll appear automatically.",
|
||||
"Variants ready. If the picked element isn't visible, retrace the path that revealed it; they'll appear automatically.",
|
||||
15000,
|
||||
);
|
||||
}, 2000);
|
||||
break;
|
||||
case 'complete':
|
||||
case 'accept':
|
||||
if (maybeCompleteAcceptedSession(msg)) break;
|
||||
break;
|
||||
case 'agent_done':
|
||||
// Carbonize accepts are not terminal until live-complete.mjs sends
|
||||
// the final complete event. Keep the browser in its recoverable
|
||||
// saving state while the source cleanup is still in flight.
|
||||
break;
|
||||
case 'error':
|
||||
if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) {
|
||||
pendingAcceptedSession = null;
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
break;
|
||||
}
|
||||
if (maybeCompleteSteer(msg)) break;
|
||||
console.error('[impeccable] Error:', msg.message);
|
||||
showToast('Error: ' + msg.message, 5000);
|
||||
@@ -4779,14 +4796,14 @@
|
||||
|
||||
/**
|
||||
* Surface a brief, non-blocking heads-up when the picked element lives
|
||||
* inside a container whose visibility is gated by ephemeral state — modals,
|
||||
* inside a container whose visibility is gated by ephemeral state - modals,
|
||||
* collapsible panels, popovers, off-screen tab panels. If HMR remounts the
|
||||
* parent during generation (Vite Fast Refresh, SvelteKit page reload), the
|
||||
* variants land in source but stay invisible until the user re-opens the
|
||||
* container. Telling the user upfront is much friendlier than the silent
|
||||
* timeout-then-toast that they'd otherwise hit.
|
||||
*
|
||||
* Heuristic, intentionally narrow — only fires for unambiguous cases so
|
||||
* Heuristic, intentionally narrow - only fires for unambiguous cases so
|
||||
* we don't cry wolf on every nested element.
|
||||
*/
|
||||
function maybeWarnConditionalAncestor(el) {
|
||||
@@ -4804,7 +4821,7 @@
|
||||
showToast('Heads up: this element lives inside an open panel. If state resets during generation, you may need to re-open it.', 6000);
|
||||
return;
|
||||
}
|
||||
// 3. Tab panel — only meaningful when the page also shows ANOTHER
|
||||
// 3. Tab panel - only meaningful when the page also shows ANOTHER
|
||||
// tab as selected. A single tabpanel with no tablist is just a static
|
||||
// section in disguise and isn't conditional.
|
||||
if (node.getAttribute && node.getAttribute('role') === 'tabpanel') {
|
||||
@@ -4833,7 +4850,7 @@
|
||||
// Fire a lightweight prefetch event the first time the user selects an
|
||||
// element on a given route. The agent uses this to Read the underlying file
|
||||
// into context before Go is hit, shaving the read off the critical path.
|
||||
// Dedupe per session by pathname — clicking around on the same page doesn't
|
||||
// Dedupe per session by pathname - clicking around on the same page doesn't
|
||||
// re-fire.
|
||||
//
|
||||
// DISABLED: quick-Go workflows pay an extra harness round trip because
|
||||
@@ -4969,6 +4986,7 @@
|
||||
disableInlineEdit();
|
||||
stripManualEditRuntimeState(selectedElement);
|
||||
|
||||
pendingAcceptedSession = null;
|
||||
currentSessionId = id8();
|
||||
expectedVariants = selectedCount;
|
||||
arrivedVariants = 0;
|
||||
@@ -4976,7 +4994,7 @@
|
||||
|
||||
// Flip to GENERATING immediately so the bar morphs without waiting on
|
||||
// capture + upload. The event is emitted from captureAndEmit() once the
|
||||
// screenshot is uploaded (or capture fails — we still emit, just without
|
||||
// screenshot is uploaded (or capture fails - we still emit, just without
|
||||
// screenshotPath).
|
||||
const elForCapture = selectedElement;
|
||||
const captureRect = elForCapture.getBoundingClientRect();
|
||||
@@ -5002,7 +5020,7 @@
|
||||
state = 'GENERATING';
|
||||
// Disable the Edit badge: starting a manual text edit mid-generation would
|
||||
// conflict with the variant wrap that's about to land in the same DOM
|
||||
// region. Only swap if the badge was visible — picked elements with no
|
||||
// region. Only swap if the badge was visible - picked elements with no
|
||||
// text rows have it hidden already.
|
||||
if (editBadgeEl && editBadgeEl.style.display !== 'none') renderEditBadge('idle-disabled');
|
||||
showBar('generating');
|
||||
@@ -5104,7 +5122,7 @@
|
||||
|
||||
// Collect @font-face rules from every stylesheet on the page. Cross-origin
|
||||
// sheets (Google Fonts, Typekit, etc.) throw SecurityError on .cssRules
|
||||
// access, so modern-screenshot can't embed them on its own — the resulting
|
||||
// access, so modern-screenshot can't embed them on its own - the resulting
|
||||
// SVG falls back to system fonts and text re-wraps + renders with different
|
||||
// weight. We fetch the raw CSS text (CORS-permitted for these providers),
|
||||
// extract @font-face blocks, inline the referenced font files as base64
|
||||
@@ -5188,7 +5206,7 @@
|
||||
// modern-screenshot force-sets `background-color: X !important` on the
|
||||
// cloned root whenever `backgroundColor` is passed, clobbering the
|
||||
// element's own background. So we only pass it when the element is
|
||||
// genuinely transparent (no own color, no own image) — in that case
|
||||
// genuinely transparent (no own color, no own image) - in that case
|
||||
// we resolve up the DOM to the nearest opaque ancestor so the capture
|
||||
// sits on the page's real background instead of rendering black.
|
||||
function resolveCanvasBackground(el) {
|
||||
@@ -5206,7 +5224,7 @@
|
||||
// `getComputedStyle(body).backgroundColor || …` chain is a trap: that
|
||||
// call returns the literal string `"rgba(0, 0, 0, 0)"` for a page that
|
||||
// never set its own bg, which is truthy and short-circuits the chain to
|
||||
// transparent-black — modern-screenshot then renders the capture on a
|
||||
// transparent-black - modern-screenshot then renders the capture on a
|
||||
// black canvas and the shader overlay flashes solid black during load.
|
||||
// The browser canvas defaults to white, so we do too.
|
||||
return '#ffffff';
|
||||
@@ -5248,7 +5266,7 @@
|
||||
// Transparent up to the root. The visible backdrop may still come from an
|
||||
// ancestor's background-image or a covering positioned layer (e.g. a hero
|
||||
// art div) that the color walk can't see. Capture that ancestor and crop
|
||||
// to the element so the real backdrop is embedded — correct for both the
|
||||
// to the element so the real backdrop is embedded - correct for both the
|
||||
// shader and the screenshot sent to the model. Fall back to white only
|
||||
// when nothing is actually painted behind the element.
|
||||
const backdrop = findBackdropAncestor(el);
|
||||
@@ -5289,13 +5307,13 @@
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] capture failed, proceeding without screenshot:', err);
|
||||
}
|
||||
// Light up the shader overlay the moment capture is ready — no reason to
|
||||
// Light up the shader overlay the moment capture is ready - no reason to
|
||||
// wait for the upload to complete before the user sees something alive.
|
||||
if (blob && state === 'GENERATING') {
|
||||
showShaderOverlay(el, blob, rect, paper);
|
||||
}
|
||||
// Only upload + forward the screenshot when annotations (comments/strokes)
|
||||
// are present. Without annotations the image is pure visual anchoring —
|
||||
// are present. Without annotations the image is pure visual anchoring -
|
||||
// it biases the model toward the current rendering and works against the
|
||||
// three-distinct-directions brief.
|
||||
const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0);
|
||||
@@ -5320,7 +5338,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shader overlay — renders the captured screenshot as a WebGL texture and
|
||||
// Shader overlay - renders the captured screenshot as a WebGL texture and
|
||||
// runs an editorial "ink-wash" fragment shader over it during generation.
|
||||
// A single rolling band sweeps top-to-bottom, desaturating + tinting kinpaku
|
||||
// and leaving a soft trail. Makes the wait feel like a letterpress scan
|
||||
@@ -5343,7 +5361,7 @@ uniform vec3 u_accent;
|
||||
uniform vec3 u_paper;
|
||||
varying vec2 v_uv;
|
||||
|
||||
// Asymmetric roller band. Product of two one-sided smoothsteps — peaks at
|
||||
// Asymmetric roller band. Product of two one-sided smoothsteps - peaks at
|
||||
// d=0 with a short sharp leading ramp and a longer soft trailing tail. Clean
|
||||
// outside the [-leadW, trailW] range (no rogue "trail=1 everywhere below"
|
||||
// failure that reversed-edge smoothstep would give).
|
||||
@@ -5369,8 +5387,8 @@ void main() {
|
||||
vec2 sampleCenter = (cellId + 0.5) * cellPx / u_resolution;
|
||||
vec3 cellImg = texture2D(u_texture, sampleCenter).rgb;
|
||||
// Dot size tracks how much the cell DIFFERS from the element's own ground
|
||||
// (u_paper), not absolute darkness. So the content — text, buttons, anything
|
||||
// that deviates from the background — always becomes the dots, on light AND
|
||||
// (u_paper), not absolute darkness. So the content - text, buttons, anything
|
||||
// that deviates from the background - always becomes the dots, on light AND
|
||||
// dark surfaces. A plain darkness curve inverts on dark elements: the dark
|
||||
// background fills with ink and the lighter content punches holes instead.
|
||||
// Capped below the cell half-width so dense content stays separated dots.
|
||||
@@ -5380,8 +5398,8 @@ void main() {
|
||||
// Two-stage dissolve as the roller passes, so the element is rebuilt purely
|
||||
// from dot size (its own halftone) and never bleeds through as raw pixels
|
||||
// behind the dots:
|
||||
// 1. cover — the element flattens to the uniform paper ground first.
|
||||
// 2. dotAmt — kinpaku dots then emerge, sized by each cell's luma.
|
||||
// 1. cover - the element flattens to the uniform paper ground first.
|
||||
// 2. dotAmt - kinpaku dots then emerge, sized by each cell's luma.
|
||||
// A plain mix(base, halftone, band) instead left the raw element visible
|
||||
// through the band's soft core/trail. The paper ground is u_paper (the
|
||||
// element's own bg tone) rather than a fixed white, so the dissolve reads the
|
||||
@@ -5399,7 +5417,7 @@ void main() {
|
||||
|
||||
// Kinpaku gold converted to approximate sRGB 0-1 (matches oklch(84% 0.19 80.46))
|
||||
const SHADER_ACCENT = [1.0, 0.78, 0.31];
|
||||
// Fallback ground when an element and all its ancestors are transparent —
|
||||
// Fallback ground when an element and all its ancestors are transparent -
|
||||
// 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 }
|
||||
@@ -5412,7 +5430,7 @@ void main() {
|
||||
// Rasterize any CSS color (oklch, color(), named, hex, rgb) through a 1x1
|
||||
// canvas and read back the sRGB pixel. String-parsing computed colors is a
|
||||
// trap: Chrome returns backgroundColor as oklch()/color() for oklch inputs,
|
||||
// which a hex/rgb regex misses — every site token would fall back to white.
|
||||
// which a hex/rgb regex misses - every site token would fall back to white.
|
||||
let colorParseCtx = null;
|
||||
function cssColorToRgb01(str) {
|
||||
if (!colorParseCtx) {
|
||||
@@ -5440,7 +5458,7 @@ void main() {
|
||||
|
||||
// When an element is transparent up to the root, its visible backdrop can
|
||||
// still come from an ancestor's background-image or a covering positioned
|
||||
// layer that is a *child* of an ancestor (e.g. a hero's absolute art div) —
|
||||
// layer that is a *child* of an ancestor (e.g. a hero's absolute art div) -
|
||||
// neither of which the ancestor background-COLOR walk can see. Return the
|
||||
// nearest such ancestor so we can capture it and crop, embedding the real
|
||||
// backdrop. Returns null when nothing is actually painted behind the element
|
||||
@@ -5481,7 +5499,7 @@ void main() {
|
||||
|
||||
// Average the backdrop sampled just OUTSIDE an element's rect within a larger
|
||||
// canvas. The ground tone for the dissolve must be the real backdrop, not the
|
||||
// mean of the element's own crop — averaging the crop folds in the element's
|
||||
// mean of the element's own crop - averaging the crop folds in the element's
|
||||
// content (e.g. bright heading text), pulling the ground toward muddy gray.
|
||||
function sampleSurroundingRgb(ctx, sx, sy, sw, sh, W, H) {
|
||||
const pad = Math.max(2, Math.round(Math.min(sw, sh) * 0.12));
|
||||
@@ -5528,11 +5546,31 @@ void main() {
|
||||
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;
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
canvas.remove();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const fallback = document.createElement('div');
|
||||
fallback.id = PREFIX + '-shader';
|
||||
// Copy positioning via cssText. Object.assign across CSSStyleDeclaration
|
||||
// throws in modern Chromium because the source's indexed properties
|
||||
// (style[0], [1], ...) are read-only and the engine forbids writing
|
||||
// them on the destination.
|
||||
fallback.style.cssText = canvas.style.cssText;
|
||||
fallback.style.backgroundImage = 'url("' + objectUrl + '")';
|
||||
fallback.style.backgroundSize = '100% 100%';
|
||||
fallback.style.backgroundRepeat = 'no-repeat';
|
||||
fallback.style.outline = '2px dashed ' + C.brand;
|
||||
fallback.style.outlineOffset = '-2px';
|
||||
document.body.appendChild(fallback);
|
||||
shaderState = { canvas: fallback, gl: null, program: null, texture: null, rafId: 0, startTime: 0, objectUrl };
|
||||
}
|
||||
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
@@ -5553,21 +5591,9 @@ void main() {
|
||||
const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false })
|
||||
|| canvas.getContext('experimental-webgl');
|
||||
if (!gl) {
|
||||
// WebGL unavailable — fall back to a plain <img> overlay so the user
|
||||
// still sees something meaningful during generation.
|
||||
canvas.remove();
|
||||
const img = document.createElement('img');
|
||||
img.src = URL.createObjectURL(blob);
|
||||
img.id = PREFIX + '-shader';
|
||||
// Copy positioning via cssText. Object.assign across CSSStyleDeclaration
|
||||
// throws in modern Chromium because the source's indexed properties
|
||||
// (style[0], [1], ...) are read-only and the engine forbids writing
|
||||
// them on the destination.
|
||||
img.style.cssText = canvas.style.cssText;
|
||||
img.style.outline = '2px dashed ' + C.brand;
|
||||
img.style.outlineOffset = '-2px';
|
||||
document.body.appendChild(img);
|
||||
shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 };
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5609,14 +5635,12 @@ void main() {
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch {
|
||||
// Safari fallback: go via a regular Image
|
||||
const imgUrl = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
img.src = imgUrl;
|
||||
await new Promise((r, rej) => { img.onload = r; img.onerror = rej; });
|
||||
bitmap = img;
|
||||
URL.revokeObjectURL(imgUrl);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
@@ -5670,14 +5694,15 @@ void main() {
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
// The accepted variant is already the only visible child of the wrapper
|
||||
// (all other variants are display:none). HMR from the source rewrite will
|
||||
// replace the wrapper imminently. Don't eagerly replaceChild here — React
|
||||
// reconciliation races with our mutation and throws NotFoundError in Next
|
||||
// 16 / Turbopack. Schedule a fallback that runs the manual swap only if
|
||||
// HMR hasn't cleaned up by then (keeps static-server flows working).
|
||||
const acceptedSessionId = currentSessionId;
|
||||
const acceptedVariant = visibleVariant;
|
||||
const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant);
|
||||
pendingAcceptedSession = {
|
||||
id: acceptedSessionId,
|
||||
variant: String(acceptedVariant),
|
||||
...acceptedSnapshot,
|
||||
finalizing: false,
|
||||
};
|
||||
|
||||
state = 'SAVING';
|
||||
updateBarContent('saving');
|
||||
@@ -5685,53 +5710,130 @@ void main() {
|
||||
sendEvent(acceptPayload, { throwOnError: true })
|
||||
.then(() => {
|
||||
markSessionHandled();
|
||||
confirmAcceptAfterReceipt();
|
||||
})
|
||||
.catch(() => {
|
||||
pendingAcceptedSession = null;
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
}
|
||||
|
||||
function confirmAcceptAfterReceipt() {
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
scheduleAcceptCleanup();
|
||||
function maybeCompleteAcceptedSession(msg) {
|
||||
const pending = pendingAcceptedSession;
|
||||
if (!pending || !msg?.id || msg.id !== pending.id) return false;
|
||||
if (currentSessionId && currentSessionId !== pending.id) {
|
||||
pendingAcceptedSession = null;
|
||||
return false;
|
||||
}
|
||||
if (pending.finalizing) return true;
|
||||
pending.finalizing = true;
|
||||
|
||||
function scheduleAcceptCleanup() {
|
||||
setTimeout(function() {
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
selectedElement = null;
|
||||
currentSessionId = null;
|
||||
selectedAction = 'impeccable';
|
||||
renderEditBadge('hidden');
|
||||
state = 'PICKING';
|
||||
}, 1800);
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
|
||||
// Static-server / no-HMR fallback: if the wrapper is still around 2s after
|
||||
// the cleanup above, swap it out manually. By now React has either moved
|
||||
// on or the app isn't React at all. Preserve the `data-impeccable-variant="N"`
|
||||
// div (with display:contents) so @scope rules anchored to the variant
|
||||
// attribute keep matching until reload replaces it with the carbonize block.
|
||||
// Give framework HMR a short chance to render the now-clean accepted
|
||||
// source. If it misses the update, unwrap the accepted variant after the
|
||||
// source-side completion event so the page is not left empty or stale.
|
||||
setTimeout(function() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + acceptedSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + acceptedVariant + '"]');
|
||||
if (accepted && accepted.firstElementChild) {
|
||||
const parent = wrapper.parentElement;
|
||||
if (!parent) return;
|
||||
accepted.style.display = 'contents';
|
||||
parent.replaceChild(accepted, wrapper);
|
||||
}
|
||||
}, 2000);
|
||||
ensureAcceptedDomClean(pending);
|
||||
cleanupAcceptedSession();
|
||||
}, 1200);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
acceptedHtml: accepted ? accepted.innerHTML : '',
|
||||
acceptedSelector: selectorForAcceptedRoot(root),
|
||||
parentElement: wrapper?.parentElement || null,
|
||||
parentSelector: selectorForAcceptedRoot(wrapper?.parentElement || null),
|
||||
nextSibling: wrapper?.nextSibling || null,
|
||||
};
|
||||
}
|
||||
|
||||
function selectorForAcceptedRoot(root) {
|
||||
if (!root || !root.tagName) return '';
|
||||
const tag = root.tagName.toLowerCase();
|
||||
const classes = [...(root.classList || [])].filter(Boolean);
|
||||
if (classes.length === 0) return tag;
|
||||
return tag + classes.map((cls) => '.' + cssIdent(cls)).join('');
|
||||
}
|
||||
|
||||
function acceptedDomAlreadyClean(pending) {
|
||||
if (!pending?.acceptedSelector) return false;
|
||||
const matches = [...document.querySelectorAll(pending.acceptedSelector)];
|
||||
return matches.some((el) => !el.closest('[data-impeccable-variants],[data-impeccable-variant]'));
|
||||
}
|
||||
|
||||
function ensureAcceptedDomClean(pending) {
|
||||
const sessionId = pending?.id;
|
||||
const variantId = pending?.variant;
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!wrapper) {
|
||||
restoreAcceptedDomFromSnapshot(pending);
|
||||
return;
|
||||
}
|
||||
if (!accepted) {
|
||||
wrapper.remove();
|
||||
restoreAcceptedDomFromSnapshot(pending);
|
||||
return;
|
||||
}
|
||||
const parent = wrapper.parentElement;
|
||||
if (!parent) return;
|
||||
while (accepted.firstChild) {
|
||||
parent.insertBefore(accepted.firstChild, wrapper);
|
||||
}
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
function restoreAcceptedDomFromSnapshot(pending) {
|
||||
if (acceptedDomAlreadyClean(pending)) return;
|
||||
if (!pending?.acceptedHtml) {
|
||||
reloadAfterMissingAcceptedDom(pending);
|
||||
return;
|
||||
}
|
||||
const parent = pending.parentElement?.isConnected
|
||||
? pending.parentElement
|
||||
: (pending.parentSelector ? document.querySelector(pending.parentSelector) : null);
|
||||
if (!parent) {
|
||||
reloadAfterMissingAcceptedDom(pending);
|
||||
return;
|
||||
}
|
||||
const template = document.createElement('template');
|
||||
template.innerHTML = pending.acceptedHtml;
|
||||
const anchor = pending.nextSibling?.isConnected && pending.nextSibling.parentElement === parent
|
||||
? pending.nextSibling
|
||||
: null;
|
||||
parent.insertBefore(template.content, anchor);
|
||||
if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending);
|
||||
}
|
||||
|
||||
function reloadAfterMissingAcceptedDom(pending) {
|
||||
if (acceptedDomAlreadyClean(pending)) return;
|
||||
if (pending?.id && document.querySelector('[data-impeccable-variants="' + pending.id + '"]')) return;
|
||||
location.reload();
|
||||
}
|
||||
|
||||
function cleanupAcceptedSession() {
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
selectedElement = null;
|
||||
currentSessionId = null;
|
||||
selectedAction = 'impeccable';
|
||||
pendingAcceptedSession = null;
|
||||
renderEditBadge('hidden');
|
||||
state = 'PICKING';
|
||||
}
|
||||
|
||||
function handleDiscard() {
|
||||
@@ -5792,7 +5894,7 @@ void main() {
|
||||
|
||||
function cleanup() {
|
||||
// Hide the wrapper immediately so variants disappear. DON'T structurally
|
||||
// mutate the DOM yet — HMR from the agent's source rewrite is on its way,
|
||||
// mutate the DOM yet - HMR from the agent's source rewrite is on its way,
|
||||
// and a manual replaceChild under React causes NotFoundError when the
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
@@ -5838,8 +5940,8 @@ void main() {
|
||||
function showToast(message, duration) {
|
||||
if (toastEl) toastEl.remove();
|
||||
// Stack the toast above the global bar (which sits at bottom:14px) so
|
||||
// the two never overlap. Read the bar's actual rect — its height varies
|
||||
// with hover-expanded labels — and fall back to a sensible default
|
||||
// the two never overlap. Read the bar's actual rect - its height varies
|
||||
// with hover-expanded labels - and fall back to a sensible default
|
||||
// when the bar isn't mounted yet.
|
||||
const barRect = globalBarEl?.getBoundingClientRect();
|
||||
const barTopFromBottom = barRect && barRect.height > 0
|
||||
@@ -6047,7 +6149,7 @@ void main() {
|
||||
let pendingApplyInFlight = false;
|
||||
let firstSaveOfSession = true;
|
||||
|
||||
// Steer — collapsed pill in the global bar; expands while typing for page-level chat.
|
||||
// Steer - collapsed pill in the global bar; expands while typing for page-level chat.
|
||||
let pageChatEl = null;
|
||||
let pageChatInput = null;
|
||||
let pageChatHint = null;
|
||||
@@ -6068,7 +6170,7 @@ void main() {
|
||||
const STEER_AWAIT_TIMEOUT_MS = 120000;
|
||||
const AGENT_STATUS_POLL_MS = 5000;
|
||||
const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)';
|
||||
const AGENT_DISCONNECTED_TIP = 'Agent disconnected — run live-poll.mjs to connect';
|
||||
const AGENT_DISCONNECTED_TIP = 'Agent disconnected: run live-poll.mjs to connect';
|
||||
const GLOBAL_BAR_SECTION_GAP = 8;
|
||||
const GLOBAL_BAR_INNER_GAP = 2;
|
||||
const GLOBAL_BAR_INNER_PAD_LEFT = 2;
|
||||
@@ -6079,7 +6181,7 @@ void main() {
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>';
|
||||
|
||||
// Theme-aware color palette for the global bar. We detect the page's
|
||||
// ambient background and invert — dark bar on light pages, light bar on
|
||||
// ambient background and invert - dark bar on light pages, light bar on
|
||||
// dark pages. This keeps the bar from fighting with the host design.
|
||||
function detectPageTheme() {
|
||||
try {
|
||||
@@ -6092,7 +6194,7 @@ void main() {
|
||||
// Walk body → html, taking the first opaque background. The browser's
|
||||
// default body / html background is `rgba(0, 0, 0, 0)`, which a naive
|
||||
// regex would read as black and mislabel a perfectly white page as
|
||||
// dark. Honoring alpha avoids that — and falling through to <html>
|
||||
// dark. Honoring alpha avoids that - and falling through to <html>
|
||||
// catches the common pattern of a bg only on <html> (or only on body).
|
||||
function readOpaque(el) {
|
||||
if (!el) return null;
|
||||
@@ -6141,7 +6243,7 @@ void main() {
|
||||
exitHover: 'oklch(58% 0.15 35 / 0.18)',
|
||||
shadow: PICKER_SHADOW,
|
||||
chatSurface: 'oklch(22% 0.012 82)',
|
||||
// Verdigris patina — secondary state (see site/styles/kinpaku-tokens.css)
|
||||
// Verdigris patina - secondary state (see site/styles/kinpaku-tokens.css)
|
||||
patina: 'oklch(70% 0.12 188)',
|
||||
patinaPale: 'oklch(82% 0.07 188)',
|
||||
patinaSoft: 'oklch(70% 0.12 188 / 0.28)',
|
||||
@@ -6913,7 +7015,7 @@ void main() {
|
||||
steerFocusLog('page-chat-mounted', {});
|
||||
}
|
||||
|
||||
// Impeccable mark — same paths as site/components/Header.astro + favicon.svg.
|
||||
// Impeccable mark - same paths as site/components/Header.astro + favicon.svg.
|
||||
function brandMarkSvg(color = C.brand, size = 18) {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="${color}" aria-hidden="true">
|
||||
<path d="M5 2.5 L13.5 2.5 L5.5 21.5 L5 21.5 Q2.5 21.5 2.5 19 L2.5 5 Q2.5 2.5 5 2.5 Z"/>
|
||||
@@ -6928,7 +7030,7 @@ void main() {
|
||||
globalBarBrandEl.dataset.agentConnected = connected ? 'true' : 'false';
|
||||
globalBarBrandEl.setAttribute('aria-label', connected
|
||||
? 'Impeccable live mode'
|
||||
: 'Impeccable live mode — agent not polling');
|
||||
: 'Impeccable live mode: agent not polling');
|
||||
globalBarBrandEl.removeAttribute('title');
|
||||
globalBarBrandEl.style.cursor = connected ? 'default' : 'help';
|
||||
const mark = globalBarBrandEl.querySelector('[data-brand-mark]');
|
||||
@@ -7053,7 +7155,7 @@ void main() {
|
||||
globalBarEl.id = PREFIX + '-global-bar';
|
||||
globalBarEl.dataset.theme = theme;
|
||||
|
||||
// Brand mark — kinpaku Impeccable icon (site header / favicon paths).
|
||||
// Brand mark - kinpaku Impeccable icon (site header / favicon paths).
|
||||
const brand = el('span', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
alignSelf: 'stretch', position: 'relative',
|
||||
@@ -7065,7 +7167,7 @@ void main() {
|
||||
brand.id = PREFIX + '-global-bar-brand';
|
||||
brand.dataset.agentConnected = 'false';
|
||||
brand.setAttribute('role', 'img');
|
||||
brand.setAttribute('aria-label', 'Impeccable live mode — agent not polling');
|
||||
brand.setAttribute('aria-label', 'Impeccable live mode: agent not polling');
|
||||
|
||||
const brandMark = el('span', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
@@ -7130,7 +7232,7 @@ void main() {
|
||||
// Per-button hover only changes color (no layout). The label expand/
|
||||
// collapse is driven by the bar-level mouseenter/mouseleave so moving
|
||||
// the mouse between adjacent buttons doesn't trigger per-button width
|
||||
// thrashing — the whole bar grows once and shrinks once.
|
||||
// thrashing - the whole bar grows once and shrinks once.
|
||||
b.addEventListener('mouseenter', () => { if (b.dataset.active !== 'true') b.style.color = P.text; });
|
||||
b.addEventListener('mouseleave', () => { if (b.dataset.active !== 'true') b.style.color = P.textDim; });
|
||||
b.addEventListener('click', onClick);
|
||||
@@ -7139,7 +7241,7 @@ void main() {
|
||||
return b;
|
||||
}
|
||||
|
||||
// Pick toggle — restored from localStorage; both pick and insert may be off.
|
||||
// Pick toggle - restored from localStorage; both pick and insert may be off.
|
||||
const pickBtn = makeIconBtn({
|
||||
id: PREFIX + '-pick-toggle',
|
||||
svg: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><circle cx="12" cy="12" r="10"/><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><line x1="12" y1="6" x2="12" y2="2"/><line x1="12" y1="22" x2="12" y2="18"/></svg>',
|
||||
@@ -7176,7 +7278,7 @@ void main() {
|
||||
detectBtn.appendChild(detectBadge);
|
||||
inner.appendChild(detectBtn);
|
||||
|
||||
// DESIGN.md panel toggle — quartet of color squares as the mark.
|
||||
// DESIGN.md panel toggle - quartet of color squares as the mark.
|
||||
const designBtn = makeIconBtn({
|
||||
id: PREFIX + '-design-toggle',
|
||||
svg: `<span style="display:inline-grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;width:14px;height:14px;border-radius:3px;overflow:hidden;box-shadow:inset 0 0 0 1px oklch(58% 0.065 82 / 0.55);flex-shrink:0">
|
||||
@@ -7378,13 +7480,13 @@ void main() {
|
||||
});
|
||||
inner.appendChild(divider);
|
||||
|
||||
// Exit × on the right — intentionally subtle (textDim at rest, text on
|
||||
// Exit × on the right - intentionally subtle (textDim at rest, text on
|
||||
// hover) so it sits behind the active toggles in visual hierarchy.
|
||||
//
|
||||
// Explicit padding + box-sizing here is load-bearing: a host page like
|
||||
// `button { padding: 0.5rem 1rem; }` (very common in resets) would
|
||||
// otherwise inflate this 24x24 button into 56x40 and push the SVG out
|
||||
// of the visible bar — the X stays invisible even though the styles in
|
||||
// of the visible bar - the X stays invisible even though the styles in
|
||||
// DevTools look fine. Every other chrome button sets padding inline;
|
||||
// this one needed it too.
|
||||
const exitBtn = el('button', {
|
||||
@@ -7472,7 +7574,7 @@ void main() {
|
||||
btn.style.opacity = controlsLocked ? '0.55' : '1';
|
||||
});
|
||||
|
||||
// If the bar is currently under the cursor, keep all labels expanded —
|
||||
// If the bar is currently under the cursor, keep all labels expanded -
|
||||
// otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
|
||||
// would collapse its label while the user's mouse is still on the bar.
|
||||
if (globalBarEl && globalBarEl.matches(':hover')) {
|
||||
@@ -7648,7 +7750,7 @@ void main() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Design System Panel — visualizes the project's .impeccable/design.json sidecar
|
||||
// Design System Panel - visualizes the project's .impeccable/design.json sidecar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DESIGN_PREFS_KEY = 'impeccable-live-design-panel';
|
||||
@@ -7674,7 +7776,7 @@ void main() {
|
||||
};
|
||||
|
||||
function loadDesignPrefs() {
|
||||
// `open` is intentionally NOT persisted — the panel always starts closed
|
||||
// `open` is intentionally NOT persisted - the panel always starts closed
|
||||
// so live mode doesn't auto-slide a big panel over the page on startup.
|
||||
try {
|
||||
const raw = localStorage.getItem(DESIGN_PREFS_KEY);
|
||||
@@ -7731,7 +7833,7 @@ void main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Neutral panel palette — deliberately NOT Impeccable-branded. The panel is
|
||||
// Neutral panel palette - deliberately NOT Impeccable-branded. The panel is
|
||||
// a viewer of the project's design system, not an Impeccable surface.
|
||||
const DP = {
|
||||
canvas: 'oklch(94% 0 0)', // panel background
|
||||
@@ -8025,7 +8127,7 @@ void main() {
|
||||
const root = designShadow.querySelector('.root');
|
||||
root.innerHTML = '';
|
||||
|
||||
// (Panel toggle lives in the global bar — no floating FAB.)
|
||||
// (Panel toggle lives in the global bar - no floating FAB.)
|
||||
// Panel
|
||||
const panel = document.createElement('aside');
|
||||
panel.className = 'panel';
|
||||
@@ -8141,7 +8243,7 @@ void main() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Visual tab — single unified render path.
|
||||
// Visual tab - single unified render path.
|
||||
if (designState.mdNewerThanJson) body.appendChild(renderStaleHint());
|
||||
if (designState.hasMd && !designState.hasSidecar) {
|
||||
body.appendChild(renderParsedMdCta());
|
||||
@@ -8357,7 +8459,7 @@ void main() {
|
||||
specimen.style.fontFamily = fontStack(t);
|
||||
specimen.style.fontWeight = String(t.weight || 400);
|
||||
specimen.style.fontStyle = t.style || 'normal';
|
||||
specimen.style.fontSize = '56px'; // Fixed specimen size — compare faces, not scales.
|
||||
specimen.style.fontSize = '56px'; // Fixed specimen size - compare faces, not scales.
|
||||
specimen.style.letterSpacing = 'normal';
|
||||
specimen.style.textTransform = 'none';
|
||||
tile.appendChild(specimen);
|
||||
@@ -8501,7 +8603,7 @@ void main() {
|
||||
}
|
||||
|
||||
// Single shared description if all items carry the same one; otherwise
|
||||
// skip — per-item descriptions clutter a grouped tile.
|
||||
// skip - per-item descriptions clutter a grouped tile.
|
||||
if (group.length === 1 && group[0].description) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'c-desc';
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Impeccable Live Variant Mode — Browser Script
|
||||
* Impeccable Live Variant Mode - Browser Script
|
||||
*
|
||||
* Injected into the user's page via <script src="http://localhost:PORT/live.js">.
|
||||
* The server prepends window.__IMPECCABLE_TOKEN__ and window.__IMPECCABLE_PORT__
|
||||
* before this code.
|
||||
*
|
||||
* UI: a single floating bar that morphs between three states —
|
||||
* UI: a single floating bar that morphs between three states -
|
||||
* configure (pick action + go), generating (progressive dots), and cycling
|
||||
* (prev/next + accept/discard). Feels like Spotlight, not a modal.
|
||||
*/
|
||||
@@ -46,7 +46,7 @@
|
||||
mist: 'oklch(90% 0.008 82 / 0.6)', // light hairline
|
||||
white: 'oklch(99% 0 0)',
|
||||
};
|
||||
// Picker bar chrome — mirrors .live-demo-gbar / .live-demo-ctx in kinpaku-kit.css.
|
||||
// Picker bar chrome - mirrors .live-demo-gbar / .live-demo-ctx in kinpaku-kit.css.
|
||||
// Quiet neutral elevation: no gold halo ring (gold is reserved for the brand
|
||||
// mark and the active control, not the container outline).
|
||||
const PICKER_SHADOW =
|
||||
@@ -83,7 +83,7 @@
|
||||
|
||||
// SVG icons stack above each chip label. All strokes use currentColor so the
|
||||
// icon recolors to C.brand when its chip is selected. 20x20 render, 24-viewBox,
|
||||
// 1.5 stroke — visually consistent with the Foundation grid on the homepage.
|
||||
// 1.5 stroke - visually consistent with the Foundation grid on the homepage.
|
||||
const ICON_ATTRS = 'width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="display:block"';
|
||||
const ICONS = {
|
||||
impeccable: `<svg ${ICON_ATTRS}><path d="M4 20l4-1L18 9l-3-3L5 16z"/><path d="M14 7l3 3"/></svg>`,
|
||||
@@ -123,6 +123,7 @@
|
||||
let hoveredElement = null;
|
||||
let selectedElement = null;
|
||||
let currentSessionId = null;
|
||||
let pendingAcceptedSession = null;
|
||||
let expectedVariants = 0;
|
||||
let arrivedVariants = 0;
|
||||
let visibleVariant = 0;
|
||||
@@ -133,7 +134,7 @@
|
||||
const browserOwner = sessionState.owner;
|
||||
let checkpointTimer = null;
|
||||
|
||||
// Scroll lock — holds window.scrollY at a fixed value while the session is
|
||||
// Scroll lock - holds window.scrollY at a fixed value while the session is
|
||||
// active, so HMR DOM patches and variant swaps can't drift the page. See
|
||||
// startScrollLock / stopScrollLock below.
|
||||
let scrollLockObserver = null;
|
||||
@@ -141,7 +142,7 @@
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
// Dedicated key for scroll position — SEPARATE from LS_KEY so that
|
||||
// Dedicated key for scroll position - SEPARATE from LS_KEY so that
|
||||
// saveSession's state updates don't clobber a carefully-captured scrollY.
|
||||
// (Previously: saveSession wrote scrollY alongside state, so every call
|
||||
// during resume overwrote the pre-reload value with whatever the browser
|
||||
@@ -231,7 +232,7 @@
|
||||
// - Stop `focusin` propagation so any focus shifts inside our chrome
|
||||
// don't read as "focus moved outside the dialog" to focus traps.
|
||||
//
|
||||
// Click events still bubble normally — only the early pointer/focus
|
||||
// Click events still bubble normally - only the early pointer/focus
|
||||
// signals that drive outside-interaction detection are silenced.
|
||||
function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) {
|
||||
if (!rootEl) return;
|
||||
@@ -319,7 +320,7 @@
|
||||
// correlate directly with the captured PNG.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DRAG_THRESHOLD = 5; // px — below this, treat pointerup as a click
|
||||
const DRAG_THRESHOLD = 5; // px - below this, treat pointerup as a click
|
||||
const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it
|
||||
let annotOverlayEl = null;
|
||||
let annotSvgEl = null;
|
||||
@@ -423,7 +424,7 @@
|
||||
placeholderResizeDrag = null;
|
||||
if (annotOverlayEl) annotOverlayEl.style.display = 'none';
|
||||
syncPlaceholderResizeHandles();
|
||||
// Drop any in-progress edit without touching annotState — clearAnnotations
|
||||
// Drop any in-progress edit without touching annotState - clearAnnotations
|
||||
// (if the caller is exiting configure mode) handles state reset.
|
||||
annotEditing = null;
|
||||
}
|
||||
@@ -488,7 +489,7 @@
|
||||
function onAnnotDown(e) {
|
||||
if (!annotActive) return;
|
||||
|
||||
// 0) Insert placeholder edge resize — wins over draw / pins.
|
||||
// 0) Insert placeholder edge resize - wins over draw / pins.
|
||||
const resizeEdge = e.target.closest?.('[data-impeccable-placeholder-resize]')?.dataset.impeccablePlaceholderResize;
|
||||
if (resizeEdge && configureKind === 'insert' && placeholderElement) {
|
||||
startPlaceholderEdgeResize(resizeEdge, e);
|
||||
@@ -536,7 +537,7 @@
|
||||
// If editing a different pin, commit that edit before starting here.
|
||||
if (annotEditing && annotEditing.idx !== idx) finalizeEditingPin();
|
||||
// If already editing THIS pin and the user clicked the dot, let the
|
||||
// input keep focus (don't start a drag — the click wasn't meant as one).
|
||||
// input keep focus (don't start a drag - the click wasn't meant as one).
|
||||
if (annotEditing && annotEditing.idx === idx) return;
|
||||
const p = localCoords(e);
|
||||
const pin = annotState.comments[idx];
|
||||
@@ -971,7 +972,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The Bar — one floating element, three modes
|
||||
// The Bar - one floating element, three modes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Contextual-bar palette. Cached at init so every build*Row reads a
|
||||
@@ -1752,7 +1753,7 @@
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
});
|
||||
|
||||
// Action pill — dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill)
|
||||
// Action pill - dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill)
|
||||
const pill = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
||||
padding: '5px 10px', borderRadius: '6px',
|
||||
@@ -1786,7 +1787,7 @@
|
||||
});
|
||||
row.appendChild(pill);
|
||||
|
||||
// Prompt field — same chat-surface chrome as the bottom Steer bar
|
||||
// Prompt field - same chat-surface chrome as the bottom Steer bar
|
||||
const inputWrap = el('div', {
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
flex: '1', minWidth: '0', height: '28px',
|
||||
@@ -2126,7 +2127,7 @@
|
||||
if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
|
||||
row.appendChild(next);
|
||||
|
||||
// Tune chip — only when the visible variant exposes params
|
||||
// Tune chip - only when the visible variant exposes params
|
||||
const visParams = parseVariantParams(getVisibleVariantEl());
|
||||
const hasParams = visParams.length > 0;
|
||||
if (hasParams) {
|
||||
@@ -2173,7 +2174,7 @@
|
||||
// Spacer
|
||||
row.appendChild(el('div', { flex: '1' }));
|
||||
|
||||
// Accept — primary action, kinpaku gold + lacquer-deep (matches demo .live-demo-ctx-accept)
|
||||
// Accept - primary action, kinpaku gold + lacquer-deep (matches demo .live-demo-ctx-accept)
|
||||
const accept = el('button', {
|
||||
padding: '5px 14px', borderRadius: '5px',
|
||||
border: 'none', background: C.brand, color: C.ink,
|
||||
@@ -2266,7 +2267,7 @@
|
||||
const active = i === visibleVariant;
|
||||
// active: solid site-brand kinpaku dot. arrived+inactive: muted neutral.
|
||||
// pending (not yet arrived): faint outline ring. No borders on arrived
|
||||
// dots — the previous "accent ring + ash fill" combo read as noisy
|
||||
// dots - the previous "accent ring + ash fill" combo read as noisy
|
||||
// kinpaku chips, especially when all variants had arrived and every
|
||||
// dot wore an accent ring.
|
||||
const dotBg = active ? C.brand
|
||||
@@ -2456,7 +2457,7 @@
|
||||
let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide)
|
||||
let paramsPanelInner = null; // translating content (carries bg, padding, knobs)
|
||||
let paramsPanelBody = null; // grid holding the knob cells
|
||||
let paramsCurrentValues = {}; // {paramId: value} — mirror of the visible variant's live values
|
||||
let paramsCurrentValues = {}; // {paramId: value} - mirror of the visible variant's live values
|
||||
let tuneOpen = false; // whether the Tune popover is open right now
|
||||
|
||||
// Theme-aware Tune popover. Appears as a drawer that slides out from the
|
||||
@@ -2471,7 +2472,7 @@
|
||||
const P = paramsPanelPalette;
|
||||
|
||||
// Single element, always in the DOM. The slide animation is a CSS mask
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis — no
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis - no
|
||||
// display toggle, no opacity toggle, no transform trickery. The mask
|
||||
// hides everything initially; as it grows, content is revealed from
|
||||
// the bar edge outward.
|
||||
@@ -2494,7 +2495,7 @@
|
||||
transition: 'clip-path 0.44s ' + EASE,
|
||||
|
||||
// Park off-screen until positionParamsPanel places it. These are NOT
|
||||
// in the transition list, so they snap instantly — no fly-in from the
|
||||
// in the transition list, so they snap instantly - no fly-in from the
|
||||
// top-left when first shown.
|
||||
top: '-9999px', left: '-9999px', width: '0',
|
||||
});
|
||||
@@ -2685,7 +2686,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline text editing — makes pure-text descendants of the picked element
|
||||
// Inline text editing - makes pure-text descendants of the picked element
|
||||
// directly contenteditable. Save stages copy edits in the live buffer; the
|
||||
// Apply copy edits dock later asks the AI to apply the staged batch.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -3132,7 +3133,7 @@
|
||||
if (detail.includes('newText cannot contain') || detail.includes('newText cannot be empty')) {
|
||||
showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500);
|
||||
} else {
|
||||
showToast('Save failed — retry or cancel', 4000);
|
||||
showToast('Save failed: retry or cancel', 4000);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3454,19 +3455,19 @@
|
||||
updatePendingCounter(remaining);
|
||||
if (result.failed && result.failed.length > 0) {
|
||||
console.warn('[impeccable] some copy edits failed:', result.failed);
|
||||
showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed — see console', 5000);
|
||||
showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed, see console', 5000);
|
||||
} else {
|
||||
const n = Array.isArray(result.applied) ? result.applied.length : (result.cleared || 0);
|
||||
if (n > 0) {
|
||||
showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500);
|
||||
} else {
|
||||
console.warn('[impeccable] apply returned no verified edits:', result);
|
||||
showToast('No edits applied — see console', 4000);
|
||||
showToast('No edits applied, see console', 4000);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[impeccable] commit failed:', err);
|
||||
showToast('Apply failed — see console', 4000);
|
||||
showToast('Apply failed, see console', 4000);
|
||||
} finally {
|
||||
if (waitForSseCompletion) return;
|
||||
const remainingCount = parseInt(pendingPillEl?.dataset.count || '0', 10) || 0;
|
||||
@@ -3496,7 +3497,7 @@
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[impeccable] discard failed:', err);
|
||||
showToast('Discard failed — see console', 4000);
|
||||
showToast('Discard failed, see console', 4000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3644,7 +3645,7 @@
|
||||
const failedCount = numberOrNull(msg.failedCount) || 0;
|
||||
const appliedCount = numberOrNull(msg.appliedCount) || numberOrNull(msg.cleared) || 0;
|
||||
if (failedCount > 0) {
|
||||
showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed — see console', 5000);
|
||||
showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed, see console', 5000);
|
||||
} else if (appliedCount > 0) {
|
||||
showToast('Applied ' + appliedCount + ' edit' + (appliedCount === 1 ? '' : 's'), 2500);
|
||||
}
|
||||
@@ -3799,7 +3800,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit content badge — floating button at element top-right to enter EDITING mode
|
||||
// Edit content badge - floating button at element top-right to enter EDITING mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initEditBadge() {
|
||||
@@ -3884,7 +3885,7 @@
|
||||
}
|
||||
editBadgeEl.appendChild(btn);
|
||||
} else {
|
||||
// 'editing' — show Cancel + Save separated
|
||||
// 'editing' - show Cancel + Save separated
|
||||
editBadgeEl.innerHTML = '';
|
||||
editBadgeEl.style.gap = '8px';
|
||||
const cancel = document.createElement('button');
|
||||
@@ -4083,7 +4084,7 @@
|
||||
if (!v) continue;
|
||||
setVariantShown(child, v === String(num));
|
||||
}
|
||||
// Unconditional refresh — covers first-reveal (no-op if state isn't
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
// CYCLING yet, the subsequent CYCLING transition triggers its own
|
||||
// refresh) and every cycle step.
|
||||
refreshParamsPanel();
|
||||
@@ -4318,7 +4319,7 @@
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
}, { passive: true, ...sig });
|
||||
|
||||
// Apply target synchronously, not via rAF — racing the browser's
|
||||
// Apply target synchronously, not via rAF - racing the browser's
|
||||
// restore or a smooth-scroll animation means we want to win now.
|
||||
if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) {
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
@@ -4545,18 +4546,34 @@
|
||||
// hidden tab, a route the user navigated away from). The variant
|
||||
// MutationObserver stays armed and auto-transitions to CYCLING
|
||||
// the moment the wrapper actually mounts. Nudge the user toward
|
||||
// that path with a toast — better than the prior force-reload
|
||||
// that path with a toast - better than the prior force-reload
|
||||
// which reset framework state and left the session stuck.
|
||||
setTimeout(() => {
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) return;
|
||||
if (state !== 'GENERATING') return;
|
||||
showToast(
|
||||
"Variants ready. If the picked element isn't visible, retrace the path that revealed it — they'll appear automatically.",
|
||||
"Variants ready. If the picked element isn't visible, retrace the path that revealed it; they'll appear automatically.",
|
||||
15000,
|
||||
);
|
||||
}, 2000);
|
||||
break;
|
||||
case 'complete':
|
||||
case 'accept':
|
||||
if (maybeCompleteAcceptedSession(msg)) break;
|
||||
break;
|
||||
case 'agent_done':
|
||||
// Carbonize accepts are not terminal until live-complete.mjs sends
|
||||
// the final complete event. Keep the browser in its recoverable
|
||||
// saving state while the source cleanup is still in flight.
|
||||
break;
|
||||
case 'error':
|
||||
if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) {
|
||||
pendingAcceptedSession = null;
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
break;
|
||||
}
|
||||
if (maybeCompleteSteer(msg)) break;
|
||||
console.error('[impeccable] Error:', msg.message);
|
||||
showToast('Error: ' + msg.message, 5000);
|
||||
@@ -4779,14 +4796,14 @@
|
||||
|
||||
/**
|
||||
* Surface a brief, non-blocking heads-up when the picked element lives
|
||||
* inside a container whose visibility is gated by ephemeral state — modals,
|
||||
* inside a container whose visibility is gated by ephemeral state - modals,
|
||||
* collapsible panels, popovers, off-screen tab panels. If HMR remounts the
|
||||
* parent during generation (Vite Fast Refresh, SvelteKit page reload), the
|
||||
* variants land in source but stay invisible until the user re-opens the
|
||||
* container. Telling the user upfront is much friendlier than the silent
|
||||
* timeout-then-toast that they'd otherwise hit.
|
||||
*
|
||||
* Heuristic, intentionally narrow — only fires for unambiguous cases so
|
||||
* Heuristic, intentionally narrow - only fires for unambiguous cases so
|
||||
* we don't cry wolf on every nested element.
|
||||
*/
|
||||
function maybeWarnConditionalAncestor(el) {
|
||||
@@ -4804,7 +4821,7 @@
|
||||
showToast('Heads up: this element lives inside an open panel. If state resets during generation, you may need to re-open it.', 6000);
|
||||
return;
|
||||
}
|
||||
// 3. Tab panel — only meaningful when the page also shows ANOTHER
|
||||
// 3. Tab panel - only meaningful when the page also shows ANOTHER
|
||||
// tab as selected. A single tabpanel with no tablist is just a static
|
||||
// section in disguise and isn't conditional.
|
||||
if (node.getAttribute && node.getAttribute('role') === 'tabpanel') {
|
||||
@@ -4833,7 +4850,7 @@
|
||||
// Fire a lightweight prefetch event the first time the user selects an
|
||||
// element on a given route. The agent uses this to Read the underlying file
|
||||
// into context before Go is hit, shaving the read off the critical path.
|
||||
// Dedupe per session by pathname — clicking around on the same page doesn't
|
||||
// Dedupe per session by pathname - clicking around on the same page doesn't
|
||||
// re-fire.
|
||||
//
|
||||
// DISABLED: quick-Go workflows pay an extra harness round trip because
|
||||
@@ -4969,6 +4986,7 @@
|
||||
disableInlineEdit();
|
||||
stripManualEditRuntimeState(selectedElement);
|
||||
|
||||
pendingAcceptedSession = null;
|
||||
currentSessionId = id8();
|
||||
expectedVariants = selectedCount;
|
||||
arrivedVariants = 0;
|
||||
@@ -4976,7 +4994,7 @@
|
||||
|
||||
// Flip to GENERATING immediately so the bar morphs without waiting on
|
||||
// capture + upload. The event is emitted from captureAndEmit() once the
|
||||
// screenshot is uploaded (or capture fails — we still emit, just without
|
||||
// screenshot is uploaded (or capture fails - we still emit, just without
|
||||
// screenshotPath).
|
||||
const elForCapture = selectedElement;
|
||||
const captureRect = elForCapture.getBoundingClientRect();
|
||||
@@ -5002,7 +5020,7 @@
|
||||
state = 'GENERATING';
|
||||
// Disable the Edit badge: starting a manual text edit mid-generation would
|
||||
// conflict with the variant wrap that's about to land in the same DOM
|
||||
// region. Only swap if the badge was visible — picked elements with no
|
||||
// region. Only swap if the badge was visible - picked elements with no
|
||||
// text rows have it hidden already.
|
||||
if (editBadgeEl && editBadgeEl.style.display !== 'none') renderEditBadge('idle-disabled');
|
||||
showBar('generating');
|
||||
@@ -5104,7 +5122,7 @@
|
||||
|
||||
// Collect @font-face rules from every stylesheet on the page. Cross-origin
|
||||
// sheets (Google Fonts, Typekit, etc.) throw SecurityError on .cssRules
|
||||
// access, so modern-screenshot can't embed them on its own — the resulting
|
||||
// access, so modern-screenshot can't embed them on its own - the resulting
|
||||
// SVG falls back to system fonts and text re-wraps + renders with different
|
||||
// weight. We fetch the raw CSS text (CORS-permitted for these providers),
|
||||
// extract @font-face blocks, inline the referenced font files as base64
|
||||
@@ -5188,7 +5206,7 @@
|
||||
// modern-screenshot force-sets `background-color: X !important` on the
|
||||
// cloned root whenever `backgroundColor` is passed, clobbering the
|
||||
// element's own background. So we only pass it when the element is
|
||||
// genuinely transparent (no own color, no own image) — in that case
|
||||
// genuinely transparent (no own color, no own image) - in that case
|
||||
// we resolve up the DOM to the nearest opaque ancestor so the capture
|
||||
// sits on the page's real background instead of rendering black.
|
||||
function resolveCanvasBackground(el) {
|
||||
@@ -5206,7 +5224,7 @@
|
||||
// `getComputedStyle(body).backgroundColor || …` chain is a trap: that
|
||||
// call returns the literal string `"rgba(0, 0, 0, 0)"` for a page that
|
||||
// never set its own bg, which is truthy and short-circuits the chain to
|
||||
// transparent-black — modern-screenshot then renders the capture on a
|
||||
// transparent-black - modern-screenshot then renders the capture on a
|
||||
// black canvas and the shader overlay flashes solid black during load.
|
||||
// The browser canvas defaults to white, so we do too.
|
||||
return '#ffffff';
|
||||
@@ -5248,7 +5266,7 @@
|
||||
// Transparent up to the root. The visible backdrop may still come from an
|
||||
// ancestor's background-image or a covering positioned layer (e.g. a hero
|
||||
// art div) that the color walk can't see. Capture that ancestor and crop
|
||||
// to the element so the real backdrop is embedded — correct for both the
|
||||
// to the element so the real backdrop is embedded - correct for both the
|
||||
// shader and the screenshot sent to the model. Fall back to white only
|
||||
// when nothing is actually painted behind the element.
|
||||
const backdrop = findBackdropAncestor(el);
|
||||
@@ -5289,13 +5307,13 @@
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] capture failed, proceeding without screenshot:', err);
|
||||
}
|
||||
// Light up the shader overlay the moment capture is ready — no reason to
|
||||
// Light up the shader overlay the moment capture is ready - no reason to
|
||||
// wait for the upload to complete before the user sees something alive.
|
||||
if (blob && state === 'GENERATING') {
|
||||
showShaderOverlay(el, blob, rect, paper);
|
||||
}
|
||||
// Only upload + forward the screenshot when annotations (comments/strokes)
|
||||
// are present. Without annotations the image is pure visual anchoring —
|
||||
// are present. Without annotations the image is pure visual anchoring -
|
||||
// it biases the model toward the current rendering and works against the
|
||||
// three-distinct-directions brief.
|
||||
const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0);
|
||||
@@ -5320,7 +5338,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shader overlay — renders the captured screenshot as a WebGL texture and
|
||||
// Shader overlay - renders the captured screenshot as a WebGL texture and
|
||||
// runs an editorial "ink-wash" fragment shader over it during generation.
|
||||
// A single rolling band sweeps top-to-bottom, desaturating + tinting kinpaku
|
||||
// and leaving a soft trail. Makes the wait feel like a letterpress scan
|
||||
@@ -5343,7 +5361,7 @@ uniform vec3 u_accent;
|
||||
uniform vec3 u_paper;
|
||||
varying vec2 v_uv;
|
||||
|
||||
// Asymmetric roller band. Product of two one-sided smoothsteps — peaks at
|
||||
// Asymmetric roller band. Product of two one-sided smoothsteps - peaks at
|
||||
// d=0 with a short sharp leading ramp and a longer soft trailing tail. Clean
|
||||
// outside the [-leadW, trailW] range (no rogue "trail=1 everywhere below"
|
||||
// failure that reversed-edge smoothstep would give).
|
||||
@@ -5369,8 +5387,8 @@ void main() {
|
||||
vec2 sampleCenter = (cellId + 0.5) * cellPx / u_resolution;
|
||||
vec3 cellImg = texture2D(u_texture, sampleCenter).rgb;
|
||||
// Dot size tracks how much the cell DIFFERS from the element's own ground
|
||||
// (u_paper), not absolute darkness. So the content — text, buttons, anything
|
||||
// that deviates from the background — always becomes the dots, on light AND
|
||||
// (u_paper), not absolute darkness. So the content - text, buttons, anything
|
||||
// that deviates from the background - always becomes the dots, on light AND
|
||||
// dark surfaces. A plain darkness curve inverts on dark elements: the dark
|
||||
// background fills with ink and the lighter content punches holes instead.
|
||||
// Capped below the cell half-width so dense content stays separated dots.
|
||||
@@ -5380,8 +5398,8 @@ void main() {
|
||||
// Two-stage dissolve as the roller passes, so the element is rebuilt purely
|
||||
// from dot size (its own halftone) and never bleeds through as raw pixels
|
||||
// behind the dots:
|
||||
// 1. cover — the element flattens to the uniform paper ground first.
|
||||
// 2. dotAmt — kinpaku dots then emerge, sized by each cell's luma.
|
||||
// 1. cover - the element flattens to the uniform paper ground first.
|
||||
// 2. dotAmt - kinpaku dots then emerge, sized by each cell's luma.
|
||||
// A plain mix(base, halftone, band) instead left the raw element visible
|
||||
// through the band's soft core/trail. The paper ground is u_paper (the
|
||||
// element's own bg tone) rather than a fixed white, so the dissolve reads the
|
||||
@@ -5399,7 +5417,7 @@ void main() {
|
||||
|
||||
// Kinpaku gold converted to approximate sRGB 0-1 (matches oklch(84% 0.19 80.46))
|
||||
const SHADER_ACCENT = [1.0, 0.78, 0.31];
|
||||
// Fallback ground when an element and all its ancestors are transparent —
|
||||
// Fallback ground when an element and all its ancestors are transparent -
|
||||
// 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 }
|
||||
@@ -5412,7 +5430,7 @@ void main() {
|
||||
// Rasterize any CSS color (oklch, color(), named, hex, rgb) through a 1x1
|
||||
// canvas and read back the sRGB pixel. String-parsing computed colors is a
|
||||
// trap: Chrome returns backgroundColor as oklch()/color() for oklch inputs,
|
||||
// which a hex/rgb regex misses — every site token would fall back to white.
|
||||
// which a hex/rgb regex misses - every site token would fall back to white.
|
||||
let colorParseCtx = null;
|
||||
function cssColorToRgb01(str) {
|
||||
if (!colorParseCtx) {
|
||||
@@ -5440,7 +5458,7 @@ void main() {
|
||||
|
||||
// When an element is transparent up to the root, its visible backdrop can
|
||||
// still come from an ancestor's background-image or a covering positioned
|
||||
// layer that is a *child* of an ancestor (e.g. a hero's absolute art div) —
|
||||
// layer that is a *child* of an ancestor (e.g. a hero's absolute art div) -
|
||||
// neither of which the ancestor background-COLOR walk can see. Return the
|
||||
// nearest such ancestor so we can capture it and crop, embedding the real
|
||||
// backdrop. Returns null when nothing is actually painted behind the element
|
||||
@@ -5481,7 +5499,7 @@ void main() {
|
||||
|
||||
// Average the backdrop sampled just OUTSIDE an element's rect within a larger
|
||||
// canvas. The ground tone for the dissolve must be the real backdrop, not the
|
||||
// mean of the element's own crop — averaging the crop folds in the element's
|
||||
// mean of the element's own crop - averaging the crop folds in the element's
|
||||
// content (e.g. bright heading text), pulling the ground toward muddy gray.
|
||||
function sampleSurroundingRgb(ctx, sx, sy, sw, sh, W, H) {
|
||||
const pad = Math.max(2, Math.round(Math.min(sw, sh) * 0.12));
|
||||
@@ -5528,11 +5546,31 @@ void main() {
|
||||
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;
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
canvas.remove();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const fallback = document.createElement('div');
|
||||
fallback.id = PREFIX + '-shader';
|
||||
// Copy positioning via cssText. Object.assign across CSSStyleDeclaration
|
||||
// throws in modern Chromium because the source's indexed properties
|
||||
// (style[0], [1], ...) are read-only and the engine forbids writing
|
||||
// them on the destination.
|
||||
fallback.style.cssText = canvas.style.cssText;
|
||||
fallback.style.backgroundImage = 'url("' + objectUrl + '")';
|
||||
fallback.style.backgroundSize = '100% 100%';
|
||||
fallback.style.backgroundRepeat = 'no-repeat';
|
||||
fallback.style.outline = '2px dashed ' + C.brand;
|
||||
fallback.style.outlineOffset = '-2px';
|
||||
document.body.appendChild(fallback);
|
||||
shaderState = { canvas: fallback, gl: null, program: null, texture: null, rafId: 0, startTime: 0, objectUrl };
|
||||
}
|
||||
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
@@ -5553,21 +5591,9 @@ void main() {
|
||||
const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false })
|
||||
|| canvas.getContext('experimental-webgl');
|
||||
if (!gl) {
|
||||
// WebGL unavailable — fall back to a plain <img> overlay so the user
|
||||
// still sees something meaningful during generation.
|
||||
canvas.remove();
|
||||
const img = document.createElement('img');
|
||||
img.src = URL.createObjectURL(blob);
|
||||
img.id = PREFIX + '-shader';
|
||||
// Copy positioning via cssText. Object.assign across CSSStyleDeclaration
|
||||
// throws in modern Chromium because the source's indexed properties
|
||||
// (style[0], [1], ...) are read-only and the engine forbids writing
|
||||
// them on the destination.
|
||||
img.style.cssText = canvas.style.cssText;
|
||||
img.style.outline = '2px dashed ' + C.brand;
|
||||
img.style.outlineOffset = '-2px';
|
||||
document.body.appendChild(img);
|
||||
shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 };
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5609,14 +5635,12 @@ void main() {
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch {
|
||||
// Safari fallback: go via a regular Image
|
||||
const imgUrl = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
img.src = imgUrl;
|
||||
await new Promise((r, rej) => { img.onload = r; img.onerror = rej; });
|
||||
bitmap = img;
|
||||
URL.revokeObjectURL(imgUrl);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
@@ -5670,14 +5694,15 @@ void main() {
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
// The accepted variant is already the only visible child of the wrapper
|
||||
// (all other variants are display:none). HMR from the source rewrite will
|
||||
// replace the wrapper imminently. Don't eagerly replaceChild here — React
|
||||
// reconciliation races with our mutation and throws NotFoundError in Next
|
||||
// 16 / Turbopack. Schedule a fallback that runs the manual swap only if
|
||||
// HMR hasn't cleaned up by then (keeps static-server flows working).
|
||||
const acceptedSessionId = currentSessionId;
|
||||
const acceptedVariant = visibleVariant;
|
||||
const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant);
|
||||
pendingAcceptedSession = {
|
||||
id: acceptedSessionId,
|
||||
variant: String(acceptedVariant),
|
||||
...acceptedSnapshot,
|
||||
finalizing: false,
|
||||
};
|
||||
|
||||
state = 'SAVING';
|
||||
updateBarContent('saving');
|
||||
@@ -5685,53 +5710,130 @@ void main() {
|
||||
sendEvent(acceptPayload, { throwOnError: true })
|
||||
.then(() => {
|
||||
markSessionHandled();
|
||||
confirmAcceptAfterReceipt();
|
||||
})
|
||||
.catch(() => {
|
||||
pendingAcceptedSession = null;
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
}
|
||||
|
||||
function confirmAcceptAfterReceipt() {
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
scheduleAcceptCleanup();
|
||||
function maybeCompleteAcceptedSession(msg) {
|
||||
const pending = pendingAcceptedSession;
|
||||
if (!pending || !msg?.id || msg.id !== pending.id) return false;
|
||||
if (currentSessionId && currentSessionId !== pending.id) {
|
||||
pendingAcceptedSession = null;
|
||||
return false;
|
||||
}
|
||||
if (pending.finalizing) return true;
|
||||
pending.finalizing = true;
|
||||
|
||||
function scheduleAcceptCleanup() {
|
||||
setTimeout(function() {
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
selectedElement = null;
|
||||
currentSessionId = null;
|
||||
selectedAction = 'impeccable';
|
||||
renderEditBadge('hidden');
|
||||
state = 'PICKING';
|
||||
}, 1800);
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
|
||||
// Static-server / no-HMR fallback: if the wrapper is still around 2s after
|
||||
// the cleanup above, swap it out manually. By now React has either moved
|
||||
// on or the app isn't React at all. Preserve the `data-impeccable-variant="N"`
|
||||
// div (with display:contents) so @scope rules anchored to the variant
|
||||
// attribute keep matching until reload replaces it with the carbonize block.
|
||||
// Give framework HMR a short chance to render the now-clean accepted
|
||||
// source. If it misses the update, unwrap the accepted variant after the
|
||||
// source-side completion event so the page is not left empty or stale.
|
||||
setTimeout(function() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + acceptedSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + acceptedVariant + '"]');
|
||||
if (accepted && accepted.firstElementChild) {
|
||||
const parent = wrapper.parentElement;
|
||||
if (!parent) return;
|
||||
accepted.style.display = 'contents';
|
||||
parent.replaceChild(accepted, wrapper);
|
||||
}
|
||||
}, 2000);
|
||||
ensureAcceptedDomClean(pending);
|
||||
cleanupAcceptedSession();
|
||||
}, 1200);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
acceptedHtml: accepted ? accepted.innerHTML : '',
|
||||
acceptedSelector: selectorForAcceptedRoot(root),
|
||||
parentElement: wrapper?.parentElement || null,
|
||||
parentSelector: selectorForAcceptedRoot(wrapper?.parentElement || null),
|
||||
nextSibling: wrapper?.nextSibling || null,
|
||||
};
|
||||
}
|
||||
|
||||
function selectorForAcceptedRoot(root) {
|
||||
if (!root || !root.tagName) return '';
|
||||
const tag = root.tagName.toLowerCase();
|
||||
const classes = [...(root.classList || [])].filter(Boolean);
|
||||
if (classes.length === 0) return tag;
|
||||
return tag + classes.map((cls) => '.' + cssIdent(cls)).join('');
|
||||
}
|
||||
|
||||
function acceptedDomAlreadyClean(pending) {
|
||||
if (!pending?.acceptedSelector) return false;
|
||||
const matches = [...document.querySelectorAll(pending.acceptedSelector)];
|
||||
return matches.some((el) => !el.closest('[data-impeccable-variants],[data-impeccable-variant]'));
|
||||
}
|
||||
|
||||
function ensureAcceptedDomClean(pending) {
|
||||
const sessionId = pending?.id;
|
||||
const variantId = pending?.variant;
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!wrapper) {
|
||||
restoreAcceptedDomFromSnapshot(pending);
|
||||
return;
|
||||
}
|
||||
if (!accepted) {
|
||||
wrapper.remove();
|
||||
restoreAcceptedDomFromSnapshot(pending);
|
||||
return;
|
||||
}
|
||||
const parent = wrapper.parentElement;
|
||||
if (!parent) return;
|
||||
while (accepted.firstChild) {
|
||||
parent.insertBefore(accepted.firstChild, wrapper);
|
||||
}
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
function restoreAcceptedDomFromSnapshot(pending) {
|
||||
if (acceptedDomAlreadyClean(pending)) return;
|
||||
if (!pending?.acceptedHtml) {
|
||||
reloadAfterMissingAcceptedDom(pending);
|
||||
return;
|
||||
}
|
||||
const parent = pending.parentElement?.isConnected
|
||||
? pending.parentElement
|
||||
: (pending.parentSelector ? document.querySelector(pending.parentSelector) : null);
|
||||
if (!parent) {
|
||||
reloadAfterMissingAcceptedDom(pending);
|
||||
return;
|
||||
}
|
||||
const template = document.createElement('template');
|
||||
template.innerHTML = pending.acceptedHtml;
|
||||
const anchor = pending.nextSibling?.isConnected && pending.nextSibling.parentElement === parent
|
||||
? pending.nextSibling
|
||||
: null;
|
||||
parent.insertBefore(template.content, anchor);
|
||||
if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending);
|
||||
}
|
||||
|
||||
function reloadAfterMissingAcceptedDom(pending) {
|
||||
if (acceptedDomAlreadyClean(pending)) return;
|
||||
if (pending?.id && document.querySelector('[data-impeccable-variants="' + pending.id + '"]')) return;
|
||||
location.reload();
|
||||
}
|
||||
|
||||
function cleanupAcceptedSession() {
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
selectedElement = null;
|
||||
currentSessionId = null;
|
||||
selectedAction = 'impeccable';
|
||||
pendingAcceptedSession = null;
|
||||
renderEditBadge('hidden');
|
||||
state = 'PICKING';
|
||||
}
|
||||
|
||||
function handleDiscard() {
|
||||
@@ -5792,7 +5894,7 @@ void main() {
|
||||
|
||||
function cleanup() {
|
||||
// Hide the wrapper immediately so variants disappear. DON'T structurally
|
||||
// mutate the DOM yet — HMR from the agent's source rewrite is on its way,
|
||||
// mutate the DOM yet - HMR from the agent's source rewrite is on its way,
|
||||
// and a manual replaceChild under React causes NotFoundError when the
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
@@ -5838,8 +5940,8 @@ void main() {
|
||||
function showToast(message, duration) {
|
||||
if (toastEl) toastEl.remove();
|
||||
// Stack the toast above the global bar (which sits at bottom:14px) so
|
||||
// the two never overlap. Read the bar's actual rect — its height varies
|
||||
// with hover-expanded labels — and fall back to a sensible default
|
||||
// the two never overlap. Read the bar's actual rect - its height varies
|
||||
// with hover-expanded labels - and fall back to a sensible default
|
||||
// when the bar isn't mounted yet.
|
||||
const barRect = globalBarEl?.getBoundingClientRect();
|
||||
const barTopFromBottom = barRect && barRect.height > 0
|
||||
@@ -6047,7 +6149,7 @@ void main() {
|
||||
let pendingApplyInFlight = false;
|
||||
let firstSaveOfSession = true;
|
||||
|
||||
// Steer — collapsed pill in the global bar; expands while typing for page-level chat.
|
||||
// Steer - collapsed pill in the global bar; expands while typing for page-level chat.
|
||||
let pageChatEl = null;
|
||||
let pageChatInput = null;
|
||||
let pageChatHint = null;
|
||||
@@ -6068,7 +6170,7 @@ void main() {
|
||||
const STEER_AWAIT_TIMEOUT_MS = 120000;
|
||||
const AGENT_STATUS_POLL_MS = 5000;
|
||||
const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)';
|
||||
const AGENT_DISCONNECTED_TIP = 'Agent disconnected — run live-poll.mjs to connect';
|
||||
const AGENT_DISCONNECTED_TIP = 'Agent disconnected: run live-poll.mjs to connect';
|
||||
const GLOBAL_BAR_SECTION_GAP = 8;
|
||||
const GLOBAL_BAR_INNER_GAP = 2;
|
||||
const GLOBAL_BAR_INNER_PAD_LEFT = 2;
|
||||
@@ -6079,7 +6181,7 @@ void main() {
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>';
|
||||
|
||||
// Theme-aware color palette for the global bar. We detect the page's
|
||||
// ambient background and invert — dark bar on light pages, light bar on
|
||||
// ambient background and invert - dark bar on light pages, light bar on
|
||||
// dark pages. This keeps the bar from fighting with the host design.
|
||||
function detectPageTheme() {
|
||||
try {
|
||||
@@ -6092,7 +6194,7 @@ void main() {
|
||||
// Walk body → html, taking the first opaque background. The browser's
|
||||
// default body / html background is `rgba(0, 0, 0, 0)`, which a naive
|
||||
// regex would read as black and mislabel a perfectly white page as
|
||||
// dark. Honoring alpha avoids that — and falling through to <html>
|
||||
// dark. Honoring alpha avoids that - and falling through to <html>
|
||||
// catches the common pattern of a bg only on <html> (or only on body).
|
||||
function readOpaque(el) {
|
||||
if (!el) return null;
|
||||
@@ -6141,7 +6243,7 @@ void main() {
|
||||
exitHover: 'oklch(58% 0.15 35 / 0.18)',
|
||||
shadow: PICKER_SHADOW,
|
||||
chatSurface: 'oklch(22% 0.012 82)',
|
||||
// Verdigris patina — secondary state (see site/styles/kinpaku-tokens.css)
|
||||
// Verdigris patina - secondary state (see site/styles/kinpaku-tokens.css)
|
||||
patina: 'oklch(70% 0.12 188)',
|
||||
patinaPale: 'oklch(82% 0.07 188)',
|
||||
patinaSoft: 'oklch(70% 0.12 188 / 0.28)',
|
||||
@@ -6913,7 +7015,7 @@ void main() {
|
||||
steerFocusLog('page-chat-mounted', {});
|
||||
}
|
||||
|
||||
// Impeccable mark — same paths as site/components/Header.astro + favicon.svg.
|
||||
// Impeccable mark - same paths as site/components/Header.astro + favicon.svg.
|
||||
function brandMarkSvg(color = C.brand, size = 18) {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="${color}" aria-hidden="true">
|
||||
<path d="M5 2.5 L13.5 2.5 L5.5 21.5 L5 21.5 Q2.5 21.5 2.5 19 L2.5 5 Q2.5 2.5 5 2.5 Z"/>
|
||||
@@ -6928,7 +7030,7 @@ void main() {
|
||||
globalBarBrandEl.dataset.agentConnected = connected ? 'true' : 'false';
|
||||
globalBarBrandEl.setAttribute('aria-label', connected
|
||||
? 'Impeccable live mode'
|
||||
: 'Impeccable live mode — agent not polling');
|
||||
: 'Impeccable live mode: agent not polling');
|
||||
globalBarBrandEl.removeAttribute('title');
|
||||
globalBarBrandEl.style.cursor = connected ? 'default' : 'help';
|
||||
const mark = globalBarBrandEl.querySelector('[data-brand-mark]');
|
||||
@@ -7053,7 +7155,7 @@ void main() {
|
||||
globalBarEl.id = PREFIX + '-global-bar';
|
||||
globalBarEl.dataset.theme = theme;
|
||||
|
||||
// Brand mark — kinpaku Impeccable icon (site header / favicon paths).
|
||||
// Brand mark - kinpaku Impeccable icon (site header / favicon paths).
|
||||
const brand = el('span', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
alignSelf: 'stretch', position: 'relative',
|
||||
@@ -7065,7 +7167,7 @@ void main() {
|
||||
brand.id = PREFIX + '-global-bar-brand';
|
||||
brand.dataset.agentConnected = 'false';
|
||||
brand.setAttribute('role', 'img');
|
||||
brand.setAttribute('aria-label', 'Impeccable live mode — agent not polling');
|
||||
brand.setAttribute('aria-label', 'Impeccable live mode: agent not polling');
|
||||
|
||||
const brandMark = el('span', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
@@ -7130,7 +7232,7 @@ void main() {
|
||||
// Per-button hover only changes color (no layout). The label expand/
|
||||
// collapse is driven by the bar-level mouseenter/mouseleave so moving
|
||||
// the mouse between adjacent buttons doesn't trigger per-button width
|
||||
// thrashing — the whole bar grows once and shrinks once.
|
||||
// thrashing - the whole bar grows once and shrinks once.
|
||||
b.addEventListener('mouseenter', () => { if (b.dataset.active !== 'true') b.style.color = P.text; });
|
||||
b.addEventListener('mouseleave', () => { if (b.dataset.active !== 'true') b.style.color = P.textDim; });
|
||||
b.addEventListener('click', onClick);
|
||||
@@ -7139,7 +7241,7 @@ void main() {
|
||||
return b;
|
||||
}
|
||||
|
||||
// Pick toggle — restored from localStorage; both pick and insert may be off.
|
||||
// Pick toggle - restored from localStorage; both pick and insert may be off.
|
||||
const pickBtn = makeIconBtn({
|
||||
id: PREFIX + '-pick-toggle',
|
||||
svg: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><circle cx="12" cy="12" r="10"/><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><line x1="12" y1="6" x2="12" y2="2"/><line x1="12" y1="22" x2="12" y2="18"/></svg>',
|
||||
@@ -7176,7 +7278,7 @@ void main() {
|
||||
detectBtn.appendChild(detectBadge);
|
||||
inner.appendChild(detectBtn);
|
||||
|
||||
// DESIGN.md panel toggle — quartet of color squares as the mark.
|
||||
// DESIGN.md panel toggle - quartet of color squares as the mark.
|
||||
const designBtn = makeIconBtn({
|
||||
id: PREFIX + '-design-toggle',
|
||||
svg: `<span style="display:inline-grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;width:14px;height:14px;border-radius:3px;overflow:hidden;box-shadow:inset 0 0 0 1px oklch(58% 0.065 82 / 0.55);flex-shrink:0">
|
||||
@@ -7378,13 +7480,13 @@ void main() {
|
||||
});
|
||||
inner.appendChild(divider);
|
||||
|
||||
// Exit × on the right — intentionally subtle (textDim at rest, text on
|
||||
// Exit × on the right - intentionally subtle (textDim at rest, text on
|
||||
// hover) so it sits behind the active toggles in visual hierarchy.
|
||||
//
|
||||
// Explicit padding + box-sizing here is load-bearing: a host page like
|
||||
// `button { padding: 0.5rem 1rem; }` (very common in resets) would
|
||||
// otherwise inflate this 24x24 button into 56x40 and push the SVG out
|
||||
// of the visible bar — the X stays invisible even though the styles in
|
||||
// of the visible bar - the X stays invisible even though the styles in
|
||||
// DevTools look fine. Every other chrome button sets padding inline;
|
||||
// this one needed it too.
|
||||
const exitBtn = el('button', {
|
||||
@@ -7472,7 +7574,7 @@ void main() {
|
||||
btn.style.opacity = controlsLocked ? '0.55' : '1';
|
||||
});
|
||||
|
||||
// If the bar is currently under the cursor, keep all labels expanded —
|
||||
// If the bar is currently under the cursor, keep all labels expanded -
|
||||
// otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
|
||||
// would collapse its label while the user's mouse is still on the bar.
|
||||
if (globalBarEl && globalBarEl.matches(':hover')) {
|
||||
@@ -7648,7 +7750,7 @@ void main() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Design System Panel — visualizes the project's .impeccable/design.json sidecar
|
||||
// Design System Panel - visualizes the project's .impeccable/design.json sidecar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DESIGN_PREFS_KEY = 'impeccable-live-design-panel';
|
||||
@@ -7674,7 +7776,7 @@ void main() {
|
||||
};
|
||||
|
||||
function loadDesignPrefs() {
|
||||
// `open` is intentionally NOT persisted — the panel always starts closed
|
||||
// `open` is intentionally NOT persisted - the panel always starts closed
|
||||
// so live mode doesn't auto-slide a big panel over the page on startup.
|
||||
try {
|
||||
const raw = localStorage.getItem(DESIGN_PREFS_KEY);
|
||||
@@ -7731,7 +7833,7 @@ void main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Neutral panel palette — deliberately NOT Impeccable-branded. The panel is
|
||||
// Neutral panel palette - deliberately NOT Impeccable-branded. The panel is
|
||||
// a viewer of the project's design system, not an Impeccable surface.
|
||||
const DP = {
|
||||
canvas: 'oklch(94% 0 0)', // panel background
|
||||
@@ -8025,7 +8127,7 @@ void main() {
|
||||
const root = designShadow.querySelector('.root');
|
||||
root.innerHTML = '';
|
||||
|
||||
// (Panel toggle lives in the global bar — no floating FAB.)
|
||||
// (Panel toggle lives in the global bar - no floating FAB.)
|
||||
// Panel
|
||||
const panel = document.createElement('aside');
|
||||
panel.className = 'panel';
|
||||
@@ -8141,7 +8243,7 @@ void main() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Visual tab — single unified render path.
|
||||
// Visual tab - single unified render path.
|
||||
if (designState.mdNewerThanJson) body.appendChild(renderStaleHint());
|
||||
if (designState.hasMd && !designState.hasSidecar) {
|
||||
body.appendChild(renderParsedMdCta());
|
||||
@@ -8357,7 +8459,7 @@ void main() {
|
||||
specimen.style.fontFamily = fontStack(t);
|
||||
specimen.style.fontWeight = String(t.weight || 400);
|
||||
specimen.style.fontStyle = t.style || 'normal';
|
||||
specimen.style.fontSize = '56px'; // Fixed specimen size — compare faces, not scales.
|
||||
specimen.style.fontSize = '56px'; // Fixed specimen size - compare faces, not scales.
|
||||
specimen.style.letterSpacing = 'normal';
|
||||
specimen.style.textTransform = 'none';
|
||||
tile.appendChild(specimen);
|
||||
@@ -8501,7 +8603,7 @@ void main() {
|
||||
}
|
||||
|
||||
// Single shared description if all items carry the same one; otherwise
|
||||
// skip — per-item descriptions clutter a grouped tile.
|
||||
// skip - per-item descriptions clutter a grouped tile.
|
||||
if (group.length === 1 && group[0].description) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'c-desc';
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Impeccable Live Variant Mode — Browser Script
|
||||
* Impeccable Live Variant Mode - Browser Script
|
||||
*
|
||||
* Injected into the user's page via <script src="http://localhost:PORT/live.js">.
|
||||
* The server prepends window.__IMPECCABLE_TOKEN__ and window.__IMPECCABLE_PORT__
|
||||
* before this code.
|
||||
*
|
||||
* UI: a single floating bar that morphs between three states —
|
||||
* UI: a single floating bar that morphs between three states -
|
||||
* configure (pick action + go), generating (progressive dots), and cycling
|
||||
* (prev/next + accept/discard). Feels like Spotlight, not a modal.
|
||||
*/
|
||||
@@ -46,7 +46,7 @@
|
||||
mist: 'oklch(90% 0.008 82 / 0.6)', // light hairline
|
||||
white: 'oklch(99% 0 0)',
|
||||
};
|
||||
// Picker bar chrome — mirrors .live-demo-gbar / .live-demo-ctx in kinpaku-kit.css.
|
||||
// Picker bar chrome - mirrors .live-demo-gbar / .live-demo-ctx in kinpaku-kit.css.
|
||||
// Quiet neutral elevation: no gold halo ring (gold is reserved for the brand
|
||||
// mark and the active control, not the container outline).
|
||||
const PICKER_SHADOW =
|
||||
@@ -83,7 +83,7 @@
|
||||
|
||||
// SVG icons stack above each chip label. All strokes use currentColor so the
|
||||
// icon recolors to C.brand when its chip is selected. 20x20 render, 24-viewBox,
|
||||
// 1.5 stroke — visually consistent with the Foundation grid on the homepage.
|
||||
// 1.5 stroke - visually consistent with the Foundation grid on the homepage.
|
||||
const ICON_ATTRS = 'width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="display:block"';
|
||||
const ICONS = {
|
||||
impeccable: `<svg ${ICON_ATTRS}><path d="M4 20l4-1L18 9l-3-3L5 16z"/><path d="M14 7l3 3"/></svg>`,
|
||||
@@ -123,6 +123,7 @@
|
||||
let hoveredElement = null;
|
||||
let selectedElement = null;
|
||||
let currentSessionId = null;
|
||||
let pendingAcceptedSession = null;
|
||||
let expectedVariants = 0;
|
||||
let arrivedVariants = 0;
|
||||
let visibleVariant = 0;
|
||||
@@ -133,7 +134,7 @@
|
||||
const browserOwner = sessionState.owner;
|
||||
let checkpointTimer = null;
|
||||
|
||||
// Scroll lock — holds window.scrollY at a fixed value while the session is
|
||||
// Scroll lock - holds window.scrollY at a fixed value while the session is
|
||||
// active, so HMR DOM patches and variant swaps can't drift the page. See
|
||||
// startScrollLock / stopScrollLock below.
|
||||
let scrollLockObserver = null;
|
||||
@@ -141,7 +142,7 @@
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
// Dedicated key for scroll position — SEPARATE from LS_KEY so that
|
||||
// Dedicated key for scroll position - SEPARATE from LS_KEY so that
|
||||
// saveSession's state updates don't clobber a carefully-captured scrollY.
|
||||
// (Previously: saveSession wrote scrollY alongside state, so every call
|
||||
// during resume overwrote the pre-reload value with whatever the browser
|
||||
@@ -231,7 +232,7 @@
|
||||
// - Stop `focusin` propagation so any focus shifts inside our chrome
|
||||
// don't read as "focus moved outside the dialog" to focus traps.
|
||||
//
|
||||
// Click events still bubble normally — only the early pointer/focus
|
||||
// Click events still bubble normally - only the early pointer/focus
|
||||
// signals that drive outside-interaction detection are silenced.
|
||||
function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) {
|
||||
if (!rootEl) return;
|
||||
@@ -319,7 +320,7 @@
|
||||
// correlate directly with the captured PNG.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DRAG_THRESHOLD = 5; // px — below this, treat pointerup as a click
|
||||
const DRAG_THRESHOLD = 5; // px - below this, treat pointerup as a click
|
||||
const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it
|
||||
let annotOverlayEl = null;
|
||||
let annotSvgEl = null;
|
||||
@@ -423,7 +424,7 @@
|
||||
placeholderResizeDrag = null;
|
||||
if (annotOverlayEl) annotOverlayEl.style.display = 'none';
|
||||
syncPlaceholderResizeHandles();
|
||||
// Drop any in-progress edit without touching annotState — clearAnnotations
|
||||
// Drop any in-progress edit without touching annotState - clearAnnotations
|
||||
// (if the caller is exiting configure mode) handles state reset.
|
||||
annotEditing = null;
|
||||
}
|
||||
@@ -488,7 +489,7 @@
|
||||
function onAnnotDown(e) {
|
||||
if (!annotActive) return;
|
||||
|
||||
// 0) Insert placeholder edge resize — wins over draw / pins.
|
||||
// 0) Insert placeholder edge resize - wins over draw / pins.
|
||||
const resizeEdge = e.target.closest?.('[data-impeccable-placeholder-resize]')?.dataset.impeccablePlaceholderResize;
|
||||
if (resizeEdge && configureKind === 'insert' && placeholderElement) {
|
||||
startPlaceholderEdgeResize(resizeEdge, e);
|
||||
@@ -536,7 +537,7 @@
|
||||
// If editing a different pin, commit that edit before starting here.
|
||||
if (annotEditing && annotEditing.idx !== idx) finalizeEditingPin();
|
||||
// If already editing THIS pin and the user clicked the dot, let the
|
||||
// input keep focus (don't start a drag — the click wasn't meant as one).
|
||||
// input keep focus (don't start a drag - the click wasn't meant as one).
|
||||
if (annotEditing && annotEditing.idx === idx) return;
|
||||
const p = localCoords(e);
|
||||
const pin = annotState.comments[idx];
|
||||
@@ -971,7 +972,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The Bar — one floating element, three modes
|
||||
// The Bar - one floating element, three modes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Contextual-bar palette. Cached at init so every build*Row reads a
|
||||
@@ -1752,7 +1753,7 @@
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
});
|
||||
|
||||
// Action pill — dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill)
|
||||
// Action pill - dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill)
|
||||
const pill = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
||||
padding: '5px 10px', borderRadius: '6px',
|
||||
@@ -1786,7 +1787,7 @@
|
||||
});
|
||||
row.appendChild(pill);
|
||||
|
||||
// Prompt field — same chat-surface chrome as the bottom Steer bar
|
||||
// Prompt field - same chat-surface chrome as the bottom Steer bar
|
||||
const inputWrap = el('div', {
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
flex: '1', minWidth: '0', height: '28px',
|
||||
@@ -2126,7 +2127,7 @@
|
||||
if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
|
||||
row.appendChild(next);
|
||||
|
||||
// Tune chip — only when the visible variant exposes params
|
||||
// Tune chip - only when the visible variant exposes params
|
||||
const visParams = parseVariantParams(getVisibleVariantEl());
|
||||
const hasParams = visParams.length > 0;
|
||||
if (hasParams) {
|
||||
@@ -2173,7 +2174,7 @@
|
||||
// Spacer
|
||||
row.appendChild(el('div', { flex: '1' }));
|
||||
|
||||
// Accept — primary action, kinpaku gold + lacquer-deep (matches demo .live-demo-ctx-accept)
|
||||
// Accept - primary action, kinpaku gold + lacquer-deep (matches demo .live-demo-ctx-accept)
|
||||
const accept = el('button', {
|
||||
padding: '5px 14px', borderRadius: '5px',
|
||||
border: 'none', background: C.brand, color: C.ink,
|
||||
@@ -2266,7 +2267,7 @@
|
||||
const active = i === visibleVariant;
|
||||
// active: solid site-brand kinpaku dot. arrived+inactive: muted neutral.
|
||||
// pending (not yet arrived): faint outline ring. No borders on arrived
|
||||
// dots — the previous "accent ring + ash fill" combo read as noisy
|
||||
// dots - the previous "accent ring + ash fill" combo read as noisy
|
||||
// kinpaku chips, especially when all variants had arrived and every
|
||||
// dot wore an accent ring.
|
||||
const dotBg = active ? C.brand
|
||||
@@ -2456,7 +2457,7 @@
|
||||
let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide)
|
||||
let paramsPanelInner = null; // translating content (carries bg, padding, knobs)
|
||||
let paramsPanelBody = null; // grid holding the knob cells
|
||||
let paramsCurrentValues = {}; // {paramId: value} — mirror of the visible variant's live values
|
||||
let paramsCurrentValues = {}; // {paramId: value} - mirror of the visible variant's live values
|
||||
let tuneOpen = false; // whether the Tune popover is open right now
|
||||
|
||||
// Theme-aware Tune popover. Appears as a drawer that slides out from the
|
||||
@@ -2471,7 +2472,7 @@
|
||||
const P = paramsPanelPalette;
|
||||
|
||||
// Single element, always in the DOM. The slide animation is a CSS mask
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis — no
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis - no
|
||||
// display toggle, no opacity toggle, no transform trickery. The mask
|
||||
// hides everything initially; as it grows, content is revealed from
|
||||
// the bar edge outward.
|
||||
@@ -2494,7 +2495,7 @@
|
||||
transition: 'clip-path 0.44s ' + EASE,
|
||||
|
||||
// Park off-screen until positionParamsPanel places it. These are NOT
|
||||
// in the transition list, so they snap instantly — no fly-in from the
|
||||
// in the transition list, so they snap instantly - no fly-in from the
|
||||
// top-left when first shown.
|
||||
top: '-9999px', left: '-9999px', width: '0',
|
||||
});
|
||||
@@ -2685,7 +2686,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline text editing — makes pure-text descendants of the picked element
|
||||
// Inline text editing - makes pure-text descendants of the picked element
|
||||
// directly contenteditable. Save stages copy edits in the live buffer; the
|
||||
// Apply copy edits dock later asks the AI to apply the staged batch.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -3132,7 +3133,7 @@
|
||||
if (detail.includes('newText cannot contain') || detail.includes('newText cannot be empty')) {
|
||||
showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500);
|
||||
} else {
|
||||
showToast('Save failed — retry or cancel', 4000);
|
||||
showToast('Save failed: retry or cancel', 4000);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3454,19 +3455,19 @@
|
||||
updatePendingCounter(remaining);
|
||||
if (result.failed && result.failed.length > 0) {
|
||||
console.warn('[impeccable] some copy edits failed:', result.failed);
|
||||
showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed — see console', 5000);
|
||||
showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed, see console', 5000);
|
||||
} else {
|
||||
const n = Array.isArray(result.applied) ? result.applied.length : (result.cleared || 0);
|
||||
if (n > 0) {
|
||||
showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500);
|
||||
} else {
|
||||
console.warn('[impeccable] apply returned no verified edits:', result);
|
||||
showToast('No edits applied — see console', 4000);
|
||||
showToast('No edits applied, see console', 4000);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[impeccable] commit failed:', err);
|
||||
showToast('Apply failed — see console', 4000);
|
||||
showToast('Apply failed, see console', 4000);
|
||||
} finally {
|
||||
if (waitForSseCompletion) return;
|
||||
const remainingCount = parseInt(pendingPillEl?.dataset.count || '0', 10) || 0;
|
||||
@@ -3496,7 +3497,7 @@
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[impeccable] discard failed:', err);
|
||||
showToast('Discard failed — see console', 4000);
|
||||
showToast('Discard failed, see console', 4000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3644,7 +3645,7 @@
|
||||
const failedCount = numberOrNull(msg.failedCount) || 0;
|
||||
const appliedCount = numberOrNull(msg.appliedCount) || numberOrNull(msg.cleared) || 0;
|
||||
if (failedCount > 0) {
|
||||
showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed — see console', 5000);
|
||||
showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed, see console', 5000);
|
||||
} else if (appliedCount > 0) {
|
||||
showToast('Applied ' + appliedCount + ' edit' + (appliedCount === 1 ? '' : 's'), 2500);
|
||||
}
|
||||
@@ -3799,7 +3800,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit content badge — floating button at element top-right to enter EDITING mode
|
||||
// Edit content badge - floating button at element top-right to enter EDITING mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initEditBadge() {
|
||||
@@ -3884,7 +3885,7 @@
|
||||
}
|
||||
editBadgeEl.appendChild(btn);
|
||||
} else {
|
||||
// 'editing' — show Cancel + Save separated
|
||||
// 'editing' - show Cancel + Save separated
|
||||
editBadgeEl.innerHTML = '';
|
||||
editBadgeEl.style.gap = '8px';
|
||||
const cancel = document.createElement('button');
|
||||
@@ -4083,7 +4084,7 @@
|
||||
if (!v) continue;
|
||||
setVariantShown(child, v === String(num));
|
||||
}
|
||||
// Unconditional refresh — covers first-reveal (no-op if state isn't
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
// CYCLING yet, the subsequent CYCLING transition triggers its own
|
||||
// refresh) and every cycle step.
|
||||
refreshParamsPanel();
|
||||
@@ -4318,7 +4319,7 @@
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
}, { passive: true, ...sig });
|
||||
|
||||
// Apply target synchronously, not via rAF — racing the browser's
|
||||
// Apply target synchronously, not via rAF - racing the browser's
|
||||
// restore or a smooth-scroll animation means we want to win now.
|
||||
if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) {
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
@@ -4545,18 +4546,34 @@
|
||||
// hidden tab, a route the user navigated away from). The variant
|
||||
// MutationObserver stays armed and auto-transitions to CYCLING
|
||||
// the moment the wrapper actually mounts. Nudge the user toward
|
||||
// that path with a toast — better than the prior force-reload
|
||||
// that path with a toast - better than the prior force-reload
|
||||
// which reset framework state and left the session stuck.
|
||||
setTimeout(() => {
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) return;
|
||||
if (state !== 'GENERATING') return;
|
||||
showToast(
|
||||
"Variants ready. If the picked element isn't visible, retrace the path that revealed it — they'll appear automatically.",
|
||||
"Variants ready. If the picked element isn't visible, retrace the path that revealed it; they'll appear automatically.",
|
||||
15000,
|
||||
);
|
||||
}, 2000);
|
||||
break;
|
||||
case 'complete':
|
||||
case 'accept':
|
||||
if (maybeCompleteAcceptedSession(msg)) break;
|
||||
break;
|
||||
case 'agent_done':
|
||||
// Carbonize accepts are not terminal until live-complete.mjs sends
|
||||
// the final complete event. Keep the browser in its recoverable
|
||||
// saving state while the source cleanup is still in flight.
|
||||
break;
|
||||
case 'error':
|
||||
if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) {
|
||||
pendingAcceptedSession = null;
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
break;
|
||||
}
|
||||
if (maybeCompleteSteer(msg)) break;
|
||||
console.error('[impeccable] Error:', msg.message);
|
||||
showToast('Error: ' + msg.message, 5000);
|
||||
@@ -4779,14 +4796,14 @@
|
||||
|
||||
/**
|
||||
* Surface a brief, non-blocking heads-up when the picked element lives
|
||||
* inside a container whose visibility is gated by ephemeral state — modals,
|
||||
* inside a container whose visibility is gated by ephemeral state - modals,
|
||||
* collapsible panels, popovers, off-screen tab panels. If HMR remounts the
|
||||
* parent during generation (Vite Fast Refresh, SvelteKit page reload), the
|
||||
* variants land in source but stay invisible until the user re-opens the
|
||||
* container. Telling the user upfront is much friendlier than the silent
|
||||
* timeout-then-toast that they'd otherwise hit.
|
||||
*
|
||||
* Heuristic, intentionally narrow — only fires for unambiguous cases so
|
||||
* Heuristic, intentionally narrow - only fires for unambiguous cases so
|
||||
* we don't cry wolf on every nested element.
|
||||
*/
|
||||
function maybeWarnConditionalAncestor(el) {
|
||||
@@ -4804,7 +4821,7 @@
|
||||
showToast('Heads up: this element lives inside an open panel. If state resets during generation, you may need to re-open it.', 6000);
|
||||
return;
|
||||
}
|
||||
// 3. Tab panel — only meaningful when the page also shows ANOTHER
|
||||
// 3. Tab panel - only meaningful when the page also shows ANOTHER
|
||||
// tab as selected. A single tabpanel with no tablist is just a static
|
||||
// section in disguise and isn't conditional.
|
||||
if (node.getAttribute && node.getAttribute('role') === 'tabpanel') {
|
||||
@@ -4833,7 +4850,7 @@
|
||||
// Fire a lightweight prefetch event the first time the user selects an
|
||||
// element on a given route. The agent uses this to Read the underlying file
|
||||
// into context before Go is hit, shaving the read off the critical path.
|
||||
// Dedupe per session by pathname — clicking around on the same page doesn't
|
||||
// Dedupe per session by pathname - clicking around on the same page doesn't
|
||||
// re-fire.
|
||||
//
|
||||
// DISABLED: quick-Go workflows pay an extra harness round trip because
|
||||
@@ -4969,6 +4986,7 @@
|
||||
disableInlineEdit();
|
||||
stripManualEditRuntimeState(selectedElement);
|
||||
|
||||
pendingAcceptedSession = null;
|
||||
currentSessionId = id8();
|
||||
expectedVariants = selectedCount;
|
||||
arrivedVariants = 0;
|
||||
@@ -4976,7 +4994,7 @@
|
||||
|
||||
// Flip to GENERATING immediately so the bar morphs without waiting on
|
||||
// capture + upload. The event is emitted from captureAndEmit() once the
|
||||
// screenshot is uploaded (or capture fails — we still emit, just without
|
||||
// screenshot is uploaded (or capture fails - we still emit, just without
|
||||
// screenshotPath).
|
||||
const elForCapture = selectedElement;
|
||||
const captureRect = elForCapture.getBoundingClientRect();
|
||||
@@ -5002,7 +5020,7 @@
|
||||
state = 'GENERATING';
|
||||
// Disable the Edit badge: starting a manual text edit mid-generation would
|
||||
// conflict with the variant wrap that's about to land in the same DOM
|
||||
// region. Only swap if the badge was visible — picked elements with no
|
||||
// region. Only swap if the badge was visible - picked elements with no
|
||||
// text rows have it hidden already.
|
||||
if (editBadgeEl && editBadgeEl.style.display !== 'none') renderEditBadge('idle-disabled');
|
||||
showBar('generating');
|
||||
@@ -5104,7 +5122,7 @@
|
||||
|
||||
// Collect @font-face rules from every stylesheet on the page. Cross-origin
|
||||
// sheets (Google Fonts, Typekit, etc.) throw SecurityError on .cssRules
|
||||
// access, so modern-screenshot can't embed them on its own — the resulting
|
||||
// access, so modern-screenshot can't embed them on its own - the resulting
|
||||
// SVG falls back to system fonts and text re-wraps + renders with different
|
||||
// weight. We fetch the raw CSS text (CORS-permitted for these providers),
|
||||
// extract @font-face blocks, inline the referenced font files as base64
|
||||
@@ -5188,7 +5206,7 @@
|
||||
// modern-screenshot force-sets `background-color: X !important` on the
|
||||
// cloned root whenever `backgroundColor` is passed, clobbering the
|
||||
// element's own background. So we only pass it when the element is
|
||||
// genuinely transparent (no own color, no own image) — in that case
|
||||
// genuinely transparent (no own color, no own image) - in that case
|
||||
// we resolve up the DOM to the nearest opaque ancestor so the capture
|
||||
// sits on the page's real background instead of rendering black.
|
||||
function resolveCanvasBackground(el) {
|
||||
@@ -5206,7 +5224,7 @@
|
||||
// `getComputedStyle(body).backgroundColor || …` chain is a trap: that
|
||||
// call returns the literal string `"rgba(0, 0, 0, 0)"` for a page that
|
||||
// never set its own bg, which is truthy and short-circuits the chain to
|
||||
// transparent-black — modern-screenshot then renders the capture on a
|
||||
// transparent-black - modern-screenshot then renders the capture on a
|
||||
// black canvas and the shader overlay flashes solid black during load.
|
||||
// The browser canvas defaults to white, so we do too.
|
||||
return '#ffffff';
|
||||
@@ -5248,7 +5266,7 @@
|
||||
// Transparent up to the root. The visible backdrop may still come from an
|
||||
// ancestor's background-image or a covering positioned layer (e.g. a hero
|
||||
// art div) that the color walk can't see. Capture that ancestor and crop
|
||||
// to the element so the real backdrop is embedded — correct for both the
|
||||
// to the element so the real backdrop is embedded - correct for both the
|
||||
// shader and the screenshot sent to the model. Fall back to white only
|
||||
// when nothing is actually painted behind the element.
|
||||
const backdrop = findBackdropAncestor(el);
|
||||
@@ -5289,13 +5307,13 @@
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] capture failed, proceeding without screenshot:', err);
|
||||
}
|
||||
// Light up the shader overlay the moment capture is ready — no reason to
|
||||
// Light up the shader overlay the moment capture is ready - no reason to
|
||||
// wait for the upload to complete before the user sees something alive.
|
||||
if (blob && state === 'GENERATING') {
|
||||
showShaderOverlay(el, blob, rect, paper);
|
||||
}
|
||||
// Only upload + forward the screenshot when annotations (comments/strokes)
|
||||
// are present. Without annotations the image is pure visual anchoring —
|
||||
// are present. Without annotations the image is pure visual anchoring -
|
||||
// it biases the model toward the current rendering and works against the
|
||||
// three-distinct-directions brief.
|
||||
const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0);
|
||||
@@ -5320,7 +5338,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shader overlay — renders the captured screenshot as a WebGL texture and
|
||||
// Shader overlay - renders the captured screenshot as a WebGL texture and
|
||||
// runs an editorial "ink-wash" fragment shader over it during generation.
|
||||
// A single rolling band sweeps top-to-bottom, desaturating + tinting kinpaku
|
||||
// and leaving a soft trail. Makes the wait feel like a letterpress scan
|
||||
@@ -5343,7 +5361,7 @@ uniform vec3 u_accent;
|
||||
uniform vec3 u_paper;
|
||||
varying vec2 v_uv;
|
||||
|
||||
// Asymmetric roller band. Product of two one-sided smoothsteps — peaks at
|
||||
// Asymmetric roller band. Product of two one-sided smoothsteps - peaks at
|
||||
// d=0 with a short sharp leading ramp and a longer soft trailing tail. Clean
|
||||
// outside the [-leadW, trailW] range (no rogue "trail=1 everywhere below"
|
||||
// failure that reversed-edge smoothstep would give).
|
||||
@@ -5369,8 +5387,8 @@ void main() {
|
||||
vec2 sampleCenter = (cellId + 0.5) * cellPx / u_resolution;
|
||||
vec3 cellImg = texture2D(u_texture, sampleCenter).rgb;
|
||||
// Dot size tracks how much the cell DIFFERS from the element's own ground
|
||||
// (u_paper), not absolute darkness. So the content — text, buttons, anything
|
||||
// that deviates from the background — always becomes the dots, on light AND
|
||||
// (u_paper), not absolute darkness. So the content - text, buttons, anything
|
||||
// that deviates from the background - always becomes the dots, on light AND
|
||||
// dark surfaces. A plain darkness curve inverts on dark elements: the dark
|
||||
// background fills with ink and the lighter content punches holes instead.
|
||||
// Capped below the cell half-width so dense content stays separated dots.
|
||||
@@ -5380,8 +5398,8 @@ void main() {
|
||||
// Two-stage dissolve as the roller passes, so the element is rebuilt purely
|
||||
// from dot size (its own halftone) and never bleeds through as raw pixels
|
||||
// behind the dots:
|
||||
// 1. cover — the element flattens to the uniform paper ground first.
|
||||
// 2. dotAmt — kinpaku dots then emerge, sized by each cell's luma.
|
||||
// 1. cover - the element flattens to the uniform paper ground first.
|
||||
// 2. dotAmt - kinpaku dots then emerge, sized by each cell's luma.
|
||||
// A plain mix(base, halftone, band) instead left the raw element visible
|
||||
// through the band's soft core/trail. The paper ground is u_paper (the
|
||||
// element's own bg tone) rather than a fixed white, so the dissolve reads the
|
||||
@@ -5399,7 +5417,7 @@ void main() {
|
||||
|
||||
// Kinpaku gold converted to approximate sRGB 0-1 (matches oklch(84% 0.19 80.46))
|
||||
const SHADER_ACCENT = [1.0, 0.78, 0.31];
|
||||
// Fallback ground when an element and all its ancestors are transparent —
|
||||
// Fallback ground when an element and all its ancestors are transparent -
|
||||
// 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 }
|
||||
@@ -5412,7 +5430,7 @@ void main() {
|
||||
// Rasterize any CSS color (oklch, color(), named, hex, rgb) through a 1x1
|
||||
// canvas and read back the sRGB pixel. String-parsing computed colors is a
|
||||
// trap: Chrome returns backgroundColor as oklch()/color() for oklch inputs,
|
||||
// which a hex/rgb regex misses — every site token would fall back to white.
|
||||
// which a hex/rgb regex misses - every site token would fall back to white.
|
||||
let colorParseCtx = null;
|
||||
function cssColorToRgb01(str) {
|
||||
if (!colorParseCtx) {
|
||||
@@ -5440,7 +5458,7 @@ void main() {
|
||||
|
||||
// When an element is transparent up to the root, its visible backdrop can
|
||||
// still come from an ancestor's background-image or a covering positioned
|
||||
// layer that is a *child* of an ancestor (e.g. a hero's absolute art div) —
|
||||
// layer that is a *child* of an ancestor (e.g. a hero's absolute art div) -
|
||||
// neither of which the ancestor background-COLOR walk can see. Return the
|
||||
// nearest such ancestor so we can capture it and crop, embedding the real
|
||||
// backdrop. Returns null when nothing is actually painted behind the element
|
||||
@@ -5481,7 +5499,7 @@ void main() {
|
||||
|
||||
// Average the backdrop sampled just OUTSIDE an element's rect within a larger
|
||||
// canvas. The ground tone for the dissolve must be the real backdrop, not the
|
||||
// mean of the element's own crop — averaging the crop folds in the element's
|
||||
// mean of the element's own crop - averaging the crop folds in the element's
|
||||
// content (e.g. bright heading text), pulling the ground toward muddy gray.
|
||||
function sampleSurroundingRgb(ctx, sx, sy, sw, sh, W, H) {
|
||||
const pad = Math.max(2, Math.round(Math.min(sw, sh) * 0.12));
|
||||
@@ -5528,11 +5546,31 @@ void main() {
|
||||
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;
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
canvas.remove();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const fallback = document.createElement('div');
|
||||
fallback.id = PREFIX + '-shader';
|
||||
// Copy positioning via cssText. Object.assign across CSSStyleDeclaration
|
||||
// throws in modern Chromium because the source's indexed properties
|
||||
// (style[0], [1], ...) are read-only and the engine forbids writing
|
||||
// them on the destination.
|
||||
fallback.style.cssText = canvas.style.cssText;
|
||||
fallback.style.backgroundImage = 'url("' + objectUrl + '")';
|
||||
fallback.style.backgroundSize = '100% 100%';
|
||||
fallback.style.backgroundRepeat = 'no-repeat';
|
||||
fallback.style.outline = '2px dashed ' + C.brand;
|
||||
fallback.style.outlineOffset = '-2px';
|
||||
document.body.appendChild(fallback);
|
||||
shaderState = { canvas: fallback, gl: null, program: null, texture: null, rafId: 0, startTime: 0, objectUrl };
|
||||
}
|
||||
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
@@ -5553,21 +5591,9 @@ void main() {
|
||||
const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false })
|
||||
|| canvas.getContext('experimental-webgl');
|
||||
if (!gl) {
|
||||
// WebGL unavailable — fall back to a plain <img> overlay so the user
|
||||
// still sees something meaningful during generation.
|
||||
canvas.remove();
|
||||
const img = document.createElement('img');
|
||||
img.src = URL.createObjectURL(blob);
|
||||
img.id = PREFIX + '-shader';
|
||||
// Copy positioning via cssText. Object.assign across CSSStyleDeclaration
|
||||
// throws in modern Chromium because the source's indexed properties
|
||||
// (style[0], [1], ...) are read-only and the engine forbids writing
|
||||
// them on the destination.
|
||||
img.style.cssText = canvas.style.cssText;
|
||||
img.style.outline = '2px dashed ' + C.brand;
|
||||
img.style.outlineOffset = '-2px';
|
||||
document.body.appendChild(img);
|
||||
shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 };
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5609,14 +5635,12 @@ void main() {
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch {
|
||||
// Safari fallback: go via a regular Image
|
||||
const imgUrl = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
img.src = imgUrl;
|
||||
await new Promise((r, rej) => { img.onload = r; img.onerror = rej; });
|
||||
bitmap = img;
|
||||
URL.revokeObjectURL(imgUrl);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
@@ -5670,14 +5694,15 @@ void main() {
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
// The accepted variant is already the only visible child of the wrapper
|
||||
// (all other variants are display:none). HMR from the source rewrite will
|
||||
// replace the wrapper imminently. Don't eagerly replaceChild here — React
|
||||
// reconciliation races with our mutation and throws NotFoundError in Next
|
||||
// 16 / Turbopack. Schedule a fallback that runs the manual swap only if
|
||||
// HMR hasn't cleaned up by then (keeps static-server flows working).
|
||||
const acceptedSessionId = currentSessionId;
|
||||
const acceptedVariant = visibleVariant;
|
||||
const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant);
|
||||
pendingAcceptedSession = {
|
||||
id: acceptedSessionId,
|
||||
variant: String(acceptedVariant),
|
||||
...acceptedSnapshot,
|
||||
finalizing: false,
|
||||
};
|
||||
|
||||
state = 'SAVING';
|
||||
updateBarContent('saving');
|
||||
@@ -5685,53 +5710,130 @@ void main() {
|
||||
sendEvent(acceptPayload, { throwOnError: true })
|
||||
.then(() => {
|
||||
markSessionHandled();
|
||||
confirmAcceptAfterReceipt();
|
||||
})
|
||||
.catch(() => {
|
||||
pendingAcceptedSession = null;
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
}
|
||||
|
||||
function confirmAcceptAfterReceipt() {
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
scheduleAcceptCleanup();
|
||||
function maybeCompleteAcceptedSession(msg) {
|
||||
const pending = pendingAcceptedSession;
|
||||
if (!pending || !msg?.id || msg.id !== pending.id) return false;
|
||||
if (currentSessionId && currentSessionId !== pending.id) {
|
||||
pendingAcceptedSession = null;
|
||||
return false;
|
||||
}
|
||||
if (pending.finalizing) return true;
|
||||
pending.finalizing = true;
|
||||
|
||||
function scheduleAcceptCleanup() {
|
||||
setTimeout(function() {
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
selectedElement = null;
|
||||
currentSessionId = null;
|
||||
selectedAction = 'impeccable';
|
||||
renderEditBadge('hidden');
|
||||
state = 'PICKING';
|
||||
}, 1800);
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
|
||||
// Static-server / no-HMR fallback: if the wrapper is still around 2s after
|
||||
// the cleanup above, swap it out manually. By now React has either moved
|
||||
// on or the app isn't React at all. Preserve the `data-impeccable-variant="N"`
|
||||
// div (with display:contents) so @scope rules anchored to the variant
|
||||
// attribute keep matching until reload replaces it with the carbonize block.
|
||||
// Give framework HMR a short chance to render the now-clean accepted
|
||||
// source. If it misses the update, unwrap the accepted variant after the
|
||||
// source-side completion event so the page is not left empty or stale.
|
||||
setTimeout(function() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + acceptedSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + acceptedVariant + '"]');
|
||||
if (accepted && accepted.firstElementChild) {
|
||||
const parent = wrapper.parentElement;
|
||||
if (!parent) return;
|
||||
accepted.style.display = 'contents';
|
||||
parent.replaceChild(accepted, wrapper);
|
||||
}
|
||||
}, 2000);
|
||||
ensureAcceptedDomClean(pending);
|
||||
cleanupAcceptedSession();
|
||||
}, 1200);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
acceptedHtml: accepted ? accepted.innerHTML : '',
|
||||
acceptedSelector: selectorForAcceptedRoot(root),
|
||||
parentElement: wrapper?.parentElement || null,
|
||||
parentSelector: selectorForAcceptedRoot(wrapper?.parentElement || null),
|
||||
nextSibling: wrapper?.nextSibling || null,
|
||||
};
|
||||
}
|
||||
|
||||
function selectorForAcceptedRoot(root) {
|
||||
if (!root || !root.tagName) return '';
|
||||
const tag = root.tagName.toLowerCase();
|
||||
const classes = [...(root.classList || [])].filter(Boolean);
|
||||
if (classes.length === 0) return tag;
|
||||
return tag + classes.map((cls) => '.' + cssIdent(cls)).join('');
|
||||
}
|
||||
|
||||
function acceptedDomAlreadyClean(pending) {
|
||||
if (!pending?.acceptedSelector) return false;
|
||||
const matches = [...document.querySelectorAll(pending.acceptedSelector)];
|
||||
return matches.some((el) => !el.closest('[data-impeccable-variants],[data-impeccable-variant]'));
|
||||
}
|
||||
|
||||
function ensureAcceptedDomClean(pending) {
|
||||
const sessionId = pending?.id;
|
||||
const variantId = pending?.variant;
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!wrapper) {
|
||||
restoreAcceptedDomFromSnapshot(pending);
|
||||
return;
|
||||
}
|
||||
if (!accepted) {
|
||||
wrapper.remove();
|
||||
restoreAcceptedDomFromSnapshot(pending);
|
||||
return;
|
||||
}
|
||||
const parent = wrapper.parentElement;
|
||||
if (!parent) return;
|
||||
while (accepted.firstChild) {
|
||||
parent.insertBefore(accepted.firstChild, wrapper);
|
||||
}
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
function restoreAcceptedDomFromSnapshot(pending) {
|
||||
if (acceptedDomAlreadyClean(pending)) return;
|
||||
if (!pending?.acceptedHtml) {
|
||||
reloadAfterMissingAcceptedDom(pending);
|
||||
return;
|
||||
}
|
||||
const parent = pending.parentElement?.isConnected
|
||||
? pending.parentElement
|
||||
: (pending.parentSelector ? document.querySelector(pending.parentSelector) : null);
|
||||
if (!parent) {
|
||||
reloadAfterMissingAcceptedDom(pending);
|
||||
return;
|
||||
}
|
||||
const template = document.createElement('template');
|
||||
template.innerHTML = pending.acceptedHtml;
|
||||
const anchor = pending.nextSibling?.isConnected && pending.nextSibling.parentElement === parent
|
||||
? pending.nextSibling
|
||||
: null;
|
||||
parent.insertBefore(template.content, anchor);
|
||||
if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending);
|
||||
}
|
||||
|
||||
function reloadAfterMissingAcceptedDom(pending) {
|
||||
if (acceptedDomAlreadyClean(pending)) return;
|
||||
if (pending?.id && document.querySelector('[data-impeccable-variants="' + pending.id + '"]')) return;
|
||||
location.reload();
|
||||
}
|
||||
|
||||
function cleanupAcceptedSession() {
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
selectedElement = null;
|
||||
currentSessionId = null;
|
||||
selectedAction = 'impeccable';
|
||||
pendingAcceptedSession = null;
|
||||
renderEditBadge('hidden');
|
||||
state = 'PICKING';
|
||||
}
|
||||
|
||||
function handleDiscard() {
|
||||
@@ -5792,7 +5894,7 @@ void main() {
|
||||
|
||||
function cleanup() {
|
||||
// Hide the wrapper immediately so variants disappear. DON'T structurally
|
||||
// mutate the DOM yet — HMR from the agent's source rewrite is on its way,
|
||||
// mutate the DOM yet - HMR from the agent's source rewrite is on its way,
|
||||
// and a manual replaceChild under React causes NotFoundError when the
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
@@ -5838,8 +5940,8 @@ void main() {
|
||||
function showToast(message, duration) {
|
||||
if (toastEl) toastEl.remove();
|
||||
// Stack the toast above the global bar (which sits at bottom:14px) so
|
||||
// the two never overlap. Read the bar's actual rect — its height varies
|
||||
// with hover-expanded labels — and fall back to a sensible default
|
||||
// the two never overlap. Read the bar's actual rect - its height varies
|
||||
// with hover-expanded labels - and fall back to a sensible default
|
||||
// when the bar isn't mounted yet.
|
||||
const barRect = globalBarEl?.getBoundingClientRect();
|
||||
const barTopFromBottom = barRect && barRect.height > 0
|
||||
@@ -6047,7 +6149,7 @@ void main() {
|
||||
let pendingApplyInFlight = false;
|
||||
let firstSaveOfSession = true;
|
||||
|
||||
// Steer — collapsed pill in the global bar; expands while typing for page-level chat.
|
||||
// Steer - collapsed pill in the global bar; expands while typing for page-level chat.
|
||||
let pageChatEl = null;
|
||||
let pageChatInput = null;
|
||||
let pageChatHint = null;
|
||||
@@ -6068,7 +6170,7 @@ void main() {
|
||||
const STEER_AWAIT_TIMEOUT_MS = 120000;
|
||||
const AGENT_STATUS_POLL_MS = 5000;
|
||||
const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)';
|
||||
const AGENT_DISCONNECTED_TIP = 'Agent disconnected — run live-poll.mjs to connect';
|
||||
const AGENT_DISCONNECTED_TIP = 'Agent disconnected: run live-poll.mjs to connect';
|
||||
const GLOBAL_BAR_SECTION_GAP = 8;
|
||||
const GLOBAL_BAR_INNER_GAP = 2;
|
||||
const GLOBAL_BAR_INNER_PAD_LEFT = 2;
|
||||
@@ -6079,7 +6181,7 @@ void main() {
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>';
|
||||
|
||||
// Theme-aware color palette for the global bar. We detect the page's
|
||||
// ambient background and invert — dark bar on light pages, light bar on
|
||||
// ambient background and invert - dark bar on light pages, light bar on
|
||||
// dark pages. This keeps the bar from fighting with the host design.
|
||||
function detectPageTheme() {
|
||||
try {
|
||||
@@ -6092,7 +6194,7 @@ void main() {
|
||||
// Walk body → html, taking the first opaque background. The browser's
|
||||
// default body / html background is `rgba(0, 0, 0, 0)`, which a naive
|
||||
// regex would read as black and mislabel a perfectly white page as
|
||||
// dark. Honoring alpha avoids that — and falling through to <html>
|
||||
// dark. Honoring alpha avoids that - and falling through to <html>
|
||||
// catches the common pattern of a bg only on <html> (or only on body).
|
||||
function readOpaque(el) {
|
||||
if (!el) return null;
|
||||
@@ -6141,7 +6243,7 @@ void main() {
|
||||
exitHover: 'oklch(58% 0.15 35 / 0.18)',
|
||||
shadow: PICKER_SHADOW,
|
||||
chatSurface: 'oklch(22% 0.012 82)',
|
||||
// Verdigris patina — secondary state (see site/styles/kinpaku-tokens.css)
|
||||
// Verdigris patina - secondary state (see site/styles/kinpaku-tokens.css)
|
||||
patina: 'oklch(70% 0.12 188)',
|
||||
patinaPale: 'oklch(82% 0.07 188)',
|
||||
patinaSoft: 'oklch(70% 0.12 188 / 0.28)',
|
||||
@@ -6913,7 +7015,7 @@ void main() {
|
||||
steerFocusLog('page-chat-mounted', {});
|
||||
}
|
||||
|
||||
// Impeccable mark — same paths as site/components/Header.astro + favicon.svg.
|
||||
// Impeccable mark - same paths as site/components/Header.astro + favicon.svg.
|
||||
function brandMarkSvg(color = C.brand, size = 18) {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="${color}" aria-hidden="true">
|
||||
<path d="M5 2.5 L13.5 2.5 L5.5 21.5 L5 21.5 Q2.5 21.5 2.5 19 L2.5 5 Q2.5 2.5 5 2.5 Z"/>
|
||||
@@ -6928,7 +7030,7 @@ void main() {
|
||||
globalBarBrandEl.dataset.agentConnected = connected ? 'true' : 'false';
|
||||
globalBarBrandEl.setAttribute('aria-label', connected
|
||||
? 'Impeccable live mode'
|
||||
: 'Impeccable live mode — agent not polling');
|
||||
: 'Impeccable live mode: agent not polling');
|
||||
globalBarBrandEl.removeAttribute('title');
|
||||
globalBarBrandEl.style.cursor = connected ? 'default' : 'help';
|
||||
const mark = globalBarBrandEl.querySelector('[data-brand-mark]');
|
||||
@@ -7053,7 +7155,7 @@ void main() {
|
||||
globalBarEl.id = PREFIX + '-global-bar';
|
||||
globalBarEl.dataset.theme = theme;
|
||||
|
||||
// Brand mark — kinpaku Impeccable icon (site header / favicon paths).
|
||||
// Brand mark - kinpaku Impeccable icon (site header / favicon paths).
|
||||
const brand = el('span', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
alignSelf: 'stretch', position: 'relative',
|
||||
@@ -7065,7 +7167,7 @@ void main() {
|
||||
brand.id = PREFIX + '-global-bar-brand';
|
||||
brand.dataset.agentConnected = 'false';
|
||||
brand.setAttribute('role', 'img');
|
||||
brand.setAttribute('aria-label', 'Impeccable live mode — agent not polling');
|
||||
brand.setAttribute('aria-label', 'Impeccable live mode: agent not polling');
|
||||
|
||||
const brandMark = el('span', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
@@ -7130,7 +7232,7 @@ void main() {
|
||||
// Per-button hover only changes color (no layout). The label expand/
|
||||
// collapse is driven by the bar-level mouseenter/mouseleave so moving
|
||||
// the mouse between adjacent buttons doesn't trigger per-button width
|
||||
// thrashing — the whole bar grows once and shrinks once.
|
||||
// thrashing - the whole bar grows once and shrinks once.
|
||||
b.addEventListener('mouseenter', () => { if (b.dataset.active !== 'true') b.style.color = P.text; });
|
||||
b.addEventListener('mouseleave', () => { if (b.dataset.active !== 'true') b.style.color = P.textDim; });
|
||||
b.addEventListener('click', onClick);
|
||||
@@ -7139,7 +7241,7 @@ void main() {
|
||||
return b;
|
||||
}
|
||||
|
||||
// Pick toggle — restored from localStorage; both pick and insert may be off.
|
||||
// Pick toggle - restored from localStorage; both pick and insert may be off.
|
||||
const pickBtn = makeIconBtn({
|
||||
id: PREFIX + '-pick-toggle',
|
||||
svg: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><circle cx="12" cy="12" r="10"/><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><line x1="12" y1="6" x2="12" y2="2"/><line x1="12" y1="22" x2="12" y2="18"/></svg>',
|
||||
@@ -7176,7 +7278,7 @@ void main() {
|
||||
detectBtn.appendChild(detectBadge);
|
||||
inner.appendChild(detectBtn);
|
||||
|
||||
// DESIGN.md panel toggle — quartet of color squares as the mark.
|
||||
// DESIGN.md panel toggle - quartet of color squares as the mark.
|
||||
const designBtn = makeIconBtn({
|
||||
id: PREFIX + '-design-toggle',
|
||||
svg: `<span style="display:inline-grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;width:14px;height:14px;border-radius:3px;overflow:hidden;box-shadow:inset 0 0 0 1px oklch(58% 0.065 82 / 0.55);flex-shrink:0">
|
||||
@@ -7378,13 +7480,13 @@ void main() {
|
||||
});
|
||||
inner.appendChild(divider);
|
||||
|
||||
// Exit × on the right — intentionally subtle (textDim at rest, text on
|
||||
// Exit × on the right - intentionally subtle (textDim at rest, text on
|
||||
// hover) so it sits behind the active toggles in visual hierarchy.
|
||||
//
|
||||
// Explicit padding + box-sizing here is load-bearing: a host page like
|
||||
// `button { padding: 0.5rem 1rem; }` (very common in resets) would
|
||||
// otherwise inflate this 24x24 button into 56x40 and push the SVG out
|
||||
// of the visible bar — the X stays invisible even though the styles in
|
||||
// of the visible bar - the X stays invisible even though the styles in
|
||||
// DevTools look fine. Every other chrome button sets padding inline;
|
||||
// this one needed it too.
|
||||
const exitBtn = el('button', {
|
||||
@@ -7472,7 +7574,7 @@ void main() {
|
||||
btn.style.opacity = controlsLocked ? '0.55' : '1';
|
||||
});
|
||||
|
||||
// If the bar is currently under the cursor, keep all labels expanded —
|
||||
// If the bar is currently under the cursor, keep all labels expanded -
|
||||
// otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
|
||||
// would collapse its label while the user's mouse is still on the bar.
|
||||
if (globalBarEl && globalBarEl.matches(':hover')) {
|
||||
@@ -7648,7 +7750,7 @@ void main() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Design System Panel — visualizes the project's .impeccable/design.json sidecar
|
||||
// Design System Panel - visualizes the project's .impeccable/design.json sidecar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DESIGN_PREFS_KEY = 'impeccable-live-design-panel';
|
||||
@@ -7674,7 +7776,7 @@ void main() {
|
||||
};
|
||||
|
||||
function loadDesignPrefs() {
|
||||
// `open` is intentionally NOT persisted — the panel always starts closed
|
||||
// `open` is intentionally NOT persisted - the panel always starts closed
|
||||
// so live mode doesn't auto-slide a big panel over the page on startup.
|
||||
try {
|
||||
const raw = localStorage.getItem(DESIGN_PREFS_KEY);
|
||||
@@ -7731,7 +7833,7 @@ void main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Neutral panel palette — deliberately NOT Impeccable-branded. The panel is
|
||||
// Neutral panel palette - deliberately NOT Impeccable-branded. The panel is
|
||||
// a viewer of the project's design system, not an Impeccable surface.
|
||||
const DP = {
|
||||
canvas: 'oklch(94% 0 0)', // panel background
|
||||
@@ -8025,7 +8127,7 @@ void main() {
|
||||
const root = designShadow.querySelector('.root');
|
||||
root.innerHTML = '';
|
||||
|
||||
// (Panel toggle lives in the global bar — no floating FAB.)
|
||||
// (Panel toggle lives in the global bar - no floating FAB.)
|
||||
// Panel
|
||||
const panel = document.createElement('aside');
|
||||
panel.className = 'panel';
|
||||
@@ -8141,7 +8243,7 @@ void main() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Visual tab — single unified render path.
|
||||
// Visual tab - single unified render path.
|
||||
if (designState.mdNewerThanJson) body.appendChild(renderStaleHint());
|
||||
if (designState.hasMd && !designState.hasSidecar) {
|
||||
body.appendChild(renderParsedMdCta());
|
||||
@@ -8357,7 +8459,7 @@ void main() {
|
||||
specimen.style.fontFamily = fontStack(t);
|
||||
specimen.style.fontWeight = String(t.weight || 400);
|
||||
specimen.style.fontStyle = t.style || 'normal';
|
||||
specimen.style.fontSize = '56px'; // Fixed specimen size — compare faces, not scales.
|
||||
specimen.style.fontSize = '56px'; // Fixed specimen size - compare faces, not scales.
|
||||
specimen.style.letterSpacing = 'normal';
|
||||
specimen.style.textTransform = 'none';
|
||||
tile.appendChild(specimen);
|
||||
@@ -8501,7 +8603,7 @@ void main() {
|
||||
}
|
||||
|
||||
// Single shared description if all items carry the same one; otherwise
|
||||
// skip — per-item descriptions clutter a grouped tile.
|
||||
// skip - per-item descriptions clutter a grouped tile.
|
||||
if (group.length === 1 && group[0].description) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'c-desc';
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Impeccable Live Variant Mode — Browser Script
|
||||
* Impeccable Live Variant Mode - Browser Script
|
||||
*
|
||||
* Injected into the user's page via <script src="http://localhost:PORT/live.js">.
|
||||
* The server prepends window.__IMPECCABLE_TOKEN__ and window.__IMPECCABLE_PORT__
|
||||
* before this code.
|
||||
*
|
||||
* UI: a single floating bar that morphs between three states —
|
||||
* UI: a single floating bar that morphs between three states -
|
||||
* configure (pick action + go), generating (progressive dots), and cycling
|
||||
* (prev/next + accept/discard). Feels like Spotlight, not a modal.
|
||||
*/
|
||||
@@ -46,7 +46,7 @@
|
||||
mist: 'oklch(90% 0.008 82 / 0.6)', // light hairline
|
||||
white: 'oklch(99% 0 0)',
|
||||
};
|
||||
// Picker bar chrome — mirrors .live-demo-gbar / .live-demo-ctx in kinpaku-kit.css.
|
||||
// Picker bar chrome - mirrors .live-demo-gbar / .live-demo-ctx in kinpaku-kit.css.
|
||||
// Quiet neutral elevation: no gold halo ring (gold is reserved for the brand
|
||||
// mark and the active control, not the container outline).
|
||||
const PICKER_SHADOW =
|
||||
@@ -83,7 +83,7 @@
|
||||
|
||||
// SVG icons stack above each chip label. All strokes use currentColor so the
|
||||
// icon recolors to C.brand when its chip is selected. 20x20 render, 24-viewBox,
|
||||
// 1.5 stroke — visually consistent with the Foundation grid on the homepage.
|
||||
// 1.5 stroke - visually consistent with the Foundation grid on the homepage.
|
||||
const ICON_ATTRS = 'width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="display:block"';
|
||||
const ICONS = {
|
||||
impeccable: `<svg ${ICON_ATTRS}><path d="M4 20l4-1L18 9l-3-3L5 16z"/><path d="M14 7l3 3"/></svg>`,
|
||||
@@ -123,6 +123,7 @@
|
||||
let hoveredElement = null;
|
||||
let selectedElement = null;
|
||||
let currentSessionId = null;
|
||||
let pendingAcceptedSession = null;
|
||||
let expectedVariants = 0;
|
||||
let arrivedVariants = 0;
|
||||
let visibleVariant = 0;
|
||||
@@ -133,7 +134,7 @@
|
||||
const browserOwner = sessionState.owner;
|
||||
let checkpointTimer = null;
|
||||
|
||||
// Scroll lock — holds window.scrollY at a fixed value while the session is
|
||||
// Scroll lock - holds window.scrollY at a fixed value while the session is
|
||||
// active, so HMR DOM patches and variant swaps can't drift the page. See
|
||||
// startScrollLock / stopScrollLock below.
|
||||
let scrollLockObserver = null;
|
||||
@@ -141,7 +142,7 @@
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
// Dedicated key for scroll position — SEPARATE from LS_KEY so that
|
||||
// Dedicated key for scroll position - SEPARATE from LS_KEY so that
|
||||
// saveSession's state updates don't clobber a carefully-captured scrollY.
|
||||
// (Previously: saveSession wrote scrollY alongside state, so every call
|
||||
// during resume overwrote the pre-reload value with whatever the browser
|
||||
@@ -231,7 +232,7 @@
|
||||
// - Stop `focusin` propagation so any focus shifts inside our chrome
|
||||
// don't read as "focus moved outside the dialog" to focus traps.
|
||||
//
|
||||
// Click events still bubble normally — only the early pointer/focus
|
||||
// Click events still bubble normally - only the early pointer/focus
|
||||
// signals that drive outside-interaction detection are silenced.
|
||||
function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) {
|
||||
if (!rootEl) return;
|
||||
@@ -319,7 +320,7 @@
|
||||
// correlate directly with the captured PNG.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DRAG_THRESHOLD = 5; // px — below this, treat pointerup as a click
|
||||
const DRAG_THRESHOLD = 5; // px - below this, treat pointerup as a click
|
||||
const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it
|
||||
let annotOverlayEl = null;
|
||||
let annotSvgEl = null;
|
||||
@@ -423,7 +424,7 @@
|
||||
placeholderResizeDrag = null;
|
||||
if (annotOverlayEl) annotOverlayEl.style.display = 'none';
|
||||
syncPlaceholderResizeHandles();
|
||||
// Drop any in-progress edit without touching annotState — clearAnnotations
|
||||
// Drop any in-progress edit without touching annotState - clearAnnotations
|
||||
// (if the caller is exiting configure mode) handles state reset.
|
||||
annotEditing = null;
|
||||
}
|
||||
@@ -488,7 +489,7 @@
|
||||
function onAnnotDown(e) {
|
||||
if (!annotActive) return;
|
||||
|
||||
// 0) Insert placeholder edge resize — wins over draw / pins.
|
||||
// 0) Insert placeholder edge resize - wins over draw / pins.
|
||||
const resizeEdge = e.target.closest?.('[data-impeccable-placeholder-resize]')?.dataset.impeccablePlaceholderResize;
|
||||
if (resizeEdge && configureKind === 'insert' && placeholderElement) {
|
||||
startPlaceholderEdgeResize(resizeEdge, e);
|
||||
@@ -536,7 +537,7 @@
|
||||
// If editing a different pin, commit that edit before starting here.
|
||||
if (annotEditing && annotEditing.idx !== idx) finalizeEditingPin();
|
||||
// If already editing THIS pin and the user clicked the dot, let the
|
||||
// input keep focus (don't start a drag — the click wasn't meant as one).
|
||||
// input keep focus (don't start a drag - the click wasn't meant as one).
|
||||
if (annotEditing && annotEditing.idx === idx) return;
|
||||
const p = localCoords(e);
|
||||
const pin = annotState.comments[idx];
|
||||
@@ -971,7 +972,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The Bar — one floating element, three modes
|
||||
// The Bar - one floating element, three modes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Contextual-bar palette. Cached at init so every build*Row reads a
|
||||
@@ -1752,7 +1753,7 @@
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
});
|
||||
|
||||
// Action pill — dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill)
|
||||
// Action pill - dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill)
|
||||
const pill = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
||||
padding: '5px 10px', borderRadius: '6px',
|
||||
@@ -1786,7 +1787,7 @@
|
||||
});
|
||||
row.appendChild(pill);
|
||||
|
||||
// Prompt field — same chat-surface chrome as the bottom Steer bar
|
||||
// Prompt field - same chat-surface chrome as the bottom Steer bar
|
||||
const inputWrap = el('div', {
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
flex: '1', minWidth: '0', height: '28px',
|
||||
@@ -2126,7 +2127,7 @@
|
||||
if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
|
||||
row.appendChild(next);
|
||||
|
||||
// Tune chip — only when the visible variant exposes params
|
||||
// Tune chip - only when the visible variant exposes params
|
||||
const visParams = parseVariantParams(getVisibleVariantEl());
|
||||
const hasParams = visParams.length > 0;
|
||||
if (hasParams) {
|
||||
@@ -2173,7 +2174,7 @@
|
||||
// Spacer
|
||||
row.appendChild(el('div', { flex: '1' }));
|
||||
|
||||
// Accept — primary action, kinpaku gold + lacquer-deep (matches demo .live-demo-ctx-accept)
|
||||
// Accept - primary action, kinpaku gold + lacquer-deep (matches demo .live-demo-ctx-accept)
|
||||
const accept = el('button', {
|
||||
padding: '5px 14px', borderRadius: '5px',
|
||||
border: 'none', background: C.brand, color: C.ink,
|
||||
@@ -2266,7 +2267,7 @@
|
||||
const active = i === visibleVariant;
|
||||
// active: solid site-brand kinpaku dot. arrived+inactive: muted neutral.
|
||||
// pending (not yet arrived): faint outline ring. No borders on arrived
|
||||
// dots — the previous "accent ring + ash fill" combo read as noisy
|
||||
// dots - the previous "accent ring + ash fill" combo read as noisy
|
||||
// kinpaku chips, especially when all variants had arrived and every
|
||||
// dot wore an accent ring.
|
||||
const dotBg = active ? C.brand
|
||||
@@ -2456,7 +2457,7 @@
|
||||
let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide)
|
||||
let paramsPanelInner = null; // translating content (carries bg, padding, knobs)
|
||||
let paramsPanelBody = null; // grid holding the knob cells
|
||||
let paramsCurrentValues = {}; // {paramId: value} — mirror of the visible variant's live values
|
||||
let paramsCurrentValues = {}; // {paramId: value} - mirror of the visible variant's live values
|
||||
let tuneOpen = false; // whether the Tune popover is open right now
|
||||
|
||||
// Theme-aware Tune popover. Appears as a drawer that slides out from the
|
||||
@@ -2471,7 +2472,7 @@
|
||||
const P = paramsPanelPalette;
|
||||
|
||||
// Single element, always in the DOM. The slide animation is a CSS mask
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis — no
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis - no
|
||||
// display toggle, no opacity toggle, no transform trickery. The mask
|
||||
// hides everything initially; as it grows, content is revealed from
|
||||
// the bar edge outward.
|
||||
@@ -2494,7 +2495,7 @@
|
||||
transition: 'clip-path 0.44s ' + EASE,
|
||||
|
||||
// Park off-screen until positionParamsPanel places it. These are NOT
|
||||
// in the transition list, so they snap instantly — no fly-in from the
|
||||
// in the transition list, so they snap instantly - no fly-in from the
|
||||
// top-left when first shown.
|
||||
top: '-9999px', left: '-9999px', width: '0',
|
||||
});
|
||||
@@ -2685,7 +2686,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline text editing — makes pure-text descendants of the picked element
|
||||
// Inline text editing - makes pure-text descendants of the picked element
|
||||
// directly contenteditable. Save stages copy edits in the live buffer; the
|
||||
// Apply copy edits dock later asks the AI to apply the staged batch.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -3132,7 +3133,7 @@
|
||||
if (detail.includes('newText cannot contain') || detail.includes('newText cannot be empty')) {
|
||||
showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500);
|
||||
} else {
|
||||
showToast('Save failed — retry or cancel', 4000);
|
||||
showToast('Save failed: retry or cancel', 4000);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3454,19 +3455,19 @@
|
||||
updatePendingCounter(remaining);
|
||||
if (result.failed && result.failed.length > 0) {
|
||||
console.warn('[impeccable] some copy edits failed:', result.failed);
|
||||
showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed — see console', 5000);
|
||||
showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed, see console', 5000);
|
||||
} else {
|
||||
const n = Array.isArray(result.applied) ? result.applied.length : (result.cleared || 0);
|
||||
if (n > 0) {
|
||||
showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500);
|
||||
} else {
|
||||
console.warn('[impeccable] apply returned no verified edits:', result);
|
||||
showToast('No edits applied — see console', 4000);
|
||||
showToast('No edits applied, see console', 4000);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[impeccable] commit failed:', err);
|
||||
showToast('Apply failed — see console', 4000);
|
||||
showToast('Apply failed, see console', 4000);
|
||||
} finally {
|
||||
if (waitForSseCompletion) return;
|
||||
const remainingCount = parseInt(pendingPillEl?.dataset.count || '0', 10) || 0;
|
||||
@@ -3496,7 +3497,7 @@
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[impeccable] discard failed:', err);
|
||||
showToast('Discard failed — see console', 4000);
|
||||
showToast('Discard failed, see console', 4000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3644,7 +3645,7 @@
|
||||
const failedCount = numberOrNull(msg.failedCount) || 0;
|
||||
const appliedCount = numberOrNull(msg.appliedCount) || numberOrNull(msg.cleared) || 0;
|
||||
if (failedCount > 0) {
|
||||
showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed — see console', 5000);
|
||||
showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed, see console', 5000);
|
||||
} else if (appliedCount > 0) {
|
||||
showToast('Applied ' + appliedCount + ' edit' + (appliedCount === 1 ? '' : 's'), 2500);
|
||||
}
|
||||
@@ -3799,7 +3800,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit content badge — floating button at element top-right to enter EDITING mode
|
||||
// Edit content badge - floating button at element top-right to enter EDITING mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initEditBadge() {
|
||||
@@ -3884,7 +3885,7 @@
|
||||
}
|
||||
editBadgeEl.appendChild(btn);
|
||||
} else {
|
||||
// 'editing' — show Cancel + Save separated
|
||||
// 'editing' - show Cancel + Save separated
|
||||
editBadgeEl.innerHTML = '';
|
||||
editBadgeEl.style.gap = '8px';
|
||||
const cancel = document.createElement('button');
|
||||
@@ -4083,7 +4084,7 @@
|
||||
if (!v) continue;
|
||||
setVariantShown(child, v === String(num));
|
||||
}
|
||||
// Unconditional refresh — covers first-reveal (no-op if state isn't
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
// CYCLING yet, the subsequent CYCLING transition triggers its own
|
||||
// refresh) and every cycle step.
|
||||
refreshParamsPanel();
|
||||
@@ -4318,7 +4319,7 @@
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
}, { passive: true, ...sig });
|
||||
|
||||
// Apply target synchronously, not via rAF — racing the browser's
|
||||
// Apply target synchronously, not via rAF - racing the browser's
|
||||
// restore or a smooth-scroll animation means we want to win now.
|
||||
if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) {
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
@@ -4545,18 +4546,34 @@
|
||||
// hidden tab, a route the user navigated away from). The variant
|
||||
// MutationObserver stays armed and auto-transitions to CYCLING
|
||||
// the moment the wrapper actually mounts. Nudge the user toward
|
||||
// that path with a toast — better than the prior force-reload
|
||||
// that path with a toast - better than the prior force-reload
|
||||
// which reset framework state and left the session stuck.
|
||||
setTimeout(() => {
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) return;
|
||||
if (state !== 'GENERATING') return;
|
||||
showToast(
|
||||
"Variants ready. If the picked element isn't visible, retrace the path that revealed it — they'll appear automatically.",
|
||||
"Variants ready. If the picked element isn't visible, retrace the path that revealed it; they'll appear automatically.",
|
||||
15000,
|
||||
);
|
||||
}, 2000);
|
||||
break;
|
||||
case 'complete':
|
||||
case 'accept':
|
||||
if (maybeCompleteAcceptedSession(msg)) break;
|
||||
break;
|
||||
case 'agent_done':
|
||||
// Carbonize accepts are not terminal until live-complete.mjs sends
|
||||
// the final complete event. Keep the browser in its recoverable
|
||||
// saving state while the source cleanup is still in flight.
|
||||
break;
|
||||
case 'error':
|
||||
if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) {
|
||||
pendingAcceptedSession = null;
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
break;
|
||||
}
|
||||
if (maybeCompleteSteer(msg)) break;
|
||||
console.error('[impeccable] Error:', msg.message);
|
||||
showToast('Error: ' + msg.message, 5000);
|
||||
@@ -4779,14 +4796,14 @@
|
||||
|
||||
/**
|
||||
* Surface a brief, non-blocking heads-up when the picked element lives
|
||||
* inside a container whose visibility is gated by ephemeral state — modals,
|
||||
* inside a container whose visibility is gated by ephemeral state - modals,
|
||||
* collapsible panels, popovers, off-screen tab panels. If HMR remounts the
|
||||
* parent during generation (Vite Fast Refresh, SvelteKit page reload), the
|
||||
* variants land in source but stay invisible until the user re-opens the
|
||||
* container. Telling the user upfront is much friendlier than the silent
|
||||
* timeout-then-toast that they'd otherwise hit.
|
||||
*
|
||||
* Heuristic, intentionally narrow — only fires for unambiguous cases so
|
||||
* Heuristic, intentionally narrow - only fires for unambiguous cases so
|
||||
* we don't cry wolf on every nested element.
|
||||
*/
|
||||
function maybeWarnConditionalAncestor(el) {
|
||||
@@ -4804,7 +4821,7 @@
|
||||
showToast('Heads up: this element lives inside an open panel. If state resets during generation, you may need to re-open it.', 6000);
|
||||
return;
|
||||
}
|
||||
// 3. Tab panel — only meaningful when the page also shows ANOTHER
|
||||
// 3. Tab panel - only meaningful when the page also shows ANOTHER
|
||||
// tab as selected. A single tabpanel with no tablist is just a static
|
||||
// section in disguise and isn't conditional.
|
||||
if (node.getAttribute && node.getAttribute('role') === 'tabpanel') {
|
||||
@@ -4833,7 +4850,7 @@
|
||||
// Fire a lightweight prefetch event the first time the user selects an
|
||||
// element on a given route. The agent uses this to Read the underlying file
|
||||
// into context before Go is hit, shaving the read off the critical path.
|
||||
// Dedupe per session by pathname — clicking around on the same page doesn't
|
||||
// Dedupe per session by pathname - clicking around on the same page doesn't
|
||||
// re-fire.
|
||||
//
|
||||
// DISABLED: quick-Go workflows pay an extra harness round trip because
|
||||
@@ -4969,6 +4986,7 @@
|
||||
disableInlineEdit();
|
||||
stripManualEditRuntimeState(selectedElement);
|
||||
|
||||
pendingAcceptedSession = null;
|
||||
currentSessionId = id8();
|
||||
expectedVariants = selectedCount;
|
||||
arrivedVariants = 0;
|
||||
@@ -4976,7 +4994,7 @@
|
||||
|
||||
// Flip to GENERATING immediately so the bar morphs without waiting on
|
||||
// capture + upload. The event is emitted from captureAndEmit() once the
|
||||
// screenshot is uploaded (or capture fails — we still emit, just without
|
||||
// screenshot is uploaded (or capture fails - we still emit, just without
|
||||
// screenshotPath).
|
||||
const elForCapture = selectedElement;
|
||||
const captureRect = elForCapture.getBoundingClientRect();
|
||||
@@ -5002,7 +5020,7 @@
|
||||
state = 'GENERATING';
|
||||
// Disable the Edit badge: starting a manual text edit mid-generation would
|
||||
// conflict with the variant wrap that's about to land in the same DOM
|
||||
// region. Only swap if the badge was visible — picked elements with no
|
||||
// region. Only swap if the badge was visible - picked elements with no
|
||||
// text rows have it hidden already.
|
||||
if (editBadgeEl && editBadgeEl.style.display !== 'none') renderEditBadge('idle-disabled');
|
||||
showBar('generating');
|
||||
@@ -5104,7 +5122,7 @@
|
||||
|
||||
// Collect @font-face rules from every stylesheet on the page. Cross-origin
|
||||
// sheets (Google Fonts, Typekit, etc.) throw SecurityError on .cssRules
|
||||
// access, so modern-screenshot can't embed them on its own — the resulting
|
||||
// access, so modern-screenshot can't embed them on its own - the resulting
|
||||
// SVG falls back to system fonts and text re-wraps + renders with different
|
||||
// weight. We fetch the raw CSS text (CORS-permitted for these providers),
|
||||
// extract @font-face blocks, inline the referenced font files as base64
|
||||
@@ -5188,7 +5206,7 @@
|
||||
// modern-screenshot force-sets `background-color: X !important` on the
|
||||
// cloned root whenever `backgroundColor` is passed, clobbering the
|
||||
// element's own background. So we only pass it when the element is
|
||||
// genuinely transparent (no own color, no own image) — in that case
|
||||
// genuinely transparent (no own color, no own image) - in that case
|
||||
// we resolve up the DOM to the nearest opaque ancestor so the capture
|
||||
// sits on the page's real background instead of rendering black.
|
||||
function resolveCanvasBackground(el) {
|
||||
@@ -5206,7 +5224,7 @@
|
||||
// `getComputedStyle(body).backgroundColor || …` chain is a trap: that
|
||||
// call returns the literal string `"rgba(0, 0, 0, 0)"` for a page that
|
||||
// never set its own bg, which is truthy and short-circuits the chain to
|
||||
// transparent-black — modern-screenshot then renders the capture on a
|
||||
// transparent-black - modern-screenshot then renders the capture on a
|
||||
// black canvas and the shader overlay flashes solid black during load.
|
||||
// The browser canvas defaults to white, so we do too.
|
||||
return '#ffffff';
|
||||
@@ -5248,7 +5266,7 @@
|
||||
// Transparent up to the root. The visible backdrop may still come from an
|
||||
// ancestor's background-image or a covering positioned layer (e.g. a hero
|
||||
// art div) that the color walk can't see. Capture that ancestor and crop
|
||||
// to the element so the real backdrop is embedded — correct for both the
|
||||
// to the element so the real backdrop is embedded - correct for both the
|
||||
// shader and the screenshot sent to the model. Fall back to white only
|
||||
// when nothing is actually painted behind the element.
|
||||
const backdrop = findBackdropAncestor(el);
|
||||
@@ -5289,13 +5307,13 @@
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] capture failed, proceeding without screenshot:', err);
|
||||
}
|
||||
// Light up the shader overlay the moment capture is ready — no reason to
|
||||
// Light up the shader overlay the moment capture is ready - no reason to
|
||||
// wait for the upload to complete before the user sees something alive.
|
||||
if (blob && state === 'GENERATING') {
|
||||
showShaderOverlay(el, blob, rect, paper);
|
||||
}
|
||||
// Only upload + forward the screenshot when annotations (comments/strokes)
|
||||
// are present. Without annotations the image is pure visual anchoring —
|
||||
// are present. Without annotations the image is pure visual anchoring -
|
||||
// it biases the model toward the current rendering and works against the
|
||||
// three-distinct-directions brief.
|
||||
const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0);
|
||||
@@ -5320,7 +5338,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shader overlay — renders the captured screenshot as a WebGL texture and
|
||||
// Shader overlay - renders the captured screenshot as a WebGL texture and
|
||||
// runs an editorial "ink-wash" fragment shader over it during generation.
|
||||
// A single rolling band sweeps top-to-bottom, desaturating + tinting kinpaku
|
||||
// and leaving a soft trail. Makes the wait feel like a letterpress scan
|
||||
@@ -5343,7 +5361,7 @@ uniform vec3 u_accent;
|
||||
uniform vec3 u_paper;
|
||||
varying vec2 v_uv;
|
||||
|
||||
// Asymmetric roller band. Product of two one-sided smoothsteps — peaks at
|
||||
// Asymmetric roller band. Product of two one-sided smoothsteps - peaks at
|
||||
// d=0 with a short sharp leading ramp and a longer soft trailing tail. Clean
|
||||
// outside the [-leadW, trailW] range (no rogue "trail=1 everywhere below"
|
||||
// failure that reversed-edge smoothstep would give).
|
||||
@@ -5369,8 +5387,8 @@ void main() {
|
||||
vec2 sampleCenter = (cellId + 0.5) * cellPx / u_resolution;
|
||||
vec3 cellImg = texture2D(u_texture, sampleCenter).rgb;
|
||||
// Dot size tracks how much the cell DIFFERS from the element's own ground
|
||||
// (u_paper), not absolute darkness. So the content — text, buttons, anything
|
||||
// that deviates from the background — always becomes the dots, on light AND
|
||||
// (u_paper), not absolute darkness. So the content - text, buttons, anything
|
||||
// that deviates from the background - always becomes the dots, on light AND
|
||||
// dark surfaces. A plain darkness curve inverts on dark elements: the dark
|
||||
// background fills with ink and the lighter content punches holes instead.
|
||||
// Capped below the cell half-width so dense content stays separated dots.
|
||||
@@ -5380,8 +5398,8 @@ void main() {
|
||||
// Two-stage dissolve as the roller passes, so the element is rebuilt purely
|
||||
// from dot size (its own halftone) and never bleeds through as raw pixels
|
||||
// behind the dots:
|
||||
// 1. cover — the element flattens to the uniform paper ground first.
|
||||
// 2. dotAmt — kinpaku dots then emerge, sized by each cell's luma.
|
||||
// 1. cover - the element flattens to the uniform paper ground first.
|
||||
// 2. dotAmt - kinpaku dots then emerge, sized by each cell's luma.
|
||||
// A plain mix(base, halftone, band) instead left the raw element visible
|
||||
// through the band's soft core/trail. The paper ground is u_paper (the
|
||||
// element's own bg tone) rather than a fixed white, so the dissolve reads the
|
||||
@@ -5399,7 +5417,7 @@ void main() {
|
||||
|
||||
// Kinpaku gold converted to approximate sRGB 0-1 (matches oklch(84% 0.19 80.46))
|
||||
const SHADER_ACCENT = [1.0, 0.78, 0.31];
|
||||
// Fallback ground when an element and all its ancestors are transparent —
|
||||
// Fallback ground when an element and all its ancestors are transparent -
|
||||
// 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 }
|
||||
@@ -5412,7 +5430,7 @@ void main() {
|
||||
// Rasterize any CSS color (oklch, color(), named, hex, rgb) through a 1x1
|
||||
// canvas and read back the sRGB pixel. String-parsing computed colors is a
|
||||
// trap: Chrome returns backgroundColor as oklch()/color() for oklch inputs,
|
||||
// which a hex/rgb regex misses — every site token would fall back to white.
|
||||
// which a hex/rgb regex misses - every site token would fall back to white.
|
||||
let colorParseCtx = null;
|
||||
function cssColorToRgb01(str) {
|
||||
if (!colorParseCtx) {
|
||||
@@ -5440,7 +5458,7 @@ void main() {
|
||||
|
||||
// When an element is transparent up to the root, its visible backdrop can
|
||||
// still come from an ancestor's background-image or a covering positioned
|
||||
// layer that is a *child* of an ancestor (e.g. a hero's absolute art div) —
|
||||
// layer that is a *child* of an ancestor (e.g. a hero's absolute art div) -
|
||||
// neither of which the ancestor background-COLOR walk can see. Return the
|
||||
// nearest such ancestor so we can capture it and crop, embedding the real
|
||||
// backdrop. Returns null when nothing is actually painted behind the element
|
||||
@@ -5481,7 +5499,7 @@ void main() {
|
||||
|
||||
// Average the backdrop sampled just OUTSIDE an element's rect within a larger
|
||||
// canvas. The ground tone for the dissolve must be the real backdrop, not the
|
||||
// mean of the element's own crop — averaging the crop folds in the element's
|
||||
// mean of the element's own crop - averaging the crop folds in the element's
|
||||
// content (e.g. bright heading text), pulling the ground toward muddy gray.
|
||||
function sampleSurroundingRgb(ctx, sx, sy, sw, sh, W, H) {
|
||||
const pad = Math.max(2, Math.round(Math.min(sw, sh) * 0.12));
|
||||
@@ -5528,11 +5546,31 @@ void main() {
|
||||
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;
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
canvas.remove();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const fallback = document.createElement('div');
|
||||
fallback.id = PREFIX + '-shader';
|
||||
// Copy positioning via cssText. Object.assign across CSSStyleDeclaration
|
||||
// throws in modern Chromium because the source's indexed properties
|
||||
// (style[0], [1], ...) are read-only and the engine forbids writing
|
||||
// them on the destination.
|
||||
fallback.style.cssText = canvas.style.cssText;
|
||||
fallback.style.backgroundImage = 'url("' + objectUrl + '")';
|
||||
fallback.style.backgroundSize = '100% 100%';
|
||||
fallback.style.backgroundRepeat = 'no-repeat';
|
||||
fallback.style.outline = '2px dashed ' + C.brand;
|
||||
fallback.style.outlineOffset = '-2px';
|
||||
document.body.appendChild(fallback);
|
||||
shaderState = { canvas: fallback, gl: null, program: null, texture: null, rafId: 0, startTime: 0, objectUrl };
|
||||
}
|
||||
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
@@ -5553,21 +5591,9 @@ void main() {
|
||||
const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false })
|
||||
|| canvas.getContext('experimental-webgl');
|
||||
if (!gl) {
|
||||
// WebGL unavailable — fall back to a plain <img> overlay so the user
|
||||
// still sees something meaningful during generation.
|
||||
canvas.remove();
|
||||
const img = document.createElement('img');
|
||||
img.src = URL.createObjectURL(blob);
|
||||
img.id = PREFIX + '-shader';
|
||||
// Copy positioning via cssText. Object.assign across CSSStyleDeclaration
|
||||
// throws in modern Chromium because the source's indexed properties
|
||||
// (style[0], [1], ...) are read-only and the engine forbids writing
|
||||
// them on the destination.
|
||||
img.style.cssText = canvas.style.cssText;
|
||||
img.style.outline = '2px dashed ' + C.brand;
|
||||
img.style.outlineOffset = '-2px';
|
||||
document.body.appendChild(img);
|
||||
shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 };
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5609,14 +5635,12 @@ void main() {
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch {
|
||||
// Safari fallback: go via a regular Image
|
||||
const imgUrl = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
img.src = imgUrl;
|
||||
await new Promise((r, rej) => { img.onload = r; img.onerror = rej; });
|
||||
bitmap = img;
|
||||
URL.revokeObjectURL(imgUrl);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
@@ -5670,14 +5694,15 @@ void main() {
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
// The accepted variant is already the only visible child of the wrapper
|
||||
// (all other variants are display:none). HMR from the source rewrite will
|
||||
// replace the wrapper imminently. Don't eagerly replaceChild here — React
|
||||
// reconciliation races with our mutation and throws NotFoundError in Next
|
||||
// 16 / Turbopack. Schedule a fallback that runs the manual swap only if
|
||||
// HMR hasn't cleaned up by then (keeps static-server flows working).
|
||||
const acceptedSessionId = currentSessionId;
|
||||
const acceptedVariant = visibleVariant;
|
||||
const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant);
|
||||
pendingAcceptedSession = {
|
||||
id: acceptedSessionId,
|
||||
variant: String(acceptedVariant),
|
||||
...acceptedSnapshot,
|
||||
finalizing: false,
|
||||
};
|
||||
|
||||
state = 'SAVING';
|
||||
updateBarContent('saving');
|
||||
@@ -5685,53 +5710,130 @@ void main() {
|
||||
sendEvent(acceptPayload, { throwOnError: true })
|
||||
.then(() => {
|
||||
markSessionHandled();
|
||||
confirmAcceptAfterReceipt();
|
||||
})
|
||||
.catch(() => {
|
||||
pendingAcceptedSession = null;
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
}
|
||||
|
||||
function confirmAcceptAfterReceipt() {
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
scheduleAcceptCleanup();
|
||||
function maybeCompleteAcceptedSession(msg) {
|
||||
const pending = pendingAcceptedSession;
|
||||
if (!pending || !msg?.id || msg.id !== pending.id) return false;
|
||||
if (currentSessionId && currentSessionId !== pending.id) {
|
||||
pendingAcceptedSession = null;
|
||||
return false;
|
||||
}
|
||||
if (pending.finalizing) return true;
|
||||
pending.finalizing = true;
|
||||
|
||||
function scheduleAcceptCleanup() {
|
||||
setTimeout(function() {
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
selectedElement = null;
|
||||
currentSessionId = null;
|
||||
selectedAction = 'impeccable';
|
||||
renderEditBadge('hidden');
|
||||
state = 'PICKING';
|
||||
}, 1800);
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
|
||||
// Static-server / no-HMR fallback: if the wrapper is still around 2s after
|
||||
// the cleanup above, swap it out manually. By now React has either moved
|
||||
// on or the app isn't React at all. Preserve the `data-impeccable-variant="N"`
|
||||
// div (with display:contents) so @scope rules anchored to the variant
|
||||
// attribute keep matching until reload replaces it with the carbonize block.
|
||||
// Give framework HMR a short chance to render the now-clean accepted
|
||||
// source. If it misses the update, unwrap the accepted variant after the
|
||||
// source-side completion event so the page is not left empty or stale.
|
||||
setTimeout(function() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + acceptedSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + acceptedVariant + '"]');
|
||||
if (accepted && accepted.firstElementChild) {
|
||||
const parent = wrapper.parentElement;
|
||||
if (!parent) return;
|
||||
accepted.style.display = 'contents';
|
||||
parent.replaceChild(accepted, wrapper);
|
||||
}
|
||||
}, 2000);
|
||||
ensureAcceptedDomClean(pending);
|
||||
cleanupAcceptedSession();
|
||||
}, 1200);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
acceptedHtml: accepted ? accepted.innerHTML : '',
|
||||
acceptedSelector: selectorForAcceptedRoot(root),
|
||||
parentElement: wrapper?.parentElement || null,
|
||||
parentSelector: selectorForAcceptedRoot(wrapper?.parentElement || null),
|
||||
nextSibling: wrapper?.nextSibling || null,
|
||||
};
|
||||
}
|
||||
|
||||
function selectorForAcceptedRoot(root) {
|
||||
if (!root || !root.tagName) return '';
|
||||
const tag = root.tagName.toLowerCase();
|
||||
const classes = [...(root.classList || [])].filter(Boolean);
|
||||
if (classes.length === 0) return tag;
|
||||
return tag + classes.map((cls) => '.' + cssIdent(cls)).join('');
|
||||
}
|
||||
|
||||
function acceptedDomAlreadyClean(pending) {
|
||||
if (!pending?.acceptedSelector) return false;
|
||||
const matches = [...document.querySelectorAll(pending.acceptedSelector)];
|
||||
return matches.some((el) => !el.closest('[data-impeccable-variants],[data-impeccable-variant]'));
|
||||
}
|
||||
|
||||
function ensureAcceptedDomClean(pending) {
|
||||
const sessionId = pending?.id;
|
||||
const variantId = pending?.variant;
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!wrapper) {
|
||||
restoreAcceptedDomFromSnapshot(pending);
|
||||
return;
|
||||
}
|
||||
if (!accepted) {
|
||||
wrapper.remove();
|
||||
restoreAcceptedDomFromSnapshot(pending);
|
||||
return;
|
||||
}
|
||||
const parent = wrapper.parentElement;
|
||||
if (!parent) return;
|
||||
while (accepted.firstChild) {
|
||||
parent.insertBefore(accepted.firstChild, wrapper);
|
||||
}
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
function restoreAcceptedDomFromSnapshot(pending) {
|
||||
if (acceptedDomAlreadyClean(pending)) return;
|
||||
if (!pending?.acceptedHtml) {
|
||||
reloadAfterMissingAcceptedDom(pending);
|
||||
return;
|
||||
}
|
||||
const parent = pending.parentElement?.isConnected
|
||||
? pending.parentElement
|
||||
: (pending.parentSelector ? document.querySelector(pending.parentSelector) : null);
|
||||
if (!parent) {
|
||||
reloadAfterMissingAcceptedDom(pending);
|
||||
return;
|
||||
}
|
||||
const template = document.createElement('template');
|
||||
template.innerHTML = pending.acceptedHtml;
|
||||
const anchor = pending.nextSibling?.isConnected && pending.nextSibling.parentElement === parent
|
||||
? pending.nextSibling
|
||||
: null;
|
||||
parent.insertBefore(template.content, anchor);
|
||||
if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending);
|
||||
}
|
||||
|
||||
function reloadAfterMissingAcceptedDom(pending) {
|
||||
if (acceptedDomAlreadyClean(pending)) return;
|
||||
if (pending?.id && document.querySelector('[data-impeccable-variants="' + pending.id + '"]')) return;
|
||||
location.reload();
|
||||
}
|
||||
|
||||
function cleanupAcceptedSession() {
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
selectedElement = null;
|
||||
currentSessionId = null;
|
||||
selectedAction = 'impeccable';
|
||||
pendingAcceptedSession = null;
|
||||
renderEditBadge('hidden');
|
||||
state = 'PICKING';
|
||||
}
|
||||
|
||||
function handleDiscard() {
|
||||
@@ -5792,7 +5894,7 @@ void main() {
|
||||
|
||||
function cleanup() {
|
||||
// Hide the wrapper immediately so variants disappear. DON'T structurally
|
||||
// mutate the DOM yet — HMR from the agent's source rewrite is on its way,
|
||||
// mutate the DOM yet - HMR from the agent's source rewrite is on its way,
|
||||
// and a manual replaceChild under React causes NotFoundError when the
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
@@ -5838,8 +5940,8 @@ void main() {
|
||||
function showToast(message, duration) {
|
||||
if (toastEl) toastEl.remove();
|
||||
// Stack the toast above the global bar (which sits at bottom:14px) so
|
||||
// the two never overlap. Read the bar's actual rect — its height varies
|
||||
// with hover-expanded labels — and fall back to a sensible default
|
||||
// the two never overlap. Read the bar's actual rect - its height varies
|
||||
// with hover-expanded labels - and fall back to a sensible default
|
||||
// when the bar isn't mounted yet.
|
||||
const barRect = globalBarEl?.getBoundingClientRect();
|
||||
const barTopFromBottom = barRect && barRect.height > 0
|
||||
@@ -6047,7 +6149,7 @@ void main() {
|
||||
let pendingApplyInFlight = false;
|
||||
let firstSaveOfSession = true;
|
||||
|
||||
// Steer — collapsed pill in the global bar; expands while typing for page-level chat.
|
||||
// Steer - collapsed pill in the global bar; expands while typing for page-level chat.
|
||||
let pageChatEl = null;
|
||||
let pageChatInput = null;
|
||||
let pageChatHint = null;
|
||||
@@ -6068,7 +6170,7 @@ void main() {
|
||||
const STEER_AWAIT_TIMEOUT_MS = 120000;
|
||||
const AGENT_STATUS_POLL_MS = 5000;
|
||||
const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)';
|
||||
const AGENT_DISCONNECTED_TIP = 'Agent disconnected — run live-poll.mjs to connect';
|
||||
const AGENT_DISCONNECTED_TIP = 'Agent disconnected: run live-poll.mjs to connect';
|
||||
const GLOBAL_BAR_SECTION_GAP = 8;
|
||||
const GLOBAL_BAR_INNER_GAP = 2;
|
||||
const GLOBAL_BAR_INNER_PAD_LEFT = 2;
|
||||
@@ -6079,7 +6181,7 @@ void main() {
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>';
|
||||
|
||||
// Theme-aware color palette for the global bar. We detect the page's
|
||||
// ambient background and invert — dark bar on light pages, light bar on
|
||||
// ambient background and invert - dark bar on light pages, light bar on
|
||||
// dark pages. This keeps the bar from fighting with the host design.
|
||||
function detectPageTheme() {
|
||||
try {
|
||||
@@ -6092,7 +6194,7 @@ void main() {
|
||||
// Walk body → html, taking the first opaque background. The browser's
|
||||
// default body / html background is `rgba(0, 0, 0, 0)`, which a naive
|
||||
// regex would read as black and mislabel a perfectly white page as
|
||||
// dark. Honoring alpha avoids that — and falling through to <html>
|
||||
// dark. Honoring alpha avoids that - and falling through to <html>
|
||||
// catches the common pattern of a bg only on <html> (or only on body).
|
||||
function readOpaque(el) {
|
||||
if (!el) return null;
|
||||
@@ -6141,7 +6243,7 @@ void main() {
|
||||
exitHover: 'oklch(58% 0.15 35 / 0.18)',
|
||||
shadow: PICKER_SHADOW,
|
||||
chatSurface: 'oklch(22% 0.012 82)',
|
||||
// Verdigris patina — secondary state (see site/styles/kinpaku-tokens.css)
|
||||
// Verdigris patina - secondary state (see site/styles/kinpaku-tokens.css)
|
||||
patina: 'oklch(70% 0.12 188)',
|
||||
patinaPale: 'oklch(82% 0.07 188)',
|
||||
patinaSoft: 'oklch(70% 0.12 188 / 0.28)',
|
||||
@@ -6913,7 +7015,7 @@ void main() {
|
||||
steerFocusLog('page-chat-mounted', {});
|
||||
}
|
||||
|
||||
// Impeccable mark — same paths as site/components/Header.astro + favicon.svg.
|
||||
// Impeccable mark - same paths as site/components/Header.astro + favicon.svg.
|
||||
function brandMarkSvg(color = C.brand, size = 18) {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="${color}" aria-hidden="true">
|
||||
<path d="M5 2.5 L13.5 2.5 L5.5 21.5 L5 21.5 Q2.5 21.5 2.5 19 L2.5 5 Q2.5 2.5 5 2.5 Z"/>
|
||||
@@ -6928,7 +7030,7 @@ void main() {
|
||||
globalBarBrandEl.dataset.agentConnected = connected ? 'true' : 'false';
|
||||
globalBarBrandEl.setAttribute('aria-label', connected
|
||||
? 'Impeccable live mode'
|
||||
: 'Impeccable live mode — agent not polling');
|
||||
: 'Impeccable live mode: agent not polling');
|
||||
globalBarBrandEl.removeAttribute('title');
|
||||
globalBarBrandEl.style.cursor = connected ? 'default' : 'help';
|
||||
const mark = globalBarBrandEl.querySelector('[data-brand-mark]');
|
||||
@@ -7053,7 +7155,7 @@ void main() {
|
||||
globalBarEl.id = PREFIX + '-global-bar';
|
||||
globalBarEl.dataset.theme = theme;
|
||||
|
||||
// Brand mark — kinpaku Impeccable icon (site header / favicon paths).
|
||||
// Brand mark - kinpaku Impeccable icon (site header / favicon paths).
|
||||
const brand = el('span', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
alignSelf: 'stretch', position: 'relative',
|
||||
@@ -7065,7 +7167,7 @@ void main() {
|
||||
brand.id = PREFIX + '-global-bar-brand';
|
||||
brand.dataset.agentConnected = 'false';
|
||||
brand.setAttribute('role', 'img');
|
||||
brand.setAttribute('aria-label', 'Impeccable live mode — agent not polling');
|
||||
brand.setAttribute('aria-label', 'Impeccable live mode: agent not polling');
|
||||
|
||||
const brandMark = el('span', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
@@ -7130,7 +7232,7 @@ void main() {
|
||||
// Per-button hover only changes color (no layout). The label expand/
|
||||
// collapse is driven by the bar-level mouseenter/mouseleave so moving
|
||||
// the mouse between adjacent buttons doesn't trigger per-button width
|
||||
// thrashing — the whole bar grows once and shrinks once.
|
||||
// thrashing - the whole bar grows once and shrinks once.
|
||||
b.addEventListener('mouseenter', () => { if (b.dataset.active !== 'true') b.style.color = P.text; });
|
||||
b.addEventListener('mouseleave', () => { if (b.dataset.active !== 'true') b.style.color = P.textDim; });
|
||||
b.addEventListener('click', onClick);
|
||||
@@ -7139,7 +7241,7 @@ void main() {
|
||||
return b;
|
||||
}
|
||||
|
||||
// Pick toggle — restored from localStorage; both pick and insert may be off.
|
||||
// Pick toggle - restored from localStorage; both pick and insert may be off.
|
||||
const pickBtn = makeIconBtn({
|
||||
id: PREFIX + '-pick-toggle',
|
||||
svg: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><circle cx="12" cy="12" r="10"/><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><line x1="12" y1="6" x2="12" y2="2"/><line x1="12" y1="22" x2="12" y2="18"/></svg>',
|
||||
@@ -7176,7 +7278,7 @@ void main() {
|
||||
detectBtn.appendChild(detectBadge);
|
||||
inner.appendChild(detectBtn);
|
||||
|
||||
// DESIGN.md panel toggle — quartet of color squares as the mark.
|
||||
// DESIGN.md panel toggle - quartet of color squares as the mark.
|
||||
const designBtn = makeIconBtn({
|
||||
id: PREFIX + '-design-toggle',
|
||||
svg: `<span style="display:inline-grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;width:14px;height:14px;border-radius:3px;overflow:hidden;box-shadow:inset 0 0 0 1px oklch(58% 0.065 82 / 0.55);flex-shrink:0">
|
||||
@@ -7378,13 +7480,13 @@ void main() {
|
||||
});
|
||||
inner.appendChild(divider);
|
||||
|
||||
// Exit × on the right — intentionally subtle (textDim at rest, text on
|
||||
// Exit × on the right - intentionally subtle (textDim at rest, text on
|
||||
// hover) so it sits behind the active toggles in visual hierarchy.
|
||||
//
|
||||
// Explicit padding + box-sizing here is load-bearing: a host page like
|
||||
// `button { padding: 0.5rem 1rem; }` (very common in resets) would
|
||||
// otherwise inflate this 24x24 button into 56x40 and push the SVG out
|
||||
// of the visible bar — the X stays invisible even though the styles in
|
||||
// of the visible bar - the X stays invisible even though the styles in
|
||||
// DevTools look fine. Every other chrome button sets padding inline;
|
||||
// this one needed it too.
|
||||
const exitBtn = el('button', {
|
||||
@@ -7472,7 +7574,7 @@ void main() {
|
||||
btn.style.opacity = controlsLocked ? '0.55' : '1';
|
||||
});
|
||||
|
||||
// If the bar is currently under the cursor, keep all labels expanded —
|
||||
// If the bar is currently under the cursor, keep all labels expanded -
|
||||
// otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
|
||||
// would collapse its label while the user's mouse is still on the bar.
|
||||
if (globalBarEl && globalBarEl.matches(':hover')) {
|
||||
@@ -7648,7 +7750,7 @@ void main() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Design System Panel — visualizes the project's .impeccable/design.json sidecar
|
||||
// Design System Panel - visualizes the project's .impeccable/design.json sidecar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DESIGN_PREFS_KEY = 'impeccable-live-design-panel';
|
||||
@@ -7674,7 +7776,7 @@ void main() {
|
||||
};
|
||||
|
||||
function loadDesignPrefs() {
|
||||
// `open` is intentionally NOT persisted — the panel always starts closed
|
||||
// `open` is intentionally NOT persisted - the panel always starts closed
|
||||
// so live mode doesn't auto-slide a big panel over the page on startup.
|
||||
try {
|
||||
const raw = localStorage.getItem(DESIGN_PREFS_KEY);
|
||||
@@ -7731,7 +7833,7 @@ void main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Neutral panel palette — deliberately NOT Impeccable-branded. The panel is
|
||||
// Neutral panel palette - deliberately NOT Impeccable-branded. The panel is
|
||||
// a viewer of the project's design system, not an Impeccable surface.
|
||||
const DP = {
|
||||
canvas: 'oklch(94% 0 0)', // panel background
|
||||
@@ -8025,7 +8127,7 @@ void main() {
|
||||
const root = designShadow.querySelector('.root');
|
||||
root.innerHTML = '';
|
||||
|
||||
// (Panel toggle lives in the global bar — no floating FAB.)
|
||||
// (Panel toggle lives in the global bar - no floating FAB.)
|
||||
// Panel
|
||||
const panel = document.createElement('aside');
|
||||
panel.className = 'panel';
|
||||
@@ -8141,7 +8243,7 @@ void main() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Visual tab — single unified render path.
|
||||
// Visual tab - single unified render path.
|
||||
if (designState.mdNewerThanJson) body.appendChild(renderStaleHint());
|
||||
if (designState.hasMd && !designState.hasSidecar) {
|
||||
body.appendChild(renderParsedMdCta());
|
||||
@@ -8357,7 +8459,7 @@ void main() {
|
||||
specimen.style.fontFamily = fontStack(t);
|
||||
specimen.style.fontWeight = String(t.weight || 400);
|
||||
specimen.style.fontStyle = t.style || 'normal';
|
||||
specimen.style.fontSize = '56px'; // Fixed specimen size — compare faces, not scales.
|
||||
specimen.style.fontSize = '56px'; // Fixed specimen size - compare faces, not scales.
|
||||
specimen.style.letterSpacing = 'normal';
|
||||
specimen.style.textTransform = 'none';
|
||||
tile.appendChild(specimen);
|
||||
@@ -8501,7 +8603,7 @@ void main() {
|
||||
}
|
||||
|
||||
// Single shared description if all items carry the same one; otherwise
|
||||
// skip — per-item descriptions clutter a grouped tile.
|
||||
// skip - per-item descriptions clutter a grouped tile.
|
||||
if (group.length === 1 && group[0].description) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'c-desc';
|
||||
|
||||
+246
-144
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Impeccable Live Variant Mode — Browser Script
|
||||
* Impeccable Live Variant Mode - Browser Script
|
||||
*
|
||||
* Injected into the user's page via <script src="http://localhost:PORT/live.js">.
|
||||
* The server prepends window.__IMPECCABLE_TOKEN__ and window.__IMPECCABLE_PORT__
|
||||
* before this code.
|
||||
*
|
||||
* UI: a single floating bar that morphs between three states —
|
||||
* UI: a single floating bar that morphs between three states -
|
||||
* configure (pick action + go), generating (progressive dots), and cycling
|
||||
* (prev/next + accept/discard). Feels like Spotlight, not a modal.
|
||||
*/
|
||||
@@ -46,7 +46,7 @@
|
||||
mist: 'oklch(90% 0.008 82 / 0.6)', // light hairline
|
||||
white: 'oklch(99% 0 0)',
|
||||
};
|
||||
// Picker bar chrome — mirrors .live-demo-gbar / .live-demo-ctx in kinpaku-kit.css.
|
||||
// Picker bar chrome - mirrors .live-demo-gbar / .live-demo-ctx in kinpaku-kit.css.
|
||||
// Quiet neutral elevation: no gold halo ring (gold is reserved for the brand
|
||||
// mark and the active control, not the container outline).
|
||||
const PICKER_SHADOW =
|
||||
@@ -83,7 +83,7 @@
|
||||
|
||||
// SVG icons stack above each chip label. All strokes use currentColor so the
|
||||
// icon recolors to C.brand when its chip is selected. 20x20 render, 24-viewBox,
|
||||
// 1.5 stroke — visually consistent with the Foundation grid on the homepage.
|
||||
// 1.5 stroke - visually consistent with the Foundation grid on the homepage.
|
||||
const ICON_ATTRS = 'width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="display:block"';
|
||||
const ICONS = {
|
||||
impeccable: `<svg ${ICON_ATTRS}><path d="M4 20l4-1L18 9l-3-3L5 16z"/><path d="M14 7l3 3"/></svg>`,
|
||||
@@ -123,6 +123,7 @@
|
||||
let hoveredElement = null;
|
||||
let selectedElement = null;
|
||||
let currentSessionId = null;
|
||||
let pendingAcceptedSession = null;
|
||||
let expectedVariants = 0;
|
||||
let arrivedVariants = 0;
|
||||
let visibleVariant = 0;
|
||||
@@ -133,7 +134,7 @@
|
||||
const browserOwner = sessionState.owner;
|
||||
let checkpointTimer = null;
|
||||
|
||||
// Scroll lock — holds window.scrollY at a fixed value while the session is
|
||||
// Scroll lock - holds window.scrollY at a fixed value while the session is
|
||||
// active, so HMR DOM patches and variant swaps can't drift the page. See
|
||||
// startScrollLock / stopScrollLock below.
|
||||
let scrollLockObserver = null;
|
||||
@@ -141,7 +142,7 @@
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
// Dedicated key for scroll position — SEPARATE from LS_KEY so that
|
||||
// Dedicated key for scroll position - SEPARATE from LS_KEY so that
|
||||
// saveSession's state updates don't clobber a carefully-captured scrollY.
|
||||
// (Previously: saveSession wrote scrollY alongside state, so every call
|
||||
// during resume overwrote the pre-reload value with whatever the browser
|
||||
@@ -231,7 +232,7 @@
|
||||
// - Stop `focusin` propagation so any focus shifts inside our chrome
|
||||
// don't read as "focus moved outside the dialog" to focus traps.
|
||||
//
|
||||
// Click events still bubble normally — only the early pointer/focus
|
||||
// Click events still bubble normally - only the early pointer/focus
|
||||
// signals that drive outside-interaction detection are silenced.
|
||||
function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) {
|
||||
if (!rootEl) return;
|
||||
@@ -319,7 +320,7 @@
|
||||
// correlate directly with the captured PNG.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DRAG_THRESHOLD = 5; // px — below this, treat pointerup as a click
|
||||
const DRAG_THRESHOLD = 5; // px - below this, treat pointerup as a click
|
||||
const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it
|
||||
let annotOverlayEl = null;
|
||||
let annotSvgEl = null;
|
||||
@@ -423,7 +424,7 @@
|
||||
placeholderResizeDrag = null;
|
||||
if (annotOverlayEl) annotOverlayEl.style.display = 'none';
|
||||
syncPlaceholderResizeHandles();
|
||||
// Drop any in-progress edit without touching annotState — clearAnnotations
|
||||
// Drop any in-progress edit without touching annotState - clearAnnotations
|
||||
// (if the caller is exiting configure mode) handles state reset.
|
||||
annotEditing = null;
|
||||
}
|
||||
@@ -488,7 +489,7 @@
|
||||
function onAnnotDown(e) {
|
||||
if (!annotActive) return;
|
||||
|
||||
// 0) Insert placeholder edge resize — wins over draw / pins.
|
||||
// 0) Insert placeholder edge resize - wins over draw / pins.
|
||||
const resizeEdge = e.target.closest?.('[data-impeccable-placeholder-resize]')?.dataset.impeccablePlaceholderResize;
|
||||
if (resizeEdge && configureKind === 'insert' && placeholderElement) {
|
||||
startPlaceholderEdgeResize(resizeEdge, e);
|
||||
@@ -536,7 +537,7 @@
|
||||
// If editing a different pin, commit that edit before starting here.
|
||||
if (annotEditing && annotEditing.idx !== idx) finalizeEditingPin();
|
||||
// If already editing THIS pin and the user clicked the dot, let the
|
||||
// input keep focus (don't start a drag — the click wasn't meant as one).
|
||||
// input keep focus (don't start a drag - the click wasn't meant as one).
|
||||
if (annotEditing && annotEditing.idx === idx) return;
|
||||
const p = localCoords(e);
|
||||
const pin = annotState.comments[idx];
|
||||
@@ -971,7 +972,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The Bar — one floating element, three modes
|
||||
// The Bar - one floating element, three modes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Contextual-bar palette. Cached at init so every build*Row reads a
|
||||
@@ -1752,7 +1753,7 @@
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
});
|
||||
|
||||
// Action pill — dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill)
|
||||
// Action pill - dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill)
|
||||
const pill = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
||||
padding: '5px 10px', borderRadius: '6px',
|
||||
@@ -1786,7 +1787,7 @@
|
||||
});
|
||||
row.appendChild(pill);
|
||||
|
||||
// Prompt field — same chat-surface chrome as the bottom Steer bar
|
||||
// Prompt field - same chat-surface chrome as the bottom Steer bar
|
||||
const inputWrap = el('div', {
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
flex: '1', minWidth: '0', height: '28px',
|
||||
@@ -2126,7 +2127,7 @@
|
||||
if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
|
||||
row.appendChild(next);
|
||||
|
||||
// Tune chip — only when the visible variant exposes params
|
||||
// Tune chip - only when the visible variant exposes params
|
||||
const visParams = parseVariantParams(getVisibleVariantEl());
|
||||
const hasParams = visParams.length > 0;
|
||||
if (hasParams) {
|
||||
@@ -2173,7 +2174,7 @@
|
||||
// Spacer
|
||||
row.appendChild(el('div', { flex: '1' }));
|
||||
|
||||
// Accept — primary action, kinpaku gold + lacquer-deep (matches demo .live-demo-ctx-accept)
|
||||
// Accept - primary action, kinpaku gold + lacquer-deep (matches demo .live-demo-ctx-accept)
|
||||
const accept = el('button', {
|
||||
padding: '5px 14px', borderRadius: '5px',
|
||||
border: 'none', background: C.brand, color: C.ink,
|
||||
@@ -2266,7 +2267,7 @@
|
||||
const active = i === visibleVariant;
|
||||
// active: solid site-brand kinpaku dot. arrived+inactive: muted neutral.
|
||||
// pending (not yet arrived): faint outline ring. No borders on arrived
|
||||
// dots — the previous "accent ring + ash fill" combo read as noisy
|
||||
// dots - the previous "accent ring + ash fill" combo read as noisy
|
||||
// kinpaku chips, especially when all variants had arrived and every
|
||||
// dot wore an accent ring.
|
||||
const dotBg = active ? C.brand
|
||||
@@ -2456,7 +2457,7 @@
|
||||
let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide)
|
||||
let paramsPanelInner = null; // translating content (carries bg, padding, knobs)
|
||||
let paramsPanelBody = null; // grid holding the knob cells
|
||||
let paramsCurrentValues = {}; // {paramId: value} — mirror of the visible variant's live values
|
||||
let paramsCurrentValues = {}; // {paramId: value} - mirror of the visible variant's live values
|
||||
let tuneOpen = false; // whether the Tune popover is open right now
|
||||
|
||||
// Theme-aware Tune popover. Appears as a drawer that slides out from the
|
||||
@@ -2471,7 +2472,7 @@
|
||||
const P = paramsPanelPalette;
|
||||
|
||||
// Single element, always in the DOM. The slide animation is a CSS mask
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis — no
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis - no
|
||||
// display toggle, no opacity toggle, no transform trickery. The mask
|
||||
// hides everything initially; as it grows, content is revealed from
|
||||
// the bar edge outward.
|
||||
@@ -2494,7 +2495,7 @@
|
||||
transition: 'clip-path 0.44s ' + EASE,
|
||||
|
||||
// Park off-screen until positionParamsPanel places it. These are NOT
|
||||
// in the transition list, so they snap instantly — no fly-in from the
|
||||
// in the transition list, so they snap instantly - no fly-in from the
|
||||
// top-left when first shown.
|
||||
top: '-9999px', left: '-9999px', width: '0',
|
||||
});
|
||||
@@ -2685,7 +2686,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline text editing — makes pure-text descendants of the picked element
|
||||
// Inline text editing - makes pure-text descendants of the picked element
|
||||
// directly contenteditable. Save stages copy edits in the live buffer; the
|
||||
// Apply copy edits dock later asks the AI to apply the staged batch.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -3132,7 +3133,7 @@
|
||||
if (detail.includes('newText cannot contain') || detail.includes('newText cannot be empty')) {
|
||||
showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500);
|
||||
} else {
|
||||
showToast('Save failed — retry or cancel', 4000);
|
||||
showToast('Save failed: retry or cancel', 4000);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3454,19 +3455,19 @@
|
||||
updatePendingCounter(remaining);
|
||||
if (result.failed && result.failed.length > 0) {
|
||||
console.warn('[impeccable] some copy edits failed:', result.failed);
|
||||
showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed — see console', 5000);
|
||||
showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed, see console', 5000);
|
||||
} else {
|
||||
const n = Array.isArray(result.applied) ? result.applied.length : (result.cleared || 0);
|
||||
if (n > 0) {
|
||||
showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500);
|
||||
} else {
|
||||
console.warn('[impeccable] apply returned no verified edits:', result);
|
||||
showToast('No edits applied — see console', 4000);
|
||||
showToast('No edits applied, see console', 4000);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[impeccable] commit failed:', err);
|
||||
showToast('Apply failed — see console', 4000);
|
||||
showToast('Apply failed, see console', 4000);
|
||||
} finally {
|
||||
if (waitForSseCompletion) return;
|
||||
const remainingCount = parseInt(pendingPillEl?.dataset.count || '0', 10) || 0;
|
||||
@@ -3496,7 +3497,7 @@
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[impeccable] discard failed:', err);
|
||||
showToast('Discard failed — see console', 4000);
|
||||
showToast('Discard failed, see console', 4000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3644,7 +3645,7 @@
|
||||
const failedCount = numberOrNull(msg.failedCount) || 0;
|
||||
const appliedCount = numberOrNull(msg.appliedCount) || numberOrNull(msg.cleared) || 0;
|
||||
if (failedCount > 0) {
|
||||
showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed — see console', 5000);
|
||||
showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed, see console', 5000);
|
||||
} else if (appliedCount > 0) {
|
||||
showToast('Applied ' + appliedCount + ' edit' + (appliedCount === 1 ? '' : 's'), 2500);
|
||||
}
|
||||
@@ -3799,7 +3800,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit content badge — floating button at element top-right to enter EDITING mode
|
||||
// Edit content badge - floating button at element top-right to enter EDITING mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initEditBadge() {
|
||||
@@ -3884,7 +3885,7 @@
|
||||
}
|
||||
editBadgeEl.appendChild(btn);
|
||||
} else {
|
||||
// 'editing' — show Cancel + Save separated
|
||||
// 'editing' - show Cancel + Save separated
|
||||
editBadgeEl.innerHTML = '';
|
||||
editBadgeEl.style.gap = '8px';
|
||||
const cancel = document.createElement('button');
|
||||
@@ -4083,7 +4084,7 @@
|
||||
if (!v) continue;
|
||||
setVariantShown(child, v === String(num));
|
||||
}
|
||||
// Unconditional refresh — covers first-reveal (no-op if state isn't
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
// CYCLING yet, the subsequent CYCLING transition triggers its own
|
||||
// refresh) and every cycle step.
|
||||
refreshParamsPanel();
|
||||
@@ -4318,7 +4319,7 @@
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
}, { passive: true, ...sig });
|
||||
|
||||
// Apply target synchronously, not via rAF — racing the browser's
|
||||
// Apply target synchronously, not via rAF - racing the browser's
|
||||
// restore or a smooth-scroll animation means we want to win now.
|
||||
if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) {
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
@@ -4545,18 +4546,34 @@
|
||||
// hidden tab, a route the user navigated away from). The variant
|
||||
// MutationObserver stays armed and auto-transitions to CYCLING
|
||||
// the moment the wrapper actually mounts. Nudge the user toward
|
||||
// that path with a toast — better than the prior force-reload
|
||||
// that path with a toast - better than the prior force-reload
|
||||
// which reset framework state and left the session stuck.
|
||||
setTimeout(() => {
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) return;
|
||||
if (state !== 'GENERATING') return;
|
||||
showToast(
|
||||
"Variants ready. If the picked element isn't visible, retrace the path that revealed it — they'll appear automatically.",
|
||||
"Variants ready. If the picked element isn't visible, retrace the path that revealed it; they'll appear automatically.",
|
||||
15000,
|
||||
);
|
||||
}, 2000);
|
||||
break;
|
||||
case 'complete':
|
||||
case 'accept':
|
||||
if (maybeCompleteAcceptedSession(msg)) break;
|
||||
break;
|
||||
case 'agent_done':
|
||||
// Carbonize accepts are not terminal until live-complete.mjs sends
|
||||
// the final complete event. Keep the browser in its recoverable
|
||||
// saving state while the source cleanup is still in flight.
|
||||
break;
|
||||
case 'error':
|
||||
if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) {
|
||||
pendingAcceptedSession = null;
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not complete accept cleanup with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
break;
|
||||
}
|
||||
if (maybeCompleteSteer(msg)) break;
|
||||
console.error('[impeccable] Error:', msg.message);
|
||||
showToast('Error: ' + msg.message, 5000);
|
||||
@@ -4779,14 +4796,14 @@
|
||||
|
||||
/**
|
||||
* Surface a brief, non-blocking heads-up when the picked element lives
|
||||
* inside a container whose visibility is gated by ephemeral state — modals,
|
||||
* inside a container whose visibility is gated by ephemeral state - modals,
|
||||
* collapsible panels, popovers, off-screen tab panels. If HMR remounts the
|
||||
* parent during generation (Vite Fast Refresh, SvelteKit page reload), the
|
||||
* variants land in source but stay invisible until the user re-opens the
|
||||
* container. Telling the user upfront is much friendlier than the silent
|
||||
* timeout-then-toast that they'd otherwise hit.
|
||||
*
|
||||
* Heuristic, intentionally narrow — only fires for unambiguous cases so
|
||||
* Heuristic, intentionally narrow - only fires for unambiguous cases so
|
||||
* we don't cry wolf on every nested element.
|
||||
*/
|
||||
function maybeWarnConditionalAncestor(el) {
|
||||
@@ -4804,7 +4821,7 @@
|
||||
showToast('Heads up: this element lives inside an open panel. If state resets during generation, you may need to re-open it.', 6000);
|
||||
return;
|
||||
}
|
||||
// 3. Tab panel — only meaningful when the page also shows ANOTHER
|
||||
// 3. Tab panel - only meaningful when the page also shows ANOTHER
|
||||
// tab as selected. A single tabpanel with no tablist is just a static
|
||||
// section in disguise and isn't conditional.
|
||||
if (node.getAttribute && node.getAttribute('role') === 'tabpanel') {
|
||||
@@ -4833,7 +4850,7 @@
|
||||
// Fire a lightweight prefetch event the first time the user selects an
|
||||
// element on a given route. The agent uses this to Read the underlying file
|
||||
// into context before Go is hit, shaving the read off the critical path.
|
||||
// Dedupe per session by pathname — clicking around on the same page doesn't
|
||||
// Dedupe per session by pathname - clicking around on the same page doesn't
|
||||
// re-fire.
|
||||
//
|
||||
// DISABLED: quick-Go workflows pay an extra harness round trip because
|
||||
@@ -4969,6 +4986,7 @@
|
||||
disableInlineEdit();
|
||||
stripManualEditRuntimeState(selectedElement);
|
||||
|
||||
pendingAcceptedSession = null;
|
||||
currentSessionId = id8();
|
||||
expectedVariants = selectedCount;
|
||||
arrivedVariants = 0;
|
||||
@@ -4976,7 +4994,7 @@
|
||||
|
||||
// Flip to GENERATING immediately so the bar morphs without waiting on
|
||||
// capture + upload. The event is emitted from captureAndEmit() once the
|
||||
// screenshot is uploaded (or capture fails — we still emit, just without
|
||||
// screenshot is uploaded (or capture fails - we still emit, just without
|
||||
// screenshotPath).
|
||||
const elForCapture = selectedElement;
|
||||
const captureRect = elForCapture.getBoundingClientRect();
|
||||
@@ -5002,7 +5020,7 @@
|
||||
state = 'GENERATING';
|
||||
// Disable the Edit badge: starting a manual text edit mid-generation would
|
||||
// conflict with the variant wrap that's about to land in the same DOM
|
||||
// region. Only swap if the badge was visible — picked elements with no
|
||||
// region. Only swap if the badge was visible - picked elements with no
|
||||
// text rows have it hidden already.
|
||||
if (editBadgeEl && editBadgeEl.style.display !== 'none') renderEditBadge('idle-disabled');
|
||||
showBar('generating');
|
||||
@@ -5104,7 +5122,7 @@
|
||||
|
||||
// Collect @font-face rules from every stylesheet on the page. Cross-origin
|
||||
// sheets (Google Fonts, Typekit, etc.) throw SecurityError on .cssRules
|
||||
// access, so modern-screenshot can't embed them on its own — the resulting
|
||||
// access, so modern-screenshot can't embed them on its own - the resulting
|
||||
// SVG falls back to system fonts and text re-wraps + renders with different
|
||||
// weight. We fetch the raw CSS text (CORS-permitted for these providers),
|
||||
// extract @font-face blocks, inline the referenced font files as base64
|
||||
@@ -5188,7 +5206,7 @@
|
||||
// modern-screenshot force-sets `background-color: X !important` on the
|
||||
// cloned root whenever `backgroundColor` is passed, clobbering the
|
||||
// element's own background. So we only pass it when the element is
|
||||
// genuinely transparent (no own color, no own image) — in that case
|
||||
// genuinely transparent (no own color, no own image) - in that case
|
||||
// we resolve up the DOM to the nearest opaque ancestor so the capture
|
||||
// sits on the page's real background instead of rendering black.
|
||||
function resolveCanvasBackground(el) {
|
||||
@@ -5206,7 +5224,7 @@
|
||||
// `getComputedStyle(body).backgroundColor || …` chain is a trap: that
|
||||
// call returns the literal string `"rgba(0, 0, 0, 0)"` for a page that
|
||||
// never set its own bg, which is truthy and short-circuits the chain to
|
||||
// transparent-black — modern-screenshot then renders the capture on a
|
||||
// transparent-black - modern-screenshot then renders the capture on a
|
||||
// black canvas and the shader overlay flashes solid black during load.
|
||||
// The browser canvas defaults to white, so we do too.
|
||||
return '#ffffff';
|
||||
@@ -5248,7 +5266,7 @@
|
||||
// Transparent up to the root. The visible backdrop may still come from an
|
||||
// ancestor's background-image or a covering positioned layer (e.g. a hero
|
||||
// art div) that the color walk can't see. Capture that ancestor and crop
|
||||
// to the element so the real backdrop is embedded — correct for both the
|
||||
// to the element so the real backdrop is embedded - correct for both the
|
||||
// shader and the screenshot sent to the model. Fall back to white only
|
||||
// when nothing is actually painted behind the element.
|
||||
const backdrop = findBackdropAncestor(el);
|
||||
@@ -5289,13 +5307,13 @@
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] capture failed, proceeding without screenshot:', err);
|
||||
}
|
||||
// Light up the shader overlay the moment capture is ready — no reason to
|
||||
// Light up the shader overlay the moment capture is ready - no reason to
|
||||
// wait for the upload to complete before the user sees something alive.
|
||||
if (blob && state === 'GENERATING') {
|
||||
showShaderOverlay(el, blob, rect, paper);
|
||||
}
|
||||
// Only upload + forward the screenshot when annotations (comments/strokes)
|
||||
// are present. Without annotations the image is pure visual anchoring —
|
||||
// are present. Without annotations the image is pure visual anchoring -
|
||||
// it biases the model toward the current rendering and works against the
|
||||
// three-distinct-directions brief.
|
||||
const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0);
|
||||
@@ -5320,7 +5338,7 @@
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shader overlay — renders the captured screenshot as a WebGL texture and
|
||||
// Shader overlay - renders the captured screenshot as a WebGL texture and
|
||||
// runs an editorial "ink-wash" fragment shader over it during generation.
|
||||
// A single rolling band sweeps top-to-bottom, desaturating + tinting kinpaku
|
||||
// and leaving a soft trail. Makes the wait feel like a letterpress scan
|
||||
@@ -5343,7 +5361,7 @@ uniform vec3 u_accent;
|
||||
uniform vec3 u_paper;
|
||||
varying vec2 v_uv;
|
||||
|
||||
// Asymmetric roller band. Product of two one-sided smoothsteps — peaks at
|
||||
// Asymmetric roller band. Product of two one-sided smoothsteps - peaks at
|
||||
// d=0 with a short sharp leading ramp and a longer soft trailing tail. Clean
|
||||
// outside the [-leadW, trailW] range (no rogue "trail=1 everywhere below"
|
||||
// failure that reversed-edge smoothstep would give).
|
||||
@@ -5369,8 +5387,8 @@ void main() {
|
||||
vec2 sampleCenter = (cellId + 0.5) * cellPx / u_resolution;
|
||||
vec3 cellImg = texture2D(u_texture, sampleCenter).rgb;
|
||||
// Dot size tracks how much the cell DIFFERS from the element's own ground
|
||||
// (u_paper), not absolute darkness. So the content — text, buttons, anything
|
||||
// that deviates from the background — always becomes the dots, on light AND
|
||||
// (u_paper), not absolute darkness. So the content - text, buttons, anything
|
||||
// that deviates from the background - always becomes the dots, on light AND
|
||||
// dark surfaces. A plain darkness curve inverts on dark elements: the dark
|
||||
// background fills with ink and the lighter content punches holes instead.
|
||||
// Capped below the cell half-width so dense content stays separated dots.
|
||||
@@ -5380,8 +5398,8 @@ void main() {
|
||||
// Two-stage dissolve as the roller passes, so the element is rebuilt purely
|
||||
// from dot size (its own halftone) and never bleeds through as raw pixels
|
||||
// behind the dots:
|
||||
// 1. cover — the element flattens to the uniform paper ground first.
|
||||
// 2. dotAmt — kinpaku dots then emerge, sized by each cell's luma.
|
||||
// 1. cover - the element flattens to the uniform paper ground first.
|
||||
// 2. dotAmt - kinpaku dots then emerge, sized by each cell's luma.
|
||||
// A plain mix(base, halftone, band) instead left the raw element visible
|
||||
// through the band's soft core/trail. The paper ground is u_paper (the
|
||||
// element's own bg tone) rather than a fixed white, so the dissolve reads the
|
||||
@@ -5399,7 +5417,7 @@ void main() {
|
||||
|
||||
// Kinpaku gold converted to approximate sRGB 0-1 (matches oklch(84% 0.19 80.46))
|
||||
const SHADER_ACCENT = [1.0, 0.78, 0.31];
|
||||
// Fallback ground when an element and all its ancestors are transparent —
|
||||
// Fallback ground when an element and all its ancestors are transparent -
|
||||
// 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 }
|
||||
@@ -5412,7 +5430,7 @@ void main() {
|
||||
// Rasterize any CSS color (oklch, color(), named, hex, rgb) through a 1x1
|
||||
// canvas and read back the sRGB pixel. String-parsing computed colors is a
|
||||
// trap: Chrome returns backgroundColor as oklch()/color() for oklch inputs,
|
||||
// which a hex/rgb regex misses — every site token would fall back to white.
|
||||
// which a hex/rgb regex misses - every site token would fall back to white.
|
||||
let colorParseCtx = null;
|
||||
function cssColorToRgb01(str) {
|
||||
if (!colorParseCtx) {
|
||||
@@ -5440,7 +5458,7 @@ void main() {
|
||||
|
||||
// When an element is transparent up to the root, its visible backdrop can
|
||||
// still come from an ancestor's background-image or a covering positioned
|
||||
// layer that is a *child* of an ancestor (e.g. a hero's absolute art div) —
|
||||
// layer that is a *child* of an ancestor (e.g. a hero's absolute art div) -
|
||||
// neither of which the ancestor background-COLOR walk can see. Return the
|
||||
// nearest such ancestor so we can capture it and crop, embedding the real
|
||||
// backdrop. Returns null when nothing is actually painted behind the element
|
||||
@@ -5481,7 +5499,7 @@ void main() {
|
||||
|
||||
// Average the backdrop sampled just OUTSIDE an element's rect within a larger
|
||||
// canvas. The ground tone for the dissolve must be the real backdrop, not the
|
||||
// mean of the element's own crop — averaging the crop folds in the element's
|
||||
// mean of the element's own crop - averaging the crop folds in the element's
|
||||
// content (e.g. bright heading text), pulling the ground toward muddy gray.
|
||||
function sampleSurroundingRgb(ctx, sx, sy, sw, sh, W, H) {
|
||||
const pad = Math.max(2, Math.round(Math.min(sw, sh) * 0.12));
|
||||
@@ -5528,11 +5546,31 @@ void main() {
|
||||
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;
|
||||
}
|
||||
|
||||
function showShaderBitmapFallback(canvas, blob) {
|
||||
canvas.remove();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const fallback = document.createElement('div');
|
||||
fallback.id = PREFIX + '-shader';
|
||||
// Copy positioning via cssText. Object.assign across CSSStyleDeclaration
|
||||
// throws in modern Chromium because the source's indexed properties
|
||||
// (style[0], [1], ...) are read-only and the engine forbids writing
|
||||
// them on the destination.
|
||||
fallback.style.cssText = canvas.style.cssText;
|
||||
fallback.style.backgroundImage = 'url("' + objectUrl + '")';
|
||||
fallback.style.backgroundSize = '100% 100%';
|
||||
fallback.style.backgroundRepeat = 'no-repeat';
|
||||
fallback.style.outline = '2px dashed ' + C.brand;
|
||||
fallback.style.outlineOffset = '-2px';
|
||||
document.body.appendChild(fallback);
|
||||
shaderState = { canvas: fallback, gl: null, program: null, texture: null, rafId: 0, startTime: 0, objectUrl };
|
||||
}
|
||||
|
||||
async function showShaderOverlay(el, blob, rect, paper) {
|
||||
hideShaderOverlay();
|
||||
if (!blob || !el) return;
|
||||
@@ -5553,21 +5591,9 @@ void main() {
|
||||
const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false })
|
||||
|| canvas.getContext('experimental-webgl');
|
||||
if (!gl) {
|
||||
// WebGL unavailable — fall back to a plain <img> overlay so the user
|
||||
// still sees something meaningful during generation.
|
||||
canvas.remove();
|
||||
const img = document.createElement('img');
|
||||
img.src = URL.createObjectURL(blob);
|
||||
img.id = PREFIX + '-shader';
|
||||
// Copy positioning via cssText. Object.assign across CSSStyleDeclaration
|
||||
// throws in modern Chromium because the source's indexed properties
|
||||
// (style[0], [1], ...) are read-only and the engine forbids writing
|
||||
// them on the destination.
|
||||
img.style.cssText = canvas.style.cssText;
|
||||
img.style.outline = '2px dashed ' + C.brand;
|
||||
img.style.outlineOffset = '-2px';
|
||||
document.body.appendChild(img);
|
||||
shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 };
|
||||
// WebGL unavailable: use the captured bitmap as a background overlay so
|
||||
// the user still sees something meaningful during generation.
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5609,14 +5635,12 @@ void main() {
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch {
|
||||
// Safari fallback: go via a regular Image
|
||||
const imgUrl = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
img.src = imgUrl;
|
||||
await new Promise((r, rej) => { img.onload = r; img.onerror = rej; });
|
||||
bitmap = img;
|
||||
URL.revokeObjectURL(imgUrl);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] shader bitmap decode failed:', err);
|
||||
const lose = gl.getExtension?.('WEBGL_lose_context');
|
||||
try { lose?.loseContext(); } catch {}
|
||||
showShaderBitmapFallback(canvas, blob);
|
||||
return;
|
||||
}
|
||||
texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
@@ -5670,14 +5694,15 @@ void main() {
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
// The accepted variant is already the only visible child of the wrapper
|
||||
// (all other variants are display:none). HMR from the source rewrite will
|
||||
// replace the wrapper imminently. Don't eagerly replaceChild here — React
|
||||
// reconciliation races with our mutation and throws NotFoundError in Next
|
||||
// 16 / Turbopack. Schedule a fallback that runs the manual swap only if
|
||||
// HMR hasn't cleaned up by then (keeps static-server flows working).
|
||||
const acceptedSessionId = currentSessionId;
|
||||
const acceptedVariant = visibleVariant;
|
||||
const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant);
|
||||
pendingAcceptedSession = {
|
||||
id: acceptedSessionId,
|
||||
variant: String(acceptedVariant),
|
||||
...acceptedSnapshot,
|
||||
finalizing: false,
|
||||
};
|
||||
|
||||
state = 'SAVING';
|
||||
updateBarContent('saving');
|
||||
@@ -5685,53 +5710,130 @@ void main() {
|
||||
sendEvent(acceptPayload, { throwOnError: true })
|
||||
.then(() => {
|
||||
markSessionHandled();
|
||||
confirmAcceptAfterReceipt();
|
||||
})
|
||||
.catch(() => {
|
||||
pendingAcceptedSession = null;
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
}
|
||||
|
||||
function confirmAcceptAfterReceipt() {
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
scheduleAcceptCleanup();
|
||||
function maybeCompleteAcceptedSession(msg) {
|
||||
const pending = pendingAcceptedSession;
|
||||
if (!pending || !msg?.id || msg.id !== pending.id) return false;
|
||||
if (currentSessionId && currentSessionId !== pending.id) {
|
||||
pendingAcceptedSession = null;
|
||||
return false;
|
||||
}
|
||||
if (pending.finalizing) return true;
|
||||
pending.finalizing = true;
|
||||
|
||||
function scheduleAcceptCleanup() {
|
||||
setTimeout(function() {
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
selectedElement = null;
|
||||
currentSessionId = null;
|
||||
selectedAction = 'impeccable';
|
||||
renderEditBadge('hidden');
|
||||
state = 'PICKING';
|
||||
}, 1800);
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
|
||||
// Static-server / no-HMR fallback: if the wrapper is still around 2s after
|
||||
// the cleanup above, swap it out manually. By now React has either moved
|
||||
// on or the app isn't React at all. Preserve the `data-impeccable-variant="N"`
|
||||
// div (with display:contents) so @scope rules anchored to the variant
|
||||
// attribute keep matching until reload replaces it with the carbonize block.
|
||||
// Give framework HMR a short chance to render the now-clean accepted
|
||||
// source. If it misses the update, unwrap the accepted variant after the
|
||||
// source-side completion event so the page is not left empty or stale.
|
||||
setTimeout(function() {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + acceptedSessionId + '"]');
|
||||
if (!wrapper) return;
|
||||
const accepted = wrapper.querySelector('[data-impeccable-variant="' + acceptedVariant + '"]');
|
||||
if (accepted && accepted.firstElementChild) {
|
||||
const parent = wrapper.parentElement;
|
||||
if (!parent) return;
|
||||
accepted.style.display = 'contents';
|
||||
parent.replaceChild(accepted, wrapper);
|
||||
}
|
||||
}, 2000);
|
||||
ensureAcceptedDomClean(pending);
|
||||
cleanupAcceptedSession();
|
||||
}, 1200);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
const root = accepted?.firstElementChild || null;
|
||||
return {
|
||||
acceptedHtml: accepted ? accepted.innerHTML : '',
|
||||
acceptedSelector: selectorForAcceptedRoot(root),
|
||||
parentElement: wrapper?.parentElement || null,
|
||||
parentSelector: selectorForAcceptedRoot(wrapper?.parentElement || null),
|
||||
nextSibling: wrapper?.nextSibling || null,
|
||||
};
|
||||
}
|
||||
|
||||
function selectorForAcceptedRoot(root) {
|
||||
if (!root || !root.tagName) return '';
|
||||
const tag = root.tagName.toLowerCase();
|
||||
const classes = [...(root.classList || [])].filter(Boolean);
|
||||
if (classes.length === 0) return tag;
|
||||
return tag + classes.map((cls) => '.' + cssIdent(cls)).join('');
|
||||
}
|
||||
|
||||
function acceptedDomAlreadyClean(pending) {
|
||||
if (!pending?.acceptedSelector) return false;
|
||||
const matches = [...document.querySelectorAll(pending.acceptedSelector)];
|
||||
return matches.some((el) => !el.closest('[data-impeccable-variants],[data-impeccable-variant]'));
|
||||
}
|
||||
|
||||
function ensureAcceptedDomClean(pending) {
|
||||
const sessionId = pending?.id;
|
||||
const variantId = pending?.variant;
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
|
||||
if (!wrapper) {
|
||||
restoreAcceptedDomFromSnapshot(pending);
|
||||
return;
|
||||
}
|
||||
if (!accepted) {
|
||||
wrapper.remove();
|
||||
restoreAcceptedDomFromSnapshot(pending);
|
||||
return;
|
||||
}
|
||||
const parent = wrapper.parentElement;
|
||||
if (!parent) return;
|
||||
while (accepted.firstChild) {
|
||||
parent.insertBefore(accepted.firstChild, wrapper);
|
||||
}
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
function restoreAcceptedDomFromSnapshot(pending) {
|
||||
if (acceptedDomAlreadyClean(pending)) return;
|
||||
if (!pending?.acceptedHtml) {
|
||||
reloadAfterMissingAcceptedDom(pending);
|
||||
return;
|
||||
}
|
||||
const parent = pending.parentElement?.isConnected
|
||||
? pending.parentElement
|
||||
: (pending.parentSelector ? document.querySelector(pending.parentSelector) : null);
|
||||
if (!parent) {
|
||||
reloadAfterMissingAcceptedDom(pending);
|
||||
return;
|
||||
}
|
||||
const template = document.createElement('template');
|
||||
template.innerHTML = pending.acceptedHtml;
|
||||
const anchor = pending.nextSibling?.isConnected && pending.nextSibling.parentElement === parent
|
||||
? pending.nextSibling
|
||||
: null;
|
||||
parent.insertBefore(template.content, anchor);
|
||||
if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending);
|
||||
}
|
||||
|
||||
function reloadAfterMissingAcceptedDom(pending) {
|
||||
if (acceptedDomAlreadyClean(pending)) return;
|
||||
if (pending?.id && document.querySelector('[data-impeccable-variants="' + pending.id + '"]')) return;
|
||||
location.reload();
|
||||
}
|
||||
|
||||
function cleanupAcceptedSession() {
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
selectedElement = null;
|
||||
currentSessionId = null;
|
||||
selectedAction = 'impeccable';
|
||||
pendingAcceptedSession = null;
|
||||
renderEditBadge('hidden');
|
||||
state = 'PICKING';
|
||||
}
|
||||
|
||||
function handleDiscard() {
|
||||
@@ -5792,7 +5894,7 @@ void main() {
|
||||
|
||||
function cleanup() {
|
||||
// Hide the wrapper immediately so variants disappear. DON'T structurally
|
||||
// mutate the DOM yet — HMR from the agent's source rewrite is on its way,
|
||||
// mutate the DOM yet - HMR from the agent's source rewrite is on its way,
|
||||
// and a manual replaceChild under React causes NotFoundError when the
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
@@ -5838,8 +5940,8 @@ void main() {
|
||||
function showToast(message, duration) {
|
||||
if (toastEl) toastEl.remove();
|
||||
// Stack the toast above the global bar (which sits at bottom:14px) so
|
||||
// the two never overlap. Read the bar's actual rect — its height varies
|
||||
// with hover-expanded labels — and fall back to a sensible default
|
||||
// the two never overlap. Read the bar's actual rect - its height varies
|
||||
// with hover-expanded labels - and fall back to a sensible default
|
||||
// when the bar isn't mounted yet.
|
||||
const barRect = globalBarEl?.getBoundingClientRect();
|
||||
const barTopFromBottom = barRect && barRect.height > 0
|
||||
@@ -6047,7 +6149,7 @@ void main() {
|
||||
let pendingApplyInFlight = false;
|
||||
let firstSaveOfSession = true;
|
||||
|
||||
// Steer — collapsed pill in the global bar; expands while typing for page-level chat.
|
||||
// Steer - collapsed pill in the global bar; expands while typing for page-level chat.
|
||||
let pageChatEl = null;
|
||||
let pageChatInput = null;
|
||||
let pageChatHint = null;
|
||||
@@ -6068,7 +6170,7 @@ void main() {
|
||||
const STEER_AWAIT_TIMEOUT_MS = 120000;
|
||||
const AGENT_STATUS_POLL_MS = 5000;
|
||||
const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)';
|
||||
const AGENT_DISCONNECTED_TIP = 'Agent disconnected — run live-poll.mjs to connect';
|
||||
const AGENT_DISCONNECTED_TIP = 'Agent disconnected: run live-poll.mjs to connect';
|
||||
const GLOBAL_BAR_SECTION_GAP = 8;
|
||||
const GLOBAL_BAR_INNER_GAP = 2;
|
||||
const GLOBAL_BAR_INNER_PAD_LEFT = 2;
|
||||
@@ -6079,7 +6181,7 @@ void main() {
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>';
|
||||
|
||||
// Theme-aware color palette for the global bar. We detect the page's
|
||||
// ambient background and invert — dark bar on light pages, light bar on
|
||||
// ambient background and invert - dark bar on light pages, light bar on
|
||||
// dark pages. This keeps the bar from fighting with the host design.
|
||||
function detectPageTheme() {
|
||||
try {
|
||||
@@ -6092,7 +6194,7 @@ void main() {
|
||||
// Walk body → html, taking the first opaque background. The browser's
|
||||
// default body / html background is `rgba(0, 0, 0, 0)`, which a naive
|
||||
// regex would read as black and mislabel a perfectly white page as
|
||||
// dark. Honoring alpha avoids that — and falling through to <html>
|
||||
// dark. Honoring alpha avoids that - and falling through to <html>
|
||||
// catches the common pattern of a bg only on <html> (or only on body).
|
||||
function readOpaque(el) {
|
||||
if (!el) return null;
|
||||
@@ -6141,7 +6243,7 @@ void main() {
|
||||
exitHover: 'oklch(58% 0.15 35 / 0.18)',
|
||||
shadow: PICKER_SHADOW,
|
||||
chatSurface: 'oklch(22% 0.012 82)',
|
||||
// Verdigris patina — secondary state (see site/styles/kinpaku-tokens.css)
|
||||
// Verdigris patina - secondary state (see site/styles/kinpaku-tokens.css)
|
||||
patina: 'oklch(70% 0.12 188)',
|
||||
patinaPale: 'oklch(82% 0.07 188)',
|
||||
patinaSoft: 'oklch(70% 0.12 188 / 0.28)',
|
||||
@@ -6913,7 +7015,7 @@ void main() {
|
||||
steerFocusLog('page-chat-mounted', {});
|
||||
}
|
||||
|
||||
// Impeccable mark — same paths as site/components/Header.astro + favicon.svg.
|
||||
// Impeccable mark - same paths as site/components/Header.astro + favicon.svg.
|
||||
function brandMarkSvg(color = C.brand, size = 18) {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="${color}" aria-hidden="true">
|
||||
<path d="M5 2.5 L13.5 2.5 L5.5 21.5 L5 21.5 Q2.5 21.5 2.5 19 L2.5 5 Q2.5 2.5 5 2.5 Z"/>
|
||||
@@ -6928,7 +7030,7 @@ void main() {
|
||||
globalBarBrandEl.dataset.agentConnected = connected ? 'true' : 'false';
|
||||
globalBarBrandEl.setAttribute('aria-label', connected
|
||||
? 'Impeccable live mode'
|
||||
: 'Impeccable live mode — agent not polling');
|
||||
: 'Impeccable live mode: agent not polling');
|
||||
globalBarBrandEl.removeAttribute('title');
|
||||
globalBarBrandEl.style.cursor = connected ? 'default' : 'help';
|
||||
const mark = globalBarBrandEl.querySelector('[data-brand-mark]');
|
||||
@@ -7053,7 +7155,7 @@ void main() {
|
||||
globalBarEl.id = PREFIX + '-global-bar';
|
||||
globalBarEl.dataset.theme = theme;
|
||||
|
||||
// Brand mark — kinpaku Impeccable icon (site header / favicon paths).
|
||||
// Brand mark - kinpaku Impeccable icon (site header / favicon paths).
|
||||
const brand = el('span', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
alignSelf: 'stretch', position: 'relative',
|
||||
@@ -7065,7 +7167,7 @@ void main() {
|
||||
brand.id = PREFIX + '-global-bar-brand';
|
||||
brand.dataset.agentConnected = 'false';
|
||||
brand.setAttribute('role', 'img');
|
||||
brand.setAttribute('aria-label', 'Impeccable live mode — agent not polling');
|
||||
brand.setAttribute('aria-label', 'Impeccable live mode: agent not polling');
|
||||
|
||||
const brandMark = el('span', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
@@ -7130,7 +7232,7 @@ void main() {
|
||||
// Per-button hover only changes color (no layout). The label expand/
|
||||
// collapse is driven by the bar-level mouseenter/mouseleave so moving
|
||||
// the mouse between adjacent buttons doesn't trigger per-button width
|
||||
// thrashing — the whole bar grows once and shrinks once.
|
||||
// thrashing - the whole bar grows once and shrinks once.
|
||||
b.addEventListener('mouseenter', () => { if (b.dataset.active !== 'true') b.style.color = P.text; });
|
||||
b.addEventListener('mouseleave', () => { if (b.dataset.active !== 'true') b.style.color = P.textDim; });
|
||||
b.addEventListener('click', onClick);
|
||||
@@ -7139,7 +7241,7 @@ void main() {
|
||||
return b;
|
||||
}
|
||||
|
||||
// Pick toggle — restored from localStorage; both pick and insert may be off.
|
||||
// Pick toggle - restored from localStorage; both pick and insert may be off.
|
||||
const pickBtn = makeIconBtn({
|
||||
id: PREFIX + '-pick-toggle',
|
||||
svg: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><circle cx="12" cy="12" r="10"/><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><line x1="12" y1="6" x2="12" y2="2"/><line x1="12" y1="22" x2="12" y2="18"/></svg>',
|
||||
@@ -7176,7 +7278,7 @@ void main() {
|
||||
detectBtn.appendChild(detectBadge);
|
||||
inner.appendChild(detectBtn);
|
||||
|
||||
// DESIGN.md panel toggle — quartet of color squares as the mark.
|
||||
// DESIGN.md panel toggle - quartet of color squares as the mark.
|
||||
const designBtn = makeIconBtn({
|
||||
id: PREFIX + '-design-toggle',
|
||||
svg: `<span style="display:inline-grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;width:14px;height:14px;border-radius:3px;overflow:hidden;box-shadow:inset 0 0 0 1px oklch(58% 0.065 82 / 0.55);flex-shrink:0">
|
||||
@@ -7378,13 +7480,13 @@ void main() {
|
||||
});
|
||||
inner.appendChild(divider);
|
||||
|
||||
// Exit × on the right — intentionally subtle (textDim at rest, text on
|
||||
// Exit × on the right - intentionally subtle (textDim at rest, text on
|
||||
// hover) so it sits behind the active toggles in visual hierarchy.
|
||||
//
|
||||
// Explicit padding + box-sizing here is load-bearing: a host page like
|
||||
// `button { padding: 0.5rem 1rem; }` (very common in resets) would
|
||||
// otherwise inflate this 24x24 button into 56x40 and push the SVG out
|
||||
// of the visible bar — the X stays invisible even though the styles in
|
||||
// of the visible bar - the X stays invisible even though the styles in
|
||||
// DevTools look fine. Every other chrome button sets padding inline;
|
||||
// this one needed it too.
|
||||
const exitBtn = el('button', {
|
||||
@@ -7472,7 +7574,7 @@ void main() {
|
||||
btn.style.opacity = controlsLocked ? '0.55' : '1';
|
||||
});
|
||||
|
||||
// If the bar is currently under the cursor, keep all labels expanded —
|
||||
// If the bar is currently under the cursor, keep all labels expanded -
|
||||
// otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
|
||||
// would collapse its label while the user's mouse is still on the bar.
|
||||
if (globalBarEl && globalBarEl.matches(':hover')) {
|
||||
@@ -7648,7 +7750,7 @@ void main() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Design System Panel — visualizes the project's .impeccable/design.json sidecar
|
||||
// Design System Panel - visualizes the project's .impeccable/design.json sidecar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DESIGN_PREFS_KEY = 'impeccable-live-design-panel';
|
||||
@@ -7674,7 +7776,7 @@ void main() {
|
||||
};
|
||||
|
||||
function loadDesignPrefs() {
|
||||
// `open` is intentionally NOT persisted — the panel always starts closed
|
||||
// `open` is intentionally NOT persisted - the panel always starts closed
|
||||
// so live mode doesn't auto-slide a big panel over the page on startup.
|
||||
try {
|
||||
const raw = localStorage.getItem(DESIGN_PREFS_KEY);
|
||||
@@ -7731,7 +7833,7 @@ void main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Neutral panel palette — deliberately NOT Impeccable-branded. The panel is
|
||||
// Neutral panel palette - deliberately NOT Impeccable-branded. The panel is
|
||||
// a viewer of the project's design system, not an Impeccable surface.
|
||||
const DP = {
|
||||
canvas: 'oklch(94% 0 0)', // panel background
|
||||
@@ -8025,7 +8127,7 @@ void main() {
|
||||
const root = designShadow.querySelector('.root');
|
||||
root.innerHTML = '';
|
||||
|
||||
// (Panel toggle lives in the global bar — no floating FAB.)
|
||||
// (Panel toggle lives in the global bar - no floating FAB.)
|
||||
// Panel
|
||||
const panel = document.createElement('aside');
|
||||
panel.className = 'panel';
|
||||
@@ -8141,7 +8243,7 @@ void main() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Visual tab — single unified render path.
|
||||
// Visual tab - single unified render path.
|
||||
if (designState.mdNewerThanJson) body.appendChild(renderStaleHint());
|
||||
if (designState.hasMd && !designState.hasSidecar) {
|
||||
body.appendChild(renderParsedMdCta());
|
||||
@@ -8357,7 +8459,7 @@ void main() {
|
||||
specimen.style.fontFamily = fontStack(t);
|
||||
specimen.style.fontWeight = String(t.weight || 400);
|
||||
specimen.style.fontStyle = t.style || 'normal';
|
||||
specimen.style.fontSize = '56px'; // Fixed specimen size — compare faces, not scales.
|
||||
specimen.style.fontSize = '56px'; // Fixed specimen size - compare faces, not scales.
|
||||
specimen.style.letterSpacing = 'normal';
|
||||
specimen.style.textTransform = 'none';
|
||||
tile.appendChild(specimen);
|
||||
@@ -8501,7 +8603,7 @@ void main() {
|
||||
}
|
||||
|
||||
// Single shared description if all items carry the same one; otherwise
|
||||
// skip — per-item descriptions clutter a grouped tile.
|
||||
// skip - per-item descriptions clutter a grouped tile.
|
||||
if (group.length === 1 && group[0].description) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'c-desc';
|
||||
|
||||
@@ -56,6 +56,24 @@ describe('live-browser.js regression guards', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('shader bitmap decode failure keeps a visible fallback overlay', () => {
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function showShaderBitmapFallback\(canvas, blob\)[\s\S]{0,900}?fallback\.style\.backgroundImage = 'url\("' \+ objectUrl \+ '"\)';[\s\S]{0,300}?shaderState = \{ canvas: fallback,[\s\S]{0,180}?objectUrl \};/,
|
||||
'shader fallback should render the captured bitmap via a background-image div and keep its object URL revocable',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/catch \(err\) \{[\s\S]{0,220}?shader bitmap decode failed[\s\S]{0,220}?showShaderBitmapFallback\(canvas, blob\);[\s\S]{0,80}?return;/,
|
||||
'createImageBitmap failures should fall back to a visible captured-bitmap overlay',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
SOURCE,
|
||||
/new Image\(/,
|
||||
'shader fallback should not use an image element fallback',
|
||||
);
|
||||
});
|
||||
|
||||
it('locks every global bar mode toggle while manual Apply is in flight', () => {
|
||||
assert.match(
|
||||
SOURCE,
|
||||
|
||||
@@ -279,4 +279,65 @@ describe('live-browser source contracts', () => {
|
||||
assert.match(SOURCE, /sendEvent\(acceptPayload, \{ throwOnError: true \}\)/);
|
||||
assert.match(SOURCE, /sendEvent\(\{ type: 'discard', id: currentSessionId \}, \{ throwOnError: true \}\)/);
|
||||
});
|
||||
|
||||
it('waits for post-carbonize completion before final accepted DOM cleanup', () => {
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/let pendingAcceptedSession = null;/,
|
||||
'accept flow should keep pending completion state after the browser sends the accept intent',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/case 'complete':\s*case 'accept':\s*if \(maybeCompleteAcceptedSession\(msg\)\) break;/,
|
||||
'final accepted DOM cleanup should be driven by explicit complete or harness accept replies',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/case 'error':\s*if \(pendingAcceptedSession\?\.id && msg\.id === pendingAcceptedSession\.id\) \{[\s\S]{0,80}?pendingAcceptedSession = null;[\s\S]{0,80}?state = 'CYCLING';[\s\S]{0,80}?updateBarContent\('cycling'\);[\s\S]{0,160}?break;/,
|
||||
'an SSE error for a queued accept should invalidate pending accept state and keep variants retryable',
|
||||
);
|
||||
assert.equal(
|
||||
SOURCE.match(/function cssIdent\(value\)/g)?.length || 0,
|
||||
1,
|
||||
'accepted DOM cleanup should reuse the existing cssIdent helper instead of shadowing it',
|
||||
);
|
||||
const agentDoneStart = SOURCE.indexOf("case 'agent_done':");
|
||||
const errorCaseStart = SOURCE.indexOf("case 'error':", agentDoneStart);
|
||||
const agentDoneSource = SOURCE.slice(agentDoneStart, errorCaseStart);
|
||||
assert.match(agentDoneSource, /Carbonize accepts are not terminal/);
|
||||
assert.match(agentDoneSource, /break;/);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function handleGo\(\)[\s\S]{0,900}?pendingAcceptedSession = null;[\s\S]{0,80}?currentSessionId = id8\(\);/,
|
||||
'starting a new generation should clear any stale accepted-session sentinel first',
|
||||
);
|
||||
const handleAcceptStart = SOURCE.indexOf('function handleAccept()');
|
||||
const maybeCompleteStart = SOURCE.indexOf('function maybeCompleteAcceptedSession', handleAcceptStart);
|
||||
const handleAcceptSource = SOURCE.slice(handleAcceptStart, maybeCompleteStart);
|
||||
assert.doesNotMatch(
|
||||
handleAcceptSource,
|
||||
/state = 'CONFIRMED'|cleanupAcceptedSession\(|hideBar\(\)/,
|
||||
'accept enqueue should not clear or confirm the browser session before source cleanup completes',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function ensureAcceptedDomClean\(pending\)[\s\S]*?parent\.insertBefore\(accepted\.firstChild, wrapper\);[\s\S]*?wrapper\.remove\(\);/,
|
||||
'post-cleanup fallback should unwrap the accepted variant instead of preserving live runtime wrappers',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/if \(!accepted\) \{[\s\S]{0,120}?wrapper\.remove\(\);[\s\S]{0,120}?restoreAcceptedDomFromSnapshot\(pending\);[\s\S]{0,80}?return;/,
|
||||
'post-cleanup fallback should not leave a variants wrapper behind when the accepted variant node is missing',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function maybeCompleteAcceptedSession\(msg\)[\s\S]{0,260}?if \(currentSessionId && currentSessionId !== pending\.id\) \{[\s\S]{0,80}?pendingAcceptedSession = null;[\s\S]{0,80}?return false;/,
|
||||
'stale accepted completions should not clean up a newer active browser session',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function reloadAfterMissingAcceptedDom\(pending\)[\s\S]*?location\.reload\(\);/,
|
||||
'missing accepted DOM after clean source should recover by reloading the clean page',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Focused regression guard for LLM-backed post-accept cleanup.
|
||||
*
|
||||
* This is intentionally opt-in and provider-backed: it narrows the failure
|
||||
* where the accepted source has been carbonized cleanly but the browser still
|
||||
* exposes the accepted element under stale data-impeccable runtime wrappers.
|
||||
*
|
||||
* Run with:
|
||||
* set -a; source .env.local; set +a
|
||||
* node --test --test-timeout=600000 tests/live-e2e-accept-cleanup-regression.test.mjs
|
||||
*/
|
||||
|
||||
import { describe, it, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { extname, join } from 'node:path';
|
||||
|
||||
import { createLlmAgent, resolveLlmAgentConfig } from './live-e2e/agents/llm-agent.mjs';
|
||||
import { bootFixtureSession, FIXTURES_DIR } from './live-e2e/session.mjs';
|
||||
import { runSteerSmoke } from './live-e2e/steer.mjs';
|
||||
import { runPreActions, waitForCyclingRobust } from './live-e2e/preactions.mjs';
|
||||
import {
|
||||
clickAccept,
|
||||
clickGo,
|
||||
clickNext,
|
||||
getVisibleVariant,
|
||||
pickElement,
|
||||
waitForHandshake,
|
||||
} from './live-e2e/ui.mjs';
|
||||
|
||||
loadEnvLocal();
|
||||
|
||||
const FIXTURE_NAME = 'vite8-react-plain';
|
||||
const PICK_SELECTOR = 'h1.hero-title';
|
||||
const EXPECTED_VARIANTS = 3;
|
||||
|
||||
let playwright;
|
||||
let browser;
|
||||
|
||||
before(async () => {
|
||||
playwright = await import('playwright');
|
||||
browser = await playwright.chromium.launch({ headless: true });
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
if (browser) await browser.close();
|
||||
});
|
||||
|
||||
describe('live-e2e accept cleanup regression', () => {
|
||||
it('cleans accepted LLM DOM wrappers after carbonize cleanup', async (t) => {
|
||||
const fixture = readFixture(FIXTURE_NAME);
|
||||
const llmConfig = resolveLlmAgentConfig({
|
||||
provider: process.env.IMPECCABLE_E2E_LLM_PROVIDER || 'deepseek',
|
||||
});
|
||||
const agent = await createLlmAgent({
|
||||
config: llmConfig,
|
||||
log: (msg) => t.diagnostic('[llm] ' + msg),
|
||||
});
|
||||
if (!agent) {
|
||||
t.skip(`${llmConfig.provider} live E2E requires ${llmConfig.requiredEnv}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await bootFixtureSession({
|
||||
name: FIXTURE_NAME,
|
||||
fixture,
|
||||
browser,
|
||||
agent,
|
||||
wrapTarget: wrapTargetFromPickedElement,
|
||||
log: (msg) => t.diagnostic(msg),
|
||||
});
|
||||
|
||||
const { page, tmp, teardown } = session;
|
||||
try {
|
||||
t.diagnostic(`Using LLM agent (provider=${llmConfig.provider} model=${llmConfig.model})`);
|
||||
await waitForHandshake(page);
|
||||
|
||||
await runSteerSmoke(page, tmp, fixture, (msg) => t.diagnostic(msg), {
|
||||
unlockTimeoutMs: 90_000,
|
||||
selectorTimeoutMs: 45_000,
|
||||
runPreActions,
|
||||
});
|
||||
|
||||
t.diagnostic(`Picking ${PICK_SELECTOR}`);
|
||||
await pickElement(page, PICK_SELECTOR);
|
||||
t.diagnostic('Clicking Go');
|
||||
await clickGo(page);
|
||||
|
||||
t.diagnostic(`Waiting for CYCLING state with ${EXPECTED_VARIANTS} variants`);
|
||||
await waitForCyclingRobust(page, EXPECTED_VARIANTS, {
|
||||
agentMode: 'llm',
|
||||
preActions: fixture.runtime.preActions,
|
||||
log: (msg) => t.diagnostic(msg),
|
||||
});
|
||||
|
||||
const sourceFile = await locateSessionFile(tmp);
|
||||
assert.match(
|
||||
readFileSync(sourceFile, 'utf-8'),
|
||||
/data-impeccable-variants="/,
|
||||
'variant wrapper should be present before accept',
|
||||
);
|
||||
|
||||
t.diagnostic('Cycling to variant 2');
|
||||
await clickNext(page);
|
||||
assert.equal(await getVisibleVariant(page), 2, 'variant 2 visible after one Next');
|
||||
|
||||
t.diagnostic('Accepting variant 2');
|
||||
await clickAccept(page, { expectedVariant: 2 });
|
||||
|
||||
t.diagnostic('Waiting for accept + carbonize cleanup to land in source');
|
||||
const finalSource = await waitForSourceClean(sourceFile, 30_000);
|
||||
assert.doesNotMatch(finalSource, /data-impeccable-variants="/, 'source variants wrapper removed');
|
||||
assert.doesNotMatch(finalSource, /impeccable-variants-start/, 'source variants markers removed');
|
||||
assert.doesNotMatch(finalSource, /impeccable-carbonize-start/, 'source carbonize markers removed');
|
||||
assert.doesNotMatch(finalSource, /data-impeccable-variant="/, 'source variant wrapper removed');
|
||||
assert.match(finalSource, /<h1[^>]*(class|className)="[^"]*\bhero-title\b[^"]*"/);
|
||||
|
||||
await waitForAcceptedDomClean(page, PICK_SELECTOR, {
|
||||
timeoutMs: 20_000,
|
||||
sourceFile,
|
||||
finalSource,
|
||||
});
|
||||
} finally {
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function readFixture(name) {
|
||||
return JSON.parse(readFileSync(join(FIXTURES_DIR, name, 'fixture.json'), 'utf-8'));
|
||||
}
|
||||
|
||||
function loadEnvLocal() {
|
||||
const envPath = join(process.cwd(), '.env.local');
|
||||
if (!existsSync(envPath)) return;
|
||||
const body = readFileSync(envPath, 'utf-8');
|
||||
for (const line of body.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
||||
if (!match) continue;
|
||||
const [, key, rawValue] = match;
|
||||
if (process.env[key]) continue;
|
||||
process.env[key] = unquoteEnvValue(rawValue.trim());
|
||||
}
|
||||
}
|
||||
|
||||
function unquoteEnvValue(value) {
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"'))
|
||||
|| (value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function wrapTargetFromPickedElement(event) {
|
||||
const element = event.element || {};
|
||||
const tag = typeof element.tagName === 'string'
|
||||
? element.tagName.trim().toLowerCase()
|
||||
: '';
|
||||
const classes = typeof element.className === 'string'
|
||||
? element.className.trim().split(/\s+/).filter(Boolean).join(' ')
|
||||
: extractClassAttr(element.outerHTML);
|
||||
const elementId = typeof element.id === 'string' ? element.id.trim() : '';
|
||||
|
||||
return {
|
||||
tag: tag || 'h1',
|
||||
...(classes ? { classes } : {}),
|
||||
...(elementId ? { elementId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function extractClassAttr(outerHTML) {
|
||||
if (typeof outerHTML !== 'string') return '';
|
||||
const match = outerHTML.match(/\sclass=(["'])(.*?)\1/);
|
||||
return match ? match[2].trim().split(/\s+/).filter(Boolean).join(' ') : '';
|
||||
}
|
||||
|
||||
async function waitForAcceptedDomClean(page, selector, { timeoutMs, sourceFile, finalSource }) {
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
(sel) => {
|
||||
const matches = [...document.querySelectorAll(sel)];
|
||||
return matches.length > 0
|
||||
&& matches.every((el) => !el.closest('[data-impeccable-variants],[data-impeccable-variant]'));
|
||||
},
|
||||
selector,
|
||||
{ timeout: timeoutMs },
|
||||
);
|
||||
} catch (err) {
|
||||
const diagnostic = await collectAcceptedDomDiagnostic(page, selector);
|
||||
diagnostic.sourceFile = sourceFile;
|
||||
diagnostic.sourceWasClean = isSourceClean(finalSource);
|
||||
throw new Error(
|
||||
'accepted DOM stayed stale after clean source; diagnostic='
|
||||
+ JSON.stringify(diagnostic, null, 2),
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function collectAcceptedDomDiagnostic(page, selector) {
|
||||
return page.evaluate((sel) => {
|
||||
const matches = [...document.querySelectorAll(sel)];
|
||||
const clean = matches.filter((el) => !el.closest('[data-impeccable-variants],[data-impeccable-variant]'));
|
||||
const stale = matches.filter((el) => el.closest('[data-impeccable-variants],[data-impeccable-variant]'));
|
||||
const local = {};
|
||||
for (const key of [
|
||||
'impeccable-live-session',
|
||||
'impeccable-live-session-handled',
|
||||
'impeccable-live-session-scroll',
|
||||
]) {
|
||||
local[key] = localStorage.getItem(key);
|
||||
}
|
||||
return {
|
||||
cleanMatchCount: clean.length,
|
||||
staleMatchCount: stale.length,
|
||||
remainingVariantsWrapperCount: document.querySelectorAll('[data-impeccable-variants]').length,
|
||||
remainingVariantWrapperCount: document.querySelectorAll('[data-impeccable-variant]').length,
|
||||
liveBarText: document.querySelector('#impeccable-live-bar')?.textContent || '',
|
||||
localStorage: local,
|
||||
heroHtml: matches.map((el) => el.outerHTML.slice(0, 500)),
|
||||
staleAncestorHtml: stale.map((el) => {
|
||||
const ancestor = el.closest('[data-impeccable-variants],[data-impeccable-variant]');
|
||||
return ancestor ? ancestor.outerHTML.slice(0, 700) : '';
|
||||
}),
|
||||
};
|
||||
}, selector);
|
||||
}
|
||||
|
||||
function isSourceClean(source) {
|
||||
return !source.includes('data-impeccable-variants=')
|
||||
&& !source.includes('impeccable-variants-start')
|
||||
&& !source.includes('impeccable-carbonize-start')
|
||||
&& !source.includes('data-impeccable-variant=');
|
||||
}
|
||||
|
||||
async function waitForSourceClean(filePath, timeoutMs) {
|
||||
const start = Date.now();
|
||||
let last = '';
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
last = readFileSync(filePath, 'utf-8');
|
||||
if (isSourceClean(last)) return last;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(`source not clean after ${timeoutMs}ms; last contents:\n${last}`);
|
||||
}
|
||||
|
||||
async function locateSessionFile(tmp) {
|
||||
for (const file of walkSources(tmp)) {
|
||||
const body = readFileSync(file, 'utf-8');
|
||||
if (
|
||||
body.includes('data-impeccable-variants=')
|
||||
|| body.includes('impeccable-carbonize-start')
|
||||
|| body.includes('impeccable-variants-start')
|
||||
) {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
throw new Error('Could not locate session source file under ' + tmp);
|
||||
}
|
||||
|
||||
function walkSources(root) {
|
||||
const results = [];
|
||||
const stack = [root];
|
||||
const skip = new Set(['node_modules', '.git', '.svelte-kit', 'dist', '.vite', 'build', '.next']);
|
||||
const exts = new Set(['.html', '.jsx', '.tsx', '.svelte', '.astro', '.vue']);
|
||||
while (stack.length) {
|
||||
const dir = stack.pop();
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (!skip.has(entry.name)) stack.push(full);
|
||||
} else if (exts.has(extname(entry.name))) {
|
||||
results.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
Reference in New Issue
Block a user