mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 14:16:28 +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';
|
||||
|
||||
Reference in New Issue
Block a user