diff --git a/.agents/skills/impeccable/reference/colorize.md b/.agents/skills/impeccable/reference/colorize.md
index de74ac95f..1bd52004f 100644
--- a/.agents/skills/impeccable/reference/colorize.md
+++ b/.agents/skills/impeccable/reference/colorize.md
@@ -142,3 +142,13 @@ Test that colorization improves the experience:
- **Not overwhelming**: Is color balanced and purposeful?
Remember: Color is emotional and powerful. Use it to create warmth, guide attention, communicate meaning, and express personality. But restraint and strategy matter more than saturation and variety. Be colorful, but be intentional.
+
+## Live-mode signature params
+
+When invoked from live mode, each variant MUST declare a `color-amount` param so the user can dial between a restrained accent and a drenched surface without regeneration. Author the variant's CSS against `var(--p-color-amount, 0.5)` — typically as the alpha multiplier on backgrounds, or as a scaling factor on the chroma axis in an OKLCH expression. 0 = neutral/monochrome, 1 = full saturation / dominant coverage.
+
+```json
+{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"}
+```
+
+Layer 1-2 variant-specific params on top: palette selection (`steps` with named options), temperature warmth, or tint vs. true color. See `reference/live.md` for the full params contract.
diff --git a/.agents/skills/impeccable/reference/layout.md b/.agents/skills/impeccable/reference/layout.md
index 981ace8a1..5e8022081 100644
--- a/.agents/skills/impeccable/reference/layout.md
+++ b/.agents/skills/impeccable/reference/layout.md
@@ -119,3 +119,23 @@ Create a systematic plan:
- **Responsiveness**: Does the layout adapt gracefully across screen sizes?
Remember: Space is the most underused design tool. A layout with the right rhythm and hierarchy can make even simple content feel polished and intentional.
+
+## Live-mode signature params
+
+Each variant MUST declare a `density` param. Drive all spacing tokens in the variant's scoped CSS through `calc(var(--p-density, 1) * )` — paddings, gaps, column widths. Users slide from airy to packed and see layout re-breathe with no regeneration.
+
+```json
+{"id":"density","kind":"range","min":0.6,"max":1.4,"step":0.05,"default":1,"label":"Density"}
+```
+
+For variants whose topology genuinely changes (stacked vs. side-by-side, grid vs. bento), use a `steps` param whose scoped CSS branches via `:scope[data-p-structure="X"]`. One structure param + one density param is a powerful combo; resist adding a third.
+
+```json
+{"id":"structure","kind":"steps","default":"grid","label":"Structure","options":[
+ {"value":"stacked","label":"Stacked"},
+ {"value":"grid","label":"Grid"},
+ {"value":"bento","label":"Bento"}
+]}
+```
+
+See `reference/live.md` for the full params contract.
diff --git a/.agents/skills/impeccable/reference/live.md b/.agents/skills/impeccable/reference/live.md
index afbcff18b..766f9c06c 100644
--- a/.agents/skills/impeccable/reference/live.md
+++ b/.agents/skills/impeccable/reference/live.md
@@ -179,7 +179,56 @@ The first variant has no `display: none` (visible by default). All others do. If
One edit, all variants — the browser's MutationObserver picks everything up in one pass.
-### 7. Signal done
+### 7. Parameters (optional, 2-5 per variant)
+
+Each variant can expose coarse knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against.
+
+**When to use.** Any time the variant has a meaningful axis the user might want to dial in: color amount, density, motion intensity, scale ratio. Not micro-level margin tweaks; those defeat the point.
+
+**Budget scales with the element's visual weight, not the user's curiosity.** Knobs need real estate to produce noticeably different output; slapping three on a small element just crowds the panel without making anything feel tunable.
+
+- **Leaf / tiny** — a single button, icon, input, bare heading, solitary paragraph: **0 params.** A slider can't meaningfully reshape one element; just use variants.
+- **Small composition** — labeled input, simple card, short callout (≤ ~5 visual children): **0-1 params.** Only add one if it's a clear dominant axis (e.g. density on a card with visible internal rhythm).
+- **Medium composition** — section component, nav cluster, dense card, short feature block (6-15 visual children): **2 params.**
+- **Large composition** — hero section, full page region, spread layout, anything with strong internal structure (16+ visual children or multiple sub-sections): **3-4 params.**
+
+When in doubt, fewer. The user can always ask for more variants to explore an axis you didn't expose as a knob. Count by visual children, not by DOM-node depth; a deeply-nested-but-visually-simple card still counts as small.
+
+**How to declare.** Put a JSON manifest on the variant wrapper:
+
+```html
+
+ ...variant content...
+
+```
+
+**Three kinds:**
+
+- `range` — smooth slider. Drives a CSS custom property `--p-` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`.
+- `steps` — segmented radio. Drives a data attribute `data-p-` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`.
+- `toggle` — on/off switch. Drives BOTH a CSS var (`--p-: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`.
+
+**Signature params per action.** Each action has one or two signature params that MUST be exposed when the variant can meaningfully express them. Check the action's reference file for the list. Layer 1-2 variant-specific params on top.
+
+**Reset on variant switch.** User dials density on v1, flips to v2, v2 starts at v2's declared defaults. Known limitation; preservation across variants may land later.
+
+**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment:
+
+```html
+
+```
+
+The carbonize cleanup step (see below) reads that comment and bakes the chosen values into the final CSS. For `steps`/`toggle` attribute selectors: keep only the branch matching the chosen value, drop the others, collapse `:scope[data-p-density="packed"] .grid` to a semantic class rule. For `range` vars: either substitute the literal or keep the var with the chosen value as its new default.
+
+### 8. Signal done
```bash
node .agents/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
@@ -244,11 +293,11 @@ When `_acceptResult.carbonize === true`, the accepted variant was stitched into
Do these five steps in the current thread, synchronously, before the next poll. Do not poll again until the file is clean.
-1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `` and `` and contains a `');
+ if (paramValues && Object.keys(paramValues).length > 0) {
+ // Preserve the user's knob positions for the carbonize-cleanup agent
+ // to bake into the final CSS when it collapses scoped rules.
+ replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
+ }
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
diff --git a/.agents/skills/impeccable/scripts/live-browser.js b/.agents/skills/impeccable/scripts/live-browser.js
index 116e1efa6..66efd7243 100644
--- a/.agents/skills/impeccable/scripts/live-browser.js
+++ b/.agents/skills/impeccable/scripts/live-browser.js
@@ -30,10 +30,13 @@
// Design tokens
// ---------------------------------------------------------------------------
+ // Brand magenta is pinned to the site token (--color-accent in main.css)
+ // so Accept / knobs / cycle-dots match the site's accent, not a washed
+ // theme-adjusted one.
const C = {
- brand: 'oklch(55% 0.25 350)',
- brandHov: 'oklch(48% 0.25 350)',
- brandSoft: 'oklch(55% 0.25 350 / 0.12)',
+ brand: 'oklch(60% 0.25 350)',
+ brandHov: 'oklch(52% 0.25 350)',
+ brandSoft: 'oklch(60% 0.25 350 / 0.15)',
ink: 'oklch(15% 0.01 350)',
ash: 'oklch(55% 0 0)',
paper: 'oklch(98% 0.005 350 / 0.92)',
@@ -60,6 +63,25 @@
'html', 'head', 'body', 'script', 'style', 'link', 'meta', 'noscript', 'br', 'wbr',
]);
+ // 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.
+ 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: ` `,
+ bolder: ` `,
+ quieter: ` `,
+ distill: ` `,
+ polish: ` `,
+ typeset: ` `,
+ colorize: ` `,
+ layout: ` `,
+ adapt: ` `,
+ animate: ` `,
+ delight: ` `,
+ overdrive: ` `,
+ };
+
const ACTIONS = [
{ value: 'impeccable', label: 'Freeform' },
{ value: 'bolder', label: 'Bolder' },
@@ -756,7 +778,21 @@
// The Bar — one floating element, three modes
// ---------------------------------------------------------------------------
+ // Contextual-bar palette. Cached at init so every build*Row reads a
+ // consistent set of colors; detectPageTheme runs once rather than on every
+ // phase transition.
+ let BP = null;
+
+ // Bar shadow variants. The default projects down + subtle around. When
+ // the Tune popover opens below the bar, a downward shadow lands on the
+ // dark popover and reads as a bright ghost line. We swap to UP-only while
+ // tune is open below so the popover's top edge is clean.
+ const BAR_SHADOW_DEFAULT = '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)';
+ const BAR_SHADOW_UP = '0 -4px 20px oklch(0% 0 0 / 0.08), 0 -1px 3px oklch(0% 0 0 / 0.06)';
+ const BAR_SHADOW_DOWN = BAR_SHADOW_DEFAULT;
+
function initBar() {
+ BP = barPaletteForTheme(detectPageTheme());
barEl = document.createElement('div');
barEl.id = PREFIX + '-bar';
Object.assign(barEl.style, {
@@ -764,15 +800,15 @@
display: 'none', opacity: '0',
transform: 'translateY(6px)',
transition: 'opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
- // Visual
- background: C.paper,
+ background: BP.surface,
backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
- border: '1px solid ' + C.mist,
+ border: '1px solid ' + BP.hairline,
borderRadius: '10px',
- boxShadow: '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)',
- fontFamily: FONT, fontSize: '13px', color: C.ink,
+ boxShadow: BAR_SHADOW_DEFAULT,
+ transition: 'box-shadow 0.2s ease, opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
+ fontFamily: FONT, fontSize: '13px', color: BP.text,
padding: '6px',
- maxWidth: '460px', minWidth: '320px',
+ maxWidth: '520px', minWidth: '320px',
});
document.body.appendChild(barEl);
}
@@ -824,14 +860,15 @@
barEl.style.transform = 'translateY(6px)';
setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250);
hideActionPicker();
+ closeTunePopover();
}
function updateBarContent(mode) {
if (!barEl || barEl.style.display === 'none') return;
barEl.innerHTML = '';
- // Reset bar styling
- barEl.style.background = C.paper;
- barEl.style.border = '1px solid ' + C.mist;
+ // Reset bar styling to the theme-aware palette
+ barEl.style.background = BP.surface;
+ barEl.style.border = '1px solid ' + BP.hairline;
if (mode === 'configure') barEl.appendChild(buildConfigureRow());
else if (mode === 'generating') barEl.appendChild(buildGeneratingRow());
else if (mode === 'cycling') barEl.appendChild(buildCyclingRow());
@@ -854,15 +891,15 @@
const pill = el('button', {
display: 'inline-flex', alignItems: 'center', gap: '4px',
padding: '5px 10px', borderRadius: '6px',
- background: C.ink, color: C.white,
+ background: BP.mark, color: BP.markText,
fontFamily: FONT, fontSize: '12px', fontWeight: '500',
border: 'none', cursor: 'pointer',
transition: 'background 0.12s ease, transform 0.1s ease',
whiteSpace: 'nowrap', flexShrink: '0',
});
pill.textContent = actionLabel() + ' \u25BE';
- pill.addEventListener('mouseenter', () => pill.style.background = C.brandHov);
- pill.addEventListener('mouseleave', () => pill.style.background = C.ink);
+ pill.addEventListener('mouseenter', () => pill.style.background = BP.accent);
+ pill.addEventListener('mouseleave', () => pill.style.background = BP.mark);
pill.addEventListener('mousedown', () => pill.style.transform = 'scale(0.97)');
pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)');
pill.addEventListener('click', (e) => { e.stopPropagation(); toggleActionPicker(); });
@@ -877,13 +914,13 @@
flex: '1', minWidth: '0',
padding: '5px 8px', borderRadius: '6px',
border: '1px solid transparent', background: 'transparent',
- fontFamily: FONT, fontSize: '12px', color: C.ink,
+ fontFamily: FONT, fontSize: '12px', color: BP.text,
outline: 'none',
transition: 'border-color 0.15s ease, background 0.15s ease',
});
input.addEventListener('focus', () => {
- input.style.borderColor = C.mist;
- input.style.background = C.white;
+ input.style.borderColor = BP.hairline;
+ input.style.background = BP.accentSoft;
});
input.addEventListener('blur', () => {
input.style.borderColor = 'transparent';
@@ -901,16 +938,16 @@
// Variant count toggle
const count = el('button', {
padding: '4px 6px', borderRadius: '5px',
- border: '1px solid ' + C.mist, background: 'transparent',
+ border: '1px solid ' + BP.hairline, background: 'transparent',
fontFamily: MONO, fontSize: '11px', fontWeight: '600',
- color: C.ash, cursor: 'pointer',
+ color: BP.textDim, cursor: 'pointer',
transition: 'color 0.12s ease, border-color 0.12s ease',
flexShrink: '0', whiteSpace: 'nowrap',
});
count.textContent = '\u00D7' + selectedCount;
count.title = 'Variants: click to change';
- count.addEventListener('mouseenter', () => { count.style.color = C.ink; count.style.borderColor = C.ink; });
- count.addEventListener('mouseleave', () => { count.style.color = C.ash; count.style.borderColor = C.mist; });
+ count.addEventListener('mouseenter', () => { count.style.color = BP.text; count.style.borderColor = BP.text; });
+ count.addEventListener('mouseleave', () => { count.style.color = BP.textDim; count.style.borderColor = BP.hairline; });
count.addEventListener('click', (e) => {
e.stopPropagation();
selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1;
@@ -921,15 +958,15 @@
// Go button
const go = el('button', {
padding: '5px 12px', borderRadius: '6px',
- border: 'none', background: C.brand, color: C.white,
+ border: 'none', background: BP.accent, color: BP.mark,
fontFamily: FONT, fontSize: '12px', fontWeight: '600',
cursor: 'pointer',
- transition: 'background 0.12s ease, transform 0.1s ease',
+ transition: 'filter 0.12s ease, transform 0.1s ease',
flexShrink: '0', whiteSpace: 'nowrap',
});
go.textContent = 'Go \u2192';
- go.addEventListener('mouseenter', () => go.style.background = C.brandHov);
- go.addEventListener('mouseleave', () => go.style.background = C.brand);
+ go.addEventListener('mouseenter', () => go.style.filter = 'brightness(1.1)');
+ go.addEventListener('mouseleave', () => go.style.filter = 'none');
go.addEventListener('mousedown', () => go.style.transform = 'scale(0.97)');
go.addEventListener('mouseup', () => go.style.transform = 'scale(1)');
go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); });
@@ -950,7 +987,7 @@
// Action label
const label = el('span', {
- fontWeight: '600', fontSize: '12px', color: C.ink,
+ fontWeight: '600', fontSize: '12px', color: BP.text,
flexShrink: '0', whiteSpace: 'nowrap',
});
label.textContent = actionLabel();
@@ -961,7 +998,7 @@
// Status
const status = el('span', {
- fontSize: '11px', color: C.ash, whiteSpace: 'nowrap',
+ fontSize: '11px', color: BP.textDim, whiteSpace: 'nowrap',
marginLeft: 'auto',
});
// Variants currently arrive atomically in a single file edit, so a
@@ -976,6 +1013,8 @@
// --- Cycling row ---
+ const TUNE_ICON_SVG = ' ';
+
function buildCyclingRow() {
const row = el('div', {
display: 'flex', alignItems: 'center', gap: '6px',
@@ -994,7 +1033,7 @@
// Counter
const counter = el('span', {
fontFamily: MONO, fontSize: '11px', fontWeight: '500',
- color: C.ash, minWidth: '24px', textAlign: 'center',
+ color: BP.textDim, minWidth: '24px', textAlign: 'center',
});
counter.textContent = visibleVariant + '/' + arrivedVariants;
row.appendChild(counter);
@@ -1005,20 +1044,67 @@
if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
row.appendChild(next);
+ // Tune chip — only when the visible variant exposes params
+ const visParams = parseVariantParams(getVisibleVariantEl());
+ const hasParams = visParams.length > 0;
+ if (hasParams) {
+ const tune = el('button', {
+ display: 'inline-flex', alignItems: 'center', gap: '6px',
+ padding: '4px 10px', borderRadius: '5px',
+ border: '1px solid transparent',
+ background: tuneOpen ? BP.accentSoft : 'transparent',
+ color: tuneOpen ? BP.accent : BP.text,
+ fontFamily: FONT, fontSize: '11px', fontWeight: '500',
+ cursor: 'pointer',
+ transition: 'color 0.12s ease, background 0.12s ease',
+ whiteSpace: 'nowrap',
+ });
+ tune.innerHTML = TUNE_ICON_SVG;
+ const tuneLabel = document.createElement('span');
+ tuneLabel.textContent = 'Tune';
+ tune.appendChild(tuneLabel);
+ const tuneBadge = document.createElement('span');
+ Object.assign(tuneBadge.style, {
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
+ minWidth: '16px', height: '16px', padding: '0 4px',
+ borderRadius: '999px',
+ background: tuneOpen ? C.brand : BP.hairline,
+ color: tuneOpen ? 'oklch(98% 0 0)' : 'inherit',
+ fontFamily: MONO, fontSize: '9.5px', fontWeight: '600',
+ lineHeight: '1',
+ boxSizing: 'border-box',
+ });
+ tuneBadge.textContent = String(visParams.length);
+ tune.appendChild(tuneBadge);
+ tune.title = 'Tune this variant (' + visParams.length + ' knob' + (visParams.length === 1 ? '' : 's') + ')';
+ tune.addEventListener('mouseenter', () => {
+ if (!tuneOpen) tune.style.background = BP.accentSoft;
+ });
+ tune.addEventListener('mouseleave', () => {
+ if (!tuneOpen) tune.style.background = 'transparent';
+ });
+ tune.addEventListener('click', (e) => { e.stopPropagation(); toggleTunePopover(); });
+ tune.dataset.iceqTune = '1';
+ row.appendChild(tune);
+ }
+
// Spacer
row.appendChild(el('div', { flex: '1' }));
- // Accept
+ // Accept — primary action, uses the site's saturated brand magenta
+ // with paper-white text, not the theme-muted BP.accent.
const accept = el('button', {
- padding: '4px 10px', borderRadius: '5px',
- border: 'none', background: C.ink, color: C.white,
+ padding: '5px 14px', borderRadius: '5px',
+ border: 'none', background: C.brand, color: 'oklch(98% 0 0)',
fontFamily: FONT, fontSize: '11px', fontWeight: '600',
- cursor: 'pointer', transition: 'background 0.12s ease',
+ cursor: 'pointer', transition: 'filter 0.12s ease, transform 0.1s ease',
whiteSpace: 'nowrap',
});
accept.textContent = '\u2713 Accept';
- accept.addEventListener('mouseenter', () => accept.style.background = C.brand);
- accept.addEventListener('mouseleave', () => accept.style.background = C.ink);
+ accept.addEventListener('mouseenter', () => accept.style.filter = 'brightness(1.08)');
+ accept.addEventListener('mouseleave', () => accept.style.filter = 'none');
+ accept.addEventListener('mousedown', () => accept.style.transform = 'scale(0.97)');
+ accept.addEventListener('mouseup', () => accept.style.transform = 'scale(1)');
accept.addEventListener('click', (e) => { e.stopPropagation(); handleAccept(); });
if (arrivedVariants === 0) { accept.style.opacity = '0.3'; accept.style.pointerEvents = 'none'; }
row.appendChild(accept);
@@ -1026,14 +1112,14 @@
// Discard
const discard = el('button', {
padding: '4px 6px', borderRadius: '5px',
- border: '1px solid ' + C.mist, background: 'transparent',
- fontFamily: FONT, fontSize: '11px', color: C.ash,
+ border: '1px solid ' + BP.hairline, background: 'transparent',
+ fontFamily: FONT, fontSize: '11px', color: BP.textDim,
cursor: 'pointer', transition: 'color 0.12s ease, border-color 0.12s ease',
});
discard.textContent = '\u2715';
discard.title = 'Discard all variants';
- discard.addEventListener('mouseenter', () => { discard.style.color = C.ink; discard.style.borderColor = C.ink; });
- discard.addEventListener('mouseleave', () => { discard.style.color = C.ash; discard.style.borderColor = C.mist; });
+ discard.addEventListener('mouseenter', () => { discard.style.color = BP.text; discard.style.borderColor = BP.text; });
+ discard.addEventListener('mouseleave', () => { discard.style.color = BP.textDim; discard.style.borderColor = BP.hairline; });
discard.addEventListener('click', (e) => { e.stopPropagation(); handleDiscard(); });
row.appendChild(discard);
@@ -1051,14 +1137,14 @@
});
const spinner = el('div', {
width: '14px', height: '14px', borderRadius: '50%',
- border: '2px solid ' + C.mist,
- borderTopColor: C.brand,
+ border: '2px solid ' + BP.hairline,
+ borderTopColor: BP.accent,
animation: 'impeccable-spin 0.6s linear infinite',
flexShrink: '0',
});
row.appendChild(spinner);
const label = el('span', {
- fontSize: '12px', color: C.ash, fontWeight: '500',
+ fontSize: '12px', color: BP.textDim, fontWeight: '500',
});
label.textContent = 'Applying variant...';
row.appendChild(label);
@@ -1103,14 +1189,26 @@
for (let i = 1; i <= expectedVariants; i++) {
const arrived = i <= arrivedVariants;
const active = i === visibleVariant;
+ // active: solid site-brand magenta 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
+ // magenta chips, especially when all variants had arrived and every
+ // dot wore an accent ring.
+ const dotBg = active ? C.brand
+ : arrived ? BP.textDim
+ : 'transparent';
+ const dotBorder = arrived ? 'none' : '1.5px solid ' + BP.hairline;
const dot = el('div', {
- width: '7px', height: '7px', borderRadius: '50%',
- background: active ? C.brand : (arrived ? C.ash : 'transparent'),
- border: '1.5px solid ' + (arrived ? C.brand : C.mist),
+ width: active ? '8px' : '6px',
+ height: active ? '8px' : '6px',
+ borderRadius: '50%',
+ background: dotBg,
+ border: dotBorder,
+ boxSizing: 'border-box',
transition: 'all 0.2s ' + EASE,
cursor: (clickable && arrived) ? 'pointer' : 'default',
- transform: arrived ? 'scale(1)' : 'scale(0.6)',
- opacity: arrived ? '1' : '0.4',
+ transform: arrived ? 'scale(1)' : 'scale(0.85)',
+ opacity: arrived ? (active ? '1' : '0.6') : '0.4',
});
if (clickable && arrived) {
const idx = i;
@@ -1130,15 +1228,15 @@
function navBtn(text) {
const b = el('button', {
width: '26px', height: '26px', borderRadius: '5px',
- border: '1px solid ' + C.mist, background: 'transparent',
- color: C.ink, fontFamily: FONT, fontSize: '13px',
+ border: '1px solid ' + BP.hairline, background: 'transparent',
+ color: BP.text, fontFamily: FONT, fontSize: '13px',
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'border-color 0.12s ease, background 0.12s ease',
padding: '0', lineHeight: '1',
});
b.textContent = text;
- b.addEventListener('mouseenter', () => { b.style.borderColor = C.ink; });
- b.addEventListener('mouseleave', () => { b.style.borderColor = C.mist; });
+ b.addEventListener('mouseenter', () => { b.style.borderColor = BP.text; });
+ b.addEventListener('mouseleave', () => { b.style.borderColor = BP.hairline; });
return b;
}
@@ -1158,6 +1256,7 @@
// ---------------------------------------------------------------------------
function initActionPicker() {
+ const P = barPaletteForTheme(detectPageTheme());
pickerEl = document.createElement('div');
pickerEl.id = PREFIX + '-picker';
Object.assign(pickerEl.style, {
@@ -1166,12 +1265,14 @@
transform: 'scale(0.96) translateY(4px)',
transformOrigin: 'bottom left',
transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE,
- background: C.paperSolid,
- border: '1px solid ' + C.mist,
+ background: P.surface,
+ border: '1px solid ' + P.hairline,
borderRadius: '10px',
boxShadow: '0 8px 30px oklch(0% 0 0 / 0.10), 0 2px 6px oklch(0% 0 0 / 0.06)',
padding: '6px',
fontFamily: FONT,
+ backdropFilter: 'blur(10px)',
+ WebkitBackdropFilter: 'blur(10px)',
});
// Build the chip grid
@@ -1181,22 +1282,32 @@
ACTIONS.forEach(action => {
const chip = el('button', {
- padding: '6px 8px', borderRadius: '6px',
+ display: 'flex', flexDirection: 'column', alignItems: 'center',
+ gap: '4px',
+ padding: '8px 6px', borderRadius: '6px',
border: 'none',
- background: action.value === selectedAction ? C.brandSoft : 'transparent',
- color: action.value === selectedAction ? C.brand : C.ink,
+ background: action.value === selectedAction ? P.accentSoft : 'transparent',
+ color: action.value === selectedAction ? P.accent : P.text,
fontFamily: FONT, fontSize: '11px', fontWeight: '500',
cursor: 'pointer',
transition: 'background 0.1s ease, color 0.1s ease',
textAlign: 'center', whiteSpace: 'nowrap',
});
- chip.textContent = action.label;
+ const iconWrap = el('span', {
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ height: '20px', opacity: '0.9',
+ });
+ iconWrap.innerHTML = ICONS[action.value] || '';
+ const labelEl = el('span', { lineHeight: '1' });
+ labelEl.textContent = action.label;
+ chip.appendChild(iconWrap);
+ chip.appendChild(labelEl);
chip.dataset.action = action.value;
chip.addEventListener('mouseenter', () => {
- if (action.value !== selectedAction) chip.style.background = C.brandSoft;
+ if (action.value !== selectedAction) chip.style.background = P.accentSoft;
});
chip.addEventListener('mouseleave', () => {
- chip.style.background = action.value === selectedAction ? C.brandSoft : 'transparent';
+ chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent';
});
chip.addEventListener('click', (e) => {
e.stopPropagation();
@@ -1209,19 +1320,24 @@
pickerEl.appendChild(grid);
document.body.appendChild(pickerEl);
+
+ // Cache the palette on the picker so toggleActionPicker's state refresh
+ // uses the same theme-aware colors when it repaints chips.
+ pickerEl.__iceq_palette = P;
}
function toggleActionPicker() {
if (pickerEl.style.display !== 'none') { hideActionPicker(); return; }
// Rebuild chips to reflect current selection
+ const P = pickerEl.__iceq_palette || barPaletteForTheme(detectPageTheme());
pickerEl.querySelectorAll('button').forEach(chip => {
const isActive = chip.dataset.action === selectedAction;
- chip.style.background = isActive ? C.brandSoft : 'transparent';
- chip.style.color = isActive ? C.brand : C.ink;
+ chip.style.background = isActive ? P.accentSoft : 'transparent';
+ chip.style.color = isActive ? P.accent : P.text;
});
// Position above the bar
const barRect = barEl.getBoundingClientRect();
- const pickerH = 90; // approximate
+ const pickerH = 170; // approximate; grows with icon + label rows
let top = barRect.top - pickerH - 6;
if (top < 8) top = barRect.bottom + 6;
Object.assign(pickerEl.style, {
@@ -1241,6 +1357,399 @@
setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180);
}
+ // ---------------------------------------------------------------------------
+ // Params panel (per-variant coarse controls)
+ //
+ // Variants may declare a parameter manifest via a JSON attribute on the
+ // variant wrapper:
+ //
+ //
+ //
+ // The panel docks to the right edge of the outline during CYCLING and
+ // exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped
+ // CSS can respond instantly without regeneration:
+ //
+ // range / numeric toggle → CSS var (`--p-
`) used via var(--p-foo, N)
+ // steps / boolean toggle → data-p- attribute used via :scope[data-p-foo="..."]
+ //
+ // On variant switch, values reset to that variant's declared defaults.
+ // On accept, current values are sent in the event payload so the agent
+ // can bake them into the source-file write.
+ // ---------------------------------------------------------------------------
+
+ 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 tuneOpen = false; // whether the Tune popover is open right now
+
+ // Theme-aware Tune popover. Appears as a drawer that slides out from the
+ // contextual bar's bar-facing edge (below if the bar sits below the
+ // element, above otherwise). Same width as the bar. Auto-wraps to extra
+ // rows when the knobs exceed one row. The bar's border-radius on the
+ // popover side goes flat while open so the two shapes read as one.
+ let paramsPanelPalette = null;
+
+ function initParamsPanel() {
+ paramsPanelPalette = barPaletteForTheme(detectPageTheme());
+ 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
+ // display toggle, no opacity toggle, no transform trickery. The mask
+ // hides everything initially; as it grows, content is revealed from
+ // the bar edge outward.
+ paramsPanelEl = document.createElement('div');
+ paramsPanelEl.id = PREFIX + '-params-panel';
+ Object.assign(paramsPanelEl.style, {
+ position: 'fixed', zIndex: String(Z.bar - 1),
+ background: P.surfaceDeep,
+ color: P.text,
+ fontFamily: FONT,
+ padding: '14px 18px',
+ boxSizing: 'border-box',
+ borderRadius: '0 0 10px 10px',
+ pointerEvents: 'none',
+ backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
+
+ // clip-path is the same conceptual reveal as mask but with rock-solid
+ // transition support across engines. Closed state clips from the far
+ // edge; open = inset(0) shows everything.
+ clipPath: 'inset(0 0 100% 0)',
+ 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
+ // top-left when first shown.
+ top: '-9999px', left: '-9999px', width: '0',
+ });
+
+ paramsPanelBody = el('div', {
+ display: 'grid',
+ gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
+ gap: '12px 16px',
+ });
+
+ paramsPanelEl.appendChild(paramsPanelBody);
+ document.body.appendChild(paramsPanelEl);
+ paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
+ }
+
+ function getVisibleVariantEl() {
+ if (!currentSessionId) return null;
+ const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
+ if (!wrapper) return null;
+ return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
+ }
+
+ function parseVariantParams(variantEl) {
+ if (!variantEl) return [];
+ const raw = variantEl.getAttribute('data-impeccable-params');
+ if (!raw) return [];
+ try {
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) ? parsed : [];
+ } catch (err) {
+ console.warn('[impeccable] Invalid data-impeccable-params JSON:', err.message);
+ return [];
+ }
+ }
+
+ function applyParamValue(variantEl, param, value) {
+ if (!variantEl) return;
+ const attr = 'data-p-' + param.id;
+ if (param.kind === 'range') {
+ variantEl.style.setProperty('--p-' + param.id, String(value));
+ } else if (param.kind === 'toggle') {
+ const on = !!value;
+ variantEl.style.setProperty('--p-' + param.id, on ? '1' : '0');
+ if (on) variantEl.setAttribute(attr, 'on');
+ else variantEl.removeAttribute(attr);
+ } else if (param.kind === 'steps') {
+ variantEl.setAttribute(attr, String(value));
+ }
+ }
+
+ function applyParamDefaults(variantEl, params) {
+ paramsCurrentValues = {};
+ for (const p of params) {
+ paramsCurrentValues[p.id] = p.default;
+ applyParamValue(variantEl, p, p.default);
+ }
+ }
+
+ function formatRangeValue(input) {
+ const max = parseFloat(input.max), min = parseFloat(input.min);
+ const v = parseFloat(input.value);
+ if (!isFinite(v)) return input.value;
+ return (max - min) <= 2 ? v.toFixed(2) : String(Math.round(v));
+ }
+
+ function buildParamsPanel(variantEl, params) {
+ const P = paramsPanelPalette || barPaletteForTheme(detectPageTheme());
+ paramsPanelBody.innerHTML = '';
+ for (const p of params) {
+ const row = el('div', { display: 'flex', flexDirection: 'column', gap: '6px' });
+ const labelRow = el('div', {
+ display: 'flex', justifyContent: 'space-between',
+ alignItems: 'baseline', gap: '8px',
+ });
+ const lbl = el('span', {
+ fontSize: '10.5px', fontWeight: '600', color: P.text,
+ letterSpacing: '0.03em',
+ });
+ lbl.textContent = p.label || p.id;
+ labelRow.appendChild(lbl);
+ const readout = el('span', {
+ fontSize: '10.5px', color: P.textDim,
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
+ });
+ labelRow.appendChild(readout);
+ row.appendChild(labelRow);
+
+ if (p.kind === 'range') {
+ const input = document.createElement('input');
+ input.type = 'range';
+ input.min = String(p.min != null ? p.min : 0);
+ input.max = String(p.max != null ? p.max : 1);
+ input.step = String(p.step != null ? p.step : 0.05);
+ input.value = String(p.default);
+ Object.assign(input.style, {
+ width: '100%', accentColor: C.brand, cursor: 'pointer',
+ });
+ readout.textContent = formatRangeValue(input);
+ input.addEventListener('input', (e) => {
+ e.stopPropagation();
+ const v = parseFloat(input.value);
+ paramsCurrentValues[p.id] = v;
+ readout.textContent = formatRangeValue(input);
+ applyParamValue(variantEl, p, v);
+ });
+ row.appendChild(input);
+ } else if (p.kind === 'toggle') {
+ const initial = !!p.default;
+ readout.textContent = initial ? 'On' : 'Off';
+ const track = el('button', {
+ position: 'relative', width: '36px', height: '20px',
+ borderRadius: '10px', border: 'none', padding: '0',
+ cursor: 'pointer',
+ background: initial ? C.brand : P.hairline,
+ transition: 'background 0.15s ease',
+ alignSelf: 'flex-start',
+ });
+ const knob = el('span', {
+ position: 'absolute', top: '2px',
+ left: initial ? '18px' : '2px',
+ width: '16px', height: '16px', borderRadius: '50%',
+ background: 'oklch(98% 0 0)',
+ transition: 'left 0.18s ' + EASE,
+ boxShadow: '0 1px 2px oklch(0% 0 0 / 0.2)',
+ });
+ track.appendChild(knob);
+ track.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const next = !paramsCurrentValues[p.id];
+ paramsCurrentValues[p.id] = next;
+ track.style.background = next ? C.brand : P.hairline;
+ knob.style.left = next ? '18px' : '2px';
+ readout.textContent = next ? 'On' : 'Off';
+ applyParamValue(variantEl, p, next);
+ });
+ row.appendChild(track);
+ } else if (p.kind === 'steps') {
+ const opts = (p.options || []).map(o =>
+ typeof o === 'string' ? { value: o, label: o } : o
+ );
+ const activeOpt = opts.find(o => o.value === p.default) || opts[0];
+ readout.textContent = activeOpt ? activeOpt.label : String(p.default);
+ const segRow = el('div', {
+ display: 'grid',
+ gridTemplateColumns: 'repeat(' + opts.length + ', 1fr)',
+ gap: '1px', padding: '2px',
+ background: P.hairline, borderRadius: '5px',
+ });
+ const segBtns = [];
+ opts.forEach(o => {
+ const active = o.value === p.default;
+ const b = el('button', {
+ padding: '5px 4px', border: 'none', borderRadius: '3px',
+ background: active ? C.brand : 'transparent',
+ color: active ? 'oklch(98% 0 0)' : P.text,
+ fontFamily: FONT, fontSize: '10.5px', fontWeight: '500',
+ cursor: 'pointer', whiteSpace: 'nowrap',
+ transition: 'background 0.1s ease, color 0.1s ease',
+ });
+ b.textContent = o.label;
+ b.addEventListener('click', (e) => {
+ e.stopPropagation();
+ paramsCurrentValues[p.id] = o.value;
+ readout.textContent = o.label;
+ segBtns.forEach(({ btn, val }) => {
+ const on = val === o.value;
+ btn.style.background = on ? C.brand : 'transparent';
+ btn.style.color = on ? 'oklch(98% 0 0)' : P.text;
+ });
+ applyParamValue(variantEl, p, o.value);
+ });
+ segRow.appendChild(b);
+ segBtns.push({ btn: b, val: o.value });
+ });
+ row.appendChild(segRow);
+ }
+
+ paramsPanelBody.appendChild(row);
+ }
+ }
+
+ // Decide which way the popover opens: away from the picked element. If the
+ // bar landed below the element, popover slides DOWN from the bar's bottom.
+ // If the bar landed above, popover slides UP from the bar's top.
+ function popoverDirection() {
+ if (!barEl || !selectedElement) return 'below';
+ const br = barEl.getBoundingClientRect();
+ const er = selectedElement.getBoundingClientRect();
+ return br.top >= er.bottom - 4 ? 'below' : 'above';
+ }
+
+ // The popover overlaps the bar by OVERLAP px on the bar-facing side. With
+ // popover z-index below bar, that overlap sits behind bar (invisible) and
+ // reinforces the "tucked behind" feel. Padding compensates so the real
+ // content starts flush with bar's outer edge.
+ const TUNE_OVERLAP = 6;
+
+ // Closed clip-path depends on direction: for 'below' clip from the far
+ // (bottom) edge so the reveal grows downward from the bar; for 'above'
+ // clip from the top edge so the reveal grows upward from the bar.
+ function closedClipPath(direction) {
+ return direction === 'below' ? 'inset(0 0 100% 0)' : 'inset(100% 0 0 0)';
+ }
+
+ function setClipPath(value, withTransition) {
+ const saved = paramsPanelEl.style.transition;
+ if (!withTransition) paramsPanelEl.style.transition = 'none';
+ paramsPanelEl.style.clipPath = value;
+ if (!withTransition) {
+ void paramsPanelEl.offsetHeight;
+ paramsPanelEl.style.transition = saved;
+ }
+ }
+
+ function positionParamsPanel() {
+ if (!paramsPanelEl || !barEl || barEl.style.display === 'none') return;
+ const br = barEl.getBoundingClientRect();
+ const direction = popoverDirection();
+ const prevDirection = paramsPanelEl.dataset.tuneDirection;
+
+ // top/left/width are NOT in the transition list, so they snap instantly.
+ paramsPanelEl.style.left = br.left + 'px';
+ paramsPanelEl.style.width = br.width + 'px';
+
+ if (direction === 'below') {
+ paramsPanelEl.style.top = (br.bottom - TUNE_OVERLAP) + 'px';
+ paramsPanelEl.style.borderRadius = '0 0 10px 10px';
+ paramsPanelEl.style.paddingTop = (14 + TUNE_OVERLAP) + 'px';
+ paramsPanelEl.style.paddingBottom = '14px';
+ } else {
+ const ih = paramsPanelEl.offsetHeight || 80;
+ paramsPanelEl.style.top = (br.top - ih + TUNE_OVERLAP) + 'px';
+ paramsPanelEl.style.borderRadius = '10px 10px 0 0';
+ paramsPanelEl.style.paddingTop = '14px';
+ paramsPanelEl.style.paddingBottom = (14 + TUNE_OVERLAP) + 'px';
+ }
+ paramsPanelEl.dataset.tuneDirection = direction;
+
+ // If currently closed and direction flipped (or first-time setup),
+ // snap the clip-path to the new direction's closed pose without
+ // transitioning (so the clip doesn't slide across the element).
+ if (!tuneOpen && (!prevDirection || prevDirection !== direction)) {
+ setClipPath(closedClipPath(direction), false);
+ }
+ }
+
+ function showParamsPanel() {
+ if (!paramsPanelEl) return;
+ positionParamsPanel();
+ paramsPanelEl.style.pointerEvents = 'auto';
+ // rAF so the positioning paint commits before the transition fires.
+ requestAnimationFrame(() => {
+ setClipPath('inset(0 0 0 0)', true);
+ });
+ }
+
+ function hideParamsPanel() {
+ if (!paramsPanelEl) return;
+ paramsPanelEl.style.pointerEvents = 'none';
+ const direction = paramsPanelEl.dataset.tuneDirection || 'below';
+ setClipPath(closedClipPath(direction), true);
+ }
+
+ // Build/rebuild the panel's contents for the current variant AND apply
+ // its defaults to the variant wrapper (so scoped CSS responds even before
+ // the user opens the popover). Visibility is governed by tuneOpen.
+ function refreshParamsPanel() {
+ if (state !== 'CYCLING') {
+ paramsCurrentValues = {};
+ tuneOpen = false;
+ hideParamsPanel();
+ return;
+ }
+ const variantEl = getVisibleVariantEl();
+ const params = parseVariantParams(variantEl);
+ if (!variantEl || params.length === 0) {
+ paramsCurrentValues = {};
+ tuneOpen = false;
+ hideParamsPanel();
+ return;
+ }
+ applyParamDefaults(variantEl, params);
+ buildParamsPanel(variantEl, params);
+ if (tuneOpen) {
+ // If already visible (variant cycled while open), refresh in place
+ // instead of re-running the clip-path animation.
+ const alreadyVisible = paramsPanelEl.style.display === 'block'
+ && paramsPanelEl.style.opacity === '1';
+ if (alreadyVisible) positionParamsPanel();
+ else showParamsPanel();
+ } else {
+ hideParamsPanel();
+ }
+ }
+
+ function toggleTunePopover() {
+ if (tuneOpen) { closeTunePopover(); return; }
+ openTunePopover();
+ }
+
+ function openTunePopover() {
+ if (state !== 'CYCLING') return;
+ const variantEl = getVisibleVariantEl();
+ const params = parseVariantParams(variantEl);
+ if (!variantEl || params.length === 0) return;
+ // Build fresh to ensure the current variant's controls are shown.
+ applyParamDefaults(variantEl, params);
+ buildParamsPanel(variantEl, params);
+ tuneOpen = true;
+ showParamsPanel();
+ // Kill the bar's shadow on the popover-facing side so the dark popover
+ // doesn't pick up a bright glow line.
+ if (barEl) {
+ const direction = paramsPanelEl?.dataset.tuneDirection || 'below';
+ barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN;
+ }
+ // Re-render the bar so the Tune chip picks up the active styling.
+ updateBarContent('cycling');
+ }
+
+ function closeTunePopover() {
+ tuneOpen = false;
+ hideParamsPanel();
+ if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT;
+ if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') {
+ updateBarContent('cycling');
+ }
+ }
+
// ---------------------------------------------------------------------------
// Variant cycling in DOM
// ---------------------------------------------------------------------------
@@ -1253,6 +1762,10 @@
if (!v) continue;
child.style.display = (v === String(num)) ? '' : 'none';
}
+ // 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();
}
/**
@@ -1316,6 +1829,7 @@
state = 'CYCLING';
hideShaderOverlay();
updateBarContent('cycling');
+ refreshParamsPanel();
saveSession();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
@@ -1329,7 +1843,7 @@
const next = visibleVariant + dir;
if (next < 1 || next > arrivedVariants) return;
visibleVariant = next;
- showVariantInDOM(currentSessionId, next);
+ showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself
updateSelectedElement();
updateBarContent('cycling');
saveSession();
@@ -1540,6 +2054,7 @@
state = 'CYCLING';
hideShaderOverlay();
updateBarContent('cycling');
+ refreshParamsPanel();
} else if (state === 'GENERATING') {
updateBarContent('generating');
}
@@ -1560,6 +2075,7 @@
if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') {
positionBar();
showHighlight(selectedElement);
+ if (tuneOpen) positionParamsPanel();
}
if (annotActive) positionAnnotOverlay(selectedElement);
// Shader overlay (via debug P toggle or generation) is repositioned
@@ -1606,6 +2122,7 @@
if (state === 'GENERATING') {
state = 'CYCLING';
updateBarContent('cycling');
+ refreshParamsPanel();
}
break;
}
@@ -1691,6 +2208,10 @@
if (pickerEl?.style.display !== 'none' && !own(e.target)) {
hideActionPicker();
}
+ // Close Tune popover on outside click (anything outside panel + bar)
+ if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) {
+ closeTunePopover();
+ }
// In CONFIGURING: click outside the bar and selected element returns to PICKING
if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) {
hideBar();
@@ -2258,7 +2779,11 @@ void main() {
function handleAccept() {
if (!currentSessionId || arrivedVariants === 0) return;
- sendEvent({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) });
+ const acceptPayload = { type: 'accept', id: currentSessionId, variantId: String(visibleVariant) };
+ if (Object.keys(paramsCurrentValues).length > 0) {
+ acceptPayload.paramValues = { ...paramsCurrentValues };
+ }
+ sendEvent(acceptPayload);
markSessionHandled();
// The accepted variant is already the only visible child of the wrapper
@@ -2477,6 +3002,11 @@ void main() {
state = arrivedVariants >= expectedVariants ? 'CYCLING' : 'GENERATING';
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
startScrollTracking();
+ // Build the params panel for the restored visible variant. Previously
+ // this was missed on page-reload resume: showVariantInDOM above fires
+ // refreshParamsPanel, but state was still IDLE at that moment so it
+ // hid. Now that state is CYCLING, re-fire.
+ if (state === 'CYCLING') refreshParamsPanel();
saveSession();
// Start observing for more variants AFTER initial setup
@@ -2522,6 +3052,11 @@ void main() {
// dark pages. This keeps the bar from fighting with the host design.
function detectPageTheme() {
try {
+ // Dev override: set localStorage 'impeccable-dev-theme' to 'light' or
+ // 'dark' to preview the opposite palette without actually changing the
+ // page bg. Used for screenshots and theme QA.
+ const override = localStorage.getItem('impeccable-dev-theme');
+ if (override === 'light' || override === 'dark') return override;
const bg = getComputedStyle(document.body).backgroundColor
|| getComputedStyle(document.documentElement).backgroundColor;
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
@@ -2538,6 +3073,7 @@ void main() {
// Light bar on dark page
return {
surface: 'oklch(98% 0 0 / 0.92)',
+ surfaceDeep: 'oklch(92% 0.005 60 / 0.96)', // slightly deeper, faint warm
hairline: 'oklch(70% 0 0 / 0.35)',
text: 'oklch(15% 0 0)',
textDim: 'oklch(45% 0 0)',
@@ -2552,6 +3088,7 @@ void main() {
// deeper so the rounded-right shape reads as a clear sculpted mark.
return {
surface: 'oklch(26% 0 0 / 0.94)',
+ surfaceDeep: 'oklch(18% 0 0 / 0.96)', // darker sand for Tune popover
hairline: 'oklch(42% 0 0 / 0.5)',
text: 'oklch(96% 0 0)',
textDim: 'oklch(72% 0 0)',
@@ -2883,6 +3420,7 @@ void main() {
if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; }
if (barEl) { barEl.remove(); barEl = null; }
if (pickerEl) { pickerEl.remove(); pickerEl = null; }
+ if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; }
if (evtSource) { evtSource.close(); evtSource = null; }
document.removeEventListener('mousemove', handleMouseMove, true);
document.removeEventListener('click', handleClick, true);
@@ -3956,6 +4494,7 @@ void main() {
initAnnotOverlay();
initBar();
initActionPicker();
+ initParamsPanel();
initGlobalBar();
initDesignPanel();
document.addEventListener('mousemove', handleMouseMove, true);
diff --git a/.agents/skills/impeccable/scripts/live-poll.mjs b/.agents/skills/impeccable/scripts/live-poll.mjs
index 9dd87e3ea..5cece1a43 100644
--- a/.agents/skills/impeccable/scripts/live-poll.mjs
+++ b/.agents/skills/impeccable/scripts/live-poll.mjs
@@ -145,6 +145,11 @@ Options:
const scriptArgs = event.type === 'discard'
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
+ if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
+ // Pass through a JSON blob; the shell-safe wrap uses single quotes because
+ // values are finite {id, number|string|boolean} pairs from a validated payload.
+ scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
+ }
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
diff --git a/.agents/skills/impeccable/scripts/live-server.mjs b/.agents/skills/impeccable/scripts/live-server.mjs
index e5fb05bfa..2ab33fd22 100644
--- a/.agents/skills/impeccable/scripts/live-server.mjs
+++ b/.agents/skills/impeccable/scripts/live-server.mjs
@@ -147,6 +147,11 @@ function validateEvent(msg) {
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
+ if (msg.paramValues !== undefined) {
+ if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
+ return 'accept: paramValues must be an object';
+ }
+ }
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
diff --git a/.claude/skills/impeccable/reference/colorize.md b/.claude/skills/impeccable/reference/colorize.md
index c9d846be6..ab3e40953 100644
--- a/.claude/skills/impeccable/reference/colorize.md
+++ b/.claude/skills/impeccable/reference/colorize.md
@@ -142,3 +142,13 @@ Test that colorization improves the experience:
- **Not overwhelming**: Is color balanced and purposeful?
Remember: Color is emotional and powerful. Use it to create warmth, guide attention, communicate meaning, and express personality. But restraint and strategy matter more than saturation and variety. Be colorful, but be intentional.
+
+## Live-mode signature params
+
+When invoked from live mode, each variant MUST declare a `color-amount` param so the user can dial between a restrained accent and a drenched surface without regeneration. Author the variant's CSS against `var(--p-color-amount, 0.5)` — typically as the alpha multiplier on backgrounds, or as a scaling factor on the chroma axis in an OKLCH expression. 0 = neutral/monochrome, 1 = full saturation / dominant coverage.
+
+```json
+{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"}
+```
+
+Layer 1-2 variant-specific params on top: palette selection (`steps` with named options), temperature warmth, or tint vs. true color. See `reference/live.md` for the full params contract.
diff --git a/.claude/skills/impeccable/reference/layout.md b/.claude/skills/impeccable/reference/layout.md
index 981ace8a1..5e8022081 100644
--- a/.claude/skills/impeccable/reference/layout.md
+++ b/.claude/skills/impeccable/reference/layout.md
@@ -119,3 +119,23 @@ Create a systematic plan:
- **Responsiveness**: Does the layout adapt gracefully across screen sizes?
Remember: Space is the most underused design tool. A layout with the right rhythm and hierarchy can make even simple content feel polished and intentional.
+
+## Live-mode signature params
+
+Each variant MUST declare a `density` param. Drive all spacing tokens in the variant's scoped CSS through `calc(var(--p-density, 1) * )` — paddings, gaps, column widths. Users slide from airy to packed and see layout re-breathe with no regeneration.
+
+```json
+{"id":"density","kind":"range","min":0.6,"max":1.4,"step":0.05,"default":1,"label":"Density"}
+```
+
+For variants whose topology genuinely changes (stacked vs. side-by-side, grid vs. bento), use a `steps` param whose scoped CSS branches via `:scope[data-p-structure="X"]`. One structure param + one density param is a powerful combo; resist adding a third.
+
+```json
+{"id":"structure","kind":"steps","default":"grid","label":"Structure","options":[
+ {"value":"stacked","label":"Stacked"},
+ {"value":"grid","label":"Grid"},
+ {"value":"bento","label":"Bento"}
+]}
+```
+
+See `reference/live.md` for the full params contract.
diff --git a/.claude/skills/impeccable/reference/live.md b/.claude/skills/impeccable/reference/live.md
index 8dbaee2f4..894fd86a1 100644
--- a/.claude/skills/impeccable/reference/live.md
+++ b/.claude/skills/impeccable/reference/live.md
@@ -179,7 +179,56 @@ The first variant has no `display: none` (visible by default). All others do. If
One edit, all variants — the browser's MutationObserver picks everything up in one pass.
-### 7. Signal done
+### 7. Parameters (optional, 2-5 per variant)
+
+Each variant can expose coarse knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against.
+
+**When to use.** Any time the variant has a meaningful axis the user might want to dial in: color amount, density, motion intensity, scale ratio. Not micro-level margin tweaks; those defeat the point.
+
+**Budget scales with the element's visual weight, not the user's curiosity.** Knobs need real estate to produce noticeably different output; slapping three on a small element just crowds the panel without making anything feel tunable.
+
+- **Leaf / tiny** — a single button, icon, input, bare heading, solitary paragraph: **0 params.** A slider can't meaningfully reshape one element; just use variants.
+- **Small composition** — labeled input, simple card, short callout (≤ ~5 visual children): **0-1 params.** Only add one if it's a clear dominant axis (e.g. density on a card with visible internal rhythm).
+- **Medium composition** — section component, nav cluster, dense card, short feature block (6-15 visual children): **2 params.**
+- **Large composition** — hero section, full page region, spread layout, anything with strong internal structure (16+ visual children or multiple sub-sections): **3-4 params.**
+
+When in doubt, fewer. The user can always ask for more variants to explore an axis you didn't expose as a knob. Count by visual children, not by DOM-node depth; a deeply-nested-but-visually-simple card still counts as small.
+
+**How to declare.** Put a JSON manifest on the variant wrapper:
+
+```html
+
+ ...variant content...
+
+```
+
+**Three kinds:**
+
+- `range` — smooth slider. Drives a CSS custom property `--p-` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`.
+- `steps` — segmented radio. Drives a data attribute `data-p-` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`.
+- `toggle` — on/off switch. Drives BOTH a CSS var (`--p-: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`.
+
+**Signature params per action.** Each action has one or two signature params that MUST be exposed when the variant can meaningfully express them. Check the action's reference file for the list. Layer 1-2 variant-specific params on top.
+
+**Reset on variant switch.** User dials density on v1, flips to v2, v2 starts at v2's declared defaults. Known limitation; preservation across variants may land later.
+
+**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment:
+
+```html
+
+```
+
+The carbonize cleanup step (see below) reads that comment and bakes the chosen values into the final CSS. For `steps`/`toggle` attribute selectors: keep only the branch matching the chosen value, drop the others, collapse `:scope[data-p-density="packed"] .grid` to a semantic class rule. For `range` vars: either substitute the literal or keep the var with the chosen value as its new default.
+
+### 8. Signal done
```bash
node .claude/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
@@ -244,11 +293,11 @@ When `_acceptResult.carbonize === true`, the accepted variant was stitched into
Do these five steps in the current thread, synchronously, before the next poll. Do not poll again until the file is clean.
-1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `` and `` and contains a `');
+ if (paramValues && Object.keys(paramValues).length > 0) {
+ // Preserve the user's knob positions for the carbonize-cleanup agent
+ // to bake into the final CSS when it collapses scoped rules.
+ replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
+ }
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
diff --git a/.claude/skills/impeccable/scripts/live-browser.js b/.claude/skills/impeccable/scripts/live-browser.js
index 116e1efa6..66efd7243 100644
--- a/.claude/skills/impeccable/scripts/live-browser.js
+++ b/.claude/skills/impeccable/scripts/live-browser.js
@@ -30,10 +30,13 @@
// Design tokens
// ---------------------------------------------------------------------------
+ // Brand magenta is pinned to the site token (--color-accent in main.css)
+ // so Accept / knobs / cycle-dots match the site's accent, not a washed
+ // theme-adjusted one.
const C = {
- brand: 'oklch(55% 0.25 350)',
- brandHov: 'oklch(48% 0.25 350)',
- brandSoft: 'oklch(55% 0.25 350 / 0.12)',
+ brand: 'oklch(60% 0.25 350)',
+ brandHov: 'oklch(52% 0.25 350)',
+ brandSoft: 'oklch(60% 0.25 350 / 0.15)',
ink: 'oklch(15% 0.01 350)',
ash: 'oklch(55% 0 0)',
paper: 'oklch(98% 0.005 350 / 0.92)',
@@ -60,6 +63,25 @@
'html', 'head', 'body', 'script', 'style', 'link', 'meta', 'noscript', 'br', 'wbr',
]);
+ // 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.
+ 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: ` `,
+ bolder: ` `,
+ quieter: ` `,
+ distill: ` `,
+ polish: ` `,
+ typeset: ` `,
+ colorize: ` `,
+ layout: ` `,
+ adapt: ` `,
+ animate: ` `,
+ delight: ` `,
+ overdrive: ` `,
+ };
+
const ACTIONS = [
{ value: 'impeccable', label: 'Freeform' },
{ value: 'bolder', label: 'Bolder' },
@@ -756,7 +778,21 @@
// The Bar — one floating element, three modes
// ---------------------------------------------------------------------------
+ // Contextual-bar palette. Cached at init so every build*Row reads a
+ // consistent set of colors; detectPageTheme runs once rather than on every
+ // phase transition.
+ let BP = null;
+
+ // Bar shadow variants. The default projects down + subtle around. When
+ // the Tune popover opens below the bar, a downward shadow lands on the
+ // dark popover and reads as a bright ghost line. We swap to UP-only while
+ // tune is open below so the popover's top edge is clean.
+ const BAR_SHADOW_DEFAULT = '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)';
+ const BAR_SHADOW_UP = '0 -4px 20px oklch(0% 0 0 / 0.08), 0 -1px 3px oklch(0% 0 0 / 0.06)';
+ const BAR_SHADOW_DOWN = BAR_SHADOW_DEFAULT;
+
function initBar() {
+ BP = barPaletteForTheme(detectPageTheme());
barEl = document.createElement('div');
barEl.id = PREFIX + '-bar';
Object.assign(barEl.style, {
@@ -764,15 +800,15 @@
display: 'none', opacity: '0',
transform: 'translateY(6px)',
transition: 'opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
- // Visual
- background: C.paper,
+ background: BP.surface,
backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
- border: '1px solid ' + C.mist,
+ border: '1px solid ' + BP.hairline,
borderRadius: '10px',
- boxShadow: '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)',
- fontFamily: FONT, fontSize: '13px', color: C.ink,
+ boxShadow: BAR_SHADOW_DEFAULT,
+ transition: 'box-shadow 0.2s ease, opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
+ fontFamily: FONT, fontSize: '13px', color: BP.text,
padding: '6px',
- maxWidth: '460px', minWidth: '320px',
+ maxWidth: '520px', minWidth: '320px',
});
document.body.appendChild(barEl);
}
@@ -824,14 +860,15 @@
barEl.style.transform = 'translateY(6px)';
setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250);
hideActionPicker();
+ closeTunePopover();
}
function updateBarContent(mode) {
if (!barEl || barEl.style.display === 'none') return;
barEl.innerHTML = '';
- // Reset bar styling
- barEl.style.background = C.paper;
- barEl.style.border = '1px solid ' + C.mist;
+ // Reset bar styling to the theme-aware palette
+ barEl.style.background = BP.surface;
+ barEl.style.border = '1px solid ' + BP.hairline;
if (mode === 'configure') barEl.appendChild(buildConfigureRow());
else if (mode === 'generating') barEl.appendChild(buildGeneratingRow());
else if (mode === 'cycling') barEl.appendChild(buildCyclingRow());
@@ -854,15 +891,15 @@
const pill = el('button', {
display: 'inline-flex', alignItems: 'center', gap: '4px',
padding: '5px 10px', borderRadius: '6px',
- background: C.ink, color: C.white,
+ background: BP.mark, color: BP.markText,
fontFamily: FONT, fontSize: '12px', fontWeight: '500',
border: 'none', cursor: 'pointer',
transition: 'background 0.12s ease, transform 0.1s ease',
whiteSpace: 'nowrap', flexShrink: '0',
});
pill.textContent = actionLabel() + ' \u25BE';
- pill.addEventListener('mouseenter', () => pill.style.background = C.brandHov);
- pill.addEventListener('mouseleave', () => pill.style.background = C.ink);
+ pill.addEventListener('mouseenter', () => pill.style.background = BP.accent);
+ pill.addEventListener('mouseleave', () => pill.style.background = BP.mark);
pill.addEventListener('mousedown', () => pill.style.transform = 'scale(0.97)');
pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)');
pill.addEventListener('click', (e) => { e.stopPropagation(); toggleActionPicker(); });
@@ -877,13 +914,13 @@
flex: '1', minWidth: '0',
padding: '5px 8px', borderRadius: '6px',
border: '1px solid transparent', background: 'transparent',
- fontFamily: FONT, fontSize: '12px', color: C.ink,
+ fontFamily: FONT, fontSize: '12px', color: BP.text,
outline: 'none',
transition: 'border-color 0.15s ease, background 0.15s ease',
});
input.addEventListener('focus', () => {
- input.style.borderColor = C.mist;
- input.style.background = C.white;
+ input.style.borderColor = BP.hairline;
+ input.style.background = BP.accentSoft;
});
input.addEventListener('blur', () => {
input.style.borderColor = 'transparent';
@@ -901,16 +938,16 @@
// Variant count toggle
const count = el('button', {
padding: '4px 6px', borderRadius: '5px',
- border: '1px solid ' + C.mist, background: 'transparent',
+ border: '1px solid ' + BP.hairline, background: 'transparent',
fontFamily: MONO, fontSize: '11px', fontWeight: '600',
- color: C.ash, cursor: 'pointer',
+ color: BP.textDim, cursor: 'pointer',
transition: 'color 0.12s ease, border-color 0.12s ease',
flexShrink: '0', whiteSpace: 'nowrap',
});
count.textContent = '\u00D7' + selectedCount;
count.title = 'Variants: click to change';
- count.addEventListener('mouseenter', () => { count.style.color = C.ink; count.style.borderColor = C.ink; });
- count.addEventListener('mouseleave', () => { count.style.color = C.ash; count.style.borderColor = C.mist; });
+ count.addEventListener('mouseenter', () => { count.style.color = BP.text; count.style.borderColor = BP.text; });
+ count.addEventListener('mouseleave', () => { count.style.color = BP.textDim; count.style.borderColor = BP.hairline; });
count.addEventListener('click', (e) => {
e.stopPropagation();
selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1;
@@ -921,15 +958,15 @@
// Go button
const go = el('button', {
padding: '5px 12px', borderRadius: '6px',
- border: 'none', background: C.brand, color: C.white,
+ border: 'none', background: BP.accent, color: BP.mark,
fontFamily: FONT, fontSize: '12px', fontWeight: '600',
cursor: 'pointer',
- transition: 'background 0.12s ease, transform 0.1s ease',
+ transition: 'filter 0.12s ease, transform 0.1s ease',
flexShrink: '0', whiteSpace: 'nowrap',
});
go.textContent = 'Go \u2192';
- go.addEventListener('mouseenter', () => go.style.background = C.brandHov);
- go.addEventListener('mouseleave', () => go.style.background = C.brand);
+ go.addEventListener('mouseenter', () => go.style.filter = 'brightness(1.1)');
+ go.addEventListener('mouseleave', () => go.style.filter = 'none');
go.addEventListener('mousedown', () => go.style.transform = 'scale(0.97)');
go.addEventListener('mouseup', () => go.style.transform = 'scale(1)');
go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); });
@@ -950,7 +987,7 @@
// Action label
const label = el('span', {
- fontWeight: '600', fontSize: '12px', color: C.ink,
+ fontWeight: '600', fontSize: '12px', color: BP.text,
flexShrink: '0', whiteSpace: 'nowrap',
});
label.textContent = actionLabel();
@@ -961,7 +998,7 @@
// Status
const status = el('span', {
- fontSize: '11px', color: C.ash, whiteSpace: 'nowrap',
+ fontSize: '11px', color: BP.textDim, whiteSpace: 'nowrap',
marginLeft: 'auto',
});
// Variants currently arrive atomically in a single file edit, so a
@@ -976,6 +1013,8 @@
// --- Cycling row ---
+ const TUNE_ICON_SVG = ' ';
+
function buildCyclingRow() {
const row = el('div', {
display: 'flex', alignItems: 'center', gap: '6px',
@@ -994,7 +1033,7 @@
// Counter
const counter = el('span', {
fontFamily: MONO, fontSize: '11px', fontWeight: '500',
- color: C.ash, minWidth: '24px', textAlign: 'center',
+ color: BP.textDim, minWidth: '24px', textAlign: 'center',
});
counter.textContent = visibleVariant + '/' + arrivedVariants;
row.appendChild(counter);
@@ -1005,20 +1044,67 @@
if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
row.appendChild(next);
+ // Tune chip — only when the visible variant exposes params
+ const visParams = parseVariantParams(getVisibleVariantEl());
+ const hasParams = visParams.length > 0;
+ if (hasParams) {
+ const tune = el('button', {
+ display: 'inline-flex', alignItems: 'center', gap: '6px',
+ padding: '4px 10px', borderRadius: '5px',
+ border: '1px solid transparent',
+ background: tuneOpen ? BP.accentSoft : 'transparent',
+ color: tuneOpen ? BP.accent : BP.text,
+ fontFamily: FONT, fontSize: '11px', fontWeight: '500',
+ cursor: 'pointer',
+ transition: 'color 0.12s ease, background 0.12s ease',
+ whiteSpace: 'nowrap',
+ });
+ tune.innerHTML = TUNE_ICON_SVG;
+ const tuneLabel = document.createElement('span');
+ tuneLabel.textContent = 'Tune';
+ tune.appendChild(tuneLabel);
+ const tuneBadge = document.createElement('span');
+ Object.assign(tuneBadge.style, {
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
+ minWidth: '16px', height: '16px', padding: '0 4px',
+ borderRadius: '999px',
+ background: tuneOpen ? C.brand : BP.hairline,
+ color: tuneOpen ? 'oklch(98% 0 0)' : 'inherit',
+ fontFamily: MONO, fontSize: '9.5px', fontWeight: '600',
+ lineHeight: '1',
+ boxSizing: 'border-box',
+ });
+ tuneBadge.textContent = String(visParams.length);
+ tune.appendChild(tuneBadge);
+ tune.title = 'Tune this variant (' + visParams.length + ' knob' + (visParams.length === 1 ? '' : 's') + ')';
+ tune.addEventListener('mouseenter', () => {
+ if (!tuneOpen) tune.style.background = BP.accentSoft;
+ });
+ tune.addEventListener('mouseleave', () => {
+ if (!tuneOpen) tune.style.background = 'transparent';
+ });
+ tune.addEventListener('click', (e) => { e.stopPropagation(); toggleTunePopover(); });
+ tune.dataset.iceqTune = '1';
+ row.appendChild(tune);
+ }
+
// Spacer
row.appendChild(el('div', { flex: '1' }));
- // Accept
+ // Accept — primary action, uses the site's saturated brand magenta
+ // with paper-white text, not the theme-muted BP.accent.
const accept = el('button', {
- padding: '4px 10px', borderRadius: '5px',
- border: 'none', background: C.ink, color: C.white,
+ padding: '5px 14px', borderRadius: '5px',
+ border: 'none', background: C.brand, color: 'oklch(98% 0 0)',
fontFamily: FONT, fontSize: '11px', fontWeight: '600',
- cursor: 'pointer', transition: 'background 0.12s ease',
+ cursor: 'pointer', transition: 'filter 0.12s ease, transform 0.1s ease',
whiteSpace: 'nowrap',
});
accept.textContent = '\u2713 Accept';
- accept.addEventListener('mouseenter', () => accept.style.background = C.brand);
- accept.addEventListener('mouseleave', () => accept.style.background = C.ink);
+ accept.addEventListener('mouseenter', () => accept.style.filter = 'brightness(1.08)');
+ accept.addEventListener('mouseleave', () => accept.style.filter = 'none');
+ accept.addEventListener('mousedown', () => accept.style.transform = 'scale(0.97)');
+ accept.addEventListener('mouseup', () => accept.style.transform = 'scale(1)');
accept.addEventListener('click', (e) => { e.stopPropagation(); handleAccept(); });
if (arrivedVariants === 0) { accept.style.opacity = '0.3'; accept.style.pointerEvents = 'none'; }
row.appendChild(accept);
@@ -1026,14 +1112,14 @@
// Discard
const discard = el('button', {
padding: '4px 6px', borderRadius: '5px',
- border: '1px solid ' + C.mist, background: 'transparent',
- fontFamily: FONT, fontSize: '11px', color: C.ash,
+ border: '1px solid ' + BP.hairline, background: 'transparent',
+ fontFamily: FONT, fontSize: '11px', color: BP.textDim,
cursor: 'pointer', transition: 'color 0.12s ease, border-color 0.12s ease',
});
discard.textContent = '\u2715';
discard.title = 'Discard all variants';
- discard.addEventListener('mouseenter', () => { discard.style.color = C.ink; discard.style.borderColor = C.ink; });
- discard.addEventListener('mouseleave', () => { discard.style.color = C.ash; discard.style.borderColor = C.mist; });
+ discard.addEventListener('mouseenter', () => { discard.style.color = BP.text; discard.style.borderColor = BP.text; });
+ discard.addEventListener('mouseleave', () => { discard.style.color = BP.textDim; discard.style.borderColor = BP.hairline; });
discard.addEventListener('click', (e) => { e.stopPropagation(); handleDiscard(); });
row.appendChild(discard);
@@ -1051,14 +1137,14 @@
});
const spinner = el('div', {
width: '14px', height: '14px', borderRadius: '50%',
- border: '2px solid ' + C.mist,
- borderTopColor: C.brand,
+ border: '2px solid ' + BP.hairline,
+ borderTopColor: BP.accent,
animation: 'impeccable-spin 0.6s linear infinite',
flexShrink: '0',
});
row.appendChild(spinner);
const label = el('span', {
- fontSize: '12px', color: C.ash, fontWeight: '500',
+ fontSize: '12px', color: BP.textDim, fontWeight: '500',
});
label.textContent = 'Applying variant...';
row.appendChild(label);
@@ -1103,14 +1189,26 @@
for (let i = 1; i <= expectedVariants; i++) {
const arrived = i <= arrivedVariants;
const active = i === visibleVariant;
+ // active: solid site-brand magenta 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
+ // magenta chips, especially when all variants had arrived and every
+ // dot wore an accent ring.
+ const dotBg = active ? C.brand
+ : arrived ? BP.textDim
+ : 'transparent';
+ const dotBorder = arrived ? 'none' : '1.5px solid ' + BP.hairline;
const dot = el('div', {
- width: '7px', height: '7px', borderRadius: '50%',
- background: active ? C.brand : (arrived ? C.ash : 'transparent'),
- border: '1.5px solid ' + (arrived ? C.brand : C.mist),
+ width: active ? '8px' : '6px',
+ height: active ? '8px' : '6px',
+ borderRadius: '50%',
+ background: dotBg,
+ border: dotBorder,
+ boxSizing: 'border-box',
transition: 'all 0.2s ' + EASE,
cursor: (clickable && arrived) ? 'pointer' : 'default',
- transform: arrived ? 'scale(1)' : 'scale(0.6)',
- opacity: arrived ? '1' : '0.4',
+ transform: arrived ? 'scale(1)' : 'scale(0.85)',
+ opacity: arrived ? (active ? '1' : '0.6') : '0.4',
});
if (clickable && arrived) {
const idx = i;
@@ -1130,15 +1228,15 @@
function navBtn(text) {
const b = el('button', {
width: '26px', height: '26px', borderRadius: '5px',
- border: '1px solid ' + C.mist, background: 'transparent',
- color: C.ink, fontFamily: FONT, fontSize: '13px',
+ border: '1px solid ' + BP.hairline, background: 'transparent',
+ color: BP.text, fontFamily: FONT, fontSize: '13px',
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'border-color 0.12s ease, background 0.12s ease',
padding: '0', lineHeight: '1',
});
b.textContent = text;
- b.addEventListener('mouseenter', () => { b.style.borderColor = C.ink; });
- b.addEventListener('mouseleave', () => { b.style.borderColor = C.mist; });
+ b.addEventListener('mouseenter', () => { b.style.borderColor = BP.text; });
+ b.addEventListener('mouseleave', () => { b.style.borderColor = BP.hairline; });
return b;
}
@@ -1158,6 +1256,7 @@
// ---------------------------------------------------------------------------
function initActionPicker() {
+ const P = barPaletteForTheme(detectPageTheme());
pickerEl = document.createElement('div');
pickerEl.id = PREFIX + '-picker';
Object.assign(pickerEl.style, {
@@ -1166,12 +1265,14 @@
transform: 'scale(0.96) translateY(4px)',
transformOrigin: 'bottom left',
transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE,
- background: C.paperSolid,
- border: '1px solid ' + C.mist,
+ background: P.surface,
+ border: '1px solid ' + P.hairline,
borderRadius: '10px',
boxShadow: '0 8px 30px oklch(0% 0 0 / 0.10), 0 2px 6px oklch(0% 0 0 / 0.06)',
padding: '6px',
fontFamily: FONT,
+ backdropFilter: 'blur(10px)',
+ WebkitBackdropFilter: 'blur(10px)',
});
// Build the chip grid
@@ -1181,22 +1282,32 @@
ACTIONS.forEach(action => {
const chip = el('button', {
- padding: '6px 8px', borderRadius: '6px',
+ display: 'flex', flexDirection: 'column', alignItems: 'center',
+ gap: '4px',
+ padding: '8px 6px', borderRadius: '6px',
border: 'none',
- background: action.value === selectedAction ? C.brandSoft : 'transparent',
- color: action.value === selectedAction ? C.brand : C.ink,
+ background: action.value === selectedAction ? P.accentSoft : 'transparent',
+ color: action.value === selectedAction ? P.accent : P.text,
fontFamily: FONT, fontSize: '11px', fontWeight: '500',
cursor: 'pointer',
transition: 'background 0.1s ease, color 0.1s ease',
textAlign: 'center', whiteSpace: 'nowrap',
});
- chip.textContent = action.label;
+ const iconWrap = el('span', {
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ height: '20px', opacity: '0.9',
+ });
+ iconWrap.innerHTML = ICONS[action.value] || '';
+ const labelEl = el('span', { lineHeight: '1' });
+ labelEl.textContent = action.label;
+ chip.appendChild(iconWrap);
+ chip.appendChild(labelEl);
chip.dataset.action = action.value;
chip.addEventListener('mouseenter', () => {
- if (action.value !== selectedAction) chip.style.background = C.brandSoft;
+ if (action.value !== selectedAction) chip.style.background = P.accentSoft;
});
chip.addEventListener('mouseleave', () => {
- chip.style.background = action.value === selectedAction ? C.brandSoft : 'transparent';
+ chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent';
});
chip.addEventListener('click', (e) => {
e.stopPropagation();
@@ -1209,19 +1320,24 @@
pickerEl.appendChild(grid);
document.body.appendChild(pickerEl);
+
+ // Cache the palette on the picker so toggleActionPicker's state refresh
+ // uses the same theme-aware colors when it repaints chips.
+ pickerEl.__iceq_palette = P;
}
function toggleActionPicker() {
if (pickerEl.style.display !== 'none') { hideActionPicker(); return; }
// Rebuild chips to reflect current selection
+ const P = pickerEl.__iceq_palette || barPaletteForTheme(detectPageTheme());
pickerEl.querySelectorAll('button').forEach(chip => {
const isActive = chip.dataset.action === selectedAction;
- chip.style.background = isActive ? C.brandSoft : 'transparent';
- chip.style.color = isActive ? C.brand : C.ink;
+ chip.style.background = isActive ? P.accentSoft : 'transparent';
+ chip.style.color = isActive ? P.accent : P.text;
});
// Position above the bar
const barRect = barEl.getBoundingClientRect();
- const pickerH = 90; // approximate
+ const pickerH = 170; // approximate; grows with icon + label rows
let top = barRect.top - pickerH - 6;
if (top < 8) top = barRect.bottom + 6;
Object.assign(pickerEl.style, {
@@ -1241,6 +1357,399 @@
setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180);
}
+ // ---------------------------------------------------------------------------
+ // Params panel (per-variant coarse controls)
+ //
+ // Variants may declare a parameter manifest via a JSON attribute on the
+ // variant wrapper:
+ //
+ //
+ //
+ // The panel docks to the right edge of the outline during CYCLING and
+ // exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped
+ // CSS can respond instantly without regeneration:
+ //
+ // range / numeric toggle → CSS var (`--p-
`) used via var(--p-foo, N)
+ // steps / boolean toggle → data-p- attribute used via :scope[data-p-foo="..."]
+ //
+ // On variant switch, values reset to that variant's declared defaults.
+ // On accept, current values are sent in the event payload so the agent
+ // can bake them into the source-file write.
+ // ---------------------------------------------------------------------------
+
+ 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 tuneOpen = false; // whether the Tune popover is open right now
+
+ // Theme-aware Tune popover. Appears as a drawer that slides out from the
+ // contextual bar's bar-facing edge (below if the bar sits below the
+ // element, above otherwise). Same width as the bar. Auto-wraps to extra
+ // rows when the knobs exceed one row. The bar's border-radius on the
+ // popover side goes flat while open so the two shapes read as one.
+ let paramsPanelPalette = null;
+
+ function initParamsPanel() {
+ paramsPanelPalette = barPaletteForTheme(detectPageTheme());
+ 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
+ // display toggle, no opacity toggle, no transform trickery. The mask
+ // hides everything initially; as it grows, content is revealed from
+ // the bar edge outward.
+ paramsPanelEl = document.createElement('div');
+ paramsPanelEl.id = PREFIX + '-params-panel';
+ Object.assign(paramsPanelEl.style, {
+ position: 'fixed', zIndex: String(Z.bar - 1),
+ background: P.surfaceDeep,
+ color: P.text,
+ fontFamily: FONT,
+ padding: '14px 18px',
+ boxSizing: 'border-box',
+ borderRadius: '0 0 10px 10px',
+ pointerEvents: 'none',
+ backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
+
+ // clip-path is the same conceptual reveal as mask but with rock-solid
+ // transition support across engines. Closed state clips from the far
+ // edge; open = inset(0) shows everything.
+ clipPath: 'inset(0 0 100% 0)',
+ 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
+ // top-left when first shown.
+ top: '-9999px', left: '-9999px', width: '0',
+ });
+
+ paramsPanelBody = el('div', {
+ display: 'grid',
+ gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
+ gap: '12px 16px',
+ });
+
+ paramsPanelEl.appendChild(paramsPanelBody);
+ document.body.appendChild(paramsPanelEl);
+ paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
+ }
+
+ function getVisibleVariantEl() {
+ if (!currentSessionId) return null;
+ const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
+ if (!wrapper) return null;
+ return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
+ }
+
+ function parseVariantParams(variantEl) {
+ if (!variantEl) return [];
+ const raw = variantEl.getAttribute('data-impeccable-params');
+ if (!raw) return [];
+ try {
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) ? parsed : [];
+ } catch (err) {
+ console.warn('[impeccable] Invalid data-impeccable-params JSON:', err.message);
+ return [];
+ }
+ }
+
+ function applyParamValue(variantEl, param, value) {
+ if (!variantEl) return;
+ const attr = 'data-p-' + param.id;
+ if (param.kind === 'range') {
+ variantEl.style.setProperty('--p-' + param.id, String(value));
+ } else if (param.kind === 'toggle') {
+ const on = !!value;
+ variantEl.style.setProperty('--p-' + param.id, on ? '1' : '0');
+ if (on) variantEl.setAttribute(attr, 'on');
+ else variantEl.removeAttribute(attr);
+ } else if (param.kind === 'steps') {
+ variantEl.setAttribute(attr, String(value));
+ }
+ }
+
+ function applyParamDefaults(variantEl, params) {
+ paramsCurrentValues = {};
+ for (const p of params) {
+ paramsCurrentValues[p.id] = p.default;
+ applyParamValue(variantEl, p, p.default);
+ }
+ }
+
+ function formatRangeValue(input) {
+ const max = parseFloat(input.max), min = parseFloat(input.min);
+ const v = parseFloat(input.value);
+ if (!isFinite(v)) return input.value;
+ return (max - min) <= 2 ? v.toFixed(2) : String(Math.round(v));
+ }
+
+ function buildParamsPanel(variantEl, params) {
+ const P = paramsPanelPalette || barPaletteForTheme(detectPageTheme());
+ paramsPanelBody.innerHTML = '';
+ for (const p of params) {
+ const row = el('div', { display: 'flex', flexDirection: 'column', gap: '6px' });
+ const labelRow = el('div', {
+ display: 'flex', justifyContent: 'space-between',
+ alignItems: 'baseline', gap: '8px',
+ });
+ const lbl = el('span', {
+ fontSize: '10.5px', fontWeight: '600', color: P.text,
+ letterSpacing: '0.03em',
+ });
+ lbl.textContent = p.label || p.id;
+ labelRow.appendChild(lbl);
+ const readout = el('span', {
+ fontSize: '10.5px', color: P.textDim,
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
+ });
+ labelRow.appendChild(readout);
+ row.appendChild(labelRow);
+
+ if (p.kind === 'range') {
+ const input = document.createElement('input');
+ input.type = 'range';
+ input.min = String(p.min != null ? p.min : 0);
+ input.max = String(p.max != null ? p.max : 1);
+ input.step = String(p.step != null ? p.step : 0.05);
+ input.value = String(p.default);
+ Object.assign(input.style, {
+ width: '100%', accentColor: C.brand, cursor: 'pointer',
+ });
+ readout.textContent = formatRangeValue(input);
+ input.addEventListener('input', (e) => {
+ e.stopPropagation();
+ const v = parseFloat(input.value);
+ paramsCurrentValues[p.id] = v;
+ readout.textContent = formatRangeValue(input);
+ applyParamValue(variantEl, p, v);
+ });
+ row.appendChild(input);
+ } else if (p.kind === 'toggle') {
+ const initial = !!p.default;
+ readout.textContent = initial ? 'On' : 'Off';
+ const track = el('button', {
+ position: 'relative', width: '36px', height: '20px',
+ borderRadius: '10px', border: 'none', padding: '0',
+ cursor: 'pointer',
+ background: initial ? C.brand : P.hairline,
+ transition: 'background 0.15s ease',
+ alignSelf: 'flex-start',
+ });
+ const knob = el('span', {
+ position: 'absolute', top: '2px',
+ left: initial ? '18px' : '2px',
+ width: '16px', height: '16px', borderRadius: '50%',
+ background: 'oklch(98% 0 0)',
+ transition: 'left 0.18s ' + EASE,
+ boxShadow: '0 1px 2px oklch(0% 0 0 / 0.2)',
+ });
+ track.appendChild(knob);
+ track.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const next = !paramsCurrentValues[p.id];
+ paramsCurrentValues[p.id] = next;
+ track.style.background = next ? C.brand : P.hairline;
+ knob.style.left = next ? '18px' : '2px';
+ readout.textContent = next ? 'On' : 'Off';
+ applyParamValue(variantEl, p, next);
+ });
+ row.appendChild(track);
+ } else if (p.kind === 'steps') {
+ const opts = (p.options || []).map(o =>
+ typeof o === 'string' ? { value: o, label: o } : o
+ );
+ const activeOpt = opts.find(o => o.value === p.default) || opts[0];
+ readout.textContent = activeOpt ? activeOpt.label : String(p.default);
+ const segRow = el('div', {
+ display: 'grid',
+ gridTemplateColumns: 'repeat(' + opts.length + ', 1fr)',
+ gap: '1px', padding: '2px',
+ background: P.hairline, borderRadius: '5px',
+ });
+ const segBtns = [];
+ opts.forEach(o => {
+ const active = o.value === p.default;
+ const b = el('button', {
+ padding: '5px 4px', border: 'none', borderRadius: '3px',
+ background: active ? C.brand : 'transparent',
+ color: active ? 'oklch(98% 0 0)' : P.text,
+ fontFamily: FONT, fontSize: '10.5px', fontWeight: '500',
+ cursor: 'pointer', whiteSpace: 'nowrap',
+ transition: 'background 0.1s ease, color 0.1s ease',
+ });
+ b.textContent = o.label;
+ b.addEventListener('click', (e) => {
+ e.stopPropagation();
+ paramsCurrentValues[p.id] = o.value;
+ readout.textContent = o.label;
+ segBtns.forEach(({ btn, val }) => {
+ const on = val === o.value;
+ btn.style.background = on ? C.brand : 'transparent';
+ btn.style.color = on ? 'oklch(98% 0 0)' : P.text;
+ });
+ applyParamValue(variantEl, p, o.value);
+ });
+ segRow.appendChild(b);
+ segBtns.push({ btn: b, val: o.value });
+ });
+ row.appendChild(segRow);
+ }
+
+ paramsPanelBody.appendChild(row);
+ }
+ }
+
+ // Decide which way the popover opens: away from the picked element. If the
+ // bar landed below the element, popover slides DOWN from the bar's bottom.
+ // If the bar landed above, popover slides UP from the bar's top.
+ function popoverDirection() {
+ if (!barEl || !selectedElement) return 'below';
+ const br = barEl.getBoundingClientRect();
+ const er = selectedElement.getBoundingClientRect();
+ return br.top >= er.bottom - 4 ? 'below' : 'above';
+ }
+
+ // The popover overlaps the bar by OVERLAP px on the bar-facing side. With
+ // popover z-index below bar, that overlap sits behind bar (invisible) and
+ // reinforces the "tucked behind" feel. Padding compensates so the real
+ // content starts flush with bar's outer edge.
+ const TUNE_OVERLAP = 6;
+
+ // Closed clip-path depends on direction: for 'below' clip from the far
+ // (bottom) edge so the reveal grows downward from the bar; for 'above'
+ // clip from the top edge so the reveal grows upward from the bar.
+ function closedClipPath(direction) {
+ return direction === 'below' ? 'inset(0 0 100% 0)' : 'inset(100% 0 0 0)';
+ }
+
+ function setClipPath(value, withTransition) {
+ const saved = paramsPanelEl.style.transition;
+ if (!withTransition) paramsPanelEl.style.transition = 'none';
+ paramsPanelEl.style.clipPath = value;
+ if (!withTransition) {
+ void paramsPanelEl.offsetHeight;
+ paramsPanelEl.style.transition = saved;
+ }
+ }
+
+ function positionParamsPanel() {
+ if (!paramsPanelEl || !barEl || barEl.style.display === 'none') return;
+ const br = barEl.getBoundingClientRect();
+ const direction = popoverDirection();
+ const prevDirection = paramsPanelEl.dataset.tuneDirection;
+
+ // top/left/width are NOT in the transition list, so they snap instantly.
+ paramsPanelEl.style.left = br.left + 'px';
+ paramsPanelEl.style.width = br.width + 'px';
+
+ if (direction === 'below') {
+ paramsPanelEl.style.top = (br.bottom - TUNE_OVERLAP) + 'px';
+ paramsPanelEl.style.borderRadius = '0 0 10px 10px';
+ paramsPanelEl.style.paddingTop = (14 + TUNE_OVERLAP) + 'px';
+ paramsPanelEl.style.paddingBottom = '14px';
+ } else {
+ const ih = paramsPanelEl.offsetHeight || 80;
+ paramsPanelEl.style.top = (br.top - ih + TUNE_OVERLAP) + 'px';
+ paramsPanelEl.style.borderRadius = '10px 10px 0 0';
+ paramsPanelEl.style.paddingTop = '14px';
+ paramsPanelEl.style.paddingBottom = (14 + TUNE_OVERLAP) + 'px';
+ }
+ paramsPanelEl.dataset.tuneDirection = direction;
+
+ // If currently closed and direction flipped (or first-time setup),
+ // snap the clip-path to the new direction's closed pose without
+ // transitioning (so the clip doesn't slide across the element).
+ if (!tuneOpen && (!prevDirection || prevDirection !== direction)) {
+ setClipPath(closedClipPath(direction), false);
+ }
+ }
+
+ function showParamsPanel() {
+ if (!paramsPanelEl) return;
+ positionParamsPanel();
+ paramsPanelEl.style.pointerEvents = 'auto';
+ // rAF so the positioning paint commits before the transition fires.
+ requestAnimationFrame(() => {
+ setClipPath('inset(0 0 0 0)', true);
+ });
+ }
+
+ function hideParamsPanel() {
+ if (!paramsPanelEl) return;
+ paramsPanelEl.style.pointerEvents = 'none';
+ const direction = paramsPanelEl.dataset.tuneDirection || 'below';
+ setClipPath(closedClipPath(direction), true);
+ }
+
+ // Build/rebuild the panel's contents for the current variant AND apply
+ // its defaults to the variant wrapper (so scoped CSS responds even before
+ // the user opens the popover). Visibility is governed by tuneOpen.
+ function refreshParamsPanel() {
+ if (state !== 'CYCLING') {
+ paramsCurrentValues = {};
+ tuneOpen = false;
+ hideParamsPanel();
+ return;
+ }
+ const variantEl = getVisibleVariantEl();
+ const params = parseVariantParams(variantEl);
+ if (!variantEl || params.length === 0) {
+ paramsCurrentValues = {};
+ tuneOpen = false;
+ hideParamsPanel();
+ return;
+ }
+ applyParamDefaults(variantEl, params);
+ buildParamsPanel(variantEl, params);
+ if (tuneOpen) {
+ // If already visible (variant cycled while open), refresh in place
+ // instead of re-running the clip-path animation.
+ const alreadyVisible = paramsPanelEl.style.display === 'block'
+ && paramsPanelEl.style.opacity === '1';
+ if (alreadyVisible) positionParamsPanel();
+ else showParamsPanel();
+ } else {
+ hideParamsPanel();
+ }
+ }
+
+ function toggleTunePopover() {
+ if (tuneOpen) { closeTunePopover(); return; }
+ openTunePopover();
+ }
+
+ function openTunePopover() {
+ if (state !== 'CYCLING') return;
+ const variantEl = getVisibleVariantEl();
+ const params = parseVariantParams(variantEl);
+ if (!variantEl || params.length === 0) return;
+ // Build fresh to ensure the current variant's controls are shown.
+ applyParamDefaults(variantEl, params);
+ buildParamsPanel(variantEl, params);
+ tuneOpen = true;
+ showParamsPanel();
+ // Kill the bar's shadow on the popover-facing side so the dark popover
+ // doesn't pick up a bright glow line.
+ if (barEl) {
+ const direction = paramsPanelEl?.dataset.tuneDirection || 'below';
+ barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN;
+ }
+ // Re-render the bar so the Tune chip picks up the active styling.
+ updateBarContent('cycling');
+ }
+
+ function closeTunePopover() {
+ tuneOpen = false;
+ hideParamsPanel();
+ if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT;
+ if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') {
+ updateBarContent('cycling');
+ }
+ }
+
// ---------------------------------------------------------------------------
// Variant cycling in DOM
// ---------------------------------------------------------------------------
@@ -1253,6 +1762,10 @@
if (!v) continue;
child.style.display = (v === String(num)) ? '' : 'none';
}
+ // 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();
}
/**
@@ -1316,6 +1829,7 @@
state = 'CYCLING';
hideShaderOverlay();
updateBarContent('cycling');
+ refreshParamsPanel();
saveSession();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
@@ -1329,7 +1843,7 @@
const next = visibleVariant + dir;
if (next < 1 || next > arrivedVariants) return;
visibleVariant = next;
- showVariantInDOM(currentSessionId, next);
+ showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself
updateSelectedElement();
updateBarContent('cycling');
saveSession();
@@ -1540,6 +2054,7 @@
state = 'CYCLING';
hideShaderOverlay();
updateBarContent('cycling');
+ refreshParamsPanel();
} else if (state === 'GENERATING') {
updateBarContent('generating');
}
@@ -1560,6 +2075,7 @@
if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') {
positionBar();
showHighlight(selectedElement);
+ if (tuneOpen) positionParamsPanel();
}
if (annotActive) positionAnnotOverlay(selectedElement);
// Shader overlay (via debug P toggle or generation) is repositioned
@@ -1606,6 +2122,7 @@
if (state === 'GENERATING') {
state = 'CYCLING';
updateBarContent('cycling');
+ refreshParamsPanel();
}
break;
}
@@ -1691,6 +2208,10 @@
if (pickerEl?.style.display !== 'none' && !own(e.target)) {
hideActionPicker();
}
+ // Close Tune popover on outside click (anything outside panel + bar)
+ if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) {
+ closeTunePopover();
+ }
// In CONFIGURING: click outside the bar and selected element returns to PICKING
if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) {
hideBar();
@@ -2258,7 +2779,11 @@ void main() {
function handleAccept() {
if (!currentSessionId || arrivedVariants === 0) return;
- sendEvent({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) });
+ const acceptPayload = { type: 'accept', id: currentSessionId, variantId: String(visibleVariant) };
+ if (Object.keys(paramsCurrentValues).length > 0) {
+ acceptPayload.paramValues = { ...paramsCurrentValues };
+ }
+ sendEvent(acceptPayload);
markSessionHandled();
// The accepted variant is already the only visible child of the wrapper
@@ -2477,6 +3002,11 @@ void main() {
state = arrivedVariants >= expectedVariants ? 'CYCLING' : 'GENERATING';
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
startScrollTracking();
+ // Build the params panel for the restored visible variant. Previously
+ // this was missed on page-reload resume: showVariantInDOM above fires
+ // refreshParamsPanel, but state was still IDLE at that moment so it
+ // hid. Now that state is CYCLING, re-fire.
+ if (state === 'CYCLING') refreshParamsPanel();
saveSession();
// Start observing for more variants AFTER initial setup
@@ -2522,6 +3052,11 @@ void main() {
// dark pages. This keeps the bar from fighting with the host design.
function detectPageTheme() {
try {
+ // Dev override: set localStorage 'impeccable-dev-theme' to 'light' or
+ // 'dark' to preview the opposite palette without actually changing the
+ // page bg. Used for screenshots and theme QA.
+ const override = localStorage.getItem('impeccable-dev-theme');
+ if (override === 'light' || override === 'dark') return override;
const bg = getComputedStyle(document.body).backgroundColor
|| getComputedStyle(document.documentElement).backgroundColor;
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
@@ -2538,6 +3073,7 @@ void main() {
// Light bar on dark page
return {
surface: 'oklch(98% 0 0 / 0.92)',
+ surfaceDeep: 'oklch(92% 0.005 60 / 0.96)', // slightly deeper, faint warm
hairline: 'oklch(70% 0 0 / 0.35)',
text: 'oklch(15% 0 0)',
textDim: 'oklch(45% 0 0)',
@@ -2552,6 +3088,7 @@ void main() {
// deeper so the rounded-right shape reads as a clear sculpted mark.
return {
surface: 'oklch(26% 0 0 / 0.94)',
+ surfaceDeep: 'oklch(18% 0 0 / 0.96)', // darker sand for Tune popover
hairline: 'oklch(42% 0 0 / 0.5)',
text: 'oklch(96% 0 0)',
textDim: 'oklch(72% 0 0)',
@@ -2883,6 +3420,7 @@ void main() {
if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; }
if (barEl) { barEl.remove(); barEl = null; }
if (pickerEl) { pickerEl.remove(); pickerEl = null; }
+ if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; }
if (evtSource) { evtSource.close(); evtSource = null; }
document.removeEventListener('mousemove', handleMouseMove, true);
document.removeEventListener('click', handleClick, true);
@@ -3956,6 +4494,7 @@ void main() {
initAnnotOverlay();
initBar();
initActionPicker();
+ initParamsPanel();
initGlobalBar();
initDesignPanel();
document.addEventListener('mousemove', handleMouseMove, true);
diff --git a/.claude/skills/impeccable/scripts/live-poll.mjs b/.claude/skills/impeccable/scripts/live-poll.mjs
index 9dd87e3ea..5cece1a43 100644
--- a/.claude/skills/impeccable/scripts/live-poll.mjs
+++ b/.claude/skills/impeccable/scripts/live-poll.mjs
@@ -145,6 +145,11 @@ Options:
const scriptArgs = event.type === 'discard'
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
+ if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
+ // Pass through a JSON blob; the shell-safe wrap uses single quotes because
+ // values are finite {id, number|string|boolean} pairs from a validated payload.
+ scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
+ }
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
diff --git a/.claude/skills/impeccable/scripts/live-server.mjs b/.claude/skills/impeccable/scripts/live-server.mjs
index e5fb05bfa..2ab33fd22 100644
--- a/.claude/skills/impeccable/scripts/live-server.mjs
+++ b/.claude/skills/impeccable/scripts/live-server.mjs
@@ -147,6 +147,11 @@ function validateEvent(msg) {
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
+ if (msg.paramValues !== undefined) {
+ if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
+ return 'accept: paramValues must be an object';
+ }
+ }
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
diff --git a/.cursor/skills/impeccable/reference/colorize.md b/.cursor/skills/impeccable/reference/colorize.md
index de74ac95f..1bd52004f 100644
--- a/.cursor/skills/impeccable/reference/colorize.md
+++ b/.cursor/skills/impeccable/reference/colorize.md
@@ -142,3 +142,13 @@ Test that colorization improves the experience:
- **Not overwhelming**: Is color balanced and purposeful?
Remember: Color is emotional and powerful. Use it to create warmth, guide attention, communicate meaning, and express personality. But restraint and strategy matter more than saturation and variety. Be colorful, but be intentional.
+
+## Live-mode signature params
+
+When invoked from live mode, each variant MUST declare a `color-amount` param so the user can dial between a restrained accent and a drenched surface without regeneration. Author the variant's CSS against `var(--p-color-amount, 0.5)` — typically as the alpha multiplier on backgrounds, or as a scaling factor on the chroma axis in an OKLCH expression. 0 = neutral/monochrome, 1 = full saturation / dominant coverage.
+
+```json
+{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"}
+```
+
+Layer 1-2 variant-specific params on top: palette selection (`steps` with named options), temperature warmth, or tint vs. true color. See `reference/live.md` for the full params contract.
diff --git a/.cursor/skills/impeccable/reference/layout.md b/.cursor/skills/impeccable/reference/layout.md
index 981ace8a1..5e8022081 100644
--- a/.cursor/skills/impeccable/reference/layout.md
+++ b/.cursor/skills/impeccable/reference/layout.md
@@ -119,3 +119,23 @@ Create a systematic plan:
- **Responsiveness**: Does the layout adapt gracefully across screen sizes?
Remember: Space is the most underused design tool. A layout with the right rhythm and hierarchy can make even simple content feel polished and intentional.
+
+## Live-mode signature params
+
+Each variant MUST declare a `density` param. Drive all spacing tokens in the variant's scoped CSS through `calc(var(--p-density, 1) * )` — paddings, gaps, column widths. Users slide from airy to packed and see layout re-breathe with no regeneration.
+
+```json
+{"id":"density","kind":"range","min":0.6,"max":1.4,"step":0.05,"default":1,"label":"Density"}
+```
+
+For variants whose topology genuinely changes (stacked vs. side-by-side, grid vs. bento), use a `steps` param whose scoped CSS branches via `:scope[data-p-structure="X"]`. One structure param + one density param is a powerful combo; resist adding a third.
+
+```json
+{"id":"structure","kind":"steps","default":"grid","label":"Structure","options":[
+ {"value":"stacked","label":"Stacked"},
+ {"value":"grid","label":"Grid"},
+ {"value":"bento","label":"Bento"}
+]}
+```
+
+See `reference/live.md` for the full params contract.
diff --git a/.cursor/skills/impeccable/reference/live.md b/.cursor/skills/impeccable/reference/live.md
index 64822bfe8..dd5fcd9fc 100644
--- a/.cursor/skills/impeccable/reference/live.md
+++ b/.cursor/skills/impeccable/reference/live.md
@@ -179,7 +179,56 @@ The first variant has no `display: none` (visible by default). All others do. If
One edit, all variants — the browser's MutationObserver picks everything up in one pass.
-### 7. Signal done
+### 7. Parameters (optional, 2-5 per variant)
+
+Each variant can expose coarse knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against.
+
+**When to use.** Any time the variant has a meaningful axis the user might want to dial in: color amount, density, motion intensity, scale ratio. Not micro-level margin tweaks; those defeat the point.
+
+**Budget scales with the element's visual weight, not the user's curiosity.** Knobs need real estate to produce noticeably different output; slapping three on a small element just crowds the panel without making anything feel tunable.
+
+- **Leaf / tiny** — a single button, icon, input, bare heading, solitary paragraph: **0 params.** A slider can't meaningfully reshape one element; just use variants.
+- **Small composition** — labeled input, simple card, short callout (≤ ~5 visual children): **0-1 params.** Only add one if it's a clear dominant axis (e.g. density on a card with visible internal rhythm).
+- **Medium composition** — section component, nav cluster, dense card, short feature block (6-15 visual children): **2 params.**
+- **Large composition** — hero section, full page region, spread layout, anything with strong internal structure (16+ visual children or multiple sub-sections): **3-4 params.**
+
+When in doubt, fewer. The user can always ask for more variants to explore an axis you didn't expose as a knob. Count by visual children, not by DOM-node depth; a deeply-nested-but-visually-simple card still counts as small.
+
+**How to declare.** Put a JSON manifest on the variant wrapper:
+
+```html
+
+ ...variant content...
+
+```
+
+**Three kinds:**
+
+- `range` — smooth slider. Drives a CSS custom property `--p-` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`.
+- `steps` — segmented radio. Drives a data attribute `data-p-` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`.
+- `toggle` — on/off switch. Drives BOTH a CSS var (`--p-: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`.
+
+**Signature params per action.** Each action has one or two signature params that MUST be exposed when the variant can meaningfully express them. Check the action's reference file for the list. Layer 1-2 variant-specific params on top.
+
+**Reset on variant switch.** User dials density on v1, flips to v2, v2 starts at v2's declared defaults. Known limitation; preservation across variants may land later.
+
+**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment:
+
+```html
+
+```
+
+The carbonize cleanup step (see below) reads that comment and bakes the chosen values into the final CSS. For `steps`/`toggle` attribute selectors: keep only the branch matching the chosen value, drop the others, collapse `:scope[data-p-density="packed"] .grid` to a semantic class rule. For `range` vars: either substitute the literal or keep the var with the chosen value as its new default.
+
+### 8. Signal done
```bash
node .cursor/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
@@ -244,11 +293,11 @@ When `_acceptResult.carbonize === true`, the accepted variant was stitched into
Do these five steps in the current thread, synchronously, before the next poll. Do not poll again until the file is clean.
-1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `` and `` and contains a `');
+ if (paramValues && Object.keys(paramValues).length > 0) {
+ // Preserve the user's knob positions for the carbonize-cleanup agent
+ // to bake into the final CSS when it collapses scoped rules.
+ replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
+ }
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
diff --git a/.cursor/skills/impeccable/scripts/live-browser.js b/.cursor/skills/impeccable/scripts/live-browser.js
index 116e1efa6..66efd7243 100644
--- a/.cursor/skills/impeccable/scripts/live-browser.js
+++ b/.cursor/skills/impeccable/scripts/live-browser.js
@@ -30,10 +30,13 @@
// Design tokens
// ---------------------------------------------------------------------------
+ // Brand magenta is pinned to the site token (--color-accent in main.css)
+ // so Accept / knobs / cycle-dots match the site's accent, not a washed
+ // theme-adjusted one.
const C = {
- brand: 'oklch(55% 0.25 350)',
- brandHov: 'oklch(48% 0.25 350)',
- brandSoft: 'oklch(55% 0.25 350 / 0.12)',
+ brand: 'oklch(60% 0.25 350)',
+ brandHov: 'oklch(52% 0.25 350)',
+ brandSoft: 'oklch(60% 0.25 350 / 0.15)',
ink: 'oklch(15% 0.01 350)',
ash: 'oklch(55% 0 0)',
paper: 'oklch(98% 0.005 350 / 0.92)',
@@ -60,6 +63,25 @@
'html', 'head', 'body', 'script', 'style', 'link', 'meta', 'noscript', 'br', 'wbr',
]);
+ // 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.
+ 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: ` `,
+ bolder: ` `,
+ quieter: ` `,
+ distill: ` `,
+ polish: ` `,
+ typeset: ` `,
+ colorize: ` `,
+ layout: ` `,
+ adapt: ` `,
+ animate: ` `,
+ delight: ` `,
+ overdrive: ` `,
+ };
+
const ACTIONS = [
{ value: 'impeccable', label: 'Freeform' },
{ value: 'bolder', label: 'Bolder' },
@@ -756,7 +778,21 @@
// The Bar — one floating element, three modes
// ---------------------------------------------------------------------------
+ // Contextual-bar palette. Cached at init so every build*Row reads a
+ // consistent set of colors; detectPageTheme runs once rather than on every
+ // phase transition.
+ let BP = null;
+
+ // Bar shadow variants. The default projects down + subtle around. When
+ // the Tune popover opens below the bar, a downward shadow lands on the
+ // dark popover and reads as a bright ghost line. We swap to UP-only while
+ // tune is open below so the popover's top edge is clean.
+ const BAR_SHADOW_DEFAULT = '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)';
+ const BAR_SHADOW_UP = '0 -4px 20px oklch(0% 0 0 / 0.08), 0 -1px 3px oklch(0% 0 0 / 0.06)';
+ const BAR_SHADOW_DOWN = BAR_SHADOW_DEFAULT;
+
function initBar() {
+ BP = barPaletteForTheme(detectPageTheme());
barEl = document.createElement('div');
barEl.id = PREFIX + '-bar';
Object.assign(barEl.style, {
@@ -764,15 +800,15 @@
display: 'none', opacity: '0',
transform: 'translateY(6px)',
transition: 'opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
- // Visual
- background: C.paper,
+ background: BP.surface,
backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
- border: '1px solid ' + C.mist,
+ border: '1px solid ' + BP.hairline,
borderRadius: '10px',
- boxShadow: '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)',
- fontFamily: FONT, fontSize: '13px', color: C.ink,
+ boxShadow: BAR_SHADOW_DEFAULT,
+ transition: 'box-shadow 0.2s ease, opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
+ fontFamily: FONT, fontSize: '13px', color: BP.text,
padding: '6px',
- maxWidth: '460px', minWidth: '320px',
+ maxWidth: '520px', minWidth: '320px',
});
document.body.appendChild(barEl);
}
@@ -824,14 +860,15 @@
barEl.style.transform = 'translateY(6px)';
setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250);
hideActionPicker();
+ closeTunePopover();
}
function updateBarContent(mode) {
if (!barEl || barEl.style.display === 'none') return;
barEl.innerHTML = '';
- // Reset bar styling
- barEl.style.background = C.paper;
- barEl.style.border = '1px solid ' + C.mist;
+ // Reset bar styling to the theme-aware palette
+ barEl.style.background = BP.surface;
+ barEl.style.border = '1px solid ' + BP.hairline;
if (mode === 'configure') barEl.appendChild(buildConfigureRow());
else if (mode === 'generating') barEl.appendChild(buildGeneratingRow());
else if (mode === 'cycling') barEl.appendChild(buildCyclingRow());
@@ -854,15 +891,15 @@
const pill = el('button', {
display: 'inline-flex', alignItems: 'center', gap: '4px',
padding: '5px 10px', borderRadius: '6px',
- background: C.ink, color: C.white,
+ background: BP.mark, color: BP.markText,
fontFamily: FONT, fontSize: '12px', fontWeight: '500',
border: 'none', cursor: 'pointer',
transition: 'background 0.12s ease, transform 0.1s ease',
whiteSpace: 'nowrap', flexShrink: '0',
});
pill.textContent = actionLabel() + ' \u25BE';
- pill.addEventListener('mouseenter', () => pill.style.background = C.brandHov);
- pill.addEventListener('mouseleave', () => pill.style.background = C.ink);
+ pill.addEventListener('mouseenter', () => pill.style.background = BP.accent);
+ pill.addEventListener('mouseleave', () => pill.style.background = BP.mark);
pill.addEventListener('mousedown', () => pill.style.transform = 'scale(0.97)');
pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)');
pill.addEventListener('click', (e) => { e.stopPropagation(); toggleActionPicker(); });
@@ -877,13 +914,13 @@
flex: '1', minWidth: '0',
padding: '5px 8px', borderRadius: '6px',
border: '1px solid transparent', background: 'transparent',
- fontFamily: FONT, fontSize: '12px', color: C.ink,
+ fontFamily: FONT, fontSize: '12px', color: BP.text,
outline: 'none',
transition: 'border-color 0.15s ease, background 0.15s ease',
});
input.addEventListener('focus', () => {
- input.style.borderColor = C.mist;
- input.style.background = C.white;
+ input.style.borderColor = BP.hairline;
+ input.style.background = BP.accentSoft;
});
input.addEventListener('blur', () => {
input.style.borderColor = 'transparent';
@@ -901,16 +938,16 @@
// Variant count toggle
const count = el('button', {
padding: '4px 6px', borderRadius: '5px',
- border: '1px solid ' + C.mist, background: 'transparent',
+ border: '1px solid ' + BP.hairline, background: 'transparent',
fontFamily: MONO, fontSize: '11px', fontWeight: '600',
- color: C.ash, cursor: 'pointer',
+ color: BP.textDim, cursor: 'pointer',
transition: 'color 0.12s ease, border-color 0.12s ease',
flexShrink: '0', whiteSpace: 'nowrap',
});
count.textContent = '\u00D7' + selectedCount;
count.title = 'Variants: click to change';
- count.addEventListener('mouseenter', () => { count.style.color = C.ink; count.style.borderColor = C.ink; });
- count.addEventListener('mouseleave', () => { count.style.color = C.ash; count.style.borderColor = C.mist; });
+ count.addEventListener('mouseenter', () => { count.style.color = BP.text; count.style.borderColor = BP.text; });
+ count.addEventListener('mouseleave', () => { count.style.color = BP.textDim; count.style.borderColor = BP.hairline; });
count.addEventListener('click', (e) => {
e.stopPropagation();
selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1;
@@ -921,15 +958,15 @@
// Go button
const go = el('button', {
padding: '5px 12px', borderRadius: '6px',
- border: 'none', background: C.brand, color: C.white,
+ border: 'none', background: BP.accent, color: BP.mark,
fontFamily: FONT, fontSize: '12px', fontWeight: '600',
cursor: 'pointer',
- transition: 'background 0.12s ease, transform 0.1s ease',
+ transition: 'filter 0.12s ease, transform 0.1s ease',
flexShrink: '0', whiteSpace: 'nowrap',
});
go.textContent = 'Go \u2192';
- go.addEventListener('mouseenter', () => go.style.background = C.brandHov);
- go.addEventListener('mouseleave', () => go.style.background = C.brand);
+ go.addEventListener('mouseenter', () => go.style.filter = 'brightness(1.1)');
+ go.addEventListener('mouseleave', () => go.style.filter = 'none');
go.addEventListener('mousedown', () => go.style.transform = 'scale(0.97)');
go.addEventListener('mouseup', () => go.style.transform = 'scale(1)');
go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); });
@@ -950,7 +987,7 @@
// Action label
const label = el('span', {
- fontWeight: '600', fontSize: '12px', color: C.ink,
+ fontWeight: '600', fontSize: '12px', color: BP.text,
flexShrink: '0', whiteSpace: 'nowrap',
});
label.textContent = actionLabel();
@@ -961,7 +998,7 @@
// Status
const status = el('span', {
- fontSize: '11px', color: C.ash, whiteSpace: 'nowrap',
+ fontSize: '11px', color: BP.textDim, whiteSpace: 'nowrap',
marginLeft: 'auto',
});
// Variants currently arrive atomically in a single file edit, so a
@@ -976,6 +1013,8 @@
// --- Cycling row ---
+ const TUNE_ICON_SVG = ' ';
+
function buildCyclingRow() {
const row = el('div', {
display: 'flex', alignItems: 'center', gap: '6px',
@@ -994,7 +1033,7 @@
// Counter
const counter = el('span', {
fontFamily: MONO, fontSize: '11px', fontWeight: '500',
- color: C.ash, minWidth: '24px', textAlign: 'center',
+ color: BP.textDim, minWidth: '24px', textAlign: 'center',
});
counter.textContent = visibleVariant + '/' + arrivedVariants;
row.appendChild(counter);
@@ -1005,20 +1044,67 @@
if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
row.appendChild(next);
+ // Tune chip — only when the visible variant exposes params
+ const visParams = parseVariantParams(getVisibleVariantEl());
+ const hasParams = visParams.length > 0;
+ if (hasParams) {
+ const tune = el('button', {
+ display: 'inline-flex', alignItems: 'center', gap: '6px',
+ padding: '4px 10px', borderRadius: '5px',
+ border: '1px solid transparent',
+ background: tuneOpen ? BP.accentSoft : 'transparent',
+ color: tuneOpen ? BP.accent : BP.text,
+ fontFamily: FONT, fontSize: '11px', fontWeight: '500',
+ cursor: 'pointer',
+ transition: 'color 0.12s ease, background 0.12s ease',
+ whiteSpace: 'nowrap',
+ });
+ tune.innerHTML = TUNE_ICON_SVG;
+ const tuneLabel = document.createElement('span');
+ tuneLabel.textContent = 'Tune';
+ tune.appendChild(tuneLabel);
+ const tuneBadge = document.createElement('span');
+ Object.assign(tuneBadge.style, {
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
+ minWidth: '16px', height: '16px', padding: '0 4px',
+ borderRadius: '999px',
+ background: tuneOpen ? C.brand : BP.hairline,
+ color: tuneOpen ? 'oklch(98% 0 0)' : 'inherit',
+ fontFamily: MONO, fontSize: '9.5px', fontWeight: '600',
+ lineHeight: '1',
+ boxSizing: 'border-box',
+ });
+ tuneBadge.textContent = String(visParams.length);
+ tune.appendChild(tuneBadge);
+ tune.title = 'Tune this variant (' + visParams.length + ' knob' + (visParams.length === 1 ? '' : 's') + ')';
+ tune.addEventListener('mouseenter', () => {
+ if (!tuneOpen) tune.style.background = BP.accentSoft;
+ });
+ tune.addEventListener('mouseleave', () => {
+ if (!tuneOpen) tune.style.background = 'transparent';
+ });
+ tune.addEventListener('click', (e) => { e.stopPropagation(); toggleTunePopover(); });
+ tune.dataset.iceqTune = '1';
+ row.appendChild(tune);
+ }
+
// Spacer
row.appendChild(el('div', { flex: '1' }));
- // Accept
+ // Accept — primary action, uses the site's saturated brand magenta
+ // with paper-white text, not the theme-muted BP.accent.
const accept = el('button', {
- padding: '4px 10px', borderRadius: '5px',
- border: 'none', background: C.ink, color: C.white,
+ padding: '5px 14px', borderRadius: '5px',
+ border: 'none', background: C.brand, color: 'oklch(98% 0 0)',
fontFamily: FONT, fontSize: '11px', fontWeight: '600',
- cursor: 'pointer', transition: 'background 0.12s ease',
+ cursor: 'pointer', transition: 'filter 0.12s ease, transform 0.1s ease',
whiteSpace: 'nowrap',
});
accept.textContent = '\u2713 Accept';
- accept.addEventListener('mouseenter', () => accept.style.background = C.brand);
- accept.addEventListener('mouseleave', () => accept.style.background = C.ink);
+ accept.addEventListener('mouseenter', () => accept.style.filter = 'brightness(1.08)');
+ accept.addEventListener('mouseleave', () => accept.style.filter = 'none');
+ accept.addEventListener('mousedown', () => accept.style.transform = 'scale(0.97)');
+ accept.addEventListener('mouseup', () => accept.style.transform = 'scale(1)');
accept.addEventListener('click', (e) => { e.stopPropagation(); handleAccept(); });
if (arrivedVariants === 0) { accept.style.opacity = '0.3'; accept.style.pointerEvents = 'none'; }
row.appendChild(accept);
@@ -1026,14 +1112,14 @@
// Discard
const discard = el('button', {
padding: '4px 6px', borderRadius: '5px',
- border: '1px solid ' + C.mist, background: 'transparent',
- fontFamily: FONT, fontSize: '11px', color: C.ash,
+ border: '1px solid ' + BP.hairline, background: 'transparent',
+ fontFamily: FONT, fontSize: '11px', color: BP.textDim,
cursor: 'pointer', transition: 'color 0.12s ease, border-color 0.12s ease',
});
discard.textContent = '\u2715';
discard.title = 'Discard all variants';
- discard.addEventListener('mouseenter', () => { discard.style.color = C.ink; discard.style.borderColor = C.ink; });
- discard.addEventListener('mouseleave', () => { discard.style.color = C.ash; discard.style.borderColor = C.mist; });
+ discard.addEventListener('mouseenter', () => { discard.style.color = BP.text; discard.style.borderColor = BP.text; });
+ discard.addEventListener('mouseleave', () => { discard.style.color = BP.textDim; discard.style.borderColor = BP.hairline; });
discard.addEventListener('click', (e) => { e.stopPropagation(); handleDiscard(); });
row.appendChild(discard);
@@ -1051,14 +1137,14 @@
});
const spinner = el('div', {
width: '14px', height: '14px', borderRadius: '50%',
- border: '2px solid ' + C.mist,
- borderTopColor: C.brand,
+ border: '2px solid ' + BP.hairline,
+ borderTopColor: BP.accent,
animation: 'impeccable-spin 0.6s linear infinite',
flexShrink: '0',
});
row.appendChild(spinner);
const label = el('span', {
- fontSize: '12px', color: C.ash, fontWeight: '500',
+ fontSize: '12px', color: BP.textDim, fontWeight: '500',
});
label.textContent = 'Applying variant...';
row.appendChild(label);
@@ -1103,14 +1189,26 @@
for (let i = 1; i <= expectedVariants; i++) {
const arrived = i <= arrivedVariants;
const active = i === visibleVariant;
+ // active: solid site-brand magenta 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
+ // magenta chips, especially when all variants had arrived and every
+ // dot wore an accent ring.
+ const dotBg = active ? C.brand
+ : arrived ? BP.textDim
+ : 'transparent';
+ const dotBorder = arrived ? 'none' : '1.5px solid ' + BP.hairline;
const dot = el('div', {
- width: '7px', height: '7px', borderRadius: '50%',
- background: active ? C.brand : (arrived ? C.ash : 'transparent'),
- border: '1.5px solid ' + (arrived ? C.brand : C.mist),
+ width: active ? '8px' : '6px',
+ height: active ? '8px' : '6px',
+ borderRadius: '50%',
+ background: dotBg,
+ border: dotBorder,
+ boxSizing: 'border-box',
transition: 'all 0.2s ' + EASE,
cursor: (clickable && arrived) ? 'pointer' : 'default',
- transform: arrived ? 'scale(1)' : 'scale(0.6)',
- opacity: arrived ? '1' : '0.4',
+ transform: arrived ? 'scale(1)' : 'scale(0.85)',
+ opacity: arrived ? (active ? '1' : '0.6') : '0.4',
});
if (clickable && arrived) {
const idx = i;
@@ -1130,15 +1228,15 @@
function navBtn(text) {
const b = el('button', {
width: '26px', height: '26px', borderRadius: '5px',
- border: '1px solid ' + C.mist, background: 'transparent',
- color: C.ink, fontFamily: FONT, fontSize: '13px',
+ border: '1px solid ' + BP.hairline, background: 'transparent',
+ color: BP.text, fontFamily: FONT, fontSize: '13px',
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'border-color 0.12s ease, background 0.12s ease',
padding: '0', lineHeight: '1',
});
b.textContent = text;
- b.addEventListener('mouseenter', () => { b.style.borderColor = C.ink; });
- b.addEventListener('mouseleave', () => { b.style.borderColor = C.mist; });
+ b.addEventListener('mouseenter', () => { b.style.borderColor = BP.text; });
+ b.addEventListener('mouseleave', () => { b.style.borderColor = BP.hairline; });
return b;
}
@@ -1158,6 +1256,7 @@
// ---------------------------------------------------------------------------
function initActionPicker() {
+ const P = barPaletteForTheme(detectPageTheme());
pickerEl = document.createElement('div');
pickerEl.id = PREFIX + '-picker';
Object.assign(pickerEl.style, {
@@ -1166,12 +1265,14 @@
transform: 'scale(0.96) translateY(4px)',
transformOrigin: 'bottom left',
transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE,
- background: C.paperSolid,
- border: '1px solid ' + C.mist,
+ background: P.surface,
+ border: '1px solid ' + P.hairline,
borderRadius: '10px',
boxShadow: '0 8px 30px oklch(0% 0 0 / 0.10), 0 2px 6px oklch(0% 0 0 / 0.06)',
padding: '6px',
fontFamily: FONT,
+ backdropFilter: 'blur(10px)',
+ WebkitBackdropFilter: 'blur(10px)',
});
// Build the chip grid
@@ -1181,22 +1282,32 @@
ACTIONS.forEach(action => {
const chip = el('button', {
- padding: '6px 8px', borderRadius: '6px',
+ display: 'flex', flexDirection: 'column', alignItems: 'center',
+ gap: '4px',
+ padding: '8px 6px', borderRadius: '6px',
border: 'none',
- background: action.value === selectedAction ? C.brandSoft : 'transparent',
- color: action.value === selectedAction ? C.brand : C.ink,
+ background: action.value === selectedAction ? P.accentSoft : 'transparent',
+ color: action.value === selectedAction ? P.accent : P.text,
fontFamily: FONT, fontSize: '11px', fontWeight: '500',
cursor: 'pointer',
transition: 'background 0.1s ease, color 0.1s ease',
textAlign: 'center', whiteSpace: 'nowrap',
});
- chip.textContent = action.label;
+ const iconWrap = el('span', {
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ height: '20px', opacity: '0.9',
+ });
+ iconWrap.innerHTML = ICONS[action.value] || '';
+ const labelEl = el('span', { lineHeight: '1' });
+ labelEl.textContent = action.label;
+ chip.appendChild(iconWrap);
+ chip.appendChild(labelEl);
chip.dataset.action = action.value;
chip.addEventListener('mouseenter', () => {
- if (action.value !== selectedAction) chip.style.background = C.brandSoft;
+ if (action.value !== selectedAction) chip.style.background = P.accentSoft;
});
chip.addEventListener('mouseleave', () => {
- chip.style.background = action.value === selectedAction ? C.brandSoft : 'transparent';
+ chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent';
});
chip.addEventListener('click', (e) => {
e.stopPropagation();
@@ -1209,19 +1320,24 @@
pickerEl.appendChild(grid);
document.body.appendChild(pickerEl);
+
+ // Cache the palette on the picker so toggleActionPicker's state refresh
+ // uses the same theme-aware colors when it repaints chips.
+ pickerEl.__iceq_palette = P;
}
function toggleActionPicker() {
if (pickerEl.style.display !== 'none') { hideActionPicker(); return; }
// Rebuild chips to reflect current selection
+ const P = pickerEl.__iceq_palette || barPaletteForTheme(detectPageTheme());
pickerEl.querySelectorAll('button').forEach(chip => {
const isActive = chip.dataset.action === selectedAction;
- chip.style.background = isActive ? C.brandSoft : 'transparent';
- chip.style.color = isActive ? C.brand : C.ink;
+ chip.style.background = isActive ? P.accentSoft : 'transparent';
+ chip.style.color = isActive ? P.accent : P.text;
});
// Position above the bar
const barRect = barEl.getBoundingClientRect();
- const pickerH = 90; // approximate
+ const pickerH = 170; // approximate; grows with icon + label rows
let top = barRect.top - pickerH - 6;
if (top < 8) top = barRect.bottom + 6;
Object.assign(pickerEl.style, {
@@ -1241,6 +1357,399 @@
setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180);
}
+ // ---------------------------------------------------------------------------
+ // Params panel (per-variant coarse controls)
+ //
+ // Variants may declare a parameter manifest via a JSON attribute on the
+ // variant wrapper:
+ //
+ //
+ //
+ // The panel docks to the right edge of the outline during CYCLING and
+ // exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped
+ // CSS can respond instantly without regeneration:
+ //
+ // range / numeric toggle → CSS var (`--p-
`) used via var(--p-foo, N)
+ // steps / boolean toggle → data-p- attribute used via :scope[data-p-foo="..."]
+ //
+ // On variant switch, values reset to that variant's declared defaults.
+ // On accept, current values are sent in the event payload so the agent
+ // can bake them into the source-file write.
+ // ---------------------------------------------------------------------------
+
+ 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 tuneOpen = false; // whether the Tune popover is open right now
+
+ // Theme-aware Tune popover. Appears as a drawer that slides out from the
+ // contextual bar's bar-facing edge (below if the bar sits below the
+ // element, above otherwise). Same width as the bar. Auto-wraps to extra
+ // rows when the knobs exceed one row. The bar's border-radius on the
+ // popover side goes flat while open so the two shapes read as one.
+ let paramsPanelPalette = null;
+
+ function initParamsPanel() {
+ paramsPanelPalette = barPaletteForTheme(detectPageTheme());
+ 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
+ // display toggle, no opacity toggle, no transform trickery. The mask
+ // hides everything initially; as it grows, content is revealed from
+ // the bar edge outward.
+ paramsPanelEl = document.createElement('div');
+ paramsPanelEl.id = PREFIX + '-params-panel';
+ Object.assign(paramsPanelEl.style, {
+ position: 'fixed', zIndex: String(Z.bar - 1),
+ background: P.surfaceDeep,
+ color: P.text,
+ fontFamily: FONT,
+ padding: '14px 18px',
+ boxSizing: 'border-box',
+ borderRadius: '0 0 10px 10px',
+ pointerEvents: 'none',
+ backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
+
+ // clip-path is the same conceptual reveal as mask but with rock-solid
+ // transition support across engines. Closed state clips from the far
+ // edge; open = inset(0) shows everything.
+ clipPath: 'inset(0 0 100% 0)',
+ 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
+ // top-left when first shown.
+ top: '-9999px', left: '-9999px', width: '0',
+ });
+
+ paramsPanelBody = el('div', {
+ display: 'grid',
+ gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
+ gap: '12px 16px',
+ });
+
+ paramsPanelEl.appendChild(paramsPanelBody);
+ document.body.appendChild(paramsPanelEl);
+ paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
+ }
+
+ function getVisibleVariantEl() {
+ if (!currentSessionId) return null;
+ const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
+ if (!wrapper) return null;
+ return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
+ }
+
+ function parseVariantParams(variantEl) {
+ if (!variantEl) return [];
+ const raw = variantEl.getAttribute('data-impeccable-params');
+ if (!raw) return [];
+ try {
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) ? parsed : [];
+ } catch (err) {
+ console.warn('[impeccable] Invalid data-impeccable-params JSON:', err.message);
+ return [];
+ }
+ }
+
+ function applyParamValue(variantEl, param, value) {
+ if (!variantEl) return;
+ const attr = 'data-p-' + param.id;
+ if (param.kind === 'range') {
+ variantEl.style.setProperty('--p-' + param.id, String(value));
+ } else if (param.kind === 'toggle') {
+ const on = !!value;
+ variantEl.style.setProperty('--p-' + param.id, on ? '1' : '0');
+ if (on) variantEl.setAttribute(attr, 'on');
+ else variantEl.removeAttribute(attr);
+ } else if (param.kind === 'steps') {
+ variantEl.setAttribute(attr, String(value));
+ }
+ }
+
+ function applyParamDefaults(variantEl, params) {
+ paramsCurrentValues = {};
+ for (const p of params) {
+ paramsCurrentValues[p.id] = p.default;
+ applyParamValue(variantEl, p, p.default);
+ }
+ }
+
+ function formatRangeValue(input) {
+ const max = parseFloat(input.max), min = parseFloat(input.min);
+ const v = parseFloat(input.value);
+ if (!isFinite(v)) return input.value;
+ return (max - min) <= 2 ? v.toFixed(2) : String(Math.round(v));
+ }
+
+ function buildParamsPanel(variantEl, params) {
+ const P = paramsPanelPalette || barPaletteForTheme(detectPageTheme());
+ paramsPanelBody.innerHTML = '';
+ for (const p of params) {
+ const row = el('div', { display: 'flex', flexDirection: 'column', gap: '6px' });
+ const labelRow = el('div', {
+ display: 'flex', justifyContent: 'space-between',
+ alignItems: 'baseline', gap: '8px',
+ });
+ const lbl = el('span', {
+ fontSize: '10.5px', fontWeight: '600', color: P.text,
+ letterSpacing: '0.03em',
+ });
+ lbl.textContent = p.label || p.id;
+ labelRow.appendChild(lbl);
+ const readout = el('span', {
+ fontSize: '10.5px', color: P.textDim,
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
+ });
+ labelRow.appendChild(readout);
+ row.appendChild(labelRow);
+
+ if (p.kind === 'range') {
+ const input = document.createElement('input');
+ input.type = 'range';
+ input.min = String(p.min != null ? p.min : 0);
+ input.max = String(p.max != null ? p.max : 1);
+ input.step = String(p.step != null ? p.step : 0.05);
+ input.value = String(p.default);
+ Object.assign(input.style, {
+ width: '100%', accentColor: C.brand, cursor: 'pointer',
+ });
+ readout.textContent = formatRangeValue(input);
+ input.addEventListener('input', (e) => {
+ e.stopPropagation();
+ const v = parseFloat(input.value);
+ paramsCurrentValues[p.id] = v;
+ readout.textContent = formatRangeValue(input);
+ applyParamValue(variantEl, p, v);
+ });
+ row.appendChild(input);
+ } else if (p.kind === 'toggle') {
+ const initial = !!p.default;
+ readout.textContent = initial ? 'On' : 'Off';
+ const track = el('button', {
+ position: 'relative', width: '36px', height: '20px',
+ borderRadius: '10px', border: 'none', padding: '0',
+ cursor: 'pointer',
+ background: initial ? C.brand : P.hairline,
+ transition: 'background 0.15s ease',
+ alignSelf: 'flex-start',
+ });
+ const knob = el('span', {
+ position: 'absolute', top: '2px',
+ left: initial ? '18px' : '2px',
+ width: '16px', height: '16px', borderRadius: '50%',
+ background: 'oklch(98% 0 0)',
+ transition: 'left 0.18s ' + EASE,
+ boxShadow: '0 1px 2px oklch(0% 0 0 / 0.2)',
+ });
+ track.appendChild(knob);
+ track.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const next = !paramsCurrentValues[p.id];
+ paramsCurrentValues[p.id] = next;
+ track.style.background = next ? C.brand : P.hairline;
+ knob.style.left = next ? '18px' : '2px';
+ readout.textContent = next ? 'On' : 'Off';
+ applyParamValue(variantEl, p, next);
+ });
+ row.appendChild(track);
+ } else if (p.kind === 'steps') {
+ const opts = (p.options || []).map(o =>
+ typeof o === 'string' ? { value: o, label: o } : o
+ );
+ const activeOpt = opts.find(o => o.value === p.default) || opts[0];
+ readout.textContent = activeOpt ? activeOpt.label : String(p.default);
+ const segRow = el('div', {
+ display: 'grid',
+ gridTemplateColumns: 'repeat(' + opts.length + ', 1fr)',
+ gap: '1px', padding: '2px',
+ background: P.hairline, borderRadius: '5px',
+ });
+ const segBtns = [];
+ opts.forEach(o => {
+ const active = o.value === p.default;
+ const b = el('button', {
+ padding: '5px 4px', border: 'none', borderRadius: '3px',
+ background: active ? C.brand : 'transparent',
+ color: active ? 'oklch(98% 0 0)' : P.text,
+ fontFamily: FONT, fontSize: '10.5px', fontWeight: '500',
+ cursor: 'pointer', whiteSpace: 'nowrap',
+ transition: 'background 0.1s ease, color 0.1s ease',
+ });
+ b.textContent = o.label;
+ b.addEventListener('click', (e) => {
+ e.stopPropagation();
+ paramsCurrentValues[p.id] = o.value;
+ readout.textContent = o.label;
+ segBtns.forEach(({ btn, val }) => {
+ const on = val === o.value;
+ btn.style.background = on ? C.brand : 'transparent';
+ btn.style.color = on ? 'oklch(98% 0 0)' : P.text;
+ });
+ applyParamValue(variantEl, p, o.value);
+ });
+ segRow.appendChild(b);
+ segBtns.push({ btn: b, val: o.value });
+ });
+ row.appendChild(segRow);
+ }
+
+ paramsPanelBody.appendChild(row);
+ }
+ }
+
+ // Decide which way the popover opens: away from the picked element. If the
+ // bar landed below the element, popover slides DOWN from the bar's bottom.
+ // If the bar landed above, popover slides UP from the bar's top.
+ function popoverDirection() {
+ if (!barEl || !selectedElement) return 'below';
+ const br = barEl.getBoundingClientRect();
+ const er = selectedElement.getBoundingClientRect();
+ return br.top >= er.bottom - 4 ? 'below' : 'above';
+ }
+
+ // The popover overlaps the bar by OVERLAP px on the bar-facing side. With
+ // popover z-index below bar, that overlap sits behind bar (invisible) and
+ // reinforces the "tucked behind" feel. Padding compensates so the real
+ // content starts flush with bar's outer edge.
+ const TUNE_OVERLAP = 6;
+
+ // Closed clip-path depends on direction: for 'below' clip from the far
+ // (bottom) edge so the reveal grows downward from the bar; for 'above'
+ // clip from the top edge so the reveal grows upward from the bar.
+ function closedClipPath(direction) {
+ return direction === 'below' ? 'inset(0 0 100% 0)' : 'inset(100% 0 0 0)';
+ }
+
+ function setClipPath(value, withTransition) {
+ const saved = paramsPanelEl.style.transition;
+ if (!withTransition) paramsPanelEl.style.transition = 'none';
+ paramsPanelEl.style.clipPath = value;
+ if (!withTransition) {
+ void paramsPanelEl.offsetHeight;
+ paramsPanelEl.style.transition = saved;
+ }
+ }
+
+ function positionParamsPanel() {
+ if (!paramsPanelEl || !barEl || barEl.style.display === 'none') return;
+ const br = barEl.getBoundingClientRect();
+ const direction = popoverDirection();
+ const prevDirection = paramsPanelEl.dataset.tuneDirection;
+
+ // top/left/width are NOT in the transition list, so they snap instantly.
+ paramsPanelEl.style.left = br.left + 'px';
+ paramsPanelEl.style.width = br.width + 'px';
+
+ if (direction === 'below') {
+ paramsPanelEl.style.top = (br.bottom - TUNE_OVERLAP) + 'px';
+ paramsPanelEl.style.borderRadius = '0 0 10px 10px';
+ paramsPanelEl.style.paddingTop = (14 + TUNE_OVERLAP) + 'px';
+ paramsPanelEl.style.paddingBottom = '14px';
+ } else {
+ const ih = paramsPanelEl.offsetHeight || 80;
+ paramsPanelEl.style.top = (br.top - ih + TUNE_OVERLAP) + 'px';
+ paramsPanelEl.style.borderRadius = '10px 10px 0 0';
+ paramsPanelEl.style.paddingTop = '14px';
+ paramsPanelEl.style.paddingBottom = (14 + TUNE_OVERLAP) + 'px';
+ }
+ paramsPanelEl.dataset.tuneDirection = direction;
+
+ // If currently closed and direction flipped (or first-time setup),
+ // snap the clip-path to the new direction's closed pose without
+ // transitioning (so the clip doesn't slide across the element).
+ if (!tuneOpen && (!prevDirection || prevDirection !== direction)) {
+ setClipPath(closedClipPath(direction), false);
+ }
+ }
+
+ function showParamsPanel() {
+ if (!paramsPanelEl) return;
+ positionParamsPanel();
+ paramsPanelEl.style.pointerEvents = 'auto';
+ // rAF so the positioning paint commits before the transition fires.
+ requestAnimationFrame(() => {
+ setClipPath('inset(0 0 0 0)', true);
+ });
+ }
+
+ function hideParamsPanel() {
+ if (!paramsPanelEl) return;
+ paramsPanelEl.style.pointerEvents = 'none';
+ const direction = paramsPanelEl.dataset.tuneDirection || 'below';
+ setClipPath(closedClipPath(direction), true);
+ }
+
+ // Build/rebuild the panel's contents for the current variant AND apply
+ // its defaults to the variant wrapper (so scoped CSS responds even before
+ // the user opens the popover). Visibility is governed by tuneOpen.
+ function refreshParamsPanel() {
+ if (state !== 'CYCLING') {
+ paramsCurrentValues = {};
+ tuneOpen = false;
+ hideParamsPanel();
+ return;
+ }
+ const variantEl = getVisibleVariantEl();
+ const params = parseVariantParams(variantEl);
+ if (!variantEl || params.length === 0) {
+ paramsCurrentValues = {};
+ tuneOpen = false;
+ hideParamsPanel();
+ return;
+ }
+ applyParamDefaults(variantEl, params);
+ buildParamsPanel(variantEl, params);
+ if (tuneOpen) {
+ // If already visible (variant cycled while open), refresh in place
+ // instead of re-running the clip-path animation.
+ const alreadyVisible = paramsPanelEl.style.display === 'block'
+ && paramsPanelEl.style.opacity === '1';
+ if (alreadyVisible) positionParamsPanel();
+ else showParamsPanel();
+ } else {
+ hideParamsPanel();
+ }
+ }
+
+ function toggleTunePopover() {
+ if (tuneOpen) { closeTunePopover(); return; }
+ openTunePopover();
+ }
+
+ function openTunePopover() {
+ if (state !== 'CYCLING') return;
+ const variantEl = getVisibleVariantEl();
+ const params = parseVariantParams(variantEl);
+ if (!variantEl || params.length === 0) return;
+ // Build fresh to ensure the current variant's controls are shown.
+ applyParamDefaults(variantEl, params);
+ buildParamsPanel(variantEl, params);
+ tuneOpen = true;
+ showParamsPanel();
+ // Kill the bar's shadow on the popover-facing side so the dark popover
+ // doesn't pick up a bright glow line.
+ if (barEl) {
+ const direction = paramsPanelEl?.dataset.tuneDirection || 'below';
+ barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN;
+ }
+ // Re-render the bar so the Tune chip picks up the active styling.
+ updateBarContent('cycling');
+ }
+
+ function closeTunePopover() {
+ tuneOpen = false;
+ hideParamsPanel();
+ if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT;
+ if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') {
+ updateBarContent('cycling');
+ }
+ }
+
// ---------------------------------------------------------------------------
// Variant cycling in DOM
// ---------------------------------------------------------------------------
@@ -1253,6 +1762,10 @@
if (!v) continue;
child.style.display = (v === String(num)) ? '' : 'none';
}
+ // 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();
}
/**
@@ -1316,6 +1829,7 @@
state = 'CYCLING';
hideShaderOverlay();
updateBarContent('cycling');
+ refreshParamsPanel();
saveSession();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
@@ -1329,7 +1843,7 @@
const next = visibleVariant + dir;
if (next < 1 || next > arrivedVariants) return;
visibleVariant = next;
- showVariantInDOM(currentSessionId, next);
+ showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself
updateSelectedElement();
updateBarContent('cycling');
saveSession();
@@ -1540,6 +2054,7 @@
state = 'CYCLING';
hideShaderOverlay();
updateBarContent('cycling');
+ refreshParamsPanel();
} else if (state === 'GENERATING') {
updateBarContent('generating');
}
@@ -1560,6 +2075,7 @@
if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') {
positionBar();
showHighlight(selectedElement);
+ if (tuneOpen) positionParamsPanel();
}
if (annotActive) positionAnnotOverlay(selectedElement);
// Shader overlay (via debug P toggle or generation) is repositioned
@@ -1606,6 +2122,7 @@
if (state === 'GENERATING') {
state = 'CYCLING';
updateBarContent('cycling');
+ refreshParamsPanel();
}
break;
}
@@ -1691,6 +2208,10 @@
if (pickerEl?.style.display !== 'none' && !own(e.target)) {
hideActionPicker();
}
+ // Close Tune popover on outside click (anything outside panel + bar)
+ if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) {
+ closeTunePopover();
+ }
// In CONFIGURING: click outside the bar and selected element returns to PICKING
if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) {
hideBar();
@@ -2258,7 +2779,11 @@ void main() {
function handleAccept() {
if (!currentSessionId || arrivedVariants === 0) return;
- sendEvent({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) });
+ const acceptPayload = { type: 'accept', id: currentSessionId, variantId: String(visibleVariant) };
+ if (Object.keys(paramsCurrentValues).length > 0) {
+ acceptPayload.paramValues = { ...paramsCurrentValues };
+ }
+ sendEvent(acceptPayload);
markSessionHandled();
// The accepted variant is already the only visible child of the wrapper
@@ -2477,6 +3002,11 @@ void main() {
state = arrivedVariants >= expectedVariants ? 'CYCLING' : 'GENERATING';
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
startScrollTracking();
+ // Build the params panel for the restored visible variant. Previously
+ // this was missed on page-reload resume: showVariantInDOM above fires
+ // refreshParamsPanel, but state was still IDLE at that moment so it
+ // hid. Now that state is CYCLING, re-fire.
+ if (state === 'CYCLING') refreshParamsPanel();
saveSession();
// Start observing for more variants AFTER initial setup
@@ -2522,6 +3052,11 @@ void main() {
// dark pages. This keeps the bar from fighting with the host design.
function detectPageTheme() {
try {
+ // Dev override: set localStorage 'impeccable-dev-theme' to 'light' or
+ // 'dark' to preview the opposite palette without actually changing the
+ // page bg. Used for screenshots and theme QA.
+ const override = localStorage.getItem('impeccable-dev-theme');
+ if (override === 'light' || override === 'dark') return override;
const bg = getComputedStyle(document.body).backgroundColor
|| getComputedStyle(document.documentElement).backgroundColor;
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
@@ -2538,6 +3073,7 @@ void main() {
// Light bar on dark page
return {
surface: 'oklch(98% 0 0 / 0.92)',
+ surfaceDeep: 'oklch(92% 0.005 60 / 0.96)', // slightly deeper, faint warm
hairline: 'oklch(70% 0 0 / 0.35)',
text: 'oklch(15% 0 0)',
textDim: 'oklch(45% 0 0)',
@@ -2552,6 +3088,7 @@ void main() {
// deeper so the rounded-right shape reads as a clear sculpted mark.
return {
surface: 'oklch(26% 0 0 / 0.94)',
+ surfaceDeep: 'oklch(18% 0 0 / 0.96)', // darker sand for Tune popover
hairline: 'oklch(42% 0 0 / 0.5)',
text: 'oklch(96% 0 0)',
textDim: 'oklch(72% 0 0)',
@@ -2883,6 +3420,7 @@ void main() {
if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; }
if (barEl) { barEl.remove(); barEl = null; }
if (pickerEl) { pickerEl.remove(); pickerEl = null; }
+ if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; }
if (evtSource) { evtSource.close(); evtSource = null; }
document.removeEventListener('mousemove', handleMouseMove, true);
document.removeEventListener('click', handleClick, true);
@@ -3956,6 +4494,7 @@ void main() {
initAnnotOverlay();
initBar();
initActionPicker();
+ initParamsPanel();
initGlobalBar();
initDesignPanel();
document.addEventListener('mousemove', handleMouseMove, true);
diff --git a/.cursor/skills/impeccable/scripts/live-poll.mjs b/.cursor/skills/impeccable/scripts/live-poll.mjs
index 9dd87e3ea..5cece1a43 100644
--- a/.cursor/skills/impeccable/scripts/live-poll.mjs
+++ b/.cursor/skills/impeccable/scripts/live-poll.mjs
@@ -145,6 +145,11 @@ Options:
const scriptArgs = event.type === 'discard'
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
+ if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
+ // Pass through a JSON blob; the shell-safe wrap uses single quotes because
+ // values are finite {id, number|string|boolean} pairs from a validated payload.
+ scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
+ }
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
diff --git a/.cursor/skills/impeccable/scripts/live-server.mjs b/.cursor/skills/impeccable/scripts/live-server.mjs
index e5fb05bfa..2ab33fd22 100644
--- a/.cursor/skills/impeccable/scripts/live-server.mjs
+++ b/.cursor/skills/impeccable/scripts/live-server.mjs
@@ -147,6 +147,11 @@ function validateEvent(msg) {
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
+ if (msg.paramValues !== undefined) {
+ if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
+ return 'accept: paramValues must be an object';
+ }
+ }
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
diff --git a/.gemini/skills/impeccable/reference/colorize.md b/.gemini/skills/impeccable/reference/colorize.md
index de74ac95f..1bd52004f 100644
--- a/.gemini/skills/impeccable/reference/colorize.md
+++ b/.gemini/skills/impeccable/reference/colorize.md
@@ -142,3 +142,13 @@ Test that colorization improves the experience:
- **Not overwhelming**: Is color balanced and purposeful?
Remember: Color is emotional and powerful. Use it to create warmth, guide attention, communicate meaning, and express personality. But restraint and strategy matter more than saturation and variety. Be colorful, but be intentional.
+
+## Live-mode signature params
+
+When invoked from live mode, each variant MUST declare a `color-amount` param so the user can dial between a restrained accent and a drenched surface without regeneration. Author the variant's CSS against `var(--p-color-amount, 0.5)` — typically as the alpha multiplier on backgrounds, or as a scaling factor on the chroma axis in an OKLCH expression. 0 = neutral/monochrome, 1 = full saturation / dominant coverage.
+
+```json
+{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"}
+```
+
+Layer 1-2 variant-specific params on top: palette selection (`steps` with named options), temperature warmth, or tint vs. true color. See `reference/live.md` for the full params contract.
diff --git a/.gemini/skills/impeccable/reference/layout.md b/.gemini/skills/impeccable/reference/layout.md
index 981ace8a1..5e8022081 100644
--- a/.gemini/skills/impeccable/reference/layout.md
+++ b/.gemini/skills/impeccable/reference/layout.md
@@ -119,3 +119,23 @@ Create a systematic plan:
- **Responsiveness**: Does the layout adapt gracefully across screen sizes?
Remember: Space is the most underused design tool. A layout with the right rhythm and hierarchy can make even simple content feel polished and intentional.
+
+## Live-mode signature params
+
+Each variant MUST declare a `density` param. Drive all spacing tokens in the variant's scoped CSS through `calc(var(--p-density, 1) * )` — paddings, gaps, column widths. Users slide from airy to packed and see layout re-breathe with no regeneration.
+
+```json
+{"id":"density","kind":"range","min":0.6,"max":1.4,"step":0.05,"default":1,"label":"Density"}
+```
+
+For variants whose topology genuinely changes (stacked vs. side-by-side, grid vs. bento), use a `steps` param whose scoped CSS branches via `:scope[data-p-structure="X"]`. One structure param + one density param is a powerful combo; resist adding a third.
+
+```json
+{"id":"structure","kind":"steps","default":"grid","label":"Structure","options":[
+ {"value":"stacked","label":"Stacked"},
+ {"value":"grid","label":"Grid"},
+ {"value":"bento","label":"Bento"}
+]}
+```
+
+See `reference/live.md` for the full params contract.
diff --git a/.gemini/skills/impeccable/reference/live.md b/.gemini/skills/impeccable/reference/live.md
index 162ede687..e868514b3 100644
--- a/.gemini/skills/impeccable/reference/live.md
+++ b/.gemini/skills/impeccable/reference/live.md
@@ -179,7 +179,56 @@ The first variant has no `display: none` (visible by default). All others do. If
One edit, all variants — the browser's MutationObserver picks everything up in one pass.
-### 7. Signal done
+### 7. Parameters (optional, 2-5 per variant)
+
+Each variant can expose coarse knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against.
+
+**When to use.** Any time the variant has a meaningful axis the user might want to dial in: color amount, density, motion intensity, scale ratio. Not micro-level margin tweaks; those defeat the point.
+
+**Budget scales with the element's visual weight, not the user's curiosity.** Knobs need real estate to produce noticeably different output; slapping three on a small element just crowds the panel without making anything feel tunable.
+
+- **Leaf / tiny** — a single button, icon, input, bare heading, solitary paragraph: **0 params.** A slider can't meaningfully reshape one element; just use variants.
+- **Small composition** — labeled input, simple card, short callout (≤ ~5 visual children): **0-1 params.** Only add one if it's a clear dominant axis (e.g. density on a card with visible internal rhythm).
+- **Medium composition** — section component, nav cluster, dense card, short feature block (6-15 visual children): **2 params.**
+- **Large composition** — hero section, full page region, spread layout, anything with strong internal structure (16+ visual children or multiple sub-sections): **3-4 params.**
+
+When in doubt, fewer. The user can always ask for more variants to explore an axis you didn't expose as a knob. Count by visual children, not by DOM-node depth; a deeply-nested-but-visually-simple card still counts as small.
+
+**How to declare.** Put a JSON manifest on the variant wrapper:
+
+```html
+
+ ...variant content...
+
+```
+
+**Three kinds:**
+
+- `range` — smooth slider. Drives a CSS custom property `--p-` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`.
+- `steps` — segmented radio. Drives a data attribute `data-p-` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`.
+- `toggle` — on/off switch. Drives BOTH a CSS var (`--p-: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`.
+
+**Signature params per action.** Each action has one or two signature params that MUST be exposed when the variant can meaningfully express them. Check the action's reference file for the list. Layer 1-2 variant-specific params on top.
+
+**Reset on variant switch.** User dials density on v1, flips to v2, v2 starts at v2's declared defaults. Known limitation; preservation across variants may land later.
+
+**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment:
+
+```html
+
+```
+
+The carbonize cleanup step (see below) reads that comment and bakes the chosen values into the final CSS. For `steps`/`toggle` attribute selectors: keep only the branch matching the chosen value, drop the others, collapse `:scope[data-p-density="packed"] .grid` to a semantic class rule. For `range` vars: either substitute the literal or keep the var with the chosen value as its new default.
+
+### 8. Signal done
```bash
node .gemini/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
@@ -244,11 +293,11 @@ When `_acceptResult.carbonize === true`, the accepted variant was stitched into
Do these five steps in the current thread, synchronously, before the next poll. Do not poll again until the file is clean.
-1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `` and `` and contains a `');
+ if (paramValues && Object.keys(paramValues).length > 0) {
+ // Preserve the user's knob positions for the carbonize-cleanup agent
+ // to bake into the final CSS when it collapses scoped rules.
+ replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
+ }
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
diff --git a/.gemini/skills/impeccable/scripts/live-browser.js b/.gemini/skills/impeccable/scripts/live-browser.js
index 116e1efa6..66efd7243 100644
--- a/.gemini/skills/impeccable/scripts/live-browser.js
+++ b/.gemini/skills/impeccable/scripts/live-browser.js
@@ -30,10 +30,13 @@
// Design tokens
// ---------------------------------------------------------------------------
+ // Brand magenta is pinned to the site token (--color-accent in main.css)
+ // so Accept / knobs / cycle-dots match the site's accent, not a washed
+ // theme-adjusted one.
const C = {
- brand: 'oklch(55% 0.25 350)',
- brandHov: 'oklch(48% 0.25 350)',
- brandSoft: 'oklch(55% 0.25 350 / 0.12)',
+ brand: 'oklch(60% 0.25 350)',
+ brandHov: 'oklch(52% 0.25 350)',
+ brandSoft: 'oklch(60% 0.25 350 / 0.15)',
ink: 'oklch(15% 0.01 350)',
ash: 'oklch(55% 0 0)',
paper: 'oklch(98% 0.005 350 / 0.92)',
@@ -60,6 +63,25 @@
'html', 'head', 'body', 'script', 'style', 'link', 'meta', 'noscript', 'br', 'wbr',
]);
+ // 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.
+ 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: ` `,
+ bolder: ` `,
+ quieter: ` `,
+ distill: ` `,
+ polish: ` `,
+ typeset: ` `,
+ colorize: ` `,
+ layout: ` `,
+ adapt: ` `,
+ animate: ` `,
+ delight: ` `,
+ overdrive: ` `,
+ };
+
const ACTIONS = [
{ value: 'impeccable', label: 'Freeform' },
{ value: 'bolder', label: 'Bolder' },
@@ -756,7 +778,21 @@
// The Bar — one floating element, three modes
// ---------------------------------------------------------------------------
+ // Contextual-bar palette. Cached at init so every build*Row reads a
+ // consistent set of colors; detectPageTheme runs once rather than on every
+ // phase transition.
+ let BP = null;
+
+ // Bar shadow variants. The default projects down + subtle around. When
+ // the Tune popover opens below the bar, a downward shadow lands on the
+ // dark popover and reads as a bright ghost line. We swap to UP-only while
+ // tune is open below so the popover's top edge is clean.
+ const BAR_SHADOW_DEFAULT = '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)';
+ const BAR_SHADOW_UP = '0 -4px 20px oklch(0% 0 0 / 0.08), 0 -1px 3px oklch(0% 0 0 / 0.06)';
+ const BAR_SHADOW_DOWN = BAR_SHADOW_DEFAULT;
+
function initBar() {
+ BP = barPaletteForTheme(detectPageTheme());
barEl = document.createElement('div');
barEl.id = PREFIX + '-bar';
Object.assign(barEl.style, {
@@ -764,15 +800,15 @@
display: 'none', opacity: '0',
transform: 'translateY(6px)',
transition: 'opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
- // Visual
- background: C.paper,
+ background: BP.surface,
backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
- border: '1px solid ' + C.mist,
+ border: '1px solid ' + BP.hairline,
borderRadius: '10px',
- boxShadow: '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)',
- fontFamily: FONT, fontSize: '13px', color: C.ink,
+ boxShadow: BAR_SHADOW_DEFAULT,
+ transition: 'box-shadow 0.2s ease, opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
+ fontFamily: FONT, fontSize: '13px', color: BP.text,
padding: '6px',
- maxWidth: '460px', minWidth: '320px',
+ maxWidth: '520px', minWidth: '320px',
});
document.body.appendChild(barEl);
}
@@ -824,14 +860,15 @@
barEl.style.transform = 'translateY(6px)';
setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250);
hideActionPicker();
+ closeTunePopover();
}
function updateBarContent(mode) {
if (!barEl || barEl.style.display === 'none') return;
barEl.innerHTML = '';
- // Reset bar styling
- barEl.style.background = C.paper;
- barEl.style.border = '1px solid ' + C.mist;
+ // Reset bar styling to the theme-aware palette
+ barEl.style.background = BP.surface;
+ barEl.style.border = '1px solid ' + BP.hairline;
if (mode === 'configure') barEl.appendChild(buildConfigureRow());
else if (mode === 'generating') barEl.appendChild(buildGeneratingRow());
else if (mode === 'cycling') barEl.appendChild(buildCyclingRow());
@@ -854,15 +891,15 @@
const pill = el('button', {
display: 'inline-flex', alignItems: 'center', gap: '4px',
padding: '5px 10px', borderRadius: '6px',
- background: C.ink, color: C.white,
+ background: BP.mark, color: BP.markText,
fontFamily: FONT, fontSize: '12px', fontWeight: '500',
border: 'none', cursor: 'pointer',
transition: 'background 0.12s ease, transform 0.1s ease',
whiteSpace: 'nowrap', flexShrink: '0',
});
pill.textContent = actionLabel() + ' \u25BE';
- pill.addEventListener('mouseenter', () => pill.style.background = C.brandHov);
- pill.addEventListener('mouseleave', () => pill.style.background = C.ink);
+ pill.addEventListener('mouseenter', () => pill.style.background = BP.accent);
+ pill.addEventListener('mouseleave', () => pill.style.background = BP.mark);
pill.addEventListener('mousedown', () => pill.style.transform = 'scale(0.97)');
pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)');
pill.addEventListener('click', (e) => { e.stopPropagation(); toggleActionPicker(); });
@@ -877,13 +914,13 @@
flex: '1', minWidth: '0',
padding: '5px 8px', borderRadius: '6px',
border: '1px solid transparent', background: 'transparent',
- fontFamily: FONT, fontSize: '12px', color: C.ink,
+ fontFamily: FONT, fontSize: '12px', color: BP.text,
outline: 'none',
transition: 'border-color 0.15s ease, background 0.15s ease',
});
input.addEventListener('focus', () => {
- input.style.borderColor = C.mist;
- input.style.background = C.white;
+ input.style.borderColor = BP.hairline;
+ input.style.background = BP.accentSoft;
});
input.addEventListener('blur', () => {
input.style.borderColor = 'transparent';
@@ -901,16 +938,16 @@
// Variant count toggle
const count = el('button', {
padding: '4px 6px', borderRadius: '5px',
- border: '1px solid ' + C.mist, background: 'transparent',
+ border: '1px solid ' + BP.hairline, background: 'transparent',
fontFamily: MONO, fontSize: '11px', fontWeight: '600',
- color: C.ash, cursor: 'pointer',
+ color: BP.textDim, cursor: 'pointer',
transition: 'color 0.12s ease, border-color 0.12s ease',
flexShrink: '0', whiteSpace: 'nowrap',
});
count.textContent = '\u00D7' + selectedCount;
count.title = 'Variants: click to change';
- count.addEventListener('mouseenter', () => { count.style.color = C.ink; count.style.borderColor = C.ink; });
- count.addEventListener('mouseleave', () => { count.style.color = C.ash; count.style.borderColor = C.mist; });
+ count.addEventListener('mouseenter', () => { count.style.color = BP.text; count.style.borderColor = BP.text; });
+ count.addEventListener('mouseleave', () => { count.style.color = BP.textDim; count.style.borderColor = BP.hairline; });
count.addEventListener('click', (e) => {
e.stopPropagation();
selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1;
@@ -921,15 +958,15 @@
// Go button
const go = el('button', {
padding: '5px 12px', borderRadius: '6px',
- border: 'none', background: C.brand, color: C.white,
+ border: 'none', background: BP.accent, color: BP.mark,
fontFamily: FONT, fontSize: '12px', fontWeight: '600',
cursor: 'pointer',
- transition: 'background 0.12s ease, transform 0.1s ease',
+ transition: 'filter 0.12s ease, transform 0.1s ease',
flexShrink: '0', whiteSpace: 'nowrap',
});
go.textContent = 'Go \u2192';
- go.addEventListener('mouseenter', () => go.style.background = C.brandHov);
- go.addEventListener('mouseleave', () => go.style.background = C.brand);
+ go.addEventListener('mouseenter', () => go.style.filter = 'brightness(1.1)');
+ go.addEventListener('mouseleave', () => go.style.filter = 'none');
go.addEventListener('mousedown', () => go.style.transform = 'scale(0.97)');
go.addEventListener('mouseup', () => go.style.transform = 'scale(1)');
go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); });
@@ -950,7 +987,7 @@
// Action label
const label = el('span', {
- fontWeight: '600', fontSize: '12px', color: C.ink,
+ fontWeight: '600', fontSize: '12px', color: BP.text,
flexShrink: '0', whiteSpace: 'nowrap',
});
label.textContent = actionLabel();
@@ -961,7 +998,7 @@
// Status
const status = el('span', {
- fontSize: '11px', color: C.ash, whiteSpace: 'nowrap',
+ fontSize: '11px', color: BP.textDim, whiteSpace: 'nowrap',
marginLeft: 'auto',
});
// Variants currently arrive atomically in a single file edit, so a
@@ -976,6 +1013,8 @@
// --- Cycling row ---
+ const TUNE_ICON_SVG = ' ';
+
function buildCyclingRow() {
const row = el('div', {
display: 'flex', alignItems: 'center', gap: '6px',
@@ -994,7 +1033,7 @@
// Counter
const counter = el('span', {
fontFamily: MONO, fontSize: '11px', fontWeight: '500',
- color: C.ash, minWidth: '24px', textAlign: 'center',
+ color: BP.textDim, minWidth: '24px', textAlign: 'center',
});
counter.textContent = visibleVariant + '/' + arrivedVariants;
row.appendChild(counter);
@@ -1005,20 +1044,67 @@
if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
row.appendChild(next);
+ // Tune chip — only when the visible variant exposes params
+ const visParams = parseVariantParams(getVisibleVariantEl());
+ const hasParams = visParams.length > 0;
+ if (hasParams) {
+ const tune = el('button', {
+ display: 'inline-flex', alignItems: 'center', gap: '6px',
+ padding: '4px 10px', borderRadius: '5px',
+ border: '1px solid transparent',
+ background: tuneOpen ? BP.accentSoft : 'transparent',
+ color: tuneOpen ? BP.accent : BP.text,
+ fontFamily: FONT, fontSize: '11px', fontWeight: '500',
+ cursor: 'pointer',
+ transition: 'color 0.12s ease, background 0.12s ease',
+ whiteSpace: 'nowrap',
+ });
+ tune.innerHTML = TUNE_ICON_SVG;
+ const tuneLabel = document.createElement('span');
+ tuneLabel.textContent = 'Tune';
+ tune.appendChild(tuneLabel);
+ const tuneBadge = document.createElement('span');
+ Object.assign(tuneBadge.style, {
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
+ minWidth: '16px', height: '16px', padding: '0 4px',
+ borderRadius: '999px',
+ background: tuneOpen ? C.brand : BP.hairline,
+ color: tuneOpen ? 'oklch(98% 0 0)' : 'inherit',
+ fontFamily: MONO, fontSize: '9.5px', fontWeight: '600',
+ lineHeight: '1',
+ boxSizing: 'border-box',
+ });
+ tuneBadge.textContent = String(visParams.length);
+ tune.appendChild(tuneBadge);
+ tune.title = 'Tune this variant (' + visParams.length + ' knob' + (visParams.length === 1 ? '' : 's') + ')';
+ tune.addEventListener('mouseenter', () => {
+ if (!tuneOpen) tune.style.background = BP.accentSoft;
+ });
+ tune.addEventListener('mouseleave', () => {
+ if (!tuneOpen) tune.style.background = 'transparent';
+ });
+ tune.addEventListener('click', (e) => { e.stopPropagation(); toggleTunePopover(); });
+ tune.dataset.iceqTune = '1';
+ row.appendChild(tune);
+ }
+
// Spacer
row.appendChild(el('div', { flex: '1' }));
- // Accept
+ // Accept — primary action, uses the site's saturated brand magenta
+ // with paper-white text, not the theme-muted BP.accent.
const accept = el('button', {
- padding: '4px 10px', borderRadius: '5px',
- border: 'none', background: C.ink, color: C.white,
+ padding: '5px 14px', borderRadius: '5px',
+ border: 'none', background: C.brand, color: 'oklch(98% 0 0)',
fontFamily: FONT, fontSize: '11px', fontWeight: '600',
- cursor: 'pointer', transition: 'background 0.12s ease',
+ cursor: 'pointer', transition: 'filter 0.12s ease, transform 0.1s ease',
whiteSpace: 'nowrap',
});
accept.textContent = '\u2713 Accept';
- accept.addEventListener('mouseenter', () => accept.style.background = C.brand);
- accept.addEventListener('mouseleave', () => accept.style.background = C.ink);
+ accept.addEventListener('mouseenter', () => accept.style.filter = 'brightness(1.08)');
+ accept.addEventListener('mouseleave', () => accept.style.filter = 'none');
+ accept.addEventListener('mousedown', () => accept.style.transform = 'scale(0.97)');
+ accept.addEventListener('mouseup', () => accept.style.transform = 'scale(1)');
accept.addEventListener('click', (e) => { e.stopPropagation(); handleAccept(); });
if (arrivedVariants === 0) { accept.style.opacity = '0.3'; accept.style.pointerEvents = 'none'; }
row.appendChild(accept);
@@ -1026,14 +1112,14 @@
// Discard
const discard = el('button', {
padding: '4px 6px', borderRadius: '5px',
- border: '1px solid ' + C.mist, background: 'transparent',
- fontFamily: FONT, fontSize: '11px', color: C.ash,
+ border: '1px solid ' + BP.hairline, background: 'transparent',
+ fontFamily: FONT, fontSize: '11px', color: BP.textDim,
cursor: 'pointer', transition: 'color 0.12s ease, border-color 0.12s ease',
});
discard.textContent = '\u2715';
discard.title = 'Discard all variants';
- discard.addEventListener('mouseenter', () => { discard.style.color = C.ink; discard.style.borderColor = C.ink; });
- discard.addEventListener('mouseleave', () => { discard.style.color = C.ash; discard.style.borderColor = C.mist; });
+ discard.addEventListener('mouseenter', () => { discard.style.color = BP.text; discard.style.borderColor = BP.text; });
+ discard.addEventListener('mouseleave', () => { discard.style.color = BP.textDim; discard.style.borderColor = BP.hairline; });
discard.addEventListener('click', (e) => { e.stopPropagation(); handleDiscard(); });
row.appendChild(discard);
@@ -1051,14 +1137,14 @@
});
const spinner = el('div', {
width: '14px', height: '14px', borderRadius: '50%',
- border: '2px solid ' + C.mist,
- borderTopColor: C.brand,
+ border: '2px solid ' + BP.hairline,
+ borderTopColor: BP.accent,
animation: 'impeccable-spin 0.6s linear infinite',
flexShrink: '0',
});
row.appendChild(spinner);
const label = el('span', {
- fontSize: '12px', color: C.ash, fontWeight: '500',
+ fontSize: '12px', color: BP.textDim, fontWeight: '500',
});
label.textContent = 'Applying variant...';
row.appendChild(label);
@@ -1103,14 +1189,26 @@
for (let i = 1; i <= expectedVariants; i++) {
const arrived = i <= arrivedVariants;
const active = i === visibleVariant;
+ // active: solid site-brand magenta 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
+ // magenta chips, especially when all variants had arrived and every
+ // dot wore an accent ring.
+ const dotBg = active ? C.brand
+ : arrived ? BP.textDim
+ : 'transparent';
+ const dotBorder = arrived ? 'none' : '1.5px solid ' + BP.hairline;
const dot = el('div', {
- width: '7px', height: '7px', borderRadius: '50%',
- background: active ? C.brand : (arrived ? C.ash : 'transparent'),
- border: '1.5px solid ' + (arrived ? C.brand : C.mist),
+ width: active ? '8px' : '6px',
+ height: active ? '8px' : '6px',
+ borderRadius: '50%',
+ background: dotBg,
+ border: dotBorder,
+ boxSizing: 'border-box',
transition: 'all 0.2s ' + EASE,
cursor: (clickable && arrived) ? 'pointer' : 'default',
- transform: arrived ? 'scale(1)' : 'scale(0.6)',
- opacity: arrived ? '1' : '0.4',
+ transform: arrived ? 'scale(1)' : 'scale(0.85)',
+ opacity: arrived ? (active ? '1' : '0.6') : '0.4',
});
if (clickable && arrived) {
const idx = i;
@@ -1130,15 +1228,15 @@
function navBtn(text) {
const b = el('button', {
width: '26px', height: '26px', borderRadius: '5px',
- border: '1px solid ' + C.mist, background: 'transparent',
- color: C.ink, fontFamily: FONT, fontSize: '13px',
+ border: '1px solid ' + BP.hairline, background: 'transparent',
+ color: BP.text, fontFamily: FONT, fontSize: '13px',
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'border-color 0.12s ease, background 0.12s ease',
padding: '0', lineHeight: '1',
});
b.textContent = text;
- b.addEventListener('mouseenter', () => { b.style.borderColor = C.ink; });
- b.addEventListener('mouseleave', () => { b.style.borderColor = C.mist; });
+ b.addEventListener('mouseenter', () => { b.style.borderColor = BP.text; });
+ b.addEventListener('mouseleave', () => { b.style.borderColor = BP.hairline; });
return b;
}
@@ -1158,6 +1256,7 @@
// ---------------------------------------------------------------------------
function initActionPicker() {
+ const P = barPaletteForTheme(detectPageTheme());
pickerEl = document.createElement('div');
pickerEl.id = PREFIX + '-picker';
Object.assign(pickerEl.style, {
@@ -1166,12 +1265,14 @@
transform: 'scale(0.96) translateY(4px)',
transformOrigin: 'bottom left',
transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE,
- background: C.paperSolid,
- border: '1px solid ' + C.mist,
+ background: P.surface,
+ border: '1px solid ' + P.hairline,
borderRadius: '10px',
boxShadow: '0 8px 30px oklch(0% 0 0 / 0.10), 0 2px 6px oklch(0% 0 0 / 0.06)',
padding: '6px',
fontFamily: FONT,
+ backdropFilter: 'blur(10px)',
+ WebkitBackdropFilter: 'blur(10px)',
});
// Build the chip grid
@@ -1181,22 +1282,32 @@
ACTIONS.forEach(action => {
const chip = el('button', {
- padding: '6px 8px', borderRadius: '6px',
+ display: 'flex', flexDirection: 'column', alignItems: 'center',
+ gap: '4px',
+ padding: '8px 6px', borderRadius: '6px',
border: 'none',
- background: action.value === selectedAction ? C.brandSoft : 'transparent',
- color: action.value === selectedAction ? C.brand : C.ink,
+ background: action.value === selectedAction ? P.accentSoft : 'transparent',
+ color: action.value === selectedAction ? P.accent : P.text,
fontFamily: FONT, fontSize: '11px', fontWeight: '500',
cursor: 'pointer',
transition: 'background 0.1s ease, color 0.1s ease',
textAlign: 'center', whiteSpace: 'nowrap',
});
- chip.textContent = action.label;
+ const iconWrap = el('span', {
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ height: '20px', opacity: '0.9',
+ });
+ iconWrap.innerHTML = ICONS[action.value] || '';
+ const labelEl = el('span', { lineHeight: '1' });
+ labelEl.textContent = action.label;
+ chip.appendChild(iconWrap);
+ chip.appendChild(labelEl);
chip.dataset.action = action.value;
chip.addEventListener('mouseenter', () => {
- if (action.value !== selectedAction) chip.style.background = C.brandSoft;
+ if (action.value !== selectedAction) chip.style.background = P.accentSoft;
});
chip.addEventListener('mouseleave', () => {
- chip.style.background = action.value === selectedAction ? C.brandSoft : 'transparent';
+ chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent';
});
chip.addEventListener('click', (e) => {
e.stopPropagation();
@@ -1209,19 +1320,24 @@
pickerEl.appendChild(grid);
document.body.appendChild(pickerEl);
+
+ // Cache the palette on the picker so toggleActionPicker's state refresh
+ // uses the same theme-aware colors when it repaints chips.
+ pickerEl.__iceq_palette = P;
}
function toggleActionPicker() {
if (pickerEl.style.display !== 'none') { hideActionPicker(); return; }
// Rebuild chips to reflect current selection
+ const P = pickerEl.__iceq_palette || barPaletteForTheme(detectPageTheme());
pickerEl.querySelectorAll('button').forEach(chip => {
const isActive = chip.dataset.action === selectedAction;
- chip.style.background = isActive ? C.brandSoft : 'transparent';
- chip.style.color = isActive ? C.brand : C.ink;
+ chip.style.background = isActive ? P.accentSoft : 'transparent';
+ chip.style.color = isActive ? P.accent : P.text;
});
// Position above the bar
const barRect = barEl.getBoundingClientRect();
- const pickerH = 90; // approximate
+ const pickerH = 170; // approximate; grows with icon + label rows
let top = barRect.top - pickerH - 6;
if (top < 8) top = barRect.bottom + 6;
Object.assign(pickerEl.style, {
@@ -1241,6 +1357,399 @@
setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180);
}
+ // ---------------------------------------------------------------------------
+ // Params panel (per-variant coarse controls)
+ //
+ // Variants may declare a parameter manifest via a JSON attribute on the
+ // variant wrapper:
+ //
+ //
+ //
+ // The panel docks to the right edge of the outline during CYCLING and
+ // exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped
+ // CSS can respond instantly without regeneration:
+ //
+ // range / numeric toggle → CSS var (`--p-
`) used via var(--p-foo, N)
+ // steps / boolean toggle → data-p- attribute used via :scope[data-p-foo="..."]
+ //
+ // On variant switch, values reset to that variant's declared defaults.
+ // On accept, current values are sent in the event payload so the agent
+ // can bake them into the source-file write.
+ // ---------------------------------------------------------------------------
+
+ 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 tuneOpen = false; // whether the Tune popover is open right now
+
+ // Theme-aware Tune popover. Appears as a drawer that slides out from the
+ // contextual bar's bar-facing edge (below if the bar sits below the
+ // element, above otherwise). Same width as the bar. Auto-wraps to extra
+ // rows when the knobs exceed one row. The bar's border-radius on the
+ // popover side goes flat while open so the two shapes read as one.
+ let paramsPanelPalette = null;
+
+ function initParamsPanel() {
+ paramsPanelPalette = barPaletteForTheme(detectPageTheme());
+ 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
+ // display toggle, no opacity toggle, no transform trickery. The mask
+ // hides everything initially; as it grows, content is revealed from
+ // the bar edge outward.
+ paramsPanelEl = document.createElement('div');
+ paramsPanelEl.id = PREFIX + '-params-panel';
+ Object.assign(paramsPanelEl.style, {
+ position: 'fixed', zIndex: String(Z.bar - 1),
+ background: P.surfaceDeep,
+ color: P.text,
+ fontFamily: FONT,
+ padding: '14px 18px',
+ boxSizing: 'border-box',
+ borderRadius: '0 0 10px 10px',
+ pointerEvents: 'none',
+ backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
+
+ // clip-path is the same conceptual reveal as mask but with rock-solid
+ // transition support across engines. Closed state clips from the far
+ // edge; open = inset(0) shows everything.
+ clipPath: 'inset(0 0 100% 0)',
+ 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
+ // top-left when first shown.
+ top: '-9999px', left: '-9999px', width: '0',
+ });
+
+ paramsPanelBody = el('div', {
+ display: 'grid',
+ gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
+ gap: '12px 16px',
+ });
+
+ paramsPanelEl.appendChild(paramsPanelBody);
+ document.body.appendChild(paramsPanelEl);
+ paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
+ }
+
+ function getVisibleVariantEl() {
+ if (!currentSessionId) return null;
+ const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
+ if (!wrapper) return null;
+ return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
+ }
+
+ function parseVariantParams(variantEl) {
+ if (!variantEl) return [];
+ const raw = variantEl.getAttribute('data-impeccable-params');
+ if (!raw) return [];
+ try {
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) ? parsed : [];
+ } catch (err) {
+ console.warn('[impeccable] Invalid data-impeccable-params JSON:', err.message);
+ return [];
+ }
+ }
+
+ function applyParamValue(variantEl, param, value) {
+ if (!variantEl) return;
+ const attr = 'data-p-' + param.id;
+ if (param.kind === 'range') {
+ variantEl.style.setProperty('--p-' + param.id, String(value));
+ } else if (param.kind === 'toggle') {
+ const on = !!value;
+ variantEl.style.setProperty('--p-' + param.id, on ? '1' : '0');
+ if (on) variantEl.setAttribute(attr, 'on');
+ else variantEl.removeAttribute(attr);
+ } else if (param.kind === 'steps') {
+ variantEl.setAttribute(attr, String(value));
+ }
+ }
+
+ function applyParamDefaults(variantEl, params) {
+ paramsCurrentValues = {};
+ for (const p of params) {
+ paramsCurrentValues[p.id] = p.default;
+ applyParamValue(variantEl, p, p.default);
+ }
+ }
+
+ function formatRangeValue(input) {
+ const max = parseFloat(input.max), min = parseFloat(input.min);
+ const v = parseFloat(input.value);
+ if (!isFinite(v)) return input.value;
+ return (max - min) <= 2 ? v.toFixed(2) : String(Math.round(v));
+ }
+
+ function buildParamsPanel(variantEl, params) {
+ const P = paramsPanelPalette || barPaletteForTheme(detectPageTheme());
+ paramsPanelBody.innerHTML = '';
+ for (const p of params) {
+ const row = el('div', { display: 'flex', flexDirection: 'column', gap: '6px' });
+ const labelRow = el('div', {
+ display: 'flex', justifyContent: 'space-between',
+ alignItems: 'baseline', gap: '8px',
+ });
+ const lbl = el('span', {
+ fontSize: '10.5px', fontWeight: '600', color: P.text,
+ letterSpacing: '0.03em',
+ });
+ lbl.textContent = p.label || p.id;
+ labelRow.appendChild(lbl);
+ const readout = el('span', {
+ fontSize: '10.5px', color: P.textDim,
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
+ });
+ labelRow.appendChild(readout);
+ row.appendChild(labelRow);
+
+ if (p.kind === 'range') {
+ const input = document.createElement('input');
+ input.type = 'range';
+ input.min = String(p.min != null ? p.min : 0);
+ input.max = String(p.max != null ? p.max : 1);
+ input.step = String(p.step != null ? p.step : 0.05);
+ input.value = String(p.default);
+ Object.assign(input.style, {
+ width: '100%', accentColor: C.brand, cursor: 'pointer',
+ });
+ readout.textContent = formatRangeValue(input);
+ input.addEventListener('input', (e) => {
+ e.stopPropagation();
+ const v = parseFloat(input.value);
+ paramsCurrentValues[p.id] = v;
+ readout.textContent = formatRangeValue(input);
+ applyParamValue(variantEl, p, v);
+ });
+ row.appendChild(input);
+ } else if (p.kind === 'toggle') {
+ const initial = !!p.default;
+ readout.textContent = initial ? 'On' : 'Off';
+ const track = el('button', {
+ position: 'relative', width: '36px', height: '20px',
+ borderRadius: '10px', border: 'none', padding: '0',
+ cursor: 'pointer',
+ background: initial ? C.brand : P.hairline,
+ transition: 'background 0.15s ease',
+ alignSelf: 'flex-start',
+ });
+ const knob = el('span', {
+ position: 'absolute', top: '2px',
+ left: initial ? '18px' : '2px',
+ width: '16px', height: '16px', borderRadius: '50%',
+ background: 'oklch(98% 0 0)',
+ transition: 'left 0.18s ' + EASE,
+ boxShadow: '0 1px 2px oklch(0% 0 0 / 0.2)',
+ });
+ track.appendChild(knob);
+ track.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const next = !paramsCurrentValues[p.id];
+ paramsCurrentValues[p.id] = next;
+ track.style.background = next ? C.brand : P.hairline;
+ knob.style.left = next ? '18px' : '2px';
+ readout.textContent = next ? 'On' : 'Off';
+ applyParamValue(variantEl, p, next);
+ });
+ row.appendChild(track);
+ } else if (p.kind === 'steps') {
+ const opts = (p.options || []).map(o =>
+ typeof o === 'string' ? { value: o, label: o } : o
+ );
+ const activeOpt = opts.find(o => o.value === p.default) || opts[0];
+ readout.textContent = activeOpt ? activeOpt.label : String(p.default);
+ const segRow = el('div', {
+ display: 'grid',
+ gridTemplateColumns: 'repeat(' + opts.length + ', 1fr)',
+ gap: '1px', padding: '2px',
+ background: P.hairline, borderRadius: '5px',
+ });
+ const segBtns = [];
+ opts.forEach(o => {
+ const active = o.value === p.default;
+ const b = el('button', {
+ padding: '5px 4px', border: 'none', borderRadius: '3px',
+ background: active ? C.brand : 'transparent',
+ color: active ? 'oklch(98% 0 0)' : P.text,
+ fontFamily: FONT, fontSize: '10.5px', fontWeight: '500',
+ cursor: 'pointer', whiteSpace: 'nowrap',
+ transition: 'background 0.1s ease, color 0.1s ease',
+ });
+ b.textContent = o.label;
+ b.addEventListener('click', (e) => {
+ e.stopPropagation();
+ paramsCurrentValues[p.id] = o.value;
+ readout.textContent = o.label;
+ segBtns.forEach(({ btn, val }) => {
+ const on = val === o.value;
+ btn.style.background = on ? C.brand : 'transparent';
+ btn.style.color = on ? 'oklch(98% 0 0)' : P.text;
+ });
+ applyParamValue(variantEl, p, o.value);
+ });
+ segRow.appendChild(b);
+ segBtns.push({ btn: b, val: o.value });
+ });
+ row.appendChild(segRow);
+ }
+
+ paramsPanelBody.appendChild(row);
+ }
+ }
+
+ // Decide which way the popover opens: away from the picked element. If the
+ // bar landed below the element, popover slides DOWN from the bar's bottom.
+ // If the bar landed above, popover slides UP from the bar's top.
+ function popoverDirection() {
+ if (!barEl || !selectedElement) return 'below';
+ const br = barEl.getBoundingClientRect();
+ const er = selectedElement.getBoundingClientRect();
+ return br.top >= er.bottom - 4 ? 'below' : 'above';
+ }
+
+ // The popover overlaps the bar by OVERLAP px on the bar-facing side. With
+ // popover z-index below bar, that overlap sits behind bar (invisible) and
+ // reinforces the "tucked behind" feel. Padding compensates so the real
+ // content starts flush with bar's outer edge.
+ const TUNE_OVERLAP = 6;
+
+ // Closed clip-path depends on direction: for 'below' clip from the far
+ // (bottom) edge so the reveal grows downward from the bar; for 'above'
+ // clip from the top edge so the reveal grows upward from the bar.
+ function closedClipPath(direction) {
+ return direction === 'below' ? 'inset(0 0 100% 0)' : 'inset(100% 0 0 0)';
+ }
+
+ function setClipPath(value, withTransition) {
+ const saved = paramsPanelEl.style.transition;
+ if (!withTransition) paramsPanelEl.style.transition = 'none';
+ paramsPanelEl.style.clipPath = value;
+ if (!withTransition) {
+ void paramsPanelEl.offsetHeight;
+ paramsPanelEl.style.transition = saved;
+ }
+ }
+
+ function positionParamsPanel() {
+ if (!paramsPanelEl || !barEl || barEl.style.display === 'none') return;
+ const br = barEl.getBoundingClientRect();
+ const direction = popoverDirection();
+ const prevDirection = paramsPanelEl.dataset.tuneDirection;
+
+ // top/left/width are NOT in the transition list, so they snap instantly.
+ paramsPanelEl.style.left = br.left + 'px';
+ paramsPanelEl.style.width = br.width + 'px';
+
+ if (direction === 'below') {
+ paramsPanelEl.style.top = (br.bottom - TUNE_OVERLAP) + 'px';
+ paramsPanelEl.style.borderRadius = '0 0 10px 10px';
+ paramsPanelEl.style.paddingTop = (14 + TUNE_OVERLAP) + 'px';
+ paramsPanelEl.style.paddingBottom = '14px';
+ } else {
+ const ih = paramsPanelEl.offsetHeight || 80;
+ paramsPanelEl.style.top = (br.top - ih + TUNE_OVERLAP) + 'px';
+ paramsPanelEl.style.borderRadius = '10px 10px 0 0';
+ paramsPanelEl.style.paddingTop = '14px';
+ paramsPanelEl.style.paddingBottom = (14 + TUNE_OVERLAP) + 'px';
+ }
+ paramsPanelEl.dataset.tuneDirection = direction;
+
+ // If currently closed and direction flipped (or first-time setup),
+ // snap the clip-path to the new direction's closed pose without
+ // transitioning (so the clip doesn't slide across the element).
+ if (!tuneOpen && (!prevDirection || prevDirection !== direction)) {
+ setClipPath(closedClipPath(direction), false);
+ }
+ }
+
+ function showParamsPanel() {
+ if (!paramsPanelEl) return;
+ positionParamsPanel();
+ paramsPanelEl.style.pointerEvents = 'auto';
+ // rAF so the positioning paint commits before the transition fires.
+ requestAnimationFrame(() => {
+ setClipPath('inset(0 0 0 0)', true);
+ });
+ }
+
+ function hideParamsPanel() {
+ if (!paramsPanelEl) return;
+ paramsPanelEl.style.pointerEvents = 'none';
+ const direction = paramsPanelEl.dataset.tuneDirection || 'below';
+ setClipPath(closedClipPath(direction), true);
+ }
+
+ // Build/rebuild the panel's contents for the current variant AND apply
+ // its defaults to the variant wrapper (so scoped CSS responds even before
+ // the user opens the popover). Visibility is governed by tuneOpen.
+ function refreshParamsPanel() {
+ if (state !== 'CYCLING') {
+ paramsCurrentValues = {};
+ tuneOpen = false;
+ hideParamsPanel();
+ return;
+ }
+ const variantEl = getVisibleVariantEl();
+ const params = parseVariantParams(variantEl);
+ if (!variantEl || params.length === 0) {
+ paramsCurrentValues = {};
+ tuneOpen = false;
+ hideParamsPanel();
+ return;
+ }
+ applyParamDefaults(variantEl, params);
+ buildParamsPanel(variantEl, params);
+ if (tuneOpen) {
+ // If already visible (variant cycled while open), refresh in place
+ // instead of re-running the clip-path animation.
+ const alreadyVisible = paramsPanelEl.style.display === 'block'
+ && paramsPanelEl.style.opacity === '1';
+ if (alreadyVisible) positionParamsPanel();
+ else showParamsPanel();
+ } else {
+ hideParamsPanel();
+ }
+ }
+
+ function toggleTunePopover() {
+ if (tuneOpen) { closeTunePopover(); return; }
+ openTunePopover();
+ }
+
+ function openTunePopover() {
+ if (state !== 'CYCLING') return;
+ const variantEl = getVisibleVariantEl();
+ const params = parseVariantParams(variantEl);
+ if (!variantEl || params.length === 0) return;
+ // Build fresh to ensure the current variant's controls are shown.
+ applyParamDefaults(variantEl, params);
+ buildParamsPanel(variantEl, params);
+ tuneOpen = true;
+ showParamsPanel();
+ // Kill the bar's shadow on the popover-facing side so the dark popover
+ // doesn't pick up a bright glow line.
+ if (barEl) {
+ const direction = paramsPanelEl?.dataset.tuneDirection || 'below';
+ barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN;
+ }
+ // Re-render the bar so the Tune chip picks up the active styling.
+ updateBarContent('cycling');
+ }
+
+ function closeTunePopover() {
+ tuneOpen = false;
+ hideParamsPanel();
+ if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT;
+ if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') {
+ updateBarContent('cycling');
+ }
+ }
+
// ---------------------------------------------------------------------------
// Variant cycling in DOM
// ---------------------------------------------------------------------------
@@ -1253,6 +1762,10 @@
if (!v) continue;
child.style.display = (v === String(num)) ? '' : 'none';
}
+ // 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();
}
/**
@@ -1316,6 +1829,7 @@
state = 'CYCLING';
hideShaderOverlay();
updateBarContent('cycling');
+ refreshParamsPanel();
saveSession();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
@@ -1329,7 +1843,7 @@
const next = visibleVariant + dir;
if (next < 1 || next > arrivedVariants) return;
visibleVariant = next;
- showVariantInDOM(currentSessionId, next);
+ showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself
updateSelectedElement();
updateBarContent('cycling');
saveSession();
@@ -1540,6 +2054,7 @@
state = 'CYCLING';
hideShaderOverlay();
updateBarContent('cycling');
+ refreshParamsPanel();
} else if (state === 'GENERATING') {
updateBarContent('generating');
}
@@ -1560,6 +2075,7 @@
if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') {
positionBar();
showHighlight(selectedElement);
+ if (tuneOpen) positionParamsPanel();
}
if (annotActive) positionAnnotOverlay(selectedElement);
// Shader overlay (via debug P toggle or generation) is repositioned
@@ -1606,6 +2122,7 @@
if (state === 'GENERATING') {
state = 'CYCLING';
updateBarContent('cycling');
+ refreshParamsPanel();
}
break;
}
@@ -1691,6 +2208,10 @@
if (pickerEl?.style.display !== 'none' && !own(e.target)) {
hideActionPicker();
}
+ // Close Tune popover on outside click (anything outside panel + bar)
+ if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) {
+ closeTunePopover();
+ }
// In CONFIGURING: click outside the bar and selected element returns to PICKING
if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) {
hideBar();
@@ -2258,7 +2779,11 @@ void main() {
function handleAccept() {
if (!currentSessionId || arrivedVariants === 0) return;
- sendEvent({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) });
+ const acceptPayload = { type: 'accept', id: currentSessionId, variantId: String(visibleVariant) };
+ if (Object.keys(paramsCurrentValues).length > 0) {
+ acceptPayload.paramValues = { ...paramsCurrentValues };
+ }
+ sendEvent(acceptPayload);
markSessionHandled();
// The accepted variant is already the only visible child of the wrapper
@@ -2477,6 +3002,11 @@ void main() {
state = arrivedVariants >= expectedVariants ? 'CYCLING' : 'GENERATING';
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
startScrollTracking();
+ // Build the params panel for the restored visible variant. Previously
+ // this was missed on page-reload resume: showVariantInDOM above fires
+ // refreshParamsPanel, but state was still IDLE at that moment so it
+ // hid. Now that state is CYCLING, re-fire.
+ if (state === 'CYCLING') refreshParamsPanel();
saveSession();
// Start observing for more variants AFTER initial setup
@@ -2522,6 +3052,11 @@ void main() {
// dark pages. This keeps the bar from fighting with the host design.
function detectPageTheme() {
try {
+ // Dev override: set localStorage 'impeccable-dev-theme' to 'light' or
+ // 'dark' to preview the opposite palette without actually changing the
+ // page bg. Used for screenshots and theme QA.
+ const override = localStorage.getItem('impeccable-dev-theme');
+ if (override === 'light' || override === 'dark') return override;
const bg = getComputedStyle(document.body).backgroundColor
|| getComputedStyle(document.documentElement).backgroundColor;
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
@@ -2538,6 +3073,7 @@ void main() {
// Light bar on dark page
return {
surface: 'oklch(98% 0 0 / 0.92)',
+ surfaceDeep: 'oklch(92% 0.005 60 / 0.96)', // slightly deeper, faint warm
hairline: 'oklch(70% 0 0 / 0.35)',
text: 'oklch(15% 0 0)',
textDim: 'oklch(45% 0 0)',
@@ -2552,6 +3088,7 @@ void main() {
// deeper so the rounded-right shape reads as a clear sculpted mark.
return {
surface: 'oklch(26% 0 0 / 0.94)',
+ surfaceDeep: 'oklch(18% 0 0 / 0.96)', // darker sand for Tune popover
hairline: 'oklch(42% 0 0 / 0.5)',
text: 'oklch(96% 0 0)',
textDim: 'oklch(72% 0 0)',
@@ -2883,6 +3420,7 @@ void main() {
if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; }
if (barEl) { barEl.remove(); barEl = null; }
if (pickerEl) { pickerEl.remove(); pickerEl = null; }
+ if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; }
if (evtSource) { evtSource.close(); evtSource = null; }
document.removeEventListener('mousemove', handleMouseMove, true);
document.removeEventListener('click', handleClick, true);
@@ -3956,6 +4494,7 @@ void main() {
initAnnotOverlay();
initBar();
initActionPicker();
+ initParamsPanel();
initGlobalBar();
initDesignPanel();
document.addEventListener('mousemove', handleMouseMove, true);
diff --git a/.gemini/skills/impeccable/scripts/live-poll.mjs b/.gemini/skills/impeccable/scripts/live-poll.mjs
index 9dd87e3ea..5cece1a43 100644
--- a/.gemini/skills/impeccable/scripts/live-poll.mjs
+++ b/.gemini/skills/impeccable/scripts/live-poll.mjs
@@ -145,6 +145,11 @@ Options:
const scriptArgs = event.type === 'discard'
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
+ if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
+ // Pass through a JSON blob; the shell-safe wrap uses single quotes because
+ // values are finite {id, number|string|boolean} pairs from a validated payload.
+ scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
+ }
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
diff --git a/.gemini/skills/impeccable/scripts/live-server.mjs b/.gemini/skills/impeccable/scripts/live-server.mjs
index e5fb05bfa..2ab33fd22 100644
--- a/.gemini/skills/impeccable/scripts/live-server.mjs
+++ b/.gemini/skills/impeccable/scripts/live-server.mjs
@@ -147,6 +147,11 @@ function validateEvent(msg) {
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
+ if (msg.paramValues !== undefined) {
+ if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
+ return 'accept: paramValues must be an object';
+ }
+ }
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
diff --git a/.github/skills/impeccable/reference/colorize.md b/.github/skills/impeccable/reference/colorize.md
index de74ac95f..1bd52004f 100644
--- a/.github/skills/impeccable/reference/colorize.md
+++ b/.github/skills/impeccable/reference/colorize.md
@@ -142,3 +142,13 @@ Test that colorization improves the experience:
- **Not overwhelming**: Is color balanced and purposeful?
Remember: Color is emotional and powerful. Use it to create warmth, guide attention, communicate meaning, and express personality. But restraint and strategy matter more than saturation and variety. Be colorful, but be intentional.
+
+## Live-mode signature params
+
+When invoked from live mode, each variant MUST declare a `color-amount` param so the user can dial between a restrained accent and a drenched surface without regeneration. Author the variant's CSS against `var(--p-color-amount, 0.5)` — typically as the alpha multiplier on backgrounds, or as a scaling factor on the chroma axis in an OKLCH expression. 0 = neutral/monochrome, 1 = full saturation / dominant coverage.
+
+```json
+{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"}
+```
+
+Layer 1-2 variant-specific params on top: palette selection (`steps` with named options), temperature warmth, or tint vs. true color. See `reference/live.md` for the full params contract.
diff --git a/.github/skills/impeccable/reference/layout.md b/.github/skills/impeccable/reference/layout.md
index 981ace8a1..5e8022081 100644
--- a/.github/skills/impeccable/reference/layout.md
+++ b/.github/skills/impeccable/reference/layout.md
@@ -119,3 +119,23 @@ Create a systematic plan:
- **Responsiveness**: Does the layout adapt gracefully across screen sizes?
Remember: Space is the most underused design tool. A layout with the right rhythm and hierarchy can make even simple content feel polished and intentional.
+
+## Live-mode signature params
+
+Each variant MUST declare a `density` param. Drive all spacing tokens in the variant's scoped CSS through `calc(var(--p-density, 1) * )` — paddings, gaps, column widths. Users slide from airy to packed and see layout re-breathe with no regeneration.
+
+```json
+{"id":"density","kind":"range","min":0.6,"max":1.4,"step":0.05,"default":1,"label":"Density"}
+```
+
+For variants whose topology genuinely changes (stacked vs. side-by-side, grid vs. bento), use a `steps` param whose scoped CSS branches via `:scope[data-p-structure="X"]`. One structure param + one density param is a powerful combo; resist adding a third.
+
+```json
+{"id":"structure","kind":"steps","default":"grid","label":"Structure","options":[
+ {"value":"stacked","label":"Stacked"},
+ {"value":"grid","label":"Grid"},
+ {"value":"bento","label":"Bento"}
+]}
+```
+
+See `reference/live.md` for the full params contract.
diff --git a/.github/skills/impeccable/reference/live.md b/.github/skills/impeccable/reference/live.md
index 59e191cf3..d7185b7ea 100644
--- a/.github/skills/impeccable/reference/live.md
+++ b/.github/skills/impeccable/reference/live.md
@@ -179,7 +179,56 @@ The first variant has no `display: none` (visible by default). All others do. If
One edit, all variants — the browser's MutationObserver picks everything up in one pass.
-### 7. Signal done
+### 7. Parameters (optional, 2-5 per variant)
+
+Each variant can expose coarse knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against.
+
+**When to use.** Any time the variant has a meaningful axis the user might want to dial in: color amount, density, motion intensity, scale ratio. Not micro-level margin tweaks; those defeat the point.
+
+**Budget scales with the element's visual weight, not the user's curiosity.** Knobs need real estate to produce noticeably different output; slapping three on a small element just crowds the panel without making anything feel tunable.
+
+- **Leaf / tiny** — a single button, icon, input, bare heading, solitary paragraph: **0 params.** A slider can't meaningfully reshape one element; just use variants.
+- **Small composition** — labeled input, simple card, short callout (≤ ~5 visual children): **0-1 params.** Only add one if it's a clear dominant axis (e.g. density on a card with visible internal rhythm).
+- **Medium composition** — section component, nav cluster, dense card, short feature block (6-15 visual children): **2 params.**
+- **Large composition** — hero section, full page region, spread layout, anything with strong internal structure (16+ visual children or multiple sub-sections): **3-4 params.**
+
+When in doubt, fewer. The user can always ask for more variants to explore an axis you didn't expose as a knob. Count by visual children, not by DOM-node depth; a deeply-nested-but-visually-simple card still counts as small.
+
+**How to declare.** Put a JSON manifest on the variant wrapper:
+
+```html
+
+ ...variant content...
+
+```
+
+**Three kinds:**
+
+- `range` — smooth slider. Drives a CSS custom property `--p-` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`.
+- `steps` — segmented radio. Drives a data attribute `data-p-` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`.
+- `toggle` — on/off switch. Drives BOTH a CSS var (`--p-: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`.
+
+**Signature params per action.** Each action has one or two signature params that MUST be exposed when the variant can meaningfully express them. Check the action's reference file for the list. Layer 1-2 variant-specific params on top.
+
+**Reset on variant switch.** User dials density on v1, flips to v2, v2 starts at v2's declared defaults. Known limitation; preservation across variants may land later.
+
+**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment:
+
+```html
+
+```
+
+The carbonize cleanup step (see below) reads that comment and bakes the chosen values into the final CSS. For `steps`/`toggle` attribute selectors: keep only the branch matching the chosen value, drop the others, collapse `:scope[data-p-density="packed"] .grid` to a semantic class rule. For `range` vars: either substitute the literal or keep the var with the chosen value as its new default.
+
+### 8. Signal done
```bash
node .github/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
@@ -244,11 +293,11 @@ When `_acceptResult.carbonize === true`, the accepted variant was stitched into
Do these five steps in the current thread, synchronously, before the next poll. Do not poll again until the file is clean.
-1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `` and `` and contains a `');
+ if (paramValues && Object.keys(paramValues).length > 0) {
+ // Preserve the user's knob positions for the carbonize-cleanup agent
+ // to bake into the final CSS when it collapses scoped rules.
+ replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
+ }
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
diff --git a/.github/skills/impeccable/scripts/live-browser.js b/.github/skills/impeccable/scripts/live-browser.js
index 116e1efa6..66efd7243 100644
--- a/.github/skills/impeccable/scripts/live-browser.js
+++ b/.github/skills/impeccable/scripts/live-browser.js
@@ -30,10 +30,13 @@
// Design tokens
// ---------------------------------------------------------------------------
+ // Brand magenta is pinned to the site token (--color-accent in main.css)
+ // so Accept / knobs / cycle-dots match the site's accent, not a washed
+ // theme-adjusted one.
const C = {
- brand: 'oklch(55% 0.25 350)',
- brandHov: 'oklch(48% 0.25 350)',
- brandSoft: 'oklch(55% 0.25 350 / 0.12)',
+ brand: 'oklch(60% 0.25 350)',
+ brandHov: 'oklch(52% 0.25 350)',
+ brandSoft: 'oklch(60% 0.25 350 / 0.15)',
ink: 'oklch(15% 0.01 350)',
ash: 'oklch(55% 0 0)',
paper: 'oklch(98% 0.005 350 / 0.92)',
@@ -60,6 +63,25 @@
'html', 'head', 'body', 'script', 'style', 'link', 'meta', 'noscript', 'br', 'wbr',
]);
+ // 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.
+ 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: ` `,
+ bolder: ` `,
+ quieter: ` `,
+ distill: ` `,
+ polish: ` `,
+ typeset: ` `,
+ colorize: ` `,
+ layout: ` `,
+ adapt: ` `,
+ animate: ` `,
+ delight: ` `,
+ overdrive: ` `,
+ };
+
const ACTIONS = [
{ value: 'impeccable', label: 'Freeform' },
{ value: 'bolder', label: 'Bolder' },
@@ -756,7 +778,21 @@
// The Bar — one floating element, three modes
// ---------------------------------------------------------------------------
+ // Contextual-bar palette. Cached at init so every build*Row reads a
+ // consistent set of colors; detectPageTheme runs once rather than on every
+ // phase transition.
+ let BP = null;
+
+ // Bar shadow variants. The default projects down + subtle around. When
+ // the Tune popover opens below the bar, a downward shadow lands on the
+ // dark popover and reads as a bright ghost line. We swap to UP-only while
+ // tune is open below so the popover's top edge is clean.
+ const BAR_SHADOW_DEFAULT = '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)';
+ const BAR_SHADOW_UP = '0 -4px 20px oklch(0% 0 0 / 0.08), 0 -1px 3px oklch(0% 0 0 / 0.06)';
+ const BAR_SHADOW_DOWN = BAR_SHADOW_DEFAULT;
+
function initBar() {
+ BP = barPaletteForTheme(detectPageTheme());
barEl = document.createElement('div');
barEl.id = PREFIX + '-bar';
Object.assign(barEl.style, {
@@ -764,15 +800,15 @@
display: 'none', opacity: '0',
transform: 'translateY(6px)',
transition: 'opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
- // Visual
- background: C.paper,
+ background: BP.surface,
backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
- border: '1px solid ' + C.mist,
+ border: '1px solid ' + BP.hairline,
borderRadius: '10px',
- boxShadow: '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)',
- fontFamily: FONT, fontSize: '13px', color: C.ink,
+ boxShadow: BAR_SHADOW_DEFAULT,
+ transition: 'box-shadow 0.2s ease, opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
+ fontFamily: FONT, fontSize: '13px', color: BP.text,
padding: '6px',
- maxWidth: '460px', minWidth: '320px',
+ maxWidth: '520px', minWidth: '320px',
});
document.body.appendChild(barEl);
}
@@ -824,14 +860,15 @@
barEl.style.transform = 'translateY(6px)';
setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250);
hideActionPicker();
+ closeTunePopover();
}
function updateBarContent(mode) {
if (!barEl || barEl.style.display === 'none') return;
barEl.innerHTML = '';
- // Reset bar styling
- barEl.style.background = C.paper;
- barEl.style.border = '1px solid ' + C.mist;
+ // Reset bar styling to the theme-aware palette
+ barEl.style.background = BP.surface;
+ barEl.style.border = '1px solid ' + BP.hairline;
if (mode === 'configure') barEl.appendChild(buildConfigureRow());
else if (mode === 'generating') barEl.appendChild(buildGeneratingRow());
else if (mode === 'cycling') barEl.appendChild(buildCyclingRow());
@@ -854,15 +891,15 @@
const pill = el('button', {
display: 'inline-flex', alignItems: 'center', gap: '4px',
padding: '5px 10px', borderRadius: '6px',
- background: C.ink, color: C.white,
+ background: BP.mark, color: BP.markText,
fontFamily: FONT, fontSize: '12px', fontWeight: '500',
border: 'none', cursor: 'pointer',
transition: 'background 0.12s ease, transform 0.1s ease',
whiteSpace: 'nowrap', flexShrink: '0',
});
pill.textContent = actionLabel() + ' \u25BE';
- pill.addEventListener('mouseenter', () => pill.style.background = C.brandHov);
- pill.addEventListener('mouseleave', () => pill.style.background = C.ink);
+ pill.addEventListener('mouseenter', () => pill.style.background = BP.accent);
+ pill.addEventListener('mouseleave', () => pill.style.background = BP.mark);
pill.addEventListener('mousedown', () => pill.style.transform = 'scale(0.97)');
pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)');
pill.addEventListener('click', (e) => { e.stopPropagation(); toggleActionPicker(); });
@@ -877,13 +914,13 @@
flex: '1', minWidth: '0',
padding: '5px 8px', borderRadius: '6px',
border: '1px solid transparent', background: 'transparent',
- fontFamily: FONT, fontSize: '12px', color: C.ink,
+ fontFamily: FONT, fontSize: '12px', color: BP.text,
outline: 'none',
transition: 'border-color 0.15s ease, background 0.15s ease',
});
input.addEventListener('focus', () => {
- input.style.borderColor = C.mist;
- input.style.background = C.white;
+ input.style.borderColor = BP.hairline;
+ input.style.background = BP.accentSoft;
});
input.addEventListener('blur', () => {
input.style.borderColor = 'transparent';
@@ -901,16 +938,16 @@
// Variant count toggle
const count = el('button', {
padding: '4px 6px', borderRadius: '5px',
- border: '1px solid ' + C.mist, background: 'transparent',
+ border: '1px solid ' + BP.hairline, background: 'transparent',
fontFamily: MONO, fontSize: '11px', fontWeight: '600',
- color: C.ash, cursor: 'pointer',
+ color: BP.textDim, cursor: 'pointer',
transition: 'color 0.12s ease, border-color 0.12s ease',
flexShrink: '0', whiteSpace: 'nowrap',
});
count.textContent = '\u00D7' + selectedCount;
count.title = 'Variants: click to change';
- count.addEventListener('mouseenter', () => { count.style.color = C.ink; count.style.borderColor = C.ink; });
- count.addEventListener('mouseleave', () => { count.style.color = C.ash; count.style.borderColor = C.mist; });
+ count.addEventListener('mouseenter', () => { count.style.color = BP.text; count.style.borderColor = BP.text; });
+ count.addEventListener('mouseleave', () => { count.style.color = BP.textDim; count.style.borderColor = BP.hairline; });
count.addEventListener('click', (e) => {
e.stopPropagation();
selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1;
@@ -921,15 +958,15 @@
// Go button
const go = el('button', {
padding: '5px 12px', borderRadius: '6px',
- border: 'none', background: C.brand, color: C.white,
+ border: 'none', background: BP.accent, color: BP.mark,
fontFamily: FONT, fontSize: '12px', fontWeight: '600',
cursor: 'pointer',
- transition: 'background 0.12s ease, transform 0.1s ease',
+ transition: 'filter 0.12s ease, transform 0.1s ease',
flexShrink: '0', whiteSpace: 'nowrap',
});
go.textContent = 'Go \u2192';
- go.addEventListener('mouseenter', () => go.style.background = C.brandHov);
- go.addEventListener('mouseleave', () => go.style.background = C.brand);
+ go.addEventListener('mouseenter', () => go.style.filter = 'brightness(1.1)');
+ go.addEventListener('mouseleave', () => go.style.filter = 'none');
go.addEventListener('mousedown', () => go.style.transform = 'scale(0.97)');
go.addEventListener('mouseup', () => go.style.transform = 'scale(1)');
go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); });
@@ -950,7 +987,7 @@
// Action label
const label = el('span', {
- fontWeight: '600', fontSize: '12px', color: C.ink,
+ fontWeight: '600', fontSize: '12px', color: BP.text,
flexShrink: '0', whiteSpace: 'nowrap',
});
label.textContent = actionLabel();
@@ -961,7 +998,7 @@
// Status
const status = el('span', {
- fontSize: '11px', color: C.ash, whiteSpace: 'nowrap',
+ fontSize: '11px', color: BP.textDim, whiteSpace: 'nowrap',
marginLeft: 'auto',
});
// Variants currently arrive atomically in a single file edit, so a
@@ -976,6 +1013,8 @@
// --- Cycling row ---
+ const TUNE_ICON_SVG = ' ';
+
function buildCyclingRow() {
const row = el('div', {
display: 'flex', alignItems: 'center', gap: '6px',
@@ -994,7 +1033,7 @@
// Counter
const counter = el('span', {
fontFamily: MONO, fontSize: '11px', fontWeight: '500',
- color: C.ash, minWidth: '24px', textAlign: 'center',
+ color: BP.textDim, minWidth: '24px', textAlign: 'center',
});
counter.textContent = visibleVariant + '/' + arrivedVariants;
row.appendChild(counter);
@@ -1005,20 +1044,67 @@
if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
row.appendChild(next);
+ // Tune chip — only when the visible variant exposes params
+ const visParams = parseVariantParams(getVisibleVariantEl());
+ const hasParams = visParams.length > 0;
+ if (hasParams) {
+ const tune = el('button', {
+ display: 'inline-flex', alignItems: 'center', gap: '6px',
+ padding: '4px 10px', borderRadius: '5px',
+ border: '1px solid transparent',
+ background: tuneOpen ? BP.accentSoft : 'transparent',
+ color: tuneOpen ? BP.accent : BP.text,
+ fontFamily: FONT, fontSize: '11px', fontWeight: '500',
+ cursor: 'pointer',
+ transition: 'color 0.12s ease, background 0.12s ease',
+ whiteSpace: 'nowrap',
+ });
+ tune.innerHTML = TUNE_ICON_SVG;
+ const tuneLabel = document.createElement('span');
+ tuneLabel.textContent = 'Tune';
+ tune.appendChild(tuneLabel);
+ const tuneBadge = document.createElement('span');
+ Object.assign(tuneBadge.style, {
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
+ minWidth: '16px', height: '16px', padding: '0 4px',
+ borderRadius: '999px',
+ background: tuneOpen ? C.brand : BP.hairline,
+ color: tuneOpen ? 'oklch(98% 0 0)' : 'inherit',
+ fontFamily: MONO, fontSize: '9.5px', fontWeight: '600',
+ lineHeight: '1',
+ boxSizing: 'border-box',
+ });
+ tuneBadge.textContent = String(visParams.length);
+ tune.appendChild(tuneBadge);
+ tune.title = 'Tune this variant (' + visParams.length + ' knob' + (visParams.length === 1 ? '' : 's') + ')';
+ tune.addEventListener('mouseenter', () => {
+ if (!tuneOpen) tune.style.background = BP.accentSoft;
+ });
+ tune.addEventListener('mouseleave', () => {
+ if (!tuneOpen) tune.style.background = 'transparent';
+ });
+ tune.addEventListener('click', (e) => { e.stopPropagation(); toggleTunePopover(); });
+ tune.dataset.iceqTune = '1';
+ row.appendChild(tune);
+ }
+
// Spacer
row.appendChild(el('div', { flex: '1' }));
- // Accept
+ // Accept — primary action, uses the site's saturated brand magenta
+ // with paper-white text, not the theme-muted BP.accent.
const accept = el('button', {
- padding: '4px 10px', borderRadius: '5px',
- border: 'none', background: C.ink, color: C.white,
+ padding: '5px 14px', borderRadius: '5px',
+ border: 'none', background: C.brand, color: 'oklch(98% 0 0)',
fontFamily: FONT, fontSize: '11px', fontWeight: '600',
- cursor: 'pointer', transition: 'background 0.12s ease',
+ cursor: 'pointer', transition: 'filter 0.12s ease, transform 0.1s ease',
whiteSpace: 'nowrap',
});
accept.textContent = '\u2713 Accept';
- accept.addEventListener('mouseenter', () => accept.style.background = C.brand);
- accept.addEventListener('mouseleave', () => accept.style.background = C.ink);
+ accept.addEventListener('mouseenter', () => accept.style.filter = 'brightness(1.08)');
+ accept.addEventListener('mouseleave', () => accept.style.filter = 'none');
+ accept.addEventListener('mousedown', () => accept.style.transform = 'scale(0.97)');
+ accept.addEventListener('mouseup', () => accept.style.transform = 'scale(1)');
accept.addEventListener('click', (e) => { e.stopPropagation(); handleAccept(); });
if (arrivedVariants === 0) { accept.style.opacity = '0.3'; accept.style.pointerEvents = 'none'; }
row.appendChild(accept);
@@ -1026,14 +1112,14 @@
// Discard
const discard = el('button', {
padding: '4px 6px', borderRadius: '5px',
- border: '1px solid ' + C.mist, background: 'transparent',
- fontFamily: FONT, fontSize: '11px', color: C.ash,
+ border: '1px solid ' + BP.hairline, background: 'transparent',
+ fontFamily: FONT, fontSize: '11px', color: BP.textDim,
cursor: 'pointer', transition: 'color 0.12s ease, border-color 0.12s ease',
});
discard.textContent = '\u2715';
discard.title = 'Discard all variants';
- discard.addEventListener('mouseenter', () => { discard.style.color = C.ink; discard.style.borderColor = C.ink; });
- discard.addEventListener('mouseleave', () => { discard.style.color = C.ash; discard.style.borderColor = C.mist; });
+ discard.addEventListener('mouseenter', () => { discard.style.color = BP.text; discard.style.borderColor = BP.text; });
+ discard.addEventListener('mouseleave', () => { discard.style.color = BP.textDim; discard.style.borderColor = BP.hairline; });
discard.addEventListener('click', (e) => { e.stopPropagation(); handleDiscard(); });
row.appendChild(discard);
@@ -1051,14 +1137,14 @@
});
const spinner = el('div', {
width: '14px', height: '14px', borderRadius: '50%',
- border: '2px solid ' + C.mist,
- borderTopColor: C.brand,
+ border: '2px solid ' + BP.hairline,
+ borderTopColor: BP.accent,
animation: 'impeccable-spin 0.6s linear infinite',
flexShrink: '0',
});
row.appendChild(spinner);
const label = el('span', {
- fontSize: '12px', color: C.ash, fontWeight: '500',
+ fontSize: '12px', color: BP.textDim, fontWeight: '500',
});
label.textContent = 'Applying variant...';
row.appendChild(label);
@@ -1103,14 +1189,26 @@
for (let i = 1; i <= expectedVariants; i++) {
const arrived = i <= arrivedVariants;
const active = i === visibleVariant;
+ // active: solid site-brand magenta 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
+ // magenta chips, especially when all variants had arrived and every
+ // dot wore an accent ring.
+ const dotBg = active ? C.brand
+ : arrived ? BP.textDim
+ : 'transparent';
+ const dotBorder = arrived ? 'none' : '1.5px solid ' + BP.hairline;
const dot = el('div', {
- width: '7px', height: '7px', borderRadius: '50%',
- background: active ? C.brand : (arrived ? C.ash : 'transparent'),
- border: '1.5px solid ' + (arrived ? C.brand : C.mist),
+ width: active ? '8px' : '6px',
+ height: active ? '8px' : '6px',
+ borderRadius: '50%',
+ background: dotBg,
+ border: dotBorder,
+ boxSizing: 'border-box',
transition: 'all 0.2s ' + EASE,
cursor: (clickable && arrived) ? 'pointer' : 'default',
- transform: arrived ? 'scale(1)' : 'scale(0.6)',
- opacity: arrived ? '1' : '0.4',
+ transform: arrived ? 'scale(1)' : 'scale(0.85)',
+ opacity: arrived ? (active ? '1' : '0.6') : '0.4',
});
if (clickable && arrived) {
const idx = i;
@@ -1130,15 +1228,15 @@
function navBtn(text) {
const b = el('button', {
width: '26px', height: '26px', borderRadius: '5px',
- border: '1px solid ' + C.mist, background: 'transparent',
- color: C.ink, fontFamily: FONT, fontSize: '13px',
+ border: '1px solid ' + BP.hairline, background: 'transparent',
+ color: BP.text, fontFamily: FONT, fontSize: '13px',
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'border-color 0.12s ease, background 0.12s ease',
padding: '0', lineHeight: '1',
});
b.textContent = text;
- b.addEventListener('mouseenter', () => { b.style.borderColor = C.ink; });
- b.addEventListener('mouseleave', () => { b.style.borderColor = C.mist; });
+ b.addEventListener('mouseenter', () => { b.style.borderColor = BP.text; });
+ b.addEventListener('mouseleave', () => { b.style.borderColor = BP.hairline; });
return b;
}
@@ -1158,6 +1256,7 @@
// ---------------------------------------------------------------------------
function initActionPicker() {
+ const P = barPaletteForTheme(detectPageTheme());
pickerEl = document.createElement('div');
pickerEl.id = PREFIX + '-picker';
Object.assign(pickerEl.style, {
@@ -1166,12 +1265,14 @@
transform: 'scale(0.96) translateY(4px)',
transformOrigin: 'bottom left',
transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE,
- background: C.paperSolid,
- border: '1px solid ' + C.mist,
+ background: P.surface,
+ border: '1px solid ' + P.hairline,
borderRadius: '10px',
boxShadow: '0 8px 30px oklch(0% 0 0 / 0.10), 0 2px 6px oklch(0% 0 0 / 0.06)',
padding: '6px',
fontFamily: FONT,
+ backdropFilter: 'blur(10px)',
+ WebkitBackdropFilter: 'blur(10px)',
});
// Build the chip grid
@@ -1181,22 +1282,32 @@
ACTIONS.forEach(action => {
const chip = el('button', {
- padding: '6px 8px', borderRadius: '6px',
+ display: 'flex', flexDirection: 'column', alignItems: 'center',
+ gap: '4px',
+ padding: '8px 6px', borderRadius: '6px',
border: 'none',
- background: action.value === selectedAction ? C.brandSoft : 'transparent',
- color: action.value === selectedAction ? C.brand : C.ink,
+ background: action.value === selectedAction ? P.accentSoft : 'transparent',
+ color: action.value === selectedAction ? P.accent : P.text,
fontFamily: FONT, fontSize: '11px', fontWeight: '500',
cursor: 'pointer',
transition: 'background 0.1s ease, color 0.1s ease',
textAlign: 'center', whiteSpace: 'nowrap',
});
- chip.textContent = action.label;
+ const iconWrap = el('span', {
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ height: '20px', opacity: '0.9',
+ });
+ iconWrap.innerHTML = ICONS[action.value] || '';
+ const labelEl = el('span', { lineHeight: '1' });
+ labelEl.textContent = action.label;
+ chip.appendChild(iconWrap);
+ chip.appendChild(labelEl);
chip.dataset.action = action.value;
chip.addEventListener('mouseenter', () => {
- if (action.value !== selectedAction) chip.style.background = C.brandSoft;
+ if (action.value !== selectedAction) chip.style.background = P.accentSoft;
});
chip.addEventListener('mouseleave', () => {
- chip.style.background = action.value === selectedAction ? C.brandSoft : 'transparent';
+ chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent';
});
chip.addEventListener('click', (e) => {
e.stopPropagation();
@@ -1209,19 +1320,24 @@
pickerEl.appendChild(grid);
document.body.appendChild(pickerEl);
+
+ // Cache the palette on the picker so toggleActionPicker's state refresh
+ // uses the same theme-aware colors when it repaints chips.
+ pickerEl.__iceq_palette = P;
}
function toggleActionPicker() {
if (pickerEl.style.display !== 'none') { hideActionPicker(); return; }
// Rebuild chips to reflect current selection
+ const P = pickerEl.__iceq_palette || barPaletteForTheme(detectPageTheme());
pickerEl.querySelectorAll('button').forEach(chip => {
const isActive = chip.dataset.action === selectedAction;
- chip.style.background = isActive ? C.brandSoft : 'transparent';
- chip.style.color = isActive ? C.brand : C.ink;
+ chip.style.background = isActive ? P.accentSoft : 'transparent';
+ chip.style.color = isActive ? P.accent : P.text;
});
// Position above the bar
const barRect = barEl.getBoundingClientRect();
- const pickerH = 90; // approximate
+ const pickerH = 170; // approximate; grows with icon + label rows
let top = barRect.top - pickerH - 6;
if (top < 8) top = barRect.bottom + 6;
Object.assign(pickerEl.style, {
@@ -1241,6 +1357,399 @@
setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180);
}
+ // ---------------------------------------------------------------------------
+ // Params panel (per-variant coarse controls)
+ //
+ // Variants may declare a parameter manifest via a JSON attribute on the
+ // variant wrapper:
+ //
+ //
+ //
+ // The panel docks to the right edge of the outline during CYCLING and
+ // exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped
+ // CSS can respond instantly without regeneration:
+ //
+ // range / numeric toggle → CSS var (`--p-
`) used via var(--p-foo, N)
+ // steps / boolean toggle → data-p- attribute used via :scope[data-p-foo="..."]
+ //
+ // On variant switch, values reset to that variant's declared defaults.
+ // On accept, current values are sent in the event payload so the agent
+ // can bake them into the source-file write.
+ // ---------------------------------------------------------------------------
+
+ 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 tuneOpen = false; // whether the Tune popover is open right now
+
+ // Theme-aware Tune popover. Appears as a drawer that slides out from the
+ // contextual bar's bar-facing edge (below if the bar sits below the
+ // element, above otherwise). Same width as the bar. Auto-wraps to extra
+ // rows when the knobs exceed one row. The bar's border-radius on the
+ // popover side goes flat while open so the two shapes read as one.
+ let paramsPanelPalette = null;
+
+ function initParamsPanel() {
+ paramsPanelPalette = barPaletteForTheme(detectPageTheme());
+ 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
+ // display toggle, no opacity toggle, no transform trickery. The mask
+ // hides everything initially; as it grows, content is revealed from
+ // the bar edge outward.
+ paramsPanelEl = document.createElement('div');
+ paramsPanelEl.id = PREFIX + '-params-panel';
+ Object.assign(paramsPanelEl.style, {
+ position: 'fixed', zIndex: String(Z.bar - 1),
+ background: P.surfaceDeep,
+ color: P.text,
+ fontFamily: FONT,
+ padding: '14px 18px',
+ boxSizing: 'border-box',
+ borderRadius: '0 0 10px 10px',
+ pointerEvents: 'none',
+ backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
+
+ // clip-path is the same conceptual reveal as mask but with rock-solid
+ // transition support across engines. Closed state clips from the far
+ // edge; open = inset(0) shows everything.
+ clipPath: 'inset(0 0 100% 0)',
+ 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
+ // top-left when first shown.
+ top: '-9999px', left: '-9999px', width: '0',
+ });
+
+ paramsPanelBody = el('div', {
+ display: 'grid',
+ gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
+ gap: '12px 16px',
+ });
+
+ paramsPanelEl.appendChild(paramsPanelBody);
+ document.body.appendChild(paramsPanelEl);
+ paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
+ }
+
+ function getVisibleVariantEl() {
+ if (!currentSessionId) return null;
+ const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
+ if (!wrapper) return null;
+ return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
+ }
+
+ function parseVariantParams(variantEl) {
+ if (!variantEl) return [];
+ const raw = variantEl.getAttribute('data-impeccable-params');
+ if (!raw) return [];
+ try {
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) ? parsed : [];
+ } catch (err) {
+ console.warn('[impeccable] Invalid data-impeccable-params JSON:', err.message);
+ return [];
+ }
+ }
+
+ function applyParamValue(variantEl, param, value) {
+ if (!variantEl) return;
+ const attr = 'data-p-' + param.id;
+ if (param.kind === 'range') {
+ variantEl.style.setProperty('--p-' + param.id, String(value));
+ } else if (param.kind === 'toggle') {
+ const on = !!value;
+ variantEl.style.setProperty('--p-' + param.id, on ? '1' : '0');
+ if (on) variantEl.setAttribute(attr, 'on');
+ else variantEl.removeAttribute(attr);
+ } else if (param.kind === 'steps') {
+ variantEl.setAttribute(attr, String(value));
+ }
+ }
+
+ function applyParamDefaults(variantEl, params) {
+ paramsCurrentValues = {};
+ for (const p of params) {
+ paramsCurrentValues[p.id] = p.default;
+ applyParamValue(variantEl, p, p.default);
+ }
+ }
+
+ function formatRangeValue(input) {
+ const max = parseFloat(input.max), min = parseFloat(input.min);
+ const v = parseFloat(input.value);
+ if (!isFinite(v)) return input.value;
+ return (max - min) <= 2 ? v.toFixed(2) : String(Math.round(v));
+ }
+
+ function buildParamsPanel(variantEl, params) {
+ const P = paramsPanelPalette || barPaletteForTheme(detectPageTheme());
+ paramsPanelBody.innerHTML = '';
+ for (const p of params) {
+ const row = el('div', { display: 'flex', flexDirection: 'column', gap: '6px' });
+ const labelRow = el('div', {
+ display: 'flex', justifyContent: 'space-between',
+ alignItems: 'baseline', gap: '8px',
+ });
+ const lbl = el('span', {
+ fontSize: '10.5px', fontWeight: '600', color: P.text,
+ letterSpacing: '0.03em',
+ });
+ lbl.textContent = p.label || p.id;
+ labelRow.appendChild(lbl);
+ const readout = el('span', {
+ fontSize: '10.5px', color: P.textDim,
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
+ });
+ labelRow.appendChild(readout);
+ row.appendChild(labelRow);
+
+ if (p.kind === 'range') {
+ const input = document.createElement('input');
+ input.type = 'range';
+ input.min = String(p.min != null ? p.min : 0);
+ input.max = String(p.max != null ? p.max : 1);
+ input.step = String(p.step != null ? p.step : 0.05);
+ input.value = String(p.default);
+ Object.assign(input.style, {
+ width: '100%', accentColor: C.brand, cursor: 'pointer',
+ });
+ readout.textContent = formatRangeValue(input);
+ input.addEventListener('input', (e) => {
+ e.stopPropagation();
+ const v = parseFloat(input.value);
+ paramsCurrentValues[p.id] = v;
+ readout.textContent = formatRangeValue(input);
+ applyParamValue(variantEl, p, v);
+ });
+ row.appendChild(input);
+ } else if (p.kind === 'toggle') {
+ const initial = !!p.default;
+ readout.textContent = initial ? 'On' : 'Off';
+ const track = el('button', {
+ position: 'relative', width: '36px', height: '20px',
+ borderRadius: '10px', border: 'none', padding: '0',
+ cursor: 'pointer',
+ background: initial ? C.brand : P.hairline,
+ transition: 'background 0.15s ease',
+ alignSelf: 'flex-start',
+ });
+ const knob = el('span', {
+ position: 'absolute', top: '2px',
+ left: initial ? '18px' : '2px',
+ width: '16px', height: '16px', borderRadius: '50%',
+ background: 'oklch(98% 0 0)',
+ transition: 'left 0.18s ' + EASE,
+ boxShadow: '0 1px 2px oklch(0% 0 0 / 0.2)',
+ });
+ track.appendChild(knob);
+ track.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const next = !paramsCurrentValues[p.id];
+ paramsCurrentValues[p.id] = next;
+ track.style.background = next ? C.brand : P.hairline;
+ knob.style.left = next ? '18px' : '2px';
+ readout.textContent = next ? 'On' : 'Off';
+ applyParamValue(variantEl, p, next);
+ });
+ row.appendChild(track);
+ } else if (p.kind === 'steps') {
+ const opts = (p.options || []).map(o =>
+ typeof o === 'string' ? { value: o, label: o } : o
+ );
+ const activeOpt = opts.find(o => o.value === p.default) || opts[0];
+ readout.textContent = activeOpt ? activeOpt.label : String(p.default);
+ const segRow = el('div', {
+ display: 'grid',
+ gridTemplateColumns: 'repeat(' + opts.length + ', 1fr)',
+ gap: '1px', padding: '2px',
+ background: P.hairline, borderRadius: '5px',
+ });
+ const segBtns = [];
+ opts.forEach(o => {
+ const active = o.value === p.default;
+ const b = el('button', {
+ padding: '5px 4px', border: 'none', borderRadius: '3px',
+ background: active ? C.brand : 'transparent',
+ color: active ? 'oklch(98% 0 0)' : P.text,
+ fontFamily: FONT, fontSize: '10.5px', fontWeight: '500',
+ cursor: 'pointer', whiteSpace: 'nowrap',
+ transition: 'background 0.1s ease, color 0.1s ease',
+ });
+ b.textContent = o.label;
+ b.addEventListener('click', (e) => {
+ e.stopPropagation();
+ paramsCurrentValues[p.id] = o.value;
+ readout.textContent = o.label;
+ segBtns.forEach(({ btn, val }) => {
+ const on = val === o.value;
+ btn.style.background = on ? C.brand : 'transparent';
+ btn.style.color = on ? 'oklch(98% 0 0)' : P.text;
+ });
+ applyParamValue(variantEl, p, o.value);
+ });
+ segRow.appendChild(b);
+ segBtns.push({ btn: b, val: o.value });
+ });
+ row.appendChild(segRow);
+ }
+
+ paramsPanelBody.appendChild(row);
+ }
+ }
+
+ // Decide which way the popover opens: away from the picked element. If the
+ // bar landed below the element, popover slides DOWN from the bar's bottom.
+ // If the bar landed above, popover slides UP from the bar's top.
+ function popoverDirection() {
+ if (!barEl || !selectedElement) return 'below';
+ const br = barEl.getBoundingClientRect();
+ const er = selectedElement.getBoundingClientRect();
+ return br.top >= er.bottom - 4 ? 'below' : 'above';
+ }
+
+ // The popover overlaps the bar by OVERLAP px on the bar-facing side. With
+ // popover z-index below bar, that overlap sits behind bar (invisible) and
+ // reinforces the "tucked behind" feel. Padding compensates so the real
+ // content starts flush with bar's outer edge.
+ const TUNE_OVERLAP = 6;
+
+ // Closed clip-path depends on direction: for 'below' clip from the far
+ // (bottom) edge so the reveal grows downward from the bar; for 'above'
+ // clip from the top edge so the reveal grows upward from the bar.
+ function closedClipPath(direction) {
+ return direction === 'below' ? 'inset(0 0 100% 0)' : 'inset(100% 0 0 0)';
+ }
+
+ function setClipPath(value, withTransition) {
+ const saved = paramsPanelEl.style.transition;
+ if (!withTransition) paramsPanelEl.style.transition = 'none';
+ paramsPanelEl.style.clipPath = value;
+ if (!withTransition) {
+ void paramsPanelEl.offsetHeight;
+ paramsPanelEl.style.transition = saved;
+ }
+ }
+
+ function positionParamsPanel() {
+ if (!paramsPanelEl || !barEl || barEl.style.display === 'none') return;
+ const br = barEl.getBoundingClientRect();
+ const direction = popoverDirection();
+ const prevDirection = paramsPanelEl.dataset.tuneDirection;
+
+ // top/left/width are NOT in the transition list, so they snap instantly.
+ paramsPanelEl.style.left = br.left + 'px';
+ paramsPanelEl.style.width = br.width + 'px';
+
+ if (direction === 'below') {
+ paramsPanelEl.style.top = (br.bottom - TUNE_OVERLAP) + 'px';
+ paramsPanelEl.style.borderRadius = '0 0 10px 10px';
+ paramsPanelEl.style.paddingTop = (14 + TUNE_OVERLAP) + 'px';
+ paramsPanelEl.style.paddingBottom = '14px';
+ } else {
+ const ih = paramsPanelEl.offsetHeight || 80;
+ paramsPanelEl.style.top = (br.top - ih + TUNE_OVERLAP) + 'px';
+ paramsPanelEl.style.borderRadius = '10px 10px 0 0';
+ paramsPanelEl.style.paddingTop = '14px';
+ paramsPanelEl.style.paddingBottom = (14 + TUNE_OVERLAP) + 'px';
+ }
+ paramsPanelEl.dataset.tuneDirection = direction;
+
+ // If currently closed and direction flipped (or first-time setup),
+ // snap the clip-path to the new direction's closed pose without
+ // transitioning (so the clip doesn't slide across the element).
+ if (!tuneOpen && (!prevDirection || prevDirection !== direction)) {
+ setClipPath(closedClipPath(direction), false);
+ }
+ }
+
+ function showParamsPanel() {
+ if (!paramsPanelEl) return;
+ positionParamsPanel();
+ paramsPanelEl.style.pointerEvents = 'auto';
+ // rAF so the positioning paint commits before the transition fires.
+ requestAnimationFrame(() => {
+ setClipPath('inset(0 0 0 0)', true);
+ });
+ }
+
+ function hideParamsPanel() {
+ if (!paramsPanelEl) return;
+ paramsPanelEl.style.pointerEvents = 'none';
+ const direction = paramsPanelEl.dataset.tuneDirection || 'below';
+ setClipPath(closedClipPath(direction), true);
+ }
+
+ // Build/rebuild the panel's contents for the current variant AND apply
+ // its defaults to the variant wrapper (so scoped CSS responds even before
+ // the user opens the popover). Visibility is governed by tuneOpen.
+ function refreshParamsPanel() {
+ if (state !== 'CYCLING') {
+ paramsCurrentValues = {};
+ tuneOpen = false;
+ hideParamsPanel();
+ return;
+ }
+ const variantEl = getVisibleVariantEl();
+ const params = parseVariantParams(variantEl);
+ if (!variantEl || params.length === 0) {
+ paramsCurrentValues = {};
+ tuneOpen = false;
+ hideParamsPanel();
+ return;
+ }
+ applyParamDefaults(variantEl, params);
+ buildParamsPanel(variantEl, params);
+ if (tuneOpen) {
+ // If already visible (variant cycled while open), refresh in place
+ // instead of re-running the clip-path animation.
+ const alreadyVisible = paramsPanelEl.style.display === 'block'
+ && paramsPanelEl.style.opacity === '1';
+ if (alreadyVisible) positionParamsPanel();
+ else showParamsPanel();
+ } else {
+ hideParamsPanel();
+ }
+ }
+
+ function toggleTunePopover() {
+ if (tuneOpen) { closeTunePopover(); return; }
+ openTunePopover();
+ }
+
+ function openTunePopover() {
+ if (state !== 'CYCLING') return;
+ const variantEl = getVisibleVariantEl();
+ const params = parseVariantParams(variantEl);
+ if (!variantEl || params.length === 0) return;
+ // Build fresh to ensure the current variant's controls are shown.
+ applyParamDefaults(variantEl, params);
+ buildParamsPanel(variantEl, params);
+ tuneOpen = true;
+ showParamsPanel();
+ // Kill the bar's shadow on the popover-facing side so the dark popover
+ // doesn't pick up a bright glow line.
+ if (barEl) {
+ const direction = paramsPanelEl?.dataset.tuneDirection || 'below';
+ barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN;
+ }
+ // Re-render the bar so the Tune chip picks up the active styling.
+ updateBarContent('cycling');
+ }
+
+ function closeTunePopover() {
+ tuneOpen = false;
+ hideParamsPanel();
+ if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT;
+ if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') {
+ updateBarContent('cycling');
+ }
+ }
+
// ---------------------------------------------------------------------------
// Variant cycling in DOM
// ---------------------------------------------------------------------------
@@ -1253,6 +1762,10 @@
if (!v) continue;
child.style.display = (v === String(num)) ? '' : 'none';
}
+ // 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();
}
/**
@@ -1316,6 +1829,7 @@
state = 'CYCLING';
hideShaderOverlay();
updateBarContent('cycling');
+ refreshParamsPanel();
saveSession();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
@@ -1329,7 +1843,7 @@
const next = visibleVariant + dir;
if (next < 1 || next > arrivedVariants) return;
visibleVariant = next;
- showVariantInDOM(currentSessionId, next);
+ showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself
updateSelectedElement();
updateBarContent('cycling');
saveSession();
@@ -1540,6 +2054,7 @@
state = 'CYCLING';
hideShaderOverlay();
updateBarContent('cycling');
+ refreshParamsPanel();
} else if (state === 'GENERATING') {
updateBarContent('generating');
}
@@ -1560,6 +2075,7 @@
if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') {
positionBar();
showHighlight(selectedElement);
+ if (tuneOpen) positionParamsPanel();
}
if (annotActive) positionAnnotOverlay(selectedElement);
// Shader overlay (via debug P toggle or generation) is repositioned
@@ -1606,6 +2122,7 @@
if (state === 'GENERATING') {
state = 'CYCLING';
updateBarContent('cycling');
+ refreshParamsPanel();
}
break;
}
@@ -1691,6 +2208,10 @@
if (pickerEl?.style.display !== 'none' && !own(e.target)) {
hideActionPicker();
}
+ // Close Tune popover on outside click (anything outside panel + bar)
+ if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) {
+ closeTunePopover();
+ }
// In CONFIGURING: click outside the bar and selected element returns to PICKING
if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) {
hideBar();
@@ -2258,7 +2779,11 @@ void main() {
function handleAccept() {
if (!currentSessionId || arrivedVariants === 0) return;
- sendEvent({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) });
+ const acceptPayload = { type: 'accept', id: currentSessionId, variantId: String(visibleVariant) };
+ if (Object.keys(paramsCurrentValues).length > 0) {
+ acceptPayload.paramValues = { ...paramsCurrentValues };
+ }
+ sendEvent(acceptPayload);
markSessionHandled();
// The accepted variant is already the only visible child of the wrapper
@@ -2477,6 +3002,11 @@ void main() {
state = arrivedVariants >= expectedVariants ? 'CYCLING' : 'GENERATING';
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
startScrollTracking();
+ // Build the params panel for the restored visible variant. Previously
+ // this was missed on page-reload resume: showVariantInDOM above fires
+ // refreshParamsPanel, but state was still IDLE at that moment so it
+ // hid. Now that state is CYCLING, re-fire.
+ if (state === 'CYCLING') refreshParamsPanel();
saveSession();
// Start observing for more variants AFTER initial setup
@@ -2522,6 +3052,11 @@ void main() {
// dark pages. This keeps the bar from fighting with the host design.
function detectPageTheme() {
try {
+ // Dev override: set localStorage 'impeccable-dev-theme' to 'light' or
+ // 'dark' to preview the opposite palette without actually changing the
+ // page bg. Used for screenshots and theme QA.
+ const override = localStorage.getItem('impeccable-dev-theme');
+ if (override === 'light' || override === 'dark') return override;
const bg = getComputedStyle(document.body).backgroundColor
|| getComputedStyle(document.documentElement).backgroundColor;
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
@@ -2538,6 +3073,7 @@ void main() {
// Light bar on dark page
return {
surface: 'oklch(98% 0 0 / 0.92)',
+ surfaceDeep: 'oklch(92% 0.005 60 / 0.96)', // slightly deeper, faint warm
hairline: 'oklch(70% 0 0 / 0.35)',
text: 'oklch(15% 0 0)',
textDim: 'oklch(45% 0 0)',
@@ -2552,6 +3088,7 @@ void main() {
// deeper so the rounded-right shape reads as a clear sculpted mark.
return {
surface: 'oklch(26% 0 0 / 0.94)',
+ surfaceDeep: 'oklch(18% 0 0 / 0.96)', // darker sand for Tune popover
hairline: 'oklch(42% 0 0 / 0.5)',
text: 'oklch(96% 0 0)',
textDim: 'oklch(72% 0 0)',
@@ -2883,6 +3420,7 @@ void main() {
if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; }
if (barEl) { barEl.remove(); barEl = null; }
if (pickerEl) { pickerEl.remove(); pickerEl = null; }
+ if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; }
if (evtSource) { evtSource.close(); evtSource = null; }
document.removeEventListener('mousemove', handleMouseMove, true);
document.removeEventListener('click', handleClick, true);
@@ -3956,6 +4494,7 @@ void main() {
initAnnotOverlay();
initBar();
initActionPicker();
+ initParamsPanel();
initGlobalBar();
initDesignPanel();
document.addEventListener('mousemove', handleMouseMove, true);
diff --git a/.github/skills/impeccable/scripts/live-poll.mjs b/.github/skills/impeccable/scripts/live-poll.mjs
index 9dd87e3ea..5cece1a43 100644
--- a/.github/skills/impeccable/scripts/live-poll.mjs
+++ b/.github/skills/impeccable/scripts/live-poll.mjs
@@ -145,6 +145,11 @@ Options:
const scriptArgs = event.type === 'discard'
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
+ if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
+ // Pass through a JSON blob; the shell-safe wrap uses single quotes because
+ // values are finite {id, number|string|boolean} pairs from a validated payload.
+ scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
+ }
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
diff --git a/.github/skills/impeccable/scripts/live-server.mjs b/.github/skills/impeccable/scripts/live-server.mjs
index e5fb05bfa..2ab33fd22 100644
--- a/.github/skills/impeccable/scripts/live-server.mjs
+++ b/.github/skills/impeccable/scripts/live-server.mjs
@@ -147,6 +147,11 @@ function validateEvent(msg) {
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
+ if (msg.paramValues !== undefined) {
+ if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
+ return 'accept: paramValues must be an object';
+ }
+ }
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
diff --git a/.kiro/skills/impeccable/reference/colorize.md b/.kiro/skills/impeccable/reference/colorize.md
index de74ac95f..1bd52004f 100644
--- a/.kiro/skills/impeccable/reference/colorize.md
+++ b/.kiro/skills/impeccable/reference/colorize.md
@@ -142,3 +142,13 @@ Test that colorization improves the experience:
- **Not overwhelming**: Is color balanced and purposeful?
Remember: Color is emotional and powerful. Use it to create warmth, guide attention, communicate meaning, and express personality. But restraint and strategy matter more than saturation and variety. Be colorful, but be intentional.
+
+## Live-mode signature params
+
+When invoked from live mode, each variant MUST declare a `color-amount` param so the user can dial between a restrained accent and a drenched surface without regeneration. Author the variant's CSS against `var(--p-color-amount, 0.5)` — typically as the alpha multiplier on backgrounds, or as a scaling factor on the chroma axis in an OKLCH expression. 0 = neutral/monochrome, 1 = full saturation / dominant coverage.
+
+```json
+{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"}
+```
+
+Layer 1-2 variant-specific params on top: palette selection (`steps` with named options), temperature warmth, or tint vs. true color. See `reference/live.md` for the full params contract.
diff --git a/.kiro/skills/impeccable/reference/layout.md b/.kiro/skills/impeccable/reference/layout.md
index 981ace8a1..5e8022081 100644
--- a/.kiro/skills/impeccable/reference/layout.md
+++ b/.kiro/skills/impeccable/reference/layout.md
@@ -119,3 +119,23 @@ Create a systematic plan:
- **Responsiveness**: Does the layout adapt gracefully across screen sizes?
Remember: Space is the most underused design tool. A layout with the right rhythm and hierarchy can make even simple content feel polished and intentional.
+
+## Live-mode signature params
+
+Each variant MUST declare a `density` param. Drive all spacing tokens in the variant's scoped CSS through `calc(var(--p-density, 1) * )` — paddings, gaps, column widths. Users slide from airy to packed and see layout re-breathe with no regeneration.
+
+```json
+{"id":"density","kind":"range","min":0.6,"max":1.4,"step":0.05,"default":1,"label":"Density"}
+```
+
+For variants whose topology genuinely changes (stacked vs. side-by-side, grid vs. bento), use a `steps` param whose scoped CSS branches via `:scope[data-p-structure="X"]`. One structure param + one density param is a powerful combo; resist adding a third.
+
+```json
+{"id":"structure","kind":"steps","default":"grid","label":"Structure","options":[
+ {"value":"stacked","label":"Stacked"},
+ {"value":"grid","label":"Grid"},
+ {"value":"bento","label":"Bento"}
+]}
+```
+
+See `reference/live.md` for the full params contract.
diff --git a/.kiro/skills/impeccable/reference/live.md b/.kiro/skills/impeccable/reference/live.md
index 65c5475b6..346288d00 100644
--- a/.kiro/skills/impeccable/reference/live.md
+++ b/.kiro/skills/impeccable/reference/live.md
@@ -179,7 +179,56 @@ The first variant has no `display: none` (visible by default). All others do. If
One edit, all variants — the browser's MutationObserver picks everything up in one pass.
-### 7. Signal done
+### 7. Parameters (optional, 2-5 per variant)
+
+Each variant can expose coarse knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against.
+
+**When to use.** Any time the variant has a meaningful axis the user might want to dial in: color amount, density, motion intensity, scale ratio. Not micro-level margin tweaks; those defeat the point.
+
+**Budget scales with the element's visual weight, not the user's curiosity.** Knobs need real estate to produce noticeably different output; slapping three on a small element just crowds the panel without making anything feel tunable.
+
+- **Leaf / tiny** — a single button, icon, input, bare heading, solitary paragraph: **0 params.** A slider can't meaningfully reshape one element; just use variants.
+- **Small composition** — labeled input, simple card, short callout (≤ ~5 visual children): **0-1 params.** Only add one if it's a clear dominant axis (e.g. density on a card with visible internal rhythm).
+- **Medium composition** — section component, nav cluster, dense card, short feature block (6-15 visual children): **2 params.**
+- **Large composition** — hero section, full page region, spread layout, anything with strong internal structure (16+ visual children or multiple sub-sections): **3-4 params.**
+
+When in doubt, fewer. The user can always ask for more variants to explore an axis you didn't expose as a knob. Count by visual children, not by DOM-node depth; a deeply-nested-but-visually-simple card still counts as small.
+
+**How to declare.** Put a JSON manifest on the variant wrapper:
+
+```html
+
+ ...variant content...
+
+```
+
+**Three kinds:**
+
+- `range` — smooth slider. Drives a CSS custom property `--p-` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`.
+- `steps` — segmented radio. Drives a data attribute `data-p-` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`.
+- `toggle` — on/off switch. Drives BOTH a CSS var (`--p-: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`.
+
+**Signature params per action.** Each action has one or two signature params that MUST be exposed when the variant can meaningfully express them. Check the action's reference file for the list. Layer 1-2 variant-specific params on top.
+
+**Reset on variant switch.** User dials density on v1, flips to v2, v2 starts at v2's declared defaults. Known limitation; preservation across variants may land later.
+
+**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment:
+
+```html
+
+```
+
+The carbonize cleanup step (see below) reads that comment and bakes the chosen values into the final CSS. For `steps`/`toggle` attribute selectors: keep only the branch matching the chosen value, drop the others, collapse `:scope[data-p-density="packed"] .grid` to a semantic class rule. For `range` vars: either substitute the literal or keep the var with the chosen value as its new default.
+
+### 8. Signal done
```bash
node .kiro/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
@@ -244,11 +293,11 @@ When `_acceptResult.carbonize === true`, the accepted variant was stitched into
Do these five steps in the current thread, synchronously, before the next poll. Do not poll again until the file is clean.
-1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `` and `` and contains a `');
+ if (paramValues && Object.keys(paramValues).length > 0) {
+ // Preserve the user's knob positions for the carbonize-cleanup agent
+ // to bake into the final CSS when it collapses scoped rules.
+ replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
+ }
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
diff --git a/.kiro/skills/impeccable/scripts/live-browser.js b/.kiro/skills/impeccable/scripts/live-browser.js
index 116e1efa6..66efd7243 100644
--- a/.kiro/skills/impeccable/scripts/live-browser.js
+++ b/.kiro/skills/impeccable/scripts/live-browser.js
@@ -30,10 +30,13 @@
// Design tokens
// ---------------------------------------------------------------------------
+ // Brand magenta is pinned to the site token (--color-accent in main.css)
+ // so Accept / knobs / cycle-dots match the site's accent, not a washed
+ // theme-adjusted one.
const C = {
- brand: 'oklch(55% 0.25 350)',
- brandHov: 'oklch(48% 0.25 350)',
- brandSoft: 'oklch(55% 0.25 350 / 0.12)',
+ brand: 'oklch(60% 0.25 350)',
+ brandHov: 'oklch(52% 0.25 350)',
+ brandSoft: 'oklch(60% 0.25 350 / 0.15)',
ink: 'oklch(15% 0.01 350)',
ash: 'oklch(55% 0 0)',
paper: 'oklch(98% 0.005 350 / 0.92)',
@@ -60,6 +63,25 @@
'html', 'head', 'body', 'script', 'style', 'link', 'meta', 'noscript', 'br', 'wbr',
]);
+ // 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.
+ 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: ` `,
+ bolder: ` `,
+ quieter: ` `,
+ distill: ` `,
+ polish: ` `,
+ typeset: ` `,
+ colorize: ` `,
+ layout: ` `,
+ adapt: ` `,
+ animate: ` `,
+ delight: ` `,
+ overdrive: ` `,
+ };
+
const ACTIONS = [
{ value: 'impeccable', label: 'Freeform' },
{ value: 'bolder', label: 'Bolder' },
@@ -756,7 +778,21 @@
// The Bar — one floating element, three modes
// ---------------------------------------------------------------------------
+ // Contextual-bar palette. Cached at init so every build*Row reads a
+ // consistent set of colors; detectPageTheme runs once rather than on every
+ // phase transition.
+ let BP = null;
+
+ // Bar shadow variants. The default projects down + subtle around. When
+ // the Tune popover opens below the bar, a downward shadow lands on the
+ // dark popover and reads as a bright ghost line. We swap to UP-only while
+ // tune is open below so the popover's top edge is clean.
+ const BAR_SHADOW_DEFAULT = '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)';
+ const BAR_SHADOW_UP = '0 -4px 20px oklch(0% 0 0 / 0.08), 0 -1px 3px oklch(0% 0 0 / 0.06)';
+ const BAR_SHADOW_DOWN = BAR_SHADOW_DEFAULT;
+
function initBar() {
+ BP = barPaletteForTheme(detectPageTheme());
barEl = document.createElement('div');
barEl.id = PREFIX + '-bar';
Object.assign(barEl.style, {
@@ -764,15 +800,15 @@
display: 'none', opacity: '0',
transform: 'translateY(6px)',
transition: 'opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
- // Visual
- background: C.paper,
+ background: BP.surface,
backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
- border: '1px solid ' + C.mist,
+ border: '1px solid ' + BP.hairline,
borderRadius: '10px',
- boxShadow: '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)',
- fontFamily: FONT, fontSize: '13px', color: C.ink,
+ boxShadow: BAR_SHADOW_DEFAULT,
+ transition: 'box-shadow 0.2s ease, opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
+ fontFamily: FONT, fontSize: '13px', color: BP.text,
padding: '6px',
- maxWidth: '460px', minWidth: '320px',
+ maxWidth: '520px', minWidth: '320px',
});
document.body.appendChild(barEl);
}
@@ -824,14 +860,15 @@
barEl.style.transform = 'translateY(6px)';
setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250);
hideActionPicker();
+ closeTunePopover();
}
function updateBarContent(mode) {
if (!barEl || barEl.style.display === 'none') return;
barEl.innerHTML = '';
- // Reset bar styling
- barEl.style.background = C.paper;
- barEl.style.border = '1px solid ' + C.mist;
+ // Reset bar styling to the theme-aware palette
+ barEl.style.background = BP.surface;
+ barEl.style.border = '1px solid ' + BP.hairline;
if (mode === 'configure') barEl.appendChild(buildConfigureRow());
else if (mode === 'generating') barEl.appendChild(buildGeneratingRow());
else if (mode === 'cycling') barEl.appendChild(buildCyclingRow());
@@ -854,15 +891,15 @@
const pill = el('button', {
display: 'inline-flex', alignItems: 'center', gap: '4px',
padding: '5px 10px', borderRadius: '6px',
- background: C.ink, color: C.white,
+ background: BP.mark, color: BP.markText,
fontFamily: FONT, fontSize: '12px', fontWeight: '500',
border: 'none', cursor: 'pointer',
transition: 'background 0.12s ease, transform 0.1s ease',
whiteSpace: 'nowrap', flexShrink: '0',
});
pill.textContent = actionLabel() + ' \u25BE';
- pill.addEventListener('mouseenter', () => pill.style.background = C.brandHov);
- pill.addEventListener('mouseleave', () => pill.style.background = C.ink);
+ pill.addEventListener('mouseenter', () => pill.style.background = BP.accent);
+ pill.addEventListener('mouseleave', () => pill.style.background = BP.mark);
pill.addEventListener('mousedown', () => pill.style.transform = 'scale(0.97)');
pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)');
pill.addEventListener('click', (e) => { e.stopPropagation(); toggleActionPicker(); });
@@ -877,13 +914,13 @@
flex: '1', minWidth: '0',
padding: '5px 8px', borderRadius: '6px',
border: '1px solid transparent', background: 'transparent',
- fontFamily: FONT, fontSize: '12px', color: C.ink,
+ fontFamily: FONT, fontSize: '12px', color: BP.text,
outline: 'none',
transition: 'border-color 0.15s ease, background 0.15s ease',
});
input.addEventListener('focus', () => {
- input.style.borderColor = C.mist;
- input.style.background = C.white;
+ input.style.borderColor = BP.hairline;
+ input.style.background = BP.accentSoft;
});
input.addEventListener('blur', () => {
input.style.borderColor = 'transparent';
@@ -901,16 +938,16 @@
// Variant count toggle
const count = el('button', {
padding: '4px 6px', borderRadius: '5px',
- border: '1px solid ' + C.mist, background: 'transparent',
+ border: '1px solid ' + BP.hairline, background: 'transparent',
fontFamily: MONO, fontSize: '11px', fontWeight: '600',
- color: C.ash, cursor: 'pointer',
+ color: BP.textDim, cursor: 'pointer',
transition: 'color 0.12s ease, border-color 0.12s ease',
flexShrink: '0', whiteSpace: 'nowrap',
});
count.textContent = '\u00D7' + selectedCount;
count.title = 'Variants: click to change';
- count.addEventListener('mouseenter', () => { count.style.color = C.ink; count.style.borderColor = C.ink; });
- count.addEventListener('mouseleave', () => { count.style.color = C.ash; count.style.borderColor = C.mist; });
+ count.addEventListener('mouseenter', () => { count.style.color = BP.text; count.style.borderColor = BP.text; });
+ count.addEventListener('mouseleave', () => { count.style.color = BP.textDim; count.style.borderColor = BP.hairline; });
count.addEventListener('click', (e) => {
e.stopPropagation();
selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1;
@@ -921,15 +958,15 @@
// Go button
const go = el('button', {
padding: '5px 12px', borderRadius: '6px',
- border: 'none', background: C.brand, color: C.white,
+ border: 'none', background: BP.accent, color: BP.mark,
fontFamily: FONT, fontSize: '12px', fontWeight: '600',
cursor: 'pointer',
- transition: 'background 0.12s ease, transform 0.1s ease',
+ transition: 'filter 0.12s ease, transform 0.1s ease',
flexShrink: '0', whiteSpace: 'nowrap',
});
go.textContent = 'Go \u2192';
- go.addEventListener('mouseenter', () => go.style.background = C.brandHov);
- go.addEventListener('mouseleave', () => go.style.background = C.brand);
+ go.addEventListener('mouseenter', () => go.style.filter = 'brightness(1.1)');
+ go.addEventListener('mouseleave', () => go.style.filter = 'none');
go.addEventListener('mousedown', () => go.style.transform = 'scale(0.97)');
go.addEventListener('mouseup', () => go.style.transform = 'scale(1)');
go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); });
@@ -950,7 +987,7 @@
// Action label
const label = el('span', {
- fontWeight: '600', fontSize: '12px', color: C.ink,
+ fontWeight: '600', fontSize: '12px', color: BP.text,
flexShrink: '0', whiteSpace: 'nowrap',
});
label.textContent = actionLabel();
@@ -961,7 +998,7 @@
// Status
const status = el('span', {
- fontSize: '11px', color: C.ash, whiteSpace: 'nowrap',
+ fontSize: '11px', color: BP.textDim, whiteSpace: 'nowrap',
marginLeft: 'auto',
});
// Variants currently arrive atomically in a single file edit, so a
@@ -976,6 +1013,8 @@
// --- Cycling row ---
+ const TUNE_ICON_SVG = ' ';
+
function buildCyclingRow() {
const row = el('div', {
display: 'flex', alignItems: 'center', gap: '6px',
@@ -994,7 +1033,7 @@
// Counter
const counter = el('span', {
fontFamily: MONO, fontSize: '11px', fontWeight: '500',
- color: C.ash, minWidth: '24px', textAlign: 'center',
+ color: BP.textDim, minWidth: '24px', textAlign: 'center',
});
counter.textContent = visibleVariant + '/' + arrivedVariants;
row.appendChild(counter);
@@ -1005,20 +1044,67 @@
if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
row.appendChild(next);
+ // Tune chip — only when the visible variant exposes params
+ const visParams = parseVariantParams(getVisibleVariantEl());
+ const hasParams = visParams.length > 0;
+ if (hasParams) {
+ const tune = el('button', {
+ display: 'inline-flex', alignItems: 'center', gap: '6px',
+ padding: '4px 10px', borderRadius: '5px',
+ border: '1px solid transparent',
+ background: tuneOpen ? BP.accentSoft : 'transparent',
+ color: tuneOpen ? BP.accent : BP.text,
+ fontFamily: FONT, fontSize: '11px', fontWeight: '500',
+ cursor: 'pointer',
+ transition: 'color 0.12s ease, background 0.12s ease',
+ whiteSpace: 'nowrap',
+ });
+ tune.innerHTML = TUNE_ICON_SVG;
+ const tuneLabel = document.createElement('span');
+ tuneLabel.textContent = 'Tune';
+ tune.appendChild(tuneLabel);
+ const tuneBadge = document.createElement('span');
+ Object.assign(tuneBadge.style, {
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
+ minWidth: '16px', height: '16px', padding: '0 4px',
+ borderRadius: '999px',
+ background: tuneOpen ? C.brand : BP.hairline,
+ color: tuneOpen ? 'oklch(98% 0 0)' : 'inherit',
+ fontFamily: MONO, fontSize: '9.5px', fontWeight: '600',
+ lineHeight: '1',
+ boxSizing: 'border-box',
+ });
+ tuneBadge.textContent = String(visParams.length);
+ tune.appendChild(tuneBadge);
+ tune.title = 'Tune this variant (' + visParams.length + ' knob' + (visParams.length === 1 ? '' : 's') + ')';
+ tune.addEventListener('mouseenter', () => {
+ if (!tuneOpen) tune.style.background = BP.accentSoft;
+ });
+ tune.addEventListener('mouseleave', () => {
+ if (!tuneOpen) tune.style.background = 'transparent';
+ });
+ tune.addEventListener('click', (e) => { e.stopPropagation(); toggleTunePopover(); });
+ tune.dataset.iceqTune = '1';
+ row.appendChild(tune);
+ }
+
// Spacer
row.appendChild(el('div', { flex: '1' }));
- // Accept
+ // Accept — primary action, uses the site's saturated brand magenta
+ // with paper-white text, not the theme-muted BP.accent.
const accept = el('button', {
- padding: '4px 10px', borderRadius: '5px',
- border: 'none', background: C.ink, color: C.white,
+ padding: '5px 14px', borderRadius: '5px',
+ border: 'none', background: C.brand, color: 'oklch(98% 0 0)',
fontFamily: FONT, fontSize: '11px', fontWeight: '600',
- cursor: 'pointer', transition: 'background 0.12s ease',
+ cursor: 'pointer', transition: 'filter 0.12s ease, transform 0.1s ease',
whiteSpace: 'nowrap',
});
accept.textContent = '\u2713 Accept';
- accept.addEventListener('mouseenter', () => accept.style.background = C.brand);
- accept.addEventListener('mouseleave', () => accept.style.background = C.ink);
+ accept.addEventListener('mouseenter', () => accept.style.filter = 'brightness(1.08)');
+ accept.addEventListener('mouseleave', () => accept.style.filter = 'none');
+ accept.addEventListener('mousedown', () => accept.style.transform = 'scale(0.97)');
+ accept.addEventListener('mouseup', () => accept.style.transform = 'scale(1)');
accept.addEventListener('click', (e) => { e.stopPropagation(); handleAccept(); });
if (arrivedVariants === 0) { accept.style.opacity = '0.3'; accept.style.pointerEvents = 'none'; }
row.appendChild(accept);
@@ -1026,14 +1112,14 @@
// Discard
const discard = el('button', {
padding: '4px 6px', borderRadius: '5px',
- border: '1px solid ' + C.mist, background: 'transparent',
- fontFamily: FONT, fontSize: '11px', color: C.ash,
+ border: '1px solid ' + BP.hairline, background: 'transparent',
+ fontFamily: FONT, fontSize: '11px', color: BP.textDim,
cursor: 'pointer', transition: 'color 0.12s ease, border-color 0.12s ease',
});
discard.textContent = '\u2715';
discard.title = 'Discard all variants';
- discard.addEventListener('mouseenter', () => { discard.style.color = C.ink; discard.style.borderColor = C.ink; });
- discard.addEventListener('mouseleave', () => { discard.style.color = C.ash; discard.style.borderColor = C.mist; });
+ discard.addEventListener('mouseenter', () => { discard.style.color = BP.text; discard.style.borderColor = BP.text; });
+ discard.addEventListener('mouseleave', () => { discard.style.color = BP.textDim; discard.style.borderColor = BP.hairline; });
discard.addEventListener('click', (e) => { e.stopPropagation(); handleDiscard(); });
row.appendChild(discard);
@@ -1051,14 +1137,14 @@
});
const spinner = el('div', {
width: '14px', height: '14px', borderRadius: '50%',
- border: '2px solid ' + C.mist,
- borderTopColor: C.brand,
+ border: '2px solid ' + BP.hairline,
+ borderTopColor: BP.accent,
animation: 'impeccable-spin 0.6s linear infinite',
flexShrink: '0',
});
row.appendChild(spinner);
const label = el('span', {
- fontSize: '12px', color: C.ash, fontWeight: '500',
+ fontSize: '12px', color: BP.textDim, fontWeight: '500',
});
label.textContent = 'Applying variant...';
row.appendChild(label);
@@ -1103,14 +1189,26 @@
for (let i = 1; i <= expectedVariants; i++) {
const arrived = i <= arrivedVariants;
const active = i === visibleVariant;
+ // active: solid site-brand magenta 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
+ // magenta chips, especially when all variants had arrived and every
+ // dot wore an accent ring.
+ const dotBg = active ? C.brand
+ : arrived ? BP.textDim
+ : 'transparent';
+ const dotBorder = arrived ? 'none' : '1.5px solid ' + BP.hairline;
const dot = el('div', {
- width: '7px', height: '7px', borderRadius: '50%',
- background: active ? C.brand : (arrived ? C.ash : 'transparent'),
- border: '1.5px solid ' + (arrived ? C.brand : C.mist),
+ width: active ? '8px' : '6px',
+ height: active ? '8px' : '6px',
+ borderRadius: '50%',
+ background: dotBg,
+ border: dotBorder,
+ boxSizing: 'border-box',
transition: 'all 0.2s ' + EASE,
cursor: (clickable && arrived) ? 'pointer' : 'default',
- transform: arrived ? 'scale(1)' : 'scale(0.6)',
- opacity: arrived ? '1' : '0.4',
+ transform: arrived ? 'scale(1)' : 'scale(0.85)',
+ opacity: arrived ? (active ? '1' : '0.6') : '0.4',
});
if (clickable && arrived) {
const idx = i;
@@ -1130,15 +1228,15 @@
function navBtn(text) {
const b = el('button', {
width: '26px', height: '26px', borderRadius: '5px',
- border: '1px solid ' + C.mist, background: 'transparent',
- color: C.ink, fontFamily: FONT, fontSize: '13px',
+ border: '1px solid ' + BP.hairline, background: 'transparent',
+ color: BP.text, fontFamily: FONT, fontSize: '13px',
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'border-color 0.12s ease, background 0.12s ease',
padding: '0', lineHeight: '1',
});
b.textContent = text;
- b.addEventListener('mouseenter', () => { b.style.borderColor = C.ink; });
- b.addEventListener('mouseleave', () => { b.style.borderColor = C.mist; });
+ b.addEventListener('mouseenter', () => { b.style.borderColor = BP.text; });
+ b.addEventListener('mouseleave', () => { b.style.borderColor = BP.hairline; });
return b;
}
@@ -1158,6 +1256,7 @@
// ---------------------------------------------------------------------------
function initActionPicker() {
+ const P = barPaletteForTheme(detectPageTheme());
pickerEl = document.createElement('div');
pickerEl.id = PREFIX + '-picker';
Object.assign(pickerEl.style, {
@@ -1166,12 +1265,14 @@
transform: 'scale(0.96) translateY(4px)',
transformOrigin: 'bottom left',
transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE,
- background: C.paperSolid,
- border: '1px solid ' + C.mist,
+ background: P.surface,
+ border: '1px solid ' + P.hairline,
borderRadius: '10px',
boxShadow: '0 8px 30px oklch(0% 0 0 / 0.10), 0 2px 6px oklch(0% 0 0 / 0.06)',
padding: '6px',
fontFamily: FONT,
+ backdropFilter: 'blur(10px)',
+ WebkitBackdropFilter: 'blur(10px)',
});
// Build the chip grid
@@ -1181,22 +1282,32 @@
ACTIONS.forEach(action => {
const chip = el('button', {
- padding: '6px 8px', borderRadius: '6px',
+ display: 'flex', flexDirection: 'column', alignItems: 'center',
+ gap: '4px',
+ padding: '8px 6px', borderRadius: '6px',
border: 'none',
- background: action.value === selectedAction ? C.brandSoft : 'transparent',
- color: action.value === selectedAction ? C.brand : C.ink,
+ background: action.value === selectedAction ? P.accentSoft : 'transparent',
+ color: action.value === selectedAction ? P.accent : P.text,
fontFamily: FONT, fontSize: '11px', fontWeight: '500',
cursor: 'pointer',
transition: 'background 0.1s ease, color 0.1s ease',
textAlign: 'center', whiteSpace: 'nowrap',
});
- chip.textContent = action.label;
+ const iconWrap = el('span', {
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ height: '20px', opacity: '0.9',
+ });
+ iconWrap.innerHTML = ICONS[action.value] || '';
+ const labelEl = el('span', { lineHeight: '1' });
+ labelEl.textContent = action.label;
+ chip.appendChild(iconWrap);
+ chip.appendChild(labelEl);
chip.dataset.action = action.value;
chip.addEventListener('mouseenter', () => {
- if (action.value !== selectedAction) chip.style.background = C.brandSoft;
+ if (action.value !== selectedAction) chip.style.background = P.accentSoft;
});
chip.addEventListener('mouseleave', () => {
- chip.style.background = action.value === selectedAction ? C.brandSoft : 'transparent';
+ chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent';
});
chip.addEventListener('click', (e) => {
e.stopPropagation();
@@ -1209,19 +1320,24 @@
pickerEl.appendChild(grid);
document.body.appendChild(pickerEl);
+
+ // Cache the palette on the picker so toggleActionPicker's state refresh
+ // uses the same theme-aware colors when it repaints chips.
+ pickerEl.__iceq_palette = P;
}
function toggleActionPicker() {
if (pickerEl.style.display !== 'none') { hideActionPicker(); return; }
// Rebuild chips to reflect current selection
+ const P = pickerEl.__iceq_palette || barPaletteForTheme(detectPageTheme());
pickerEl.querySelectorAll('button').forEach(chip => {
const isActive = chip.dataset.action === selectedAction;
- chip.style.background = isActive ? C.brandSoft : 'transparent';
- chip.style.color = isActive ? C.brand : C.ink;
+ chip.style.background = isActive ? P.accentSoft : 'transparent';
+ chip.style.color = isActive ? P.accent : P.text;
});
// Position above the bar
const barRect = barEl.getBoundingClientRect();
- const pickerH = 90; // approximate
+ const pickerH = 170; // approximate; grows with icon + label rows
let top = barRect.top - pickerH - 6;
if (top < 8) top = barRect.bottom + 6;
Object.assign(pickerEl.style, {
@@ -1241,6 +1357,399 @@
setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180);
}
+ // ---------------------------------------------------------------------------
+ // Params panel (per-variant coarse controls)
+ //
+ // Variants may declare a parameter manifest via a JSON attribute on the
+ // variant wrapper:
+ //
+ //
+ //
+ // The panel docks to the right edge of the outline during CYCLING and
+ // exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped
+ // CSS can respond instantly without regeneration:
+ //
+ // range / numeric toggle → CSS var (`--p-
`) used via var(--p-foo, N)
+ // steps / boolean toggle → data-p- attribute used via :scope[data-p-foo="..."]
+ //
+ // On variant switch, values reset to that variant's declared defaults.
+ // On accept, current values are sent in the event payload so the agent
+ // can bake them into the source-file write.
+ // ---------------------------------------------------------------------------
+
+ 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 tuneOpen = false; // whether the Tune popover is open right now
+
+ // Theme-aware Tune popover. Appears as a drawer that slides out from the
+ // contextual bar's bar-facing edge (below if the bar sits below the
+ // element, above otherwise). Same width as the bar. Auto-wraps to extra
+ // rows when the knobs exceed one row. The bar's border-radius on the
+ // popover side goes flat while open so the two shapes read as one.
+ let paramsPanelPalette = null;
+
+ function initParamsPanel() {
+ paramsPanelPalette = barPaletteForTheme(detectPageTheme());
+ 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
+ // display toggle, no opacity toggle, no transform trickery. The mask
+ // hides everything initially; as it grows, content is revealed from
+ // the bar edge outward.
+ paramsPanelEl = document.createElement('div');
+ paramsPanelEl.id = PREFIX + '-params-panel';
+ Object.assign(paramsPanelEl.style, {
+ position: 'fixed', zIndex: String(Z.bar - 1),
+ background: P.surfaceDeep,
+ color: P.text,
+ fontFamily: FONT,
+ padding: '14px 18px',
+ boxSizing: 'border-box',
+ borderRadius: '0 0 10px 10px',
+ pointerEvents: 'none',
+ backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
+
+ // clip-path is the same conceptual reveal as mask but with rock-solid
+ // transition support across engines. Closed state clips from the far
+ // edge; open = inset(0) shows everything.
+ clipPath: 'inset(0 0 100% 0)',
+ 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
+ // top-left when first shown.
+ top: '-9999px', left: '-9999px', width: '0',
+ });
+
+ paramsPanelBody = el('div', {
+ display: 'grid',
+ gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
+ gap: '12px 16px',
+ });
+
+ paramsPanelEl.appendChild(paramsPanelBody);
+ document.body.appendChild(paramsPanelEl);
+ paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
+ }
+
+ function getVisibleVariantEl() {
+ if (!currentSessionId) return null;
+ const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
+ if (!wrapper) return null;
+ return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
+ }
+
+ function parseVariantParams(variantEl) {
+ if (!variantEl) return [];
+ const raw = variantEl.getAttribute('data-impeccable-params');
+ if (!raw) return [];
+ try {
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) ? parsed : [];
+ } catch (err) {
+ console.warn('[impeccable] Invalid data-impeccable-params JSON:', err.message);
+ return [];
+ }
+ }
+
+ function applyParamValue(variantEl, param, value) {
+ if (!variantEl) return;
+ const attr = 'data-p-' + param.id;
+ if (param.kind === 'range') {
+ variantEl.style.setProperty('--p-' + param.id, String(value));
+ } else if (param.kind === 'toggle') {
+ const on = !!value;
+ variantEl.style.setProperty('--p-' + param.id, on ? '1' : '0');
+ if (on) variantEl.setAttribute(attr, 'on');
+ else variantEl.removeAttribute(attr);
+ } else if (param.kind === 'steps') {
+ variantEl.setAttribute(attr, String(value));
+ }
+ }
+
+ function applyParamDefaults(variantEl, params) {
+ paramsCurrentValues = {};
+ for (const p of params) {
+ paramsCurrentValues[p.id] = p.default;
+ applyParamValue(variantEl, p, p.default);
+ }
+ }
+
+ function formatRangeValue(input) {
+ const max = parseFloat(input.max), min = parseFloat(input.min);
+ const v = parseFloat(input.value);
+ if (!isFinite(v)) return input.value;
+ return (max - min) <= 2 ? v.toFixed(2) : String(Math.round(v));
+ }
+
+ function buildParamsPanel(variantEl, params) {
+ const P = paramsPanelPalette || barPaletteForTheme(detectPageTheme());
+ paramsPanelBody.innerHTML = '';
+ for (const p of params) {
+ const row = el('div', { display: 'flex', flexDirection: 'column', gap: '6px' });
+ const labelRow = el('div', {
+ display: 'flex', justifyContent: 'space-between',
+ alignItems: 'baseline', gap: '8px',
+ });
+ const lbl = el('span', {
+ fontSize: '10.5px', fontWeight: '600', color: P.text,
+ letterSpacing: '0.03em',
+ });
+ lbl.textContent = p.label || p.id;
+ labelRow.appendChild(lbl);
+ const readout = el('span', {
+ fontSize: '10.5px', color: P.textDim,
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
+ });
+ labelRow.appendChild(readout);
+ row.appendChild(labelRow);
+
+ if (p.kind === 'range') {
+ const input = document.createElement('input');
+ input.type = 'range';
+ input.min = String(p.min != null ? p.min : 0);
+ input.max = String(p.max != null ? p.max : 1);
+ input.step = String(p.step != null ? p.step : 0.05);
+ input.value = String(p.default);
+ Object.assign(input.style, {
+ width: '100%', accentColor: C.brand, cursor: 'pointer',
+ });
+ readout.textContent = formatRangeValue(input);
+ input.addEventListener('input', (e) => {
+ e.stopPropagation();
+ const v = parseFloat(input.value);
+ paramsCurrentValues[p.id] = v;
+ readout.textContent = formatRangeValue(input);
+ applyParamValue(variantEl, p, v);
+ });
+ row.appendChild(input);
+ } else if (p.kind === 'toggle') {
+ const initial = !!p.default;
+ readout.textContent = initial ? 'On' : 'Off';
+ const track = el('button', {
+ position: 'relative', width: '36px', height: '20px',
+ borderRadius: '10px', border: 'none', padding: '0',
+ cursor: 'pointer',
+ background: initial ? C.brand : P.hairline,
+ transition: 'background 0.15s ease',
+ alignSelf: 'flex-start',
+ });
+ const knob = el('span', {
+ position: 'absolute', top: '2px',
+ left: initial ? '18px' : '2px',
+ width: '16px', height: '16px', borderRadius: '50%',
+ background: 'oklch(98% 0 0)',
+ transition: 'left 0.18s ' + EASE,
+ boxShadow: '0 1px 2px oklch(0% 0 0 / 0.2)',
+ });
+ track.appendChild(knob);
+ track.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const next = !paramsCurrentValues[p.id];
+ paramsCurrentValues[p.id] = next;
+ track.style.background = next ? C.brand : P.hairline;
+ knob.style.left = next ? '18px' : '2px';
+ readout.textContent = next ? 'On' : 'Off';
+ applyParamValue(variantEl, p, next);
+ });
+ row.appendChild(track);
+ } else if (p.kind === 'steps') {
+ const opts = (p.options || []).map(o =>
+ typeof o === 'string' ? { value: o, label: o } : o
+ );
+ const activeOpt = opts.find(o => o.value === p.default) || opts[0];
+ readout.textContent = activeOpt ? activeOpt.label : String(p.default);
+ const segRow = el('div', {
+ display: 'grid',
+ gridTemplateColumns: 'repeat(' + opts.length + ', 1fr)',
+ gap: '1px', padding: '2px',
+ background: P.hairline, borderRadius: '5px',
+ });
+ const segBtns = [];
+ opts.forEach(o => {
+ const active = o.value === p.default;
+ const b = el('button', {
+ padding: '5px 4px', border: 'none', borderRadius: '3px',
+ background: active ? C.brand : 'transparent',
+ color: active ? 'oklch(98% 0 0)' : P.text,
+ fontFamily: FONT, fontSize: '10.5px', fontWeight: '500',
+ cursor: 'pointer', whiteSpace: 'nowrap',
+ transition: 'background 0.1s ease, color 0.1s ease',
+ });
+ b.textContent = o.label;
+ b.addEventListener('click', (e) => {
+ e.stopPropagation();
+ paramsCurrentValues[p.id] = o.value;
+ readout.textContent = o.label;
+ segBtns.forEach(({ btn, val }) => {
+ const on = val === o.value;
+ btn.style.background = on ? C.brand : 'transparent';
+ btn.style.color = on ? 'oklch(98% 0 0)' : P.text;
+ });
+ applyParamValue(variantEl, p, o.value);
+ });
+ segRow.appendChild(b);
+ segBtns.push({ btn: b, val: o.value });
+ });
+ row.appendChild(segRow);
+ }
+
+ paramsPanelBody.appendChild(row);
+ }
+ }
+
+ // Decide which way the popover opens: away from the picked element. If the
+ // bar landed below the element, popover slides DOWN from the bar's bottom.
+ // If the bar landed above, popover slides UP from the bar's top.
+ function popoverDirection() {
+ if (!barEl || !selectedElement) return 'below';
+ const br = barEl.getBoundingClientRect();
+ const er = selectedElement.getBoundingClientRect();
+ return br.top >= er.bottom - 4 ? 'below' : 'above';
+ }
+
+ // The popover overlaps the bar by OVERLAP px on the bar-facing side. With
+ // popover z-index below bar, that overlap sits behind bar (invisible) and
+ // reinforces the "tucked behind" feel. Padding compensates so the real
+ // content starts flush with bar's outer edge.
+ const TUNE_OVERLAP = 6;
+
+ // Closed clip-path depends on direction: for 'below' clip from the far
+ // (bottom) edge so the reveal grows downward from the bar; for 'above'
+ // clip from the top edge so the reveal grows upward from the bar.
+ function closedClipPath(direction) {
+ return direction === 'below' ? 'inset(0 0 100% 0)' : 'inset(100% 0 0 0)';
+ }
+
+ function setClipPath(value, withTransition) {
+ const saved = paramsPanelEl.style.transition;
+ if (!withTransition) paramsPanelEl.style.transition = 'none';
+ paramsPanelEl.style.clipPath = value;
+ if (!withTransition) {
+ void paramsPanelEl.offsetHeight;
+ paramsPanelEl.style.transition = saved;
+ }
+ }
+
+ function positionParamsPanel() {
+ if (!paramsPanelEl || !barEl || barEl.style.display === 'none') return;
+ const br = barEl.getBoundingClientRect();
+ const direction = popoverDirection();
+ const prevDirection = paramsPanelEl.dataset.tuneDirection;
+
+ // top/left/width are NOT in the transition list, so they snap instantly.
+ paramsPanelEl.style.left = br.left + 'px';
+ paramsPanelEl.style.width = br.width + 'px';
+
+ if (direction === 'below') {
+ paramsPanelEl.style.top = (br.bottom - TUNE_OVERLAP) + 'px';
+ paramsPanelEl.style.borderRadius = '0 0 10px 10px';
+ paramsPanelEl.style.paddingTop = (14 + TUNE_OVERLAP) + 'px';
+ paramsPanelEl.style.paddingBottom = '14px';
+ } else {
+ const ih = paramsPanelEl.offsetHeight || 80;
+ paramsPanelEl.style.top = (br.top - ih + TUNE_OVERLAP) + 'px';
+ paramsPanelEl.style.borderRadius = '10px 10px 0 0';
+ paramsPanelEl.style.paddingTop = '14px';
+ paramsPanelEl.style.paddingBottom = (14 + TUNE_OVERLAP) + 'px';
+ }
+ paramsPanelEl.dataset.tuneDirection = direction;
+
+ // If currently closed and direction flipped (or first-time setup),
+ // snap the clip-path to the new direction's closed pose without
+ // transitioning (so the clip doesn't slide across the element).
+ if (!tuneOpen && (!prevDirection || prevDirection !== direction)) {
+ setClipPath(closedClipPath(direction), false);
+ }
+ }
+
+ function showParamsPanel() {
+ if (!paramsPanelEl) return;
+ positionParamsPanel();
+ paramsPanelEl.style.pointerEvents = 'auto';
+ // rAF so the positioning paint commits before the transition fires.
+ requestAnimationFrame(() => {
+ setClipPath('inset(0 0 0 0)', true);
+ });
+ }
+
+ function hideParamsPanel() {
+ if (!paramsPanelEl) return;
+ paramsPanelEl.style.pointerEvents = 'none';
+ const direction = paramsPanelEl.dataset.tuneDirection || 'below';
+ setClipPath(closedClipPath(direction), true);
+ }
+
+ // Build/rebuild the panel's contents for the current variant AND apply
+ // its defaults to the variant wrapper (so scoped CSS responds even before
+ // the user opens the popover). Visibility is governed by tuneOpen.
+ function refreshParamsPanel() {
+ if (state !== 'CYCLING') {
+ paramsCurrentValues = {};
+ tuneOpen = false;
+ hideParamsPanel();
+ return;
+ }
+ const variantEl = getVisibleVariantEl();
+ const params = parseVariantParams(variantEl);
+ if (!variantEl || params.length === 0) {
+ paramsCurrentValues = {};
+ tuneOpen = false;
+ hideParamsPanel();
+ return;
+ }
+ applyParamDefaults(variantEl, params);
+ buildParamsPanel(variantEl, params);
+ if (tuneOpen) {
+ // If already visible (variant cycled while open), refresh in place
+ // instead of re-running the clip-path animation.
+ const alreadyVisible = paramsPanelEl.style.display === 'block'
+ && paramsPanelEl.style.opacity === '1';
+ if (alreadyVisible) positionParamsPanel();
+ else showParamsPanel();
+ } else {
+ hideParamsPanel();
+ }
+ }
+
+ function toggleTunePopover() {
+ if (tuneOpen) { closeTunePopover(); return; }
+ openTunePopover();
+ }
+
+ function openTunePopover() {
+ if (state !== 'CYCLING') return;
+ const variantEl = getVisibleVariantEl();
+ const params = parseVariantParams(variantEl);
+ if (!variantEl || params.length === 0) return;
+ // Build fresh to ensure the current variant's controls are shown.
+ applyParamDefaults(variantEl, params);
+ buildParamsPanel(variantEl, params);
+ tuneOpen = true;
+ showParamsPanel();
+ // Kill the bar's shadow on the popover-facing side so the dark popover
+ // doesn't pick up a bright glow line.
+ if (barEl) {
+ const direction = paramsPanelEl?.dataset.tuneDirection || 'below';
+ barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN;
+ }
+ // Re-render the bar so the Tune chip picks up the active styling.
+ updateBarContent('cycling');
+ }
+
+ function closeTunePopover() {
+ tuneOpen = false;
+ hideParamsPanel();
+ if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT;
+ if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') {
+ updateBarContent('cycling');
+ }
+ }
+
// ---------------------------------------------------------------------------
// Variant cycling in DOM
// ---------------------------------------------------------------------------
@@ -1253,6 +1762,10 @@
if (!v) continue;
child.style.display = (v === String(num)) ? '' : 'none';
}
+ // 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();
}
/**
@@ -1316,6 +1829,7 @@
state = 'CYCLING';
hideShaderOverlay();
updateBarContent('cycling');
+ refreshParamsPanel();
saveSession();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
@@ -1329,7 +1843,7 @@
const next = visibleVariant + dir;
if (next < 1 || next > arrivedVariants) return;
visibleVariant = next;
- showVariantInDOM(currentSessionId, next);
+ showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself
updateSelectedElement();
updateBarContent('cycling');
saveSession();
@@ -1540,6 +2054,7 @@
state = 'CYCLING';
hideShaderOverlay();
updateBarContent('cycling');
+ refreshParamsPanel();
} else if (state === 'GENERATING') {
updateBarContent('generating');
}
@@ -1560,6 +2075,7 @@
if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') {
positionBar();
showHighlight(selectedElement);
+ if (tuneOpen) positionParamsPanel();
}
if (annotActive) positionAnnotOverlay(selectedElement);
// Shader overlay (via debug P toggle or generation) is repositioned
@@ -1606,6 +2122,7 @@
if (state === 'GENERATING') {
state = 'CYCLING';
updateBarContent('cycling');
+ refreshParamsPanel();
}
break;
}
@@ -1691,6 +2208,10 @@
if (pickerEl?.style.display !== 'none' && !own(e.target)) {
hideActionPicker();
}
+ // Close Tune popover on outside click (anything outside panel + bar)
+ if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) {
+ closeTunePopover();
+ }
// In CONFIGURING: click outside the bar and selected element returns to PICKING
if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) {
hideBar();
@@ -2258,7 +2779,11 @@ void main() {
function handleAccept() {
if (!currentSessionId || arrivedVariants === 0) return;
- sendEvent({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) });
+ const acceptPayload = { type: 'accept', id: currentSessionId, variantId: String(visibleVariant) };
+ if (Object.keys(paramsCurrentValues).length > 0) {
+ acceptPayload.paramValues = { ...paramsCurrentValues };
+ }
+ sendEvent(acceptPayload);
markSessionHandled();
// The accepted variant is already the only visible child of the wrapper
@@ -2477,6 +3002,11 @@ void main() {
state = arrivedVariants >= expectedVariants ? 'CYCLING' : 'GENERATING';
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
startScrollTracking();
+ // Build the params panel for the restored visible variant. Previously
+ // this was missed on page-reload resume: showVariantInDOM above fires
+ // refreshParamsPanel, but state was still IDLE at that moment so it
+ // hid. Now that state is CYCLING, re-fire.
+ if (state === 'CYCLING') refreshParamsPanel();
saveSession();
// Start observing for more variants AFTER initial setup
@@ -2522,6 +3052,11 @@ void main() {
// dark pages. This keeps the bar from fighting with the host design.
function detectPageTheme() {
try {
+ // Dev override: set localStorage 'impeccable-dev-theme' to 'light' or
+ // 'dark' to preview the opposite palette without actually changing the
+ // page bg. Used for screenshots and theme QA.
+ const override = localStorage.getItem('impeccable-dev-theme');
+ if (override === 'light' || override === 'dark') return override;
const bg = getComputedStyle(document.body).backgroundColor
|| getComputedStyle(document.documentElement).backgroundColor;
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
@@ -2538,6 +3073,7 @@ void main() {
// Light bar on dark page
return {
surface: 'oklch(98% 0 0 / 0.92)',
+ surfaceDeep: 'oklch(92% 0.005 60 / 0.96)', // slightly deeper, faint warm
hairline: 'oklch(70% 0 0 / 0.35)',
text: 'oklch(15% 0 0)',
textDim: 'oklch(45% 0 0)',
@@ -2552,6 +3088,7 @@ void main() {
// deeper so the rounded-right shape reads as a clear sculpted mark.
return {
surface: 'oklch(26% 0 0 / 0.94)',
+ surfaceDeep: 'oklch(18% 0 0 / 0.96)', // darker sand for Tune popover
hairline: 'oklch(42% 0 0 / 0.5)',
text: 'oklch(96% 0 0)',
textDim: 'oklch(72% 0 0)',
@@ -2883,6 +3420,7 @@ void main() {
if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; }
if (barEl) { barEl.remove(); barEl = null; }
if (pickerEl) { pickerEl.remove(); pickerEl = null; }
+ if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; }
if (evtSource) { evtSource.close(); evtSource = null; }
document.removeEventListener('mousemove', handleMouseMove, true);
document.removeEventListener('click', handleClick, true);
@@ -3956,6 +4494,7 @@ void main() {
initAnnotOverlay();
initBar();
initActionPicker();
+ initParamsPanel();
initGlobalBar();
initDesignPanel();
document.addEventListener('mousemove', handleMouseMove, true);
diff --git a/.kiro/skills/impeccable/scripts/live-poll.mjs b/.kiro/skills/impeccable/scripts/live-poll.mjs
index 9dd87e3ea..5cece1a43 100644
--- a/.kiro/skills/impeccable/scripts/live-poll.mjs
+++ b/.kiro/skills/impeccable/scripts/live-poll.mjs
@@ -145,6 +145,11 @@ Options:
const scriptArgs = event.type === 'discard'
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
+ if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
+ // Pass through a JSON blob; the shell-safe wrap uses single quotes because
+ // values are finite {id, number|string|boolean} pairs from a validated payload.
+ scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
+ }
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
diff --git a/.kiro/skills/impeccable/scripts/live-server.mjs b/.kiro/skills/impeccable/scripts/live-server.mjs
index e5fb05bfa..2ab33fd22 100644
--- a/.kiro/skills/impeccable/scripts/live-server.mjs
+++ b/.kiro/skills/impeccable/scripts/live-server.mjs
@@ -147,6 +147,11 @@ function validateEvent(msg) {
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
+ if (msg.paramValues !== undefined) {
+ if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
+ return 'accept: paramValues must be an object';
+ }
+ }
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
diff --git a/.opencode/skills/impeccable/reference/colorize.md b/.opencode/skills/impeccable/reference/colorize.md
index 2829f3812..d0428a8b9 100644
--- a/.opencode/skills/impeccable/reference/colorize.md
+++ b/.opencode/skills/impeccable/reference/colorize.md
@@ -142,3 +142,13 @@ Test that colorization improves the experience:
- **Not overwhelming**: Is color balanced and purposeful?
Remember: Color is emotional and powerful. Use it to create warmth, guide attention, communicate meaning, and express personality. But restraint and strategy matter more than saturation and variety. Be colorful, but be intentional.
+
+## Live-mode signature params
+
+When invoked from live mode, each variant MUST declare a `color-amount` param so the user can dial between a restrained accent and a drenched surface without regeneration. Author the variant's CSS against `var(--p-color-amount, 0.5)` — typically as the alpha multiplier on backgrounds, or as a scaling factor on the chroma axis in an OKLCH expression. 0 = neutral/monochrome, 1 = full saturation / dominant coverage.
+
+```json
+{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"}
+```
+
+Layer 1-2 variant-specific params on top: palette selection (`steps` with named options), temperature warmth, or tint vs. true color. See `reference/live.md` for the full params contract.
diff --git a/.opencode/skills/impeccable/reference/layout.md b/.opencode/skills/impeccable/reference/layout.md
index 981ace8a1..5e8022081 100644
--- a/.opencode/skills/impeccable/reference/layout.md
+++ b/.opencode/skills/impeccable/reference/layout.md
@@ -119,3 +119,23 @@ Create a systematic plan:
- **Responsiveness**: Does the layout adapt gracefully across screen sizes?
Remember: Space is the most underused design tool. A layout with the right rhythm and hierarchy can make even simple content feel polished and intentional.
+
+## Live-mode signature params
+
+Each variant MUST declare a `density` param. Drive all spacing tokens in the variant's scoped CSS through `calc(var(--p-density, 1) * )` — paddings, gaps, column widths. Users slide from airy to packed and see layout re-breathe with no regeneration.
+
+```json
+{"id":"density","kind":"range","min":0.6,"max":1.4,"step":0.05,"default":1,"label":"Density"}
+```
+
+For variants whose topology genuinely changes (stacked vs. side-by-side, grid vs. bento), use a `steps` param whose scoped CSS branches via `:scope[data-p-structure="X"]`. One structure param + one density param is a powerful combo; resist adding a third.
+
+```json
+{"id":"structure","kind":"steps","default":"grid","label":"Structure","options":[
+ {"value":"stacked","label":"Stacked"},
+ {"value":"grid","label":"Grid"},
+ {"value":"bento","label":"Bento"}
+]}
+```
+
+See `reference/live.md` for the full params contract.
diff --git a/.opencode/skills/impeccable/reference/live.md b/.opencode/skills/impeccable/reference/live.md
index 7fdcb0bb7..f1de2bebc 100644
--- a/.opencode/skills/impeccable/reference/live.md
+++ b/.opencode/skills/impeccable/reference/live.md
@@ -179,7 +179,56 @@ The first variant has no `display: none` (visible by default). All others do. If
One edit, all variants — the browser's MutationObserver picks everything up in one pass.
-### 7. Signal done
+### 7. Parameters (optional, 2-5 per variant)
+
+Each variant can expose coarse knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against.
+
+**When to use.** Any time the variant has a meaningful axis the user might want to dial in: color amount, density, motion intensity, scale ratio. Not micro-level margin tweaks; those defeat the point.
+
+**Budget scales with the element's visual weight, not the user's curiosity.** Knobs need real estate to produce noticeably different output; slapping three on a small element just crowds the panel without making anything feel tunable.
+
+- **Leaf / tiny** — a single button, icon, input, bare heading, solitary paragraph: **0 params.** A slider can't meaningfully reshape one element; just use variants.
+- **Small composition** — labeled input, simple card, short callout (≤ ~5 visual children): **0-1 params.** Only add one if it's a clear dominant axis (e.g. density on a card with visible internal rhythm).
+- **Medium composition** — section component, nav cluster, dense card, short feature block (6-15 visual children): **2 params.**
+- **Large composition** — hero section, full page region, spread layout, anything with strong internal structure (16+ visual children or multiple sub-sections): **3-4 params.**
+
+When in doubt, fewer. The user can always ask for more variants to explore an axis you didn't expose as a knob. Count by visual children, not by DOM-node depth; a deeply-nested-but-visually-simple card still counts as small.
+
+**How to declare.** Put a JSON manifest on the variant wrapper:
+
+```html
+
+ ...variant content...
+
+```
+
+**Three kinds:**
+
+- `range` — smooth slider. Drives a CSS custom property `--p-` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`.
+- `steps` — segmented radio. Drives a data attribute `data-p-` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`.
+- `toggle` — on/off switch. Drives BOTH a CSS var (`--p-: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`.
+
+**Signature params per action.** Each action has one or two signature params that MUST be exposed when the variant can meaningfully express them. Check the action's reference file for the list. Layer 1-2 variant-specific params on top.
+
+**Reset on variant switch.** User dials density on v1, flips to v2, v2 starts at v2's declared defaults. Known limitation; preservation across variants may land later.
+
+**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment:
+
+```html
+
+```
+
+The carbonize cleanup step (see below) reads that comment and bakes the chosen values into the final CSS. For `steps`/`toggle` attribute selectors: keep only the branch matching the chosen value, drop the others, collapse `:scope[data-p-density="packed"] .grid` to a semantic class rule. For `range` vars: either substitute the literal or keep the var with the chosen value as its new default.
+
+### 8. Signal done
```bash
node .opencode/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
@@ -244,11 +293,11 @@ When `_acceptResult.carbonize === true`, the accepted variant was stitched into
Do these five steps in the current thread, synchronously, before the next poll. Do not poll again until the file is clean.
-1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `` and `` and contains a `');
+ if (paramValues && Object.keys(paramValues).length > 0) {
+ // Preserve the user's knob positions for the carbonize-cleanup agent
+ // to bake into the final CSS when it collapses scoped rules.
+ replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
+ }
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
diff --git a/.opencode/skills/impeccable/scripts/live-browser.js b/.opencode/skills/impeccable/scripts/live-browser.js
index 116e1efa6..66efd7243 100644
--- a/.opencode/skills/impeccable/scripts/live-browser.js
+++ b/.opencode/skills/impeccable/scripts/live-browser.js
@@ -30,10 +30,13 @@
// Design tokens
// ---------------------------------------------------------------------------
+ // Brand magenta is pinned to the site token (--color-accent in main.css)
+ // so Accept / knobs / cycle-dots match the site's accent, not a washed
+ // theme-adjusted one.
const C = {
- brand: 'oklch(55% 0.25 350)',
- brandHov: 'oklch(48% 0.25 350)',
- brandSoft: 'oklch(55% 0.25 350 / 0.12)',
+ brand: 'oklch(60% 0.25 350)',
+ brandHov: 'oklch(52% 0.25 350)',
+ brandSoft: 'oklch(60% 0.25 350 / 0.15)',
ink: 'oklch(15% 0.01 350)',
ash: 'oklch(55% 0 0)',
paper: 'oklch(98% 0.005 350 / 0.92)',
@@ -60,6 +63,25 @@
'html', 'head', 'body', 'script', 'style', 'link', 'meta', 'noscript', 'br', 'wbr',
]);
+ // 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.
+ 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: ` `,
+ bolder: ` `,
+ quieter: ` `,
+ distill: ` `,
+ polish: ` `,
+ typeset: ` `,
+ colorize: ` `,
+ layout: ` `,
+ adapt: ` `,
+ animate: ` `,
+ delight: ` `,
+ overdrive: ` `,
+ };
+
const ACTIONS = [
{ value: 'impeccable', label: 'Freeform' },
{ value: 'bolder', label: 'Bolder' },
@@ -756,7 +778,21 @@
// The Bar — one floating element, three modes
// ---------------------------------------------------------------------------
+ // Contextual-bar palette. Cached at init so every build*Row reads a
+ // consistent set of colors; detectPageTheme runs once rather than on every
+ // phase transition.
+ let BP = null;
+
+ // Bar shadow variants. The default projects down + subtle around. When
+ // the Tune popover opens below the bar, a downward shadow lands on the
+ // dark popover and reads as a bright ghost line. We swap to UP-only while
+ // tune is open below so the popover's top edge is clean.
+ const BAR_SHADOW_DEFAULT = '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)';
+ const BAR_SHADOW_UP = '0 -4px 20px oklch(0% 0 0 / 0.08), 0 -1px 3px oklch(0% 0 0 / 0.06)';
+ const BAR_SHADOW_DOWN = BAR_SHADOW_DEFAULT;
+
function initBar() {
+ BP = barPaletteForTheme(detectPageTheme());
barEl = document.createElement('div');
barEl.id = PREFIX + '-bar';
Object.assign(barEl.style, {
@@ -764,15 +800,15 @@
display: 'none', opacity: '0',
transform: 'translateY(6px)',
transition: 'opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
- // Visual
- background: C.paper,
+ background: BP.surface,
backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
- border: '1px solid ' + C.mist,
+ border: '1px solid ' + BP.hairline,
borderRadius: '10px',
- boxShadow: '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)',
- fontFamily: FONT, fontSize: '13px', color: C.ink,
+ boxShadow: BAR_SHADOW_DEFAULT,
+ transition: 'box-shadow 0.2s ease, opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
+ fontFamily: FONT, fontSize: '13px', color: BP.text,
padding: '6px',
- maxWidth: '460px', minWidth: '320px',
+ maxWidth: '520px', minWidth: '320px',
});
document.body.appendChild(barEl);
}
@@ -824,14 +860,15 @@
barEl.style.transform = 'translateY(6px)';
setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250);
hideActionPicker();
+ closeTunePopover();
}
function updateBarContent(mode) {
if (!barEl || barEl.style.display === 'none') return;
barEl.innerHTML = '';
- // Reset bar styling
- barEl.style.background = C.paper;
- barEl.style.border = '1px solid ' + C.mist;
+ // Reset bar styling to the theme-aware palette
+ barEl.style.background = BP.surface;
+ barEl.style.border = '1px solid ' + BP.hairline;
if (mode === 'configure') barEl.appendChild(buildConfigureRow());
else if (mode === 'generating') barEl.appendChild(buildGeneratingRow());
else if (mode === 'cycling') barEl.appendChild(buildCyclingRow());
@@ -854,15 +891,15 @@
const pill = el('button', {
display: 'inline-flex', alignItems: 'center', gap: '4px',
padding: '5px 10px', borderRadius: '6px',
- background: C.ink, color: C.white,
+ background: BP.mark, color: BP.markText,
fontFamily: FONT, fontSize: '12px', fontWeight: '500',
border: 'none', cursor: 'pointer',
transition: 'background 0.12s ease, transform 0.1s ease',
whiteSpace: 'nowrap', flexShrink: '0',
});
pill.textContent = actionLabel() + ' \u25BE';
- pill.addEventListener('mouseenter', () => pill.style.background = C.brandHov);
- pill.addEventListener('mouseleave', () => pill.style.background = C.ink);
+ pill.addEventListener('mouseenter', () => pill.style.background = BP.accent);
+ pill.addEventListener('mouseleave', () => pill.style.background = BP.mark);
pill.addEventListener('mousedown', () => pill.style.transform = 'scale(0.97)');
pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)');
pill.addEventListener('click', (e) => { e.stopPropagation(); toggleActionPicker(); });
@@ -877,13 +914,13 @@
flex: '1', minWidth: '0',
padding: '5px 8px', borderRadius: '6px',
border: '1px solid transparent', background: 'transparent',
- fontFamily: FONT, fontSize: '12px', color: C.ink,
+ fontFamily: FONT, fontSize: '12px', color: BP.text,
outline: 'none',
transition: 'border-color 0.15s ease, background 0.15s ease',
});
input.addEventListener('focus', () => {
- input.style.borderColor = C.mist;
- input.style.background = C.white;
+ input.style.borderColor = BP.hairline;
+ input.style.background = BP.accentSoft;
});
input.addEventListener('blur', () => {
input.style.borderColor = 'transparent';
@@ -901,16 +938,16 @@
// Variant count toggle
const count = el('button', {
padding: '4px 6px', borderRadius: '5px',
- border: '1px solid ' + C.mist, background: 'transparent',
+ border: '1px solid ' + BP.hairline, background: 'transparent',
fontFamily: MONO, fontSize: '11px', fontWeight: '600',
- color: C.ash, cursor: 'pointer',
+ color: BP.textDim, cursor: 'pointer',
transition: 'color 0.12s ease, border-color 0.12s ease',
flexShrink: '0', whiteSpace: 'nowrap',
});
count.textContent = '\u00D7' + selectedCount;
count.title = 'Variants: click to change';
- count.addEventListener('mouseenter', () => { count.style.color = C.ink; count.style.borderColor = C.ink; });
- count.addEventListener('mouseleave', () => { count.style.color = C.ash; count.style.borderColor = C.mist; });
+ count.addEventListener('mouseenter', () => { count.style.color = BP.text; count.style.borderColor = BP.text; });
+ count.addEventListener('mouseleave', () => { count.style.color = BP.textDim; count.style.borderColor = BP.hairline; });
count.addEventListener('click', (e) => {
e.stopPropagation();
selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1;
@@ -921,15 +958,15 @@
// Go button
const go = el('button', {
padding: '5px 12px', borderRadius: '6px',
- border: 'none', background: C.brand, color: C.white,
+ border: 'none', background: BP.accent, color: BP.mark,
fontFamily: FONT, fontSize: '12px', fontWeight: '600',
cursor: 'pointer',
- transition: 'background 0.12s ease, transform 0.1s ease',
+ transition: 'filter 0.12s ease, transform 0.1s ease',
flexShrink: '0', whiteSpace: 'nowrap',
});
go.textContent = 'Go \u2192';
- go.addEventListener('mouseenter', () => go.style.background = C.brandHov);
- go.addEventListener('mouseleave', () => go.style.background = C.brand);
+ go.addEventListener('mouseenter', () => go.style.filter = 'brightness(1.1)');
+ go.addEventListener('mouseleave', () => go.style.filter = 'none');
go.addEventListener('mousedown', () => go.style.transform = 'scale(0.97)');
go.addEventListener('mouseup', () => go.style.transform = 'scale(1)');
go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); });
@@ -950,7 +987,7 @@
// Action label
const label = el('span', {
- fontWeight: '600', fontSize: '12px', color: C.ink,
+ fontWeight: '600', fontSize: '12px', color: BP.text,
flexShrink: '0', whiteSpace: 'nowrap',
});
label.textContent = actionLabel();
@@ -961,7 +998,7 @@
// Status
const status = el('span', {
- fontSize: '11px', color: C.ash, whiteSpace: 'nowrap',
+ fontSize: '11px', color: BP.textDim, whiteSpace: 'nowrap',
marginLeft: 'auto',
});
// Variants currently arrive atomically in a single file edit, so a
@@ -976,6 +1013,8 @@
// --- Cycling row ---
+ const TUNE_ICON_SVG = ' ';
+
function buildCyclingRow() {
const row = el('div', {
display: 'flex', alignItems: 'center', gap: '6px',
@@ -994,7 +1033,7 @@
// Counter
const counter = el('span', {
fontFamily: MONO, fontSize: '11px', fontWeight: '500',
- color: C.ash, minWidth: '24px', textAlign: 'center',
+ color: BP.textDim, minWidth: '24px', textAlign: 'center',
});
counter.textContent = visibleVariant + '/' + arrivedVariants;
row.appendChild(counter);
@@ -1005,20 +1044,67 @@
if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
row.appendChild(next);
+ // Tune chip — only when the visible variant exposes params
+ const visParams = parseVariantParams(getVisibleVariantEl());
+ const hasParams = visParams.length > 0;
+ if (hasParams) {
+ const tune = el('button', {
+ display: 'inline-flex', alignItems: 'center', gap: '6px',
+ padding: '4px 10px', borderRadius: '5px',
+ border: '1px solid transparent',
+ background: tuneOpen ? BP.accentSoft : 'transparent',
+ color: tuneOpen ? BP.accent : BP.text,
+ fontFamily: FONT, fontSize: '11px', fontWeight: '500',
+ cursor: 'pointer',
+ transition: 'color 0.12s ease, background 0.12s ease',
+ whiteSpace: 'nowrap',
+ });
+ tune.innerHTML = TUNE_ICON_SVG;
+ const tuneLabel = document.createElement('span');
+ tuneLabel.textContent = 'Tune';
+ tune.appendChild(tuneLabel);
+ const tuneBadge = document.createElement('span');
+ Object.assign(tuneBadge.style, {
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
+ minWidth: '16px', height: '16px', padding: '0 4px',
+ borderRadius: '999px',
+ background: tuneOpen ? C.brand : BP.hairline,
+ color: tuneOpen ? 'oklch(98% 0 0)' : 'inherit',
+ fontFamily: MONO, fontSize: '9.5px', fontWeight: '600',
+ lineHeight: '1',
+ boxSizing: 'border-box',
+ });
+ tuneBadge.textContent = String(visParams.length);
+ tune.appendChild(tuneBadge);
+ tune.title = 'Tune this variant (' + visParams.length + ' knob' + (visParams.length === 1 ? '' : 's') + ')';
+ tune.addEventListener('mouseenter', () => {
+ if (!tuneOpen) tune.style.background = BP.accentSoft;
+ });
+ tune.addEventListener('mouseleave', () => {
+ if (!tuneOpen) tune.style.background = 'transparent';
+ });
+ tune.addEventListener('click', (e) => { e.stopPropagation(); toggleTunePopover(); });
+ tune.dataset.iceqTune = '1';
+ row.appendChild(tune);
+ }
+
// Spacer
row.appendChild(el('div', { flex: '1' }));
- // Accept
+ // Accept — primary action, uses the site's saturated brand magenta
+ // with paper-white text, not the theme-muted BP.accent.
const accept = el('button', {
- padding: '4px 10px', borderRadius: '5px',
- border: 'none', background: C.ink, color: C.white,
+ padding: '5px 14px', borderRadius: '5px',
+ border: 'none', background: C.brand, color: 'oklch(98% 0 0)',
fontFamily: FONT, fontSize: '11px', fontWeight: '600',
- cursor: 'pointer', transition: 'background 0.12s ease',
+ cursor: 'pointer', transition: 'filter 0.12s ease, transform 0.1s ease',
whiteSpace: 'nowrap',
});
accept.textContent = '\u2713 Accept';
- accept.addEventListener('mouseenter', () => accept.style.background = C.brand);
- accept.addEventListener('mouseleave', () => accept.style.background = C.ink);
+ accept.addEventListener('mouseenter', () => accept.style.filter = 'brightness(1.08)');
+ accept.addEventListener('mouseleave', () => accept.style.filter = 'none');
+ accept.addEventListener('mousedown', () => accept.style.transform = 'scale(0.97)');
+ accept.addEventListener('mouseup', () => accept.style.transform = 'scale(1)');
accept.addEventListener('click', (e) => { e.stopPropagation(); handleAccept(); });
if (arrivedVariants === 0) { accept.style.opacity = '0.3'; accept.style.pointerEvents = 'none'; }
row.appendChild(accept);
@@ -1026,14 +1112,14 @@
// Discard
const discard = el('button', {
padding: '4px 6px', borderRadius: '5px',
- border: '1px solid ' + C.mist, background: 'transparent',
- fontFamily: FONT, fontSize: '11px', color: C.ash,
+ border: '1px solid ' + BP.hairline, background: 'transparent',
+ fontFamily: FONT, fontSize: '11px', color: BP.textDim,
cursor: 'pointer', transition: 'color 0.12s ease, border-color 0.12s ease',
});
discard.textContent = '\u2715';
discard.title = 'Discard all variants';
- discard.addEventListener('mouseenter', () => { discard.style.color = C.ink; discard.style.borderColor = C.ink; });
- discard.addEventListener('mouseleave', () => { discard.style.color = C.ash; discard.style.borderColor = C.mist; });
+ discard.addEventListener('mouseenter', () => { discard.style.color = BP.text; discard.style.borderColor = BP.text; });
+ discard.addEventListener('mouseleave', () => { discard.style.color = BP.textDim; discard.style.borderColor = BP.hairline; });
discard.addEventListener('click', (e) => { e.stopPropagation(); handleDiscard(); });
row.appendChild(discard);
@@ -1051,14 +1137,14 @@
});
const spinner = el('div', {
width: '14px', height: '14px', borderRadius: '50%',
- border: '2px solid ' + C.mist,
- borderTopColor: C.brand,
+ border: '2px solid ' + BP.hairline,
+ borderTopColor: BP.accent,
animation: 'impeccable-spin 0.6s linear infinite',
flexShrink: '0',
});
row.appendChild(spinner);
const label = el('span', {
- fontSize: '12px', color: C.ash, fontWeight: '500',
+ fontSize: '12px', color: BP.textDim, fontWeight: '500',
});
label.textContent = 'Applying variant...';
row.appendChild(label);
@@ -1103,14 +1189,26 @@
for (let i = 1; i <= expectedVariants; i++) {
const arrived = i <= arrivedVariants;
const active = i === visibleVariant;
+ // active: solid site-brand magenta 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
+ // magenta chips, especially when all variants had arrived and every
+ // dot wore an accent ring.
+ const dotBg = active ? C.brand
+ : arrived ? BP.textDim
+ : 'transparent';
+ const dotBorder = arrived ? 'none' : '1.5px solid ' + BP.hairline;
const dot = el('div', {
- width: '7px', height: '7px', borderRadius: '50%',
- background: active ? C.brand : (arrived ? C.ash : 'transparent'),
- border: '1.5px solid ' + (arrived ? C.brand : C.mist),
+ width: active ? '8px' : '6px',
+ height: active ? '8px' : '6px',
+ borderRadius: '50%',
+ background: dotBg,
+ border: dotBorder,
+ boxSizing: 'border-box',
transition: 'all 0.2s ' + EASE,
cursor: (clickable && arrived) ? 'pointer' : 'default',
- transform: arrived ? 'scale(1)' : 'scale(0.6)',
- opacity: arrived ? '1' : '0.4',
+ transform: arrived ? 'scale(1)' : 'scale(0.85)',
+ opacity: arrived ? (active ? '1' : '0.6') : '0.4',
});
if (clickable && arrived) {
const idx = i;
@@ -1130,15 +1228,15 @@
function navBtn(text) {
const b = el('button', {
width: '26px', height: '26px', borderRadius: '5px',
- border: '1px solid ' + C.mist, background: 'transparent',
- color: C.ink, fontFamily: FONT, fontSize: '13px',
+ border: '1px solid ' + BP.hairline, background: 'transparent',
+ color: BP.text, fontFamily: FONT, fontSize: '13px',
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'border-color 0.12s ease, background 0.12s ease',
padding: '0', lineHeight: '1',
});
b.textContent = text;
- b.addEventListener('mouseenter', () => { b.style.borderColor = C.ink; });
- b.addEventListener('mouseleave', () => { b.style.borderColor = C.mist; });
+ b.addEventListener('mouseenter', () => { b.style.borderColor = BP.text; });
+ b.addEventListener('mouseleave', () => { b.style.borderColor = BP.hairline; });
return b;
}
@@ -1158,6 +1256,7 @@
// ---------------------------------------------------------------------------
function initActionPicker() {
+ const P = barPaletteForTheme(detectPageTheme());
pickerEl = document.createElement('div');
pickerEl.id = PREFIX + '-picker';
Object.assign(pickerEl.style, {
@@ -1166,12 +1265,14 @@
transform: 'scale(0.96) translateY(4px)',
transformOrigin: 'bottom left',
transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE,
- background: C.paperSolid,
- border: '1px solid ' + C.mist,
+ background: P.surface,
+ border: '1px solid ' + P.hairline,
borderRadius: '10px',
boxShadow: '0 8px 30px oklch(0% 0 0 / 0.10), 0 2px 6px oklch(0% 0 0 / 0.06)',
padding: '6px',
fontFamily: FONT,
+ backdropFilter: 'blur(10px)',
+ WebkitBackdropFilter: 'blur(10px)',
});
// Build the chip grid
@@ -1181,22 +1282,32 @@
ACTIONS.forEach(action => {
const chip = el('button', {
- padding: '6px 8px', borderRadius: '6px',
+ display: 'flex', flexDirection: 'column', alignItems: 'center',
+ gap: '4px',
+ padding: '8px 6px', borderRadius: '6px',
border: 'none',
- background: action.value === selectedAction ? C.brandSoft : 'transparent',
- color: action.value === selectedAction ? C.brand : C.ink,
+ background: action.value === selectedAction ? P.accentSoft : 'transparent',
+ color: action.value === selectedAction ? P.accent : P.text,
fontFamily: FONT, fontSize: '11px', fontWeight: '500',
cursor: 'pointer',
transition: 'background 0.1s ease, color 0.1s ease',
textAlign: 'center', whiteSpace: 'nowrap',
});
- chip.textContent = action.label;
+ const iconWrap = el('span', {
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ height: '20px', opacity: '0.9',
+ });
+ iconWrap.innerHTML = ICONS[action.value] || '';
+ const labelEl = el('span', { lineHeight: '1' });
+ labelEl.textContent = action.label;
+ chip.appendChild(iconWrap);
+ chip.appendChild(labelEl);
chip.dataset.action = action.value;
chip.addEventListener('mouseenter', () => {
- if (action.value !== selectedAction) chip.style.background = C.brandSoft;
+ if (action.value !== selectedAction) chip.style.background = P.accentSoft;
});
chip.addEventListener('mouseleave', () => {
- chip.style.background = action.value === selectedAction ? C.brandSoft : 'transparent';
+ chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent';
});
chip.addEventListener('click', (e) => {
e.stopPropagation();
@@ -1209,19 +1320,24 @@
pickerEl.appendChild(grid);
document.body.appendChild(pickerEl);
+
+ // Cache the palette on the picker so toggleActionPicker's state refresh
+ // uses the same theme-aware colors when it repaints chips.
+ pickerEl.__iceq_palette = P;
}
function toggleActionPicker() {
if (pickerEl.style.display !== 'none') { hideActionPicker(); return; }
// Rebuild chips to reflect current selection
+ const P = pickerEl.__iceq_palette || barPaletteForTheme(detectPageTheme());
pickerEl.querySelectorAll('button').forEach(chip => {
const isActive = chip.dataset.action === selectedAction;
- chip.style.background = isActive ? C.brandSoft : 'transparent';
- chip.style.color = isActive ? C.brand : C.ink;
+ chip.style.background = isActive ? P.accentSoft : 'transparent';
+ chip.style.color = isActive ? P.accent : P.text;
});
// Position above the bar
const barRect = barEl.getBoundingClientRect();
- const pickerH = 90; // approximate
+ const pickerH = 170; // approximate; grows with icon + label rows
let top = barRect.top - pickerH - 6;
if (top < 8) top = barRect.bottom + 6;
Object.assign(pickerEl.style, {
@@ -1241,6 +1357,399 @@
setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180);
}
+ // ---------------------------------------------------------------------------
+ // Params panel (per-variant coarse controls)
+ //
+ // Variants may declare a parameter manifest via a JSON attribute on the
+ // variant wrapper:
+ //
+ //
+ //
+ // The panel docks to the right edge of the outline during CYCLING and
+ // exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped
+ // CSS can respond instantly without regeneration:
+ //
+ // range / numeric toggle → CSS var (`--p-
`) used via var(--p-foo, N)
+ // steps / boolean toggle → data-p- attribute used via :scope[data-p-foo="..."]
+ //
+ // On variant switch, values reset to that variant's declared defaults.
+ // On accept, current values are sent in the event payload so the agent
+ // can bake them into the source-file write.
+ // ---------------------------------------------------------------------------
+
+ 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 tuneOpen = false; // whether the Tune popover is open right now
+
+ // Theme-aware Tune popover. Appears as a drawer that slides out from the
+ // contextual bar's bar-facing edge (below if the bar sits below the
+ // element, above otherwise). Same width as the bar. Auto-wraps to extra
+ // rows when the knobs exceed one row. The bar's border-radius on the
+ // popover side goes flat while open so the two shapes read as one.
+ let paramsPanelPalette = null;
+
+ function initParamsPanel() {
+ paramsPanelPalette = barPaletteForTheme(detectPageTheme());
+ 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
+ // display toggle, no opacity toggle, no transform trickery. The mask
+ // hides everything initially; as it grows, content is revealed from
+ // the bar edge outward.
+ paramsPanelEl = document.createElement('div');
+ paramsPanelEl.id = PREFIX + '-params-panel';
+ Object.assign(paramsPanelEl.style, {
+ position: 'fixed', zIndex: String(Z.bar - 1),
+ background: P.surfaceDeep,
+ color: P.text,
+ fontFamily: FONT,
+ padding: '14px 18px',
+ boxSizing: 'border-box',
+ borderRadius: '0 0 10px 10px',
+ pointerEvents: 'none',
+ backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
+
+ // clip-path is the same conceptual reveal as mask but with rock-solid
+ // transition support across engines. Closed state clips from the far
+ // edge; open = inset(0) shows everything.
+ clipPath: 'inset(0 0 100% 0)',
+ 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
+ // top-left when first shown.
+ top: '-9999px', left: '-9999px', width: '0',
+ });
+
+ paramsPanelBody = el('div', {
+ display: 'grid',
+ gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
+ gap: '12px 16px',
+ });
+
+ paramsPanelEl.appendChild(paramsPanelBody);
+ document.body.appendChild(paramsPanelEl);
+ paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
+ }
+
+ function getVisibleVariantEl() {
+ if (!currentSessionId) return null;
+ const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
+ if (!wrapper) return null;
+ return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
+ }
+
+ function parseVariantParams(variantEl) {
+ if (!variantEl) return [];
+ const raw = variantEl.getAttribute('data-impeccable-params');
+ if (!raw) return [];
+ try {
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) ? parsed : [];
+ } catch (err) {
+ console.warn('[impeccable] Invalid data-impeccable-params JSON:', err.message);
+ return [];
+ }
+ }
+
+ function applyParamValue(variantEl, param, value) {
+ if (!variantEl) return;
+ const attr = 'data-p-' + param.id;
+ if (param.kind === 'range') {
+ variantEl.style.setProperty('--p-' + param.id, String(value));
+ } else if (param.kind === 'toggle') {
+ const on = !!value;
+ variantEl.style.setProperty('--p-' + param.id, on ? '1' : '0');
+ if (on) variantEl.setAttribute(attr, 'on');
+ else variantEl.removeAttribute(attr);
+ } else if (param.kind === 'steps') {
+ variantEl.setAttribute(attr, String(value));
+ }
+ }
+
+ function applyParamDefaults(variantEl, params) {
+ paramsCurrentValues = {};
+ for (const p of params) {
+ paramsCurrentValues[p.id] = p.default;
+ applyParamValue(variantEl, p, p.default);
+ }
+ }
+
+ function formatRangeValue(input) {
+ const max = parseFloat(input.max), min = parseFloat(input.min);
+ const v = parseFloat(input.value);
+ if (!isFinite(v)) return input.value;
+ return (max - min) <= 2 ? v.toFixed(2) : String(Math.round(v));
+ }
+
+ function buildParamsPanel(variantEl, params) {
+ const P = paramsPanelPalette || barPaletteForTheme(detectPageTheme());
+ paramsPanelBody.innerHTML = '';
+ for (const p of params) {
+ const row = el('div', { display: 'flex', flexDirection: 'column', gap: '6px' });
+ const labelRow = el('div', {
+ display: 'flex', justifyContent: 'space-between',
+ alignItems: 'baseline', gap: '8px',
+ });
+ const lbl = el('span', {
+ fontSize: '10.5px', fontWeight: '600', color: P.text,
+ letterSpacing: '0.03em',
+ });
+ lbl.textContent = p.label || p.id;
+ labelRow.appendChild(lbl);
+ const readout = el('span', {
+ fontSize: '10.5px', color: P.textDim,
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
+ });
+ labelRow.appendChild(readout);
+ row.appendChild(labelRow);
+
+ if (p.kind === 'range') {
+ const input = document.createElement('input');
+ input.type = 'range';
+ input.min = String(p.min != null ? p.min : 0);
+ input.max = String(p.max != null ? p.max : 1);
+ input.step = String(p.step != null ? p.step : 0.05);
+ input.value = String(p.default);
+ Object.assign(input.style, {
+ width: '100%', accentColor: C.brand, cursor: 'pointer',
+ });
+ readout.textContent = formatRangeValue(input);
+ input.addEventListener('input', (e) => {
+ e.stopPropagation();
+ const v = parseFloat(input.value);
+ paramsCurrentValues[p.id] = v;
+ readout.textContent = formatRangeValue(input);
+ applyParamValue(variantEl, p, v);
+ });
+ row.appendChild(input);
+ } else if (p.kind === 'toggle') {
+ const initial = !!p.default;
+ readout.textContent = initial ? 'On' : 'Off';
+ const track = el('button', {
+ position: 'relative', width: '36px', height: '20px',
+ borderRadius: '10px', border: 'none', padding: '0',
+ cursor: 'pointer',
+ background: initial ? C.brand : P.hairline,
+ transition: 'background 0.15s ease',
+ alignSelf: 'flex-start',
+ });
+ const knob = el('span', {
+ position: 'absolute', top: '2px',
+ left: initial ? '18px' : '2px',
+ width: '16px', height: '16px', borderRadius: '50%',
+ background: 'oklch(98% 0 0)',
+ transition: 'left 0.18s ' + EASE,
+ boxShadow: '0 1px 2px oklch(0% 0 0 / 0.2)',
+ });
+ track.appendChild(knob);
+ track.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const next = !paramsCurrentValues[p.id];
+ paramsCurrentValues[p.id] = next;
+ track.style.background = next ? C.brand : P.hairline;
+ knob.style.left = next ? '18px' : '2px';
+ readout.textContent = next ? 'On' : 'Off';
+ applyParamValue(variantEl, p, next);
+ });
+ row.appendChild(track);
+ } else if (p.kind === 'steps') {
+ const opts = (p.options || []).map(o =>
+ typeof o === 'string' ? { value: o, label: o } : o
+ );
+ const activeOpt = opts.find(o => o.value === p.default) || opts[0];
+ readout.textContent = activeOpt ? activeOpt.label : String(p.default);
+ const segRow = el('div', {
+ display: 'grid',
+ gridTemplateColumns: 'repeat(' + opts.length + ', 1fr)',
+ gap: '1px', padding: '2px',
+ background: P.hairline, borderRadius: '5px',
+ });
+ const segBtns = [];
+ opts.forEach(o => {
+ const active = o.value === p.default;
+ const b = el('button', {
+ padding: '5px 4px', border: 'none', borderRadius: '3px',
+ background: active ? C.brand : 'transparent',
+ color: active ? 'oklch(98% 0 0)' : P.text,
+ fontFamily: FONT, fontSize: '10.5px', fontWeight: '500',
+ cursor: 'pointer', whiteSpace: 'nowrap',
+ transition: 'background 0.1s ease, color 0.1s ease',
+ });
+ b.textContent = o.label;
+ b.addEventListener('click', (e) => {
+ e.stopPropagation();
+ paramsCurrentValues[p.id] = o.value;
+ readout.textContent = o.label;
+ segBtns.forEach(({ btn, val }) => {
+ const on = val === o.value;
+ btn.style.background = on ? C.brand : 'transparent';
+ btn.style.color = on ? 'oklch(98% 0 0)' : P.text;
+ });
+ applyParamValue(variantEl, p, o.value);
+ });
+ segRow.appendChild(b);
+ segBtns.push({ btn: b, val: o.value });
+ });
+ row.appendChild(segRow);
+ }
+
+ paramsPanelBody.appendChild(row);
+ }
+ }
+
+ // Decide which way the popover opens: away from the picked element. If the
+ // bar landed below the element, popover slides DOWN from the bar's bottom.
+ // If the bar landed above, popover slides UP from the bar's top.
+ function popoverDirection() {
+ if (!barEl || !selectedElement) return 'below';
+ const br = barEl.getBoundingClientRect();
+ const er = selectedElement.getBoundingClientRect();
+ return br.top >= er.bottom - 4 ? 'below' : 'above';
+ }
+
+ // The popover overlaps the bar by OVERLAP px on the bar-facing side. With
+ // popover z-index below bar, that overlap sits behind bar (invisible) and
+ // reinforces the "tucked behind" feel. Padding compensates so the real
+ // content starts flush with bar's outer edge.
+ const TUNE_OVERLAP = 6;
+
+ // Closed clip-path depends on direction: for 'below' clip from the far
+ // (bottom) edge so the reveal grows downward from the bar; for 'above'
+ // clip from the top edge so the reveal grows upward from the bar.
+ function closedClipPath(direction) {
+ return direction === 'below' ? 'inset(0 0 100% 0)' : 'inset(100% 0 0 0)';
+ }
+
+ function setClipPath(value, withTransition) {
+ const saved = paramsPanelEl.style.transition;
+ if (!withTransition) paramsPanelEl.style.transition = 'none';
+ paramsPanelEl.style.clipPath = value;
+ if (!withTransition) {
+ void paramsPanelEl.offsetHeight;
+ paramsPanelEl.style.transition = saved;
+ }
+ }
+
+ function positionParamsPanel() {
+ if (!paramsPanelEl || !barEl || barEl.style.display === 'none') return;
+ const br = barEl.getBoundingClientRect();
+ const direction = popoverDirection();
+ const prevDirection = paramsPanelEl.dataset.tuneDirection;
+
+ // top/left/width are NOT in the transition list, so they snap instantly.
+ paramsPanelEl.style.left = br.left + 'px';
+ paramsPanelEl.style.width = br.width + 'px';
+
+ if (direction === 'below') {
+ paramsPanelEl.style.top = (br.bottom - TUNE_OVERLAP) + 'px';
+ paramsPanelEl.style.borderRadius = '0 0 10px 10px';
+ paramsPanelEl.style.paddingTop = (14 + TUNE_OVERLAP) + 'px';
+ paramsPanelEl.style.paddingBottom = '14px';
+ } else {
+ const ih = paramsPanelEl.offsetHeight || 80;
+ paramsPanelEl.style.top = (br.top - ih + TUNE_OVERLAP) + 'px';
+ paramsPanelEl.style.borderRadius = '10px 10px 0 0';
+ paramsPanelEl.style.paddingTop = '14px';
+ paramsPanelEl.style.paddingBottom = (14 + TUNE_OVERLAP) + 'px';
+ }
+ paramsPanelEl.dataset.tuneDirection = direction;
+
+ // If currently closed and direction flipped (or first-time setup),
+ // snap the clip-path to the new direction's closed pose without
+ // transitioning (so the clip doesn't slide across the element).
+ if (!tuneOpen && (!prevDirection || prevDirection !== direction)) {
+ setClipPath(closedClipPath(direction), false);
+ }
+ }
+
+ function showParamsPanel() {
+ if (!paramsPanelEl) return;
+ positionParamsPanel();
+ paramsPanelEl.style.pointerEvents = 'auto';
+ // rAF so the positioning paint commits before the transition fires.
+ requestAnimationFrame(() => {
+ setClipPath('inset(0 0 0 0)', true);
+ });
+ }
+
+ function hideParamsPanel() {
+ if (!paramsPanelEl) return;
+ paramsPanelEl.style.pointerEvents = 'none';
+ const direction = paramsPanelEl.dataset.tuneDirection || 'below';
+ setClipPath(closedClipPath(direction), true);
+ }
+
+ // Build/rebuild the panel's contents for the current variant AND apply
+ // its defaults to the variant wrapper (so scoped CSS responds even before
+ // the user opens the popover). Visibility is governed by tuneOpen.
+ function refreshParamsPanel() {
+ if (state !== 'CYCLING') {
+ paramsCurrentValues = {};
+ tuneOpen = false;
+ hideParamsPanel();
+ return;
+ }
+ const variantEl = getVisibleVariantEl();
+ const params = parseVariantParams(variantEl);
+ if (!variantEl || params.length === 0) {
+ paramsCurrentValues = {};
+ tuneOpen = false;
+ hideParamsPanel();
+ return;
+ }
+ applyParamDefaults(variantEl, params);
+ buildParamsPanel(variantEl, params);
+ if (tuneOpen) {
+ // If already visible (variant cycled while open), refresh in place
+ // instead of re-running the clip-path animation.
+ const alreadyVisible = paramsPanelEl.style.display === 'block'
+ && paramsPanelEl.style.opacity === '1';
+ if (alreadyVisible) positionParamsPanel();
+ else showParamsPanel();
+ } else {
+ hideParamsPanel();
+ }
+ }
+
+ function toggleTunePopover() {
+ if (tuneOpen) { closeTunePopover(); return; }
+ openTunePopover();
+ }
+
+ function openTunePopover() {
+ if (state !== 'CYCLING') return;
+ const variantEl = getVisibleVariantEl();
+ const params = parseVariantParams(variantEl);
+ if (!variantEl || params.length === 0) return;
+ // Build fresh to ensure the current variant's controls are shown.
+ applyParamDefaults(variantEl, params);
+ buildParamsPanel(variantEl, params);
+ tuneOpen = true;
+ showParamsPanel();
+ // Kill the bar's shadow on the popover-facing side so the dark popover
+ // doesn't pick up a bright glow line.
+ if (barEl) {
+ const direction = paramsPanelEl?.dataset.tuneDirection || 'below';
+ barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN;
+ }
+ // Re-render the bar so the Tune chip picks up the active styling.
+ updateBarContent('cycling');
+ }
+
+ function closeTunePopover() {
+ tuneOpen = false;
+ hideParamsPanel();
+ if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT;
+ if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') {
+ updateBarContent('cycling');
+ }
+ }
+
// ---------------------------------------------------------------------------
// Variant cycling in DOM
// ---------------------------------------------------------------------------
@@ -1253,6 +1762,10 @@
if (!v) continue;
child.style.display = (v === String(num)) ? '' : 'none';
}
+ // 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();
}
/**
@@ -1316,6 +1829,7 @@
state = 'CYCLING';
hideShaderOverlay();
updateBarContent('cycling');
+ refreshParamsPanel();
saveSession();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
@@ -1329,7 +1843,7 @@
const next = visibleVariant + dir;
if (next < 1 || next > arrivedVariants) return;
visibleVariant = next;
- showVariantInDOM(currentSessionId, next);
+ showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself
updateSelectedElement();
updateBarContent('cycling');
saveSession();
@@ -1540,6 +2054,7 @@
state = 'CYCLING';
hideShaderOverlay();
updateBarContent('cycling');
+ refreshParamsPanel();
} else if (state === 'GENERATING') {
updateBarContent('generating');
}
@@ -1560,6 +2075,7 @@
if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') {
positionBar();
showHighlight(selectedElement);
+ if (tuneOpen) positionParamsPanel();
}
if (annotActive) positionAnnotOverlay(selectedElement);
// Shader overlay (via debug P toggle or generation) is repositioned
@@ -1606,6 +2122,7 @@
if (state === 'GENERATING') {
state = 'CYCLING';
updateBarContent('cycling');
+ refreshParamsPanel();
}
break;
}
@@ -1691,6 +2208,10 @@
if (pickerEl?.style.display !== 'none' && !own(e.target)) {
hideActionPicker();
}
+ // Close Tune popover on outside click (anything outside panel + bar)
+ if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) {
+ closeTunePopover();
+ }
// In CONFIGURING: click outside the bar and selected element returns to PICKING
if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) {
hideBar();
@@ -2258,7 +2779,11 @@ void main() {
function handleAccept() {
if (!currentSessionId || arrivedVariants === 0) return;
- sendEvent({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) });
+ const acceptPayload = { type: 'accept', id: currentSessionId, variantId: String(visibleVariant) };
+ if (Object.keys(paramsCurrentValues).length > 0) {
+ acceptPayload.paramValues = { ...paramsCurrentValues };
+ }
+ sendEvent(acceptPayload);
markSessionHandled();
// The accepted variant is already the only visible child of the wrapper
@@ -2477,6 +3002,11 @@ void main() {
state = arrivedVariants >= expectedVariants ? 'CYCLING' : 'GENERATING';
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
startScrollTracking();
+ // Build the params panel for the restored visible variant. Previously
+ // this was missed on page-reload resume: showVariantInDOM above fires
+ // refreshParamsPanel, but state was still IDLE at that moment so it
+ // hid. Now that state is CYCLING, re-fire.
+ if (state === 'CYCLING') refreshParamsPanel();
saveSession();
// Start observing for more variants AFTER initial setup
@@ -2522,6 +3052,11 @@ void main() {
// dark pages. This keeps the bar from fighting with the host design.
function detectPageTheme() {
try {
+ // Dev override: set localStorage 'impeccable-dev-theme' to 'light' or
+ // 'dark' to preview the opposite palette without actually changing the
+ // page bg. Used for screenshots and theme QA.
+ const override = localStorage.getItem('impeccable-dev-theme');
+ if (override === 'light' || override === 'dark') return override;
const bg = getComputedStyle(document.body).backgroundColor
|| getComputedStyle(document.documentElement).backgroundColor;
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
@@ -2538,6 +3073,7 @@ void main() {
// Light bar on dark page
return {
surface: 'oklch(98% 0 0 / 0.92)',
+ surfaceDeep: 'oklch(92% 0.005 60 / 0.96)', // slightly deeper, faint warm
hairline: 'oklch(70% 0 0 / 0.35)',
text: 'oklch(15% 0 0)',
textDim: 'oklch(45% 0 0)',
@@ -2552,6 +3088,7 @@ void main() {
// deeper so the rounded-right shape reads as a clear sculpted mark.
return {
surface: 'oklch(26% 0 0 / 0.94)',
+ surfaceDeep: 'oklch(18% 0 0 / 0.96)', // darker sand for Tune popover
hairline: 'oklch(42% 0 0 / 0.5)',
text: 'oklch(96% 0 0)',
textDim: 'oklch(72% 0 0)',
@@ -2883,6 +3420,7 @@ void main() {
if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; }
if (barEl) { barEl.remove(); barEl = null; }
if (pickerEl) { pickerEl.remove(); pickerEl = null; }
+ if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; }
if (evtSource) { evtSource.close(); evtSource = null; }
document.removeEventListener('mousemove', handleMouseMove, true);
document.removeEventListener('click', handleClick, true);
@@ -3956,6 +4494,7 @@ void main() {
initAnnotOverlay();
initBar();
initActionPicker();
+ initParamsPanel();
initGlobalBar();
initDesignPanel();
document.addEventListener('mousemove', handleMouseMove, true);
diff --git a/.opencode/skills/impeccable/scripts/live-poll.mjs b/.opencode/skills/impeccable/scripts/live-poll.mjs
index 9dd87e3ea..5cece1a43 100644
--- a/.opencode/skills/impeccable/scripts/live-poll.mjs
+++ b/.opencode/skills/impeccable/scripts/live-poll.mjs
@@ -145,6 +145,11 @@ Options:
const scriptArgs = event.type === 'discard'
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
+ if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
+ // Pass through a JSON blob; the shell-safe wrap uses single quotes because
+ // values are finite {id, number|string|boolean} pairs from a validated payload.
+ scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
+ }
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
diff --git a/.opencode/skills/impeccable/scripts/live-server.mjs b/.opencode/skills/impeccable/scripts/live-server.mjs
index e5fb05bfa..2ab33fd22 100644
--- a/.opencode/skills/impeccable/scripts/live-server.mjs
+++ b/.opencode/skills/impeccable/scripts/live-server.mjs
@@ -147,6 +147,11 @@ function validateEvent(msg) {
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
+ if (msg.paramValues !== undefined) {
+ if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
+ return 'accept: paramValues must be an object';
+ }
+ }
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
diff --git a/.pi/skills/impeccable/reference/colorize.md b/.pi/skills/impeccable/reference/colorize.md
index de74ac95f..1bd52004f 100644
--- a/.pi/skills/impeccable/reference/colorize.md
+++ b/.pi/skills/impeccable/reference/colorize.md
@@ -142,3 +142,13 @@ Test that colorization improves the experience:
- **Not overwhelming**: Is color balanced and purposeful?
Remember: Color is emotional and powerful. Use it to create warmth, guide attention, communicate meaning, and express personality. But restraint and strategy matter more than saturation and variety. Be colorful, but be intentional.
+
+## Live-mode signature params
+
+When invoked from live mode, each variant MUST declare a `color-amount` param so the user can dial between a restrained accent and a drenched surface without regeneration. Author the variant's CSS against `var(--p-color-amount, 0.5)` — typically as the alpha multiplier on backgrounds, or as a scaling factor on the chroma axis in an OKLCH expression. 0 = neutral/monochrome, 1 = full saturation / dominant coverage.
+
+```json
+{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"}
+```
+
+Layer 1-2 variant-specific params on top: palette selection (`steps` with named options), temperature warmth, or tint vs. true color. See `reference/live.md` for the full params contract.
diff --git a/.pi/skills/impeccable/reference/layout.md b/.pi/skills/impeccable/reference/layout.md
index 981ace8a1..5e8022081 100644
--- a/.pi/skills/impeccable/reference/layout.md
+++ b/.pi/skills/impeccable/reference/layout.md
@@ -119,3 +119,23 @@ Create a systematic plan:
- **Responsiveness**: Does the layout adapt gracefully across screen sizes?
Remember: Space is the most underused design tool. A layout with the right rhythm and hierarchy can make even simple content feel polished and intentional.
+
+## Live-mode signature params
+
+Each variant MUST declare a `density` param. Drive all spacing tokens in the variant's scoped CSS through `calc(var(--p-density, 1) * )` — paddings, gaps, column widths. Users slide from airy to packed and see layout re-breathe with no regeneration.
+
+```json
+{"id":"density","kind":"range","min":0.6,"max":1.4,"step":0.05,"default":1,"label":"Density"}
+```
+
+For variants whose topology genuinely changes (stacked vs. side-by-side, grid vs. bento), use a `steps` param whose scoped CSS branches via `:scope[data-p-structure="X"]`. One structure param + one density param is a powerful combo; resist adding a third.
+
+```json
+{"id":"structure","kind":"steps","default":"grid","label":"Structure","options":[
+ {"value":"stacked","label":"Stacked"},
+ {"value":"grid","label":"Grid"},
+ {"value":"bento","label":"Bento"}
+]}
+```
+
+See `reference/live.md` for the full params contract.
diff --git a/.pi/skills/impeccable/reference/live.md b/.pi/skills/impeccable/reference/live.md
index 64570f56a..a38c3f2c9 100644
--- a/.pi/skills/impeccable/reference/live.md
+++ b/.pi/skills/impeccable/reference/live.md
@@ -179,7 +179,56 @@ The first variant has no `display: none` (visible by default). All others do. If
One edit, all variants — the browser's MutationObserver picks everything up in one pass.
-### 7. Signal done
+### 7. Parameters (optional, 2-5 per variant)
+
+Each variant can expose coarse knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against.
+
+**When to use.** Any time the variant has a meaningful axis the user might want to dial in: color amount, density, motion intensity, scale ratio. Not micro-level margin tweaks; those defeat the point.
+
+**Budget scales with the element's visual weight, not the user's curiosity.** Knobs need real estate to produce noticeably different output; slapping three on a small element just crowds the panel without making anything feel tunable.
+
+- **Leaf / tiny** — a single button, icon, input, bare heading, solitary paragraph: **0 params.** A slider can't meaningfully reshape one element; just use variants.
+- **Small composition** — labeled input, simple card, short callout (≤ ~5 visual children): **0-1 params.** Only add one if it's a clear dominant axis (e.g. density on a card with visible internal rhythm).
+- **Medium composition** — section component, nav cluster, dense card, short feature block (6-15 visual children): **2 params.**
+- **Large composition** — hero section, full page region, spread layout, anything with strong internal structure (16+ visual children or multiple sub-sections): **3-4 params.**
+
+When in doubt, fewer. The user can always ask for more variants to explore an axis you didn't expose as a knob. Count by visual children, not by DOM-node depth; a deeply-nested-but-visually-simple card still counts as small.
+
+**How to declare.** Put a JSON manifest on the variant wrapper:
+
+```html
+
+ ...variant content...
+
+```
+
+**Three kinds:**
+
+- `range` — smooth slider. Drives a CSS custom property `--p-` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`.
+- `steps` — segmented radio. Drives a data attribute `data-p-` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`.
+- `toggle` — on/off switch. Drives BOTH a CSS var (`--p-: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`.
+
+**Signature params per action.** Each action has one or two signature params that MUST be exposed when the variant can meaningfully express them. Check the action's reference file for the list. Layer 1-2 variant-specific params on top.
+
+**Reset on variant switch.** User dials density on v1, flips to v2, v2 starts at v2's declared defaults. Known limitation; preservation across variants may land later.
+
+**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment:
+
+```html
+
+```
+
+The carbonize cleanup step (see below) reads that comment and bakes the chosen values into the final CSS. For `steps`/`toggle` attribute selectors: keep only the branch matching the chosen value, drop the others, collapse `:scope[data-p-density="packed"] .grid` to a semantic class rule. For `range` vars: either substitute the literal or keep the var with the chosen value as its new default.
+
+### 8. Signal done
```bash
node .pi/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
@@ -244,11 +293,11 @@ When `_acceptResult.carbonize === true`, the accepted variant was stitched into
Do these five steps in the current thread, synchronously, before the next poll. Do not poll again until the file is clean.
-1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `` and `` and contains a `');
+ if (paramValues && Object.keys(paramValues).length > 0) {
+ // Preserve the user's knob positions for the carbonize-cleanup agent
+ // to bake into the final CSS when it collapses scoped rules.
+ replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
+ }
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
diff --git a/.pi/skills/impeccable/scripts/live-browser.js b/.pi/skills/impeccable/scripts/live-browser.js
index 116e1efa6..66efd7243 100644
--- a/.pi/skills/impeccable/scripts/live-browser.js
+++ b/.pi/skills/impeccable/scripts/live-browser.js
@@ -30,10 +30,13 @@
// Design tokens
// ---------------------------------------------------------------------------
+ // Brand magenta is pinned to the site token (--color-accent in main.css)
+ // so Accept / knobs / cycle-dots match the site's accent, not a washed
+ // theme-adjusted one.
const C = {
- brand: 'oklch(55% 0.25 350)',
- brandHov: 'oklch(48% 0.25 350)',
- brandSoft: 'oklch(55% 0.25 350 / 0.12)',
+ brand: 'oklch(60% 0.25 350)',
+ brandHov: 'oklch(52% 0.25 350)',
+ brandSoft: 'oklch(60% 0.25 350 / 0.15)',
ink: 'oklch(15% 0.01 350)',
ash: 'oklch(55% 0 0)',
paper: 'oklch(98% 0.005 350 / 0.92)',
@@ -60,6 +63,25 @@
'html', 'head', 'body', 'script', 'style', 'link', 'meta', 'noscript', 'br', 'wbr',
]);
+ // 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.
+ 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: ` `,
+ bolder: ` `,
+ quieter: ` `,
+ distill: ` `,
+ polish: ` `,
+ typeset: ` `,
+ colorize: ` `,
+ layout: ` `,
+ adapt: ` `,
+ animate: ` `,
+ delight: ` `,
+ overdrive: ` `,
+ };
+
const ACTIONS = [
{ value: 'impeccable', label: 'Freeform' },
{ value: 'bolder', label: 'Bolder' },
@@ -756,7 +778,21 @@
// The Bar — one floating element, three modes
// ---------------------------------------------------------------------------
+ // Contextual-bar palette. Cached at init so every build*Row reads a
+ // consistent set of colors; detectPageTheme runs once rather than on every
+ // phase transition.
+ let BP = null;
+
+ // Bar shadow variants. The default projects down + subtle around. When
+ // the Tune popover opens below the bar, a downward shadow lands on the
+ // dark popover and reads as a bright ghost line. We swap to UP-only while
+ // tune is open below so the popover's top edge is clean.
+ const BAR_SHADOW_DEFAULT = '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)';
+ const BAR_SHADOW_UP = '0 -4px 20px oklch(0% 0 0 / 0.08), 0 -1px 3px oklch(0% 0 0 / 0.06)';
+ const BAR_SHADOW_DOWN = BAR_SHADOW_DEFAULT;
+
function initBar() {
+ BP = barPaletteForTheme(detectPageTheme());
barEl = document.createElement('div');
barEl.id = PREFIX + '-bar';
Object.assign(barEl.style, {
@@ -764,15 +800,15 @@
display: 'none', opacity: '0',
transform: 'translateY(6px)',
transition: 'opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
- // Visual
- background: C.paper,
+ background: BP.surface,
backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
- border: '1px solid ' + C.mist,
+ border: '1px solid ' + BP.hairline,
borderRadius: '10px',
- boxShadow: '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)',
- fontFamily: FONT, fontSize: '13px', color: C.ink,
+ boxShadow: BAR_SHADOW_DEFAULT,
+ transition: 'box-shadow 0.2s ease, opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
+ fontFamily: FONT, fontSize: '13px', color: BP.text,
padding: '6px',
- maxWidth: '460px', minWidth: '320px',
+ maxWidth: '520px', minWidth: '320px',
});
document.body.appendChild(barEl);
}
@@ -824,14 +860,15 @@
barEl.style.transform = 'translateY(6px)';
setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250);
hideActionPicker();
+ closeTunePopover();
}
function updateBarContent(mode) {
if (!barEl || barEl.style.display === 'none') return;
barEl.innerHTML = '';
- // Reset bar styling
- barEl.style.background = C.paper;
- barEl.style.border = '1px solid ' + C.mist;
+ // Reset bar styling to the theme-aware palette
+ barEl.style.background = BP.surface;
+ barEl.style.border = '1px solid ' + BP.hairline;
if (mode === 'configure') barEl.appendChild(buildConfigureRow());
else if (mode === 'generating') barEl.appendChild(buildGeneratingRow());
else if (mode === 'cycling') barEl.appendChild(buildCyclingRow());
@@ -854,15 +891,15 @@
const pill = el('button', {
display: 'inline-flex', alignItems: 'center', gap: '4px',
padding: '5px 10px', borderRadius: '6px',
- background: C.ink, color: C.white,
+ background: BP.mark, color: BP.markText,
fontFamily: FONT, fontSize: '12px', fontWeight: '500',
border: 'none', cursor: 'pointer',
transition: 'background 0.12s ease, transform 0.1s ease',
whiteSpace: 'nowrap', flexShrink: '0',
});
pill.textContent = actionLabel() + ' \u25BE';
- pill.addEventListener('mouseenter', () => pill.style.background = C.brandHov);
- pill.addEventListener('mouseleave', () => pill.style.background = C.ink);
+ pill.addEventListener('mouseenter', () => pill.style.background = BP.accent);
+ pill.addEventListener('mouseleave', () => pill.style.background = BP.mark);
pill.addEventListener('mousedown', () => pill.style.transform = 'scale(0.97)');
pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)');
pill.addEventListener('click', (e) => { e.stopPropagation(); toggleActionPicker(); });
@@ -877,13 +914,13 @@
flex: '1', minWidth: '0',
padding: '5px 8px', borderRadius: '6px',
border: '1px solid transparent', background: 'transparent',
- fontFamily: FONT, fontSize: '12px', color: C.ink,
+ fontFamily: FONT, fontSize: '12px', color: BP.text,
outline: 'none',
transition: 'border-color 0.15s ease, background 0.15s ease',
});
input.addEventListener('focus', () => {
- input.style.borderColor = C.mist;
- input.style.background = C.white;
+ input.style.borderColor = BP.hairline;
+ input.style.background = BP.accentSoft;
});
input.addEventListener('blur', () => {
input.style.borderColor = 'transparent';
@@ -901,16 +938,16 @@
// Variant count toggle
const count = el('button', {
padding: '4px 6px', borderRadius: '5px',
- border: '1px solid ' + C.mist, background: 'transparent',
+ border: '1px solid ' + BP.hairline, background: 'transparent',
fontFamily: MONO, fontSize: '11px', fontWeight: '600',
- color: C.ash, cursor: 'pointer',
+ color: BP.textDim, cursor: 'pointer',
transition: 'color 0.12s ease, border-color 0.12s ease',
flexShrink: '0', whiteSpace: 'nowrap',
});
count.textContent = '\u00D7' + selectedCount;
count.title = 'Variants: click to change';
- count.addEventListener('mouseenter', () => { count.style.color = C.ink; count.style.borderColor = C.ink; });
- count.addEventListener('mouseleave', () => { count.style.color = C.ash; count.style.borderColor = C.mist; });
+ count.addEventListener('mouseenter', () => { count.style.color = BP.text; count.style.borderColor = BP.text; });
+ count.addEventListener('mouseleave', () => { count.style.color = BP.textDim; count.style.borderColor = BP.hairline; });
count.addEventListener('click', (e) => {
e.stopPropagation();
selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1;
@@ -921,15 +958,15 @@
// Go button
const go = el('button', {
padding: '5px 12px', borderRadius: '6px',
- border: 'none', background: C.brand, color: C.white,
+ border: 'none', background: BP.accent, color: BP.mark,
fontFamily: FONT, fontSize: '12px', fontWeight: '600',
cursor: 'pointer',
- transition: 'background 0.12s ease, transform 0.1s ease',
+ transition: 'filter 0.12s ease, transform 0.1s ease',
flexShrink: '0', whiteSpace: 'nowrap',
});
go.textContent = 'Go \u2192';
- go.addEventListener('mouseenter', () => go.style.background = C.brandHov);
- go.addEventListener('mouseleave', () => go.style.background = C.brand);
+ go.addEventListener('mouseenter', () => go.style.filter = 'brightness(1.1)');
+ go.addEventListener('mouseleave', () => go.style.filter = 'none');
go.addEventListener('mousedown', () => go.style.transform = 'scale(0.97)');
go.addEventListener('mouseup', () => go.style.transform = 'scale(1)');
go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); });
@@ -950,7 +987,7 @@
// Action label
const label = el('span', {
- fontWeight: '600', fontSize: '12px', color: C.ink,
+ fontWeight: '600', fontSize: '12px', color: BP.text,
flexShrink: '0', whiteSpace: 'nowrap',
});
label.textContent = actionLabel();
@@ -961,7 +998,7 @@
// Status
const status = el('span', {
- fontSize: '11px', color: C.ash, whiteSpace: 'nowrap',
+ fontSize: '11px', color: BP.textDim, whiteSpace: 'nowrap',
marginLeft: 'auto',
});
// Variants currently arrive atomically in a single file edit, so a
@@ -976,6 +1013,8 @@
// --- Cycling row ---
+ const TUNE_ICON_SVG = ' ';
+
function buildCyclingRow() {
const row = el('div', {
display: 'flex', alignItems: 'center', gap: '6px',
@@ -994,7 +1033,7 @@
// Counter
const counter = el('span', {
fontFamily: MONO, fontSize: '11px', fontWeight: '500',
- color: C.ash, minWidth: '24px', textAlign: 'center',
+ color: BP.textDim, minWidth: '24px', textAlign: 'center',
});
counter.textContent = visibleVariant + '/' + arrivedVariants;
row.appendChild(counter);
@@ -1005,20 +1044,67 @@
if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
row.appendChild(next);
+ // Tune chip — only when the visible variant exposes params
+ const visParams = parseVariantParams(getVisibleVariantEl());
+ const hasParams = visParams.length > 0;
+ if (hasParams) {
+ const tune = el('button', {
+ display: 'inline-flex', alignItems: 'center', gap: '6px',
+ padding: '4px 10px', borderRadius: '5px',
+ border: '1px solid transparent',
+ background: tuneOpen ? BP.accentSoft : 'transparent',
+ color: tuneOpen ? BP.accent : BP.text,
+ fontFamily: FONT, fontSize: '11px', fontWeight: '500',
+ cursor: 'pointer',
+ transition: 'color 0.12s ease, background 0.12s ease',
+ whiteSpace: 'nowrap',
+ });
+ tune.innerHTML = TUNE_ICON_SVG;
+ const tuneLabel = document.createElement('span');
+ tuneLabel.textContent = 'Tune';
+ tune.appendChild(tuneLabel);
+ const tuneBadge = document.createElement('span');
+ Object.assign(tuneBadge.style, {
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
+ minWidth: '16px', height: '16px', padding: '0 4px',
+ borderRadius: '999px',
+ background: tuneOpen ? C.brand : BP.hairline,
+ color: tuneOpen ? 'oklch(98% 0 0)' : 'inherit',
+ fontFamily: MONO, fontSize: '9.5px', fontWeight: '600',
+ lineHeight: '1',
+ boxSizing: 'border-box',
+ });
+ tuneBadge.textContent = String(visParams.length);
+ tune.appendChild(tuneBadge);
+ tune.title = 'Tune this variant (' + visParams.length + ' knob' + (visParams.length === 1 ? '' : 's') + ')';
+ tune.addEventListener('mouseenter', () => {
+ if (!tuneOpen) tune.style.background = BP.accentSoft;
+ });
+ tune.addEventListener('mouseleave', () => {
+ if (!tuneOpen) tune.style.background = 'transparent';
+ });
+ tune.addEventListener('click', (e) => { e.stopPropagation(); toggleTunePopover(); });
+ tune.dataset.iceqTune = '1';
+ row.appendChild(tune);
+ }
+
// Spacer
row.appendChild(el('div', { flex: '1' }));
- // Accept
+ // Accept — primary action, uses the site's saturated brand magenta
+ // with paper-white text, not the theme-muted BP.accent.
const accept = el('button', {
- padding: '4px 10px', borderRadius: '5px',
- border: 'none', background: C.ink, color: C.white,
+ padding: '5px 14px', borderRadius: '5px',
+ border: 'none', background: C.brand, color: 'oklch(98% 0 0)',
fontFamily: FONT, fontSize: '11px', fontWeight: '600',
- cursor: 'pointer', transition: 'background 0.12s ease',
+ cursor: 'pointer', transition: 'filter 0.12s ease, transform 0.1s ease',
whiteSpace: 'nowrap',
});
accept.textContent = '\u2713 Accept';
- accept.addEventListener('mouseenter', () => accept.style.background = C.brand);
- accept.addEventListener('mouseleave', () => accept.style.background = C.ink);
+ accept.addEventListener('mouseenter', () => accept.style.filter = 'brightness(1.08)');
+ accept.addEventListener('mouseleave', () => accept.style.filter = 'none');
+ accept.addEventListener('mousedown', () => accept.style.transform = 'scale(0.97)');
+ accept.addEventListener('mouseup', () => accept.style.transform = 'scale(1)');
accept.addEventListener('click', (e) => { e.stopPropagation(); handleAccept(); });
if (arrivedVariants === 0) { accept.style.opacity = '0.3'; accept.style.pointerEvents = 'none'; }
row.appendChild(accept);
@@ -1026,14 +1112,14 @@
// Discard
const discard = el('button', {
padding: '4px 6px', borderRadius: '5px',
- border: '1px solid ' + C.mist, background: 'transparent',
- fontFamily: FONT, fontSize: '11px', color: C.ash,
+ border: '1px solid ' + BP.hairline, background: 'transparent',
+ fontFamily: FONT, fontSize: '11px', color: BP.textDim,
cursor: 'pointer', transition: 'color 0.12s ease, border-color 0.12s ease',
});
discard.textContent = '\u2715';
discard.title = 'Discard all variants';
- discard.addEventListener('mouseenter', () => { discard.style.color = C.ink; discard.style.borderColor = C.ink; });
- discard.addEventListener('mouseleave', () => { discard.style.color = C.ash; discard.style.borderColor = C.mist; });
+ discard.addEventListener('mouseenter', () => { discard.style.color = BP.text; discard.style.borderColor = BP.text; });
+ discard.addEventListener('mouseleave', () => { discard.style.color = BP.textDim; discard.style.borderColor = BP.hairline; });
discard.addEventListener('click', (e) => { e.stopPropagation(); handleDiscard(); });
row.appendChild(discard);
@@ -1051,14 +1137,14 @@
});
const spinner = el('div', {
width: '14px', height: '14px', borderRadius: '50%',
- border: '2px solid ' + C.mist,
- borderTopColor: C.brand,
+ border: '2px solid ' + BP.hairline,
+ borderTopColor: BP.accent,
animation: 'impeccable-spin 0.6s linear infinite',
flexShrink: '0',
});
row.appendChild(spinner);
const label = el('span', {
- fontSize: '12px', color: C.ash, fontWeight: '500',
+ fontSize: '12px', color: BP.textDim, fontWeight: '500',
});
label.textContent = 'Applying variant...';
row.appendChild(label);
@@ -1103,14 +1189,26 @@
for (let i = 1; i <= expectedVariants; i++) {
const arrived = i <= arrivedVariants;
const active = i === visibleVariant;
+ // active: solid site-brand magenta 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
+ // magenta chips, especially when all variants had arrived and every
+ // dot wore an accent ring.
+ const dotBg = active ? C.brand
+ : arrived ? BP.textDim
+ : 'transparent';
+ const dotBorder = arrived ? 'none' : '1.5px solid ' + BP.hairline;
const dot = el('div', {
- width: '7px', height: '7px', borderRadius: '50%',
- background: active ? C.brand : (arrived ? C.ash : 'transparent'),
- border: '1.5px solid ' + (arrived ? C.brand : C.mist),
+ width: active ? '8px' : '6px',
+ height: active ? '8px' : '6px',
+ borderRadius: '50%',
+ background: dotBg,
+ border: dotBorder,
+ boxSizing: 'border-box',
transition: 'all 0.2s ' + EASE,
cursor: (clickable && arrived) ? 'pointer' : 'default',
- transform: arrived ? 'scale(1)' : 'scale(0.6)',
- opacity: arrived ? '1' : '0.4',
+ transform: arrived ? 'scale(1)' : 'scale(0.85)',
+ opacity: arrived ? (active ? '1' : '0.6') : '0.4',
});
if (clickable && arrived) {
const idx = i;
@@ -1130,15 +1228,15 @@
function navBtn(text) {
const b = el('button', {
width: '26px', height: '26px', borderRadius: '5px',
- border: '1px solid ' + C.mist, background: 'transparent',
- color: C.ink, fontFamily: FONT, fontSize: '13px',
+ border: '1px solid ' + BP.hairline, background: 'transparent',
+ color: BP.text, fontFamily: FONT, fontSize: '13px',
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'border-color 0.12s ease, background 0.12s ease',
padding: '0', lineHeight: '1',
});
b.textContent = text;
- b.addEventListener('mouseenter', () => { b.style.borderColor = C.ink; });
- b.addEventListener('mouseleave', () => { b.style.borderColor = C.mist; });
+ b.addEventListener('mouseenter', () => { b.style.borderColor = BP.text; });
+ b.addEventListener('mouseleave', () => { b.style.borderColor = BP.hairline; });
return b;
}
@@ -1158,6 +1256,7 @@
// ---------------------------------------------------------------------------
function initActionPicker() {
+ const P = barPaletteForTheme(detectPageTheme());
pickerEl = document.createElement('div');
pickerEl.id = PREFIX + '-picker';
Object.assign(pickerEl.style, {
@@ -1166,12 +1265,14 @@
transform: 'scale(0.96) translateY(4px)',
transformOrigin: 'bottom left',
transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE,
- background: C.paperSolid,
- border: '1px solid ' + C.mist,
+ background: P.surface,
+ border: '1px solid ' + P.hairline,
borderRadius: '10px',
boxShadow: '0 8px 30px oklch(0% 0 0 / 0.10), 0 2px 6px oklch(0% 0 0 / 0.06)',
padding: '6px',
fontFamily: FONT,
+ backdropFilter: 'blur(10px)',
+ WebkitBackdropFilter: 'blur(10px)',
});
// Build the chip grid
@@ -1181,22 +1282,32 @@
ACTIONS.forEach(action => {
const chip = el('button', {
- padding: '6px 8px', borderRadius: '6px',
+ display: 'flex', flexDirection: 'column', alignItems: 'center',
+ gap: '4px',
+ padding: '8px 6px', borderRadius: '6px',
border: 'none',
- background: action.value === selectedAction ? C.brandSoft : 'transparent',
- color: action.value === selectedAction ? C.brand : C.ink,
+ background: action.value === selectedAction ? P.accentSoft : 'transparent',
+ color: action.value === selectedAction ? P.accent : P.text,
fontFamily: FONT, fontSize: '11px', fontWeight: '500',
cursor: 'pointer',
transition: 'background 0.1s ease, color 0.1s ease',
textAlign: 'center', whiteSpace: 'nowrap',
});
- chip.textContent = action.label;
+ const iconWrap = el('span', {
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ height: '20px', opacity: '0.9',
+ });
+ iconWrap.innerHTML = ICONS[action.value] || '';
+ const labelEl = el('span', { lineHeight: '1' });
+ labelEl.textContent = action.label;
+ chip.appendChild(iconWrap);
+ chip.appendChild(labelEl);
chip.dataset.action = action.value;
chip.addEventListener('mouseenter', () => {
- if (action.value !== selectedAction) chip.style.background = C.brandSoft;
+ if (action.value !== selectedAction) chip.style.background = P.accentSoft;
});
chip.addEventListener('mouseleave', () => {
- chip.style.background = action.value === selectedAction ? C.brandSoft : 'transparent';
+ chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent';
});
chip.addEventListener('click', (e) => {
e.stopPropagation();
@@ -1209,19 +1320,24 @@
pickerEl.appendChild(grid);
document.body.appendChild(pickerEl);
+
+ // Cache the palette on the picker so toggleActionPicker's state refresh
+ // uses the same theme-aware colors when it repaints chips.
+ pickerEl.__iceq_palette = P;
}
function toggleActionPicker() {
if (pickerEl.style.display !== 'none') { hideActionPicker(); return; }
// Rebuild chips to reflect current selection
+ const P = pickerEl.__iceq_palette || barPaletteForTheme(detectPageTheme());
pickerEl.querySelectorAll('button').forEach(chip => {
const isActive = chip.dataset.action === selectedAction;
- chip.style.background = isActive ? C.brandSoft : 'transparent';
- chip.style.color = isActive ? C.brand : C.ink;
+ chip.style.background = isActive ? P.accentSoft : 'transparent';
+ chip.style.color = isActive ? P.accent : P.text;
});
// Position above the bar
const barRect = barEl.getBoundingClientRect();
- const pickerH = 90; // approximate
+ const pickerH = 170; // approximate; grows with icon + label rows
let top = barRect.top - pickerH - 6;
if (top < 8) top = barRect.bottom + 6;
Object.assign(pickerEl.style, {
@@ -1241,6 +1357,399 @@
setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180);
}
+ // ---------------------------------------------------------------------------
+ // Params panel (per-variant coarse controls)
+ //
+ // Variants may declare a parameter manifest via a JSON attribute on the
+ // variant wrapper:
+ //
+ //
+ //
+ // The panel docks to the right edge of the outline during CYCLING and
+ // exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped
+ // CSS can respond instantly without regeneration:
+ //
+ // range / numeric toggle → CSS var (`--p-
`) used via var(--p-foo, N)
+ // steps / boolean toggle → data-p- attribute used via :scope[data-p-foo="..."]
+ //
+ // On variant switch, values reset to that variant's declared defaults.
+ // On accept, current values are sent in the event payload so the agent
+ // can bake them into the source-file write.
+ // ---------------------------------------------------------------------------
+
+ 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 tuneOpen = false; // whether the Tune popover is open right now
+
+ // Theme-aware Tune popover. Appears as a drawer that slides out from the
+ // contextual bar's bar-facing edge (below if the bar sits below the
+ // element, above otherwise). Same width as the bar. Auto-wraps to extra
+ // rows when the knobs exceed one row. The bar's border-radius on the
+ // popover side goes flat while open so the two shapes read as one.
+ let paramsPanelPalette = null;
+
+ function initParamsPanel() {
+ paramsPanelPalette = barPaletteForTheme(detectPageTheme());
+ 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
+ // display toggle, no opacity toggle, no transform trickery. The mask
+ // hides everything initially; as it grows, content is revealed from
+ // the bar edge outward.
+ paramsPanelEl = document.createElement('div');
+ paramsPanelEl.id = PREFIX + '-params-panel';
+ Object.assign(paramsPanelEl.style, {
+ position: 'fixed', zIndex: String(Z.bar - 1),
+ background: P.surfaceDeep,
+ color: P.text,
+ fontFamily: FONT,
+ padding: '14px 18px',
+ boxSizing: 'border-box',
+ borderRadius: '0 0 10px 10px',
+ pointerEvents: 'none',
+ backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
+
+ // clip-path is the same conceptual reveal as mask but with rock-solid
+ // transition support across engines. Closed state clips from the far
+ // edge; open = inset(0) shows everything.
+ clipPath: 'inset(0 0 100% 0)',
+ 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
+ // top-left when first shown.
+ top: '-9999px', left: '-9999px', width: '0',
+ });
+
+ paramsPanelBody = el('div', {
+ display: 'grid',
+ gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
+ gap: '12px 16px',
+ });
+
+ paramsPanelEl.appendChild(paramsPanelBody);
+ document.body.appendChild(paramsPanelEl);
+ paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
+ }
+
+ function getVisibleVariantEl() {
+ if (!currentSessionId) return null;
+ const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
+ if (!wrapper) return null;
+ return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
+ }
+
+ function parseVariantParams(variantEl) {
+ if (!variantEl) return [];
+ const raw = variantEl.getAttribute('data-impeccable-params');
+ if (!raw) return [];
+ try {
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) ? parsed : [];
+ } catch (err) {
+ console.warn('[impeccable] Invalid data-impeccable-params JSON:', err.message);
+ return [];
+ }
+ }
+
+ function applyParamValue(variantEl, param, value) {
+ if (!variantEl) return;
+ const attr = 'data-p-' + param.id;
+ if (param.kind === 'range') {
+ variantEl.style.setProperty('--p-' + param.id, String(value));
+ } else if (param.kind === 'toggle') {
+ const on = !!value;
+ variantEl.style.setProperty('--p-' + param.id, on ? '1' : '0');
+ if (on) variantEl.setAttribute(attr, 'on');
+ else variantEl.removeAttribute(attr);
+ } else if (param.kind === 'steps') {
+ variantEl.setAttribute(attr, String(value));
+ }
+ }
+
+ function applyParamDefaults(variantEl, params) {
+ paramsCurrentValues = {};
+ for (const p of params) {
+ paramsCurrentValues[p.id] = p.default;
+ applyParamValue(variantEl, p, p.default);
+ }
+ }
+
+ function formatRangeValue(input) {
+ const max = parseFloat(input.max), min = parseFloat(input.min);
+ const v = parseFloat(input.value);
+ if (!isFinite(v)) return input.value;
+ return (max - min) <= 2 ? v.toFixed(2) : String(Math.round(v));
+ }
+
+ function buildParamsPanel(variantEl, params) {
+ const P = paramsPanelPalette || barPaletteForTheme(detectPageTheme());
+ paramsPanelBody.innerHTML = '';
+ for (const p of params) {
+ const row = el('div', { display: 'flex', flexDirection: 'column', gap: '6px' });
+ const labelRow = el('div', {
+ display: 'flex', justifyContent: 'space-between',
+ alignItems: 'baseline', gap: '8px',
+ });
+ const lbl = el('span', {
+ fontSize: '10.5px', fontWeight: '600', color: P.text,
+ letterSpacing: '0.03em',
+ });
+ lbl.textContent = p.label || p.id;
+ labelRow.appendChild(lbl);
+ const readout = el('span', {
+ fontSize: '10.5px', color: P.textDim,
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
+ });
+ labelRow.appendChild(readout);
+ row.appendChild(labelRow);
+
+ if (p.kind === 'range') {
+ const input = document.createElement('input');
+ input.type = 'range';
+ input.min = String(p.min != null ? p.min : 0);
+ input.max = String(p.max != null ? p.max : 1);
+ input.step = String(p.step != null ? p.step : 0.05);
+ input.value = String(p.default);
+ Object.assign(input.style, {
+ width: '100%', accentColor: C.brand, cursor: 'pointer',
+ });
+ readout.textContent = formatRangeValue(input);
+ input.addEventListener('input', (e) => {
+ e.stopPropagation();
+ const v = parseFloat(input.value);
+ paramsCurrentValues[p.id] = v;
+ readout.textContent = formatRangeValue(input);
+ applyParamValue(variantEl, p, v);
+ });
+ row.appendChild(input);
+ } else if (p.kind === 'toggle') {
+ const initial = !!p.default;
+ readout.textContent = initial ? 'On' : 'Off';
+ const track = el('button', {
+ position: 'relative', width: '36px', height: '20px',
+ borderRadius: '10px', border: 'none', padding: '0',
+ cursor: 'pointer',
+ background: initial ? C.brand : P.hairline,
+ transition: 'background 0.15s ease',
+ alignSelf: 'flex-start',
+ });
+ const knob = el('span', {
+ position: 'absolute', top: '2px',
+ left: initial ? '18px' : '2px',
+ width: '16px', height: '16px', borderRadius: '50%',
+ background: 'oklch(98% 0 0)',
+ transition: 'left 0.18s ' + EASE,
+ boxShadow: '0 1px 2px oklch(0% 0 0 / 0.2)',
+ });
+ track.appendChild(knob);
+ track.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const next = !paramsCurrentValues[p.id];
+ paramsCurrentValues[p.id] = next;
+ track.style.background = next ? C.brand : P.hairline;
+ knob.style.left = next ? '18px' : '2px';
+ readout.textContent = next ? 'On' : 'Off';
+ applyParamValue(variantEl, p, next);
+ });
+ row.appendChild(track);
+ } else if (p.kind === 'steps') {
+ const opts = (p.options || []).map(o =>
+ typeof o === 'string' ? { value: o, label: o } : o
+ );
+ const activeOpt = opts.find(o => o.value === p.default) || opts[0];
+ readout.textContent = activeOpt ? activeOpt.label : String(p.default);
+ const segRow = el('div', {
+ display: 'grid',
+ gridTemplateColumns: 'repeat(' + opts.length + ', 1fr)',
+ gap: '1px', padding: '2px',
+ background: P.hairline, borderRadius: '5px',
+ });
+ const segBtns = [];
+ opts.forEach(o => {
+ const active = o.value === p.default;
+ const b = el('button', {
+ padding: '5px 4px', border: 'none', borderRadius: '3px',
+ background: active ? C.brand : 'transparent',
+ color: active ? 'oklch(98% 0 0)' : P.text,
+ fontFamily: FONT, fontSize: '10.5px', fontWeight: '500',
+ cursor: 'pointer', whiteSpace: 'nowrap',
+ transition: 'background 0.1s ease, color 0.1s ease',
+ });
+ b.textContent = o.label;
+ b.addEventListener('click', (e) => {
+ e.stopPropagation();
+ paramsCurrentValues[p.id] = o.value;
+ readout.textContent = o.label;
+ segBtns.forEach(({ btn, val }) => {
+ const on = val === o.value;
+ btn.style.background = on ? C.brand : 'transparent';
+ btn.style.color = on ? 'oklch(98% 0 0)' : P.text;
+ });
+ applyParamValue(variantEl, p, o.value);
+ });
+ segRow.appendChild(b);
+ segBtns.push({ btn: b, val: o.value });
+ });
+ row.appendChild(segRow);
+ }
+
+ paramsPanelBody.appendChild(row);
+ }
+ }
+
+ // Decide which way the popover opens: away from the picked element. If the
+ // bar landed below the element, popover slides DOWN from the bar's bottom.
+ // If the bar landed above, popover slides UP from the bar's top.
+ function popoverDirection() {
+ if (!barEl || !selectedElement) return 'below';
+ const br = barEl.getBoundingClientRect();
+ const er = selectedElement.getBoundingClientRect();
+ return br.top >= er.bottom - 4 ? 'below' : 'above';
+ }
+
+ // The popover overlaps the bar by OVERLAP px on the bar-facing side. With
+ // popover z-index below bar, that overlap sits behind bar (invisible) and
+ // reinforces the "tucked behind" feel. Padding compensates so the real
+ // content starts flush with bar's outer edge.
+ const TUNE_OVERLAP = 6;
+
+ // Closed clip-path depends on direction: for 'below' clip from the far
+ // (bottom) edge so the reveal grows downward from the bar; for 'above'
+ // clip from the top edge so the reveal grows upward from the bar.
+ function closedClipPath(direction) {
+ return direction === 'below' ? 'inset(0 0 100% 0)' : 'inset(100% 0 0 0)';
+ }
+
+ function setClipPath(value, withTransition) {
+ const saved = paramsPanelEl.style.transition;
+ if (!withTransition) paramsPanelEl.style.transition = 'none';
+ paramsPanelEl.style.clipPath = value;
+ if (!withTransition) {
+ void paramsPanelEl.offsetHeight;
+ paramsPanelEl.style.transition = saved;
+ }
+ }
+
+ function positionParamsPanel() {
+ if (!paramsPanelEl || !barEl || barEl.style.display === 'none') return;
+ const br = barEl.getBoundingClientRect();
+ const direction = popoverDirection();
+ const prevDirection = paramsPanelEl.dataset.tuneDirection;
+
+ // top/left/width are NOT in the transition list, so they snap instantly.
+ paramsPanelEl.style.left = br.left + 'px';
+ paramsPanelEl.style.width = br.width + 'px';
+
+ if (direction === 'below') {
+ paramsPanelEl.style.top = (br.bottom - TUNE_OVERLAP) + 'px';
+ paramsPanelEl.style.borderRadius = '0 0 10px 10px';
+ paramsPanelEl.style.paddingTop = (14 + TUNE_OVERLAP) + 'px';
+ paramsPanelEl.style.paddingBottom = '14px';
+ } else {
+ const ih = paramsPanelEl.offsetHeight || 80;
+ paramsPanelEl.style.top = (br.top - ih + TUNE_OVERLAP) + 'px';
+ paramsPanelEl.style.borderRadius = '10px 10px 0 0';
+ paramsPanelEl.style.paddingTop = '14px';
+ paramsPanelEl.style.paddingBottom = (14 + TUNE_OVERLAP) + 'px';
+ }
+ paramsPanelEl.dataset.tuneDirection = direction;
+
+ // If currently closed and direction flipped (or first-time setup),
+ // snap the clip-path to the new direction's closed pose without
+ // transitioning (so the clip doesn't slide across the element).
+ if (!tuneOpen && (!prevDirection || prevDirection !== direction)) {
+ setClipPath(closedClipPath(direction), false);
+ }
+ }
+
+ function showParamsPanel() {
+ if (!paramsPanelEl) return;
+ positionParamsPanel();
+ paramsPanelEl.style.pointerEvents = 'auto';
+ // rAF so the positioning paint commits before the transition fires.
+ requestAnimationFrame(() => {
+ setClipPath('inset(0 0 0 0)', true);
+ });
+ }
+
+ function hideParamsPanel() {
+ if (!paramsPanelEl) return;
+ paramsPanelEl.style.pointerEvents = 'none';
+ const direction = paramsPanelEl.dataset.tuneDirection || 'below';
+ setClipPath(closedClipPath(direction), true);
+ }
+
+ // Build/rebuild the panel's contents for the current variant AND apply
+ // its defaults to the variant wrapper (so scoped CSS responds even before
+ // the user opens the popover). Visibility is governed by tuneOpen.
+ function refreshParamsPanel() {
+ if (state !== 'CYCLING') {
+ paramsCurrentValues = {};
+ tuneOpen = false;
+ hideParamsPanel();
+ return;
+ }
+ const variantEl = getVisibleVariantEl();
+ const params = parseVariantParams(variantEl);
+ if (!variantEl || params.length === 0) {
+ paramsCurrentValues = {};
+ tuneOpen = false;
+ hideParamsPanel();
+ return;
+ }
+ applyParamDefaults(variantEl, params);
+ buildParamsPanel(variantEl, params);
+ if (tuneOpen) {
+ // If already visible (variant cycled while open), refresh in place
+ // instead of re-running the clip-path animation.
+ const alreadyVisible = paramsPanelEl.style.display === 'block'
+ && paramsPanelEl.style.opacity === '1';
+ if (alreadyVisible) positionParamsPanel();
+ else showParamsPanel();
+ } else {
+ hideParamsPanel();
+ }
+ }
+
+ function toggleTunePopover() {
+ if (tuneOpen) { closeTunePopover(); return; }
+ openTunePopover();
+ }
+
+ function openTunePopover() {
+ if (state !== 'CYCLING') return;
+ const variantEl = getVisibleVariantEl();
+ const params = parseVariantParams(variantEl);
+ if (!variantEl || params.length === 0) return;
+ // Build fresh to ensure the current variant's controls are shown.
+ applyParamDefaults(variantEl, params);
+ buildParamsPanel(variantEl, params);
+ tuneOpen = true;
+ showParamsPanel();
+ // Kill the bar's shadow on the popover-facing side so the dark popover
+ // doesn't pick up a bright glow line.
+ if (barEl) {
+ const direction = paramsPanelEl?.dataset.tuneDirection || 'below';
+ barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN;
+ }
+ // Re-render the bar so the Tune chip picks up the active styling.
+ updateBarContent('cycling');
+ }
+
+ function closeTunePopover() {
+ tuneOpen = false;
+ hideParamsPanel();
+ if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT;
+ if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') {
+ updateBarContent('cycling');
+ }
+ }
+
// ---------------------------------------------------------------------------
// Variant cycling in DOM
// ---------------------------------------------------------------------------
@@ -1253,6 +1762,10 @@
if (!v) continue;
child.style.display = (v === String(num)) ? '' : 'none';
}
+ // 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();
}
/**
@@ -1316,6 +1829,7 @@
state = 'CYCLING';
hideShaderOverlay();
updateBarContent('cycling');
+ refreshParamsPanel();
saveSession();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
@@ -1329,7 +1843,7 @@
const next = visibleVariant + dir;
if (next < 1 || next > arrivedVariants) return;
visibleVariant = next;
- showVariantInDOM(currentSessionId, next);
+ showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself
updateSelectedElement();
updateBarContent('cycling');
saveSession();
@@ -1540,6 +2054,7 @@
state = 'CYCLING';
hideShaderOverlay();
updateBarContent('cycling');
+ refreshParamsPanel();
} else if (state === 'GENERATING') {
updateBarContent('generating');
}
@@ -1560,6 +2075,7 @@
if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') {
positionBar();
showHighlight(selectedElement);
+ if (tuneOpen) positionParamsPanel();
}
if (annotActive) positionAnnotOverlay(selectedElement);
// Shader overlay (via debug P toggle or generation) is repositioned
@@ -1606,6 +2122,7 @@
if (state === 'GENERATING') {
state = 'CYCLING';
updateBarContent('cycling');
+ refreshParamsPanel();
}
break;
}
@@ -1691,6 +2208,10 @@
if (pickerEl?.style.display !== 'none' && !own(e.target)) {
hideActionPicker();
}
+ // Close Tune popover on outside click (anything outside panel + bar)
+ if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) {
+ closeTunePopover();
+ }
// In CONFIGURING: click outside the bar and selected element returns to PICKING
if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) {
hideBar();
@@ -2258,7 +2779,11 @@ void main() {
function handleAccept() {
if (!currentSessionId || arrivedVariants === 0) return;
- sendEvent({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) });
+ const acceptPayload = { type: 'accept', id: currentSessionId, variantId: String(visibleVariant) };
+ if (Object.keys(paramsCurrentValues).length > 0) {
+ acceptPayload.paramValues = { ...paramsCurrentValues };
+ }
+ sendEvent(acceptPayload);
markSessionHandled();
// The accepted variant is already the only visible child of the wrapper
@@ -2477,6 +3002,11 @@ void main() {
state = arrivedVariants >= expectedVariants ? 'CYCLING' : 'GENERATING';
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
startScrollTracking();
+ // Build the params panel for the restored visible variant. Previously
+ // this was missed on page-reload resume: showVariantInDOM above fires
+ // refreshParamsPanel, but state was still IDLE at that moment so it
+ // hid. Now that state is CYCLING, re-fire.
+ if (state === 'CYCLING') refreshParamsPanel();
saveSession();
// Start observing for more variants AFTER initial setup
@@ -2522,6 +3052,11 @@ void main() {
// dark pages. This keeps the bar from fighting with the host design.
function detectPageTheme() {
try {
+ // Dev override: set localStorage 'impeccable-dev-theme' to 'light' or
+ // 'dark' to preview the opposite palette without actually changing the
+ // page bg. Used for screenshots and theme QA.
+ const override = localStorage.getItem('impeccable-dev-theme');
+ if (override === 'light' || override === 'dark') return override;
const bg = getComputedStyle(document.body).backgroundColor
|| getComputedStyle(document.documentElement).backgroundColor;
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
@@ -2538,6 +3073,7 @@ void main() {
// Light bar on dark page
return {
surface: 'oklch(98% 0 0 / 0.92)',
+ surfaceDeep: 'oklch(92% 0.005 60 / 0.96)', // slightly deeper, faint warm
hairline: 'oklch(70% 0 0 / 0.35)',
text: 'oklch(15% 0 0)',
textDim: 'oklch(45% 0 0)',
@@ -2552,6 +3088,7 @@ void main() {
// deeper so the rounded-right shape reads as a clear sculpted mark.
return {
surface: 'oklch(26% 0 0 / 0.94)',
+ surfaceDeep: 'oklch(18% 0 0 / 0.96)', // darker sand for Tune popover
hairline: 'oklch(42% 0 0 / 0.5)',
text: 'oklch(96% 0 0)',
textDim: 'oklch(72% 0 0)',
@@ -2883,6 +3420,7 @@ void main() {
if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; }
if (barEl) { barEl.remove(); barEl = null; }
if (pickerEl) { pickerEl.remove(); pickerEl = null; }
+ if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; }
if (evtSource) { evtSource.close(); evtSource = null; }
document.removeEventListener('mousemove', handleMouseMove, true);
document.removeEventListener('click', handleClick, true);
@@ -3956,6 +4494,7 @@ void main() {
initAnnotOverlay();
initBar();
initActionPicker();
+ initParamsPanel();
initGlobalBar();
initDesignPanel();
document.addEventListener('mousemove', handleMouseMove, true);
diff --git a/.pi/skills/impeccable/scripts/live-poll.mjs b/.pi/skills/impeccable/scripts/live-poll.mjs
index 9dd87e3ea..5cece1a43 100644
--- a/.pi/skills/impeccable/scripts/live-poll.mjs
+++ b/.pi/skills/impeccable/scripts/live-poll.mjs
@@ -145,6 +145,11 @@ Options:
const scriptArgs = event.type === 'discard'
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
+ if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
+ // Pass through a JSON blob; the shell-safe wrap uses single quotes because
+ // values are finite {id, number|string|boolean} pairs from a validated payload.
+ scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
+ }
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
diff --git a/.pi/skills/impeccable/scripts/live-server.mjs b/.pi/skills/impeccable/scripts/live-server.mjs
index e5fb05bfa..2ab33fd22 100644
--- a/.pi/skills/impeccable/scripts/live-server.mjs
+++ b/.pi/skills/impeccable/scripts/live-server.mjs
@@ -147,6 +147,11 @@ function validateEvent(msg) {
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
+ if (msg.paramValues !== undefined) {
+ if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
+ return 'accept: paramValues must be an object';
+ }
+ }
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
diff --git a/.rovodev/skills/impeccable/reference/colorize.md b/.rovodev/skills/impeccable/reference/colorize.md
index de74ac95f..1bd52004f 100644
--- a/.rovodev/skills/impeccable/reference/colorize.md
+++ b/.rovodev/skills/impeccable/reference/colorize.md
@@ -142,3 +142,13 @@ Test that colorization improves the experience:
- **Not overwhelming**: Is color balanced and purposeful?
Remember: Color is emotional and powerful. Use it to create warmth, guide attention, communicate meaning, and express personality. But restraint and strategy matter more than saturation and variety. Be colorful, but be intentional.
+
+## Live-mode signature params
+
+When invoked from live mode, each variant MUST declare a `color-amount` param so the user can dial between a restrained accent and a drenched surface without regeneration. Author the variant's CSS against `var(--p-color-amount, 0.5)` — typically as the alpha multiplier on backgrounds, or as a scaling factor on the chroma axis in an OKLCH expression. 0 = neutral/monochrome, 1 = full saturation / dominant coverage.
+
+```json
+{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"}
+```
+
+Layer 1-2 variant-specific params on top: palette selection (`steps` with named options), temperature warmth, or tint vs. true color. See `reference/live.md` for the full params contract.
diff --git a/.rovodev/skills/impeccable/reference/layout.md b/.rovodev/skills/impeccable/reference/layout.md
index 981ace8a1..5e8022081 100644
--- a/.rovodev/skills/impeccable/reference/layout.md
+++ b/.rovodev/skills/impeccable/reference/layout.md
@@ -119,3 +119,23 @@ Create a systematic plan:
- **Responsiveness**: Does the layout adapt gracefully across screen sizes?
Remember: Space is the most underused design tool. A layout with the right rhythm and hierarchy can make even simple content feel polished and intentional.
+
+## Live-mode signature params
+
+Each variant MUST declare a `density` param. Drive all spacing tokens in the variant's scoped CSS through `calc(var(--p-density, 1) * )` — paddings, gaps, column widths. Users slide from airy to packed and see layout re-breathe with no regeneration.
+
+```json
+{"id":"density","kind":"range","min":0.6,"max":1.4,"step":0.05,"default":1,"label":"Density"}
+```
+
+For variants whose topology genuinely changes (stacked vs. side-by-side, grid vs. bento), use a `steps` param whose scoped CSS branches via `:scope[data-p-structure="X"]`. One structure param + one density param is a powerful combo; resist adding a third.
+
+```json
+{"id":"structure","kind":"steps","default":"grid","label":"Structure","options":[
+ {"value":"stacked","label":"Stacked"},
+ {"value":"grid","label":"Grid"},
+ {"value":"bento","label":"Bento"}
+]}
+```
+
+See `reference/live.md` for the full params contract.
diff --git a/.rovodev/skills/impeccable/reference/live.md b/.rovodev/skills/impeccable/reference/live.md
index b1187a25d..0a9be8b16 100644
--- a/.rovodev/skills/impeccable/reference/live.md
+++ b/.rovodev/skills/impeccable/reference/live.md
@@ -179,7 +179,56 @@ The first variant has no `display: none` (visible by default). All others do. If
One edit, all variants — the browser's MutationObserver picks everything up in one pass.
-### 7. Signal done
+### 7. Parameters (optional, 2-5 per variant)
+
+Each variant can expose coarse knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against.
+
+**When to use.** Any time the variant has a meaningful axis the user might want to dial in: color amount, density, motion intensity, scale ratio. Not micro-level margin tweaks; those defeat the point.
+
+**Budget scales with the element's visual weight, not the user's curiosity.** Knobs need real estate to produce noticeably different output; slapping three on a small element just crowds the panel without making anything feel tunable.
+
+- **Leaf / tiny** — a single button, icon, input, bare heading, solitary paragraph: **0 params.** A slider can't meaningfully reshape one element; just use variants.
+- **Small composition** — labeled input, simple card, short callout (≤ ~5 visual children): **0-1 params.** Only add one if it's a clear dominant axis (e.g. density on a card with visible internal rhythm).
+- **Medium composition** — section component, nav cluster, dense card, short feature block (6-15 visual children): **2 params.**
+- **Large composition** — hero section, full page region, spread layout, anything with strong internal structure (16+ visual children or multiple sub-sections): **3-4 params.**
+
+When in doubt, fewer. The user can always ask for more variants to explore an axis you didn't expose as a knob. Count by visual children, not by DOM-node depth; a deeply-nested-but-visually-simple card still counts as small.
+
+**How to declare.** Put a JSON manifest on the variant wrapper:
+
+```html
+
+ ...variant content...
+
+```
+
+**Three kinds:**
+
+- `range` — smooth slider. Drives a CSS custom property `--p-` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`.
+- `steps` — segmented radio. Drives a data attribute `data-p-` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`.
+- `toggle` — on/off switch. Drives BOTH a CSS var (`--p-: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`.
+
+**Signature params per action.** Each action has one or two signature params that MUST be exposed when the variant can meaningfully express them. Check the action's reference file for the list. Layer 1-2 variant-specific params on top.
+
+**Reset on variant switch.** User dials density on v1, flips to v2, v2 starts at v2's declared defaults. Known limitation; preservation across variants may land later.
+
+**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment:
+
+```html
+
+```
+
+The carbonize cleanup step (see below) reads that comment and bakes the chosen values into the final CSS. For `steps`/`toggle` attribute selectors: keep only the branch matching the chosen value, drop the others, collapse `:scope[data-p-density="packed"] .grid` to a semantic class rule. For `range` vars: either substitute the literal or keep the var with the chosen value as its new default.
+
+### 8. Signal done
```bash
node .rovodev/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
@@ -244,11 +293,11 @@ When `_acceptResult.carbonize === true`, the accepted variant was stitched into
Do these five steps in the current thread, synchronously, before the next poll. Do not poll again until the file is clean.
-1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `` and `` and contains a `');
+ if (paramValues && Object.keys(paramValues).length > 0) {
+ // Preserve the user's knob positions for the carbonize-cleanup agent
+ // to bake into the final CSS when it collapses scoped rules.
+ replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
+ }
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
diff --git a/.rovodev/skills/impeccable/scripts/live-browser.js b/.rovodev/skills/impeccable/scripts/live-browser.js
index 116e1efa6..66efd7243 100644
--- a/.rovodev/skills/impeccable/scripts/live-browser.js
+++ b/.rovodev/skills/impeccable/scripts/live-browser.js
@@ -30,10 +30,13 @@
// Design tokens
// ---------------------------------------------------------------------------
+ // Brand magenta is pinned to the site token (--color-accent in main.css)
+ // so Accept / knobs / cycle-dots match the site's accent, not a washed
+ // theme-adjusted one.
const C = {
- brand: 'oklch(55% 0.25 350)',
- brandHov: 'oklch(48% 0.25 350)',
- brandSoft: 'oklch(55% 0.25 350 / 0.12)',
+ brand: 'oklch(60% 0.25 350)',
+ brandHov: 'oklch(52% 0.25 350)',
+ brandSoft: 'oklch(60% 0.25 350 / 0.15)',
ink: 'oklch(15% 0.01 350)',
ash: 'oklch(55% 0 0)',
paper: 'oklch(98% 0.005 350 / 0.92)',
@@ -60,6 +63,25 @@
'html', 'head', 'body', 'script', 'style', 'link', 'meta', 'noscript', 'br', 'wbr',
]);
+ // 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.
+ 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: ` `,
+ bolder: ` `,
+ quieter: ` `,
+ distill: ` `,
+ polish: ` `,
+ typeset: ` `,
+ colorize: ` `,
+ layout: ` `,
+ adapt: ` `,
+ animate: ` `,
+ delight: ` `,
+ overdrive: ` `,
+ };
+
const ACTIONS = [
{ value: 'impeccable', label: 'Freeform' },
{ value: 'bolder', label: 'Bolder' },
@@ -756,7 +778,21 @@
// The Bar — one floating element, three modes
// ---------------------------------------------------------------------------
+ // Contextual-bar palette. Cached at init so every build*Row reads a
+ // consistent set of colors; detectPageTheme runs once rather than on every
+ // phase transition.
+ let BP = null;
+
+ // Bar shadow variants. The default projects down + subtle around. When
+ // the Tune popover opens below the bar, a downward shadow lands on the
+ // dark popover and reads as a bright ghost line. We swap to UP-only while
+ // tune is open below so the popover's top edge is clean.
+ const BAR_SHADOW_DEFAULT = '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)';
+ const BAR_SHADOW_UP = '0 -4px 20px oklch(0% 0 0 / 0.08), 0 -1px 3px oklch(0% 0 0 / 0.06)';
+ const BAR_SHADOW_DOWN = BAR_SHADOW_DEFAULT;
+
function initBar() {
+ BP = barPaletteForTheme(detectPageTheme());
barEl = document.createElement('div');
barEl.id = PREFIX + '-bar';
Object.assign(barEl.style, {
@@ -764,15 +800,15 @@
display: 'none', opacity: '0',
transform: 'translateY(6px)',
transition: 'opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
- // Visual
- background: C.paper,
+ background: BP.surface,
backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
- border: '1px solid ' + C.mist,
+ border: '1px solid ' + BP.hairline,
borderRadius: '10px',
- boxShadow: '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)',
- fontFamily: FONT, fontSize: '13px', color: C.ink,
+ boxShadow: BAR_SHADOW_DEFAULT,
+ transition: 'box-shadow 0.2s ease, opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
+ fontFamily: FONT, fontSize: '13px', color: BP.text,
padding: '6px',
- maxWidth: '460px', minWidth: '320px',
+ maxWidth: '520px', minWidth: '320px',
});
document.body.appendChild(barEl);
}
@@ -824,14 +860,15 @@
barEl.style.transform = 'translateY(6px)';
setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250);
hideActionPicker();
+ closeTunePopover();
}
function updateBarContent(mode) {
if (!barEl || barEl.style.display === 'none') return;
barEl.innerHTML = '';
- // Reset bar styling
- barEl.style.background = C.paper;
- barEl.style.border = '1px solid ' + C.mist;
+ // Reset bar styling to the theme-aware palette
+ barEl.style.background = BP.surface;
+ barEl.style.border = '1px solid ' + BP.hairline;
if (mode === 'configure') barEl.appendChild(buildConfigureRow());
else if (mode === 'generating') barEl.appendChild(buildGeneratingRow());
else if (mode === 'cycling') barEl.appendChild(buildCyclingRow());
@@ -854,15 +891,15 @@
const pill = el('button', {
display: 'inline-flex', alignItems: 'center', gap: '4px',
padding: '5px 10px', borderRadius: '6px',
- background: C.ink, color: C.white,
+ background: BP.mark, color: BP.markText,
fontFamily: FONT, fontSize: '12px', fontWeight: '500',
border: 'none', cursor: 'pointer',
transition: 'background 0.12s ease, transform 0.1s ease',
whiteSpace: 'nowrap', flexShrink: '0',
});
pill.textContent = actionLabel() + ' \u25BE';
- pill.addEventListener('mouseenter', () => pill.style.background = C.brandHov);
- pill.addEventListener('mouseleave', () => pill.style.background = C.ink);
+ pill.addEventListener('mouseenter', () => pill.style.background = BP.accent);
+ pill.addEventListener('mouseleave', () => pill.style.background = BP.mark);
pill.addEventListener('mousedown', () => pill.style.transform = 'scale(0.97)');
pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)');
pill.addEventListener('click', (e) => { e.stopPropagation(); toggleActionPicker(); });
@@ -877,13 +914,13 @@
flex: '1', minWidth: '0',
padding: '5px 8px', borderRadius: '6px',
border: '1px solid transparent', background: 'transparent',
- fontFamily: FONT, fontSize: '12px', color: C.ink,
+ fontFamily: FONT, fontSize: '12px', color: BP.text,
outline: 'none',
transition: 'border-color 0.15s ease, background 0.15s ease',
});
input.addEventListener('focus', () => {
- input.style.borderColor = C.mist;
- input.style.background = C.white;
+ input.style.borderColor = BP.hairline;
+ input.style.background = BP.accentSoft;
});
input.addEventListener('blur', () => {
input.style.borderColor = 'transparent';
@@ -901,16 +938,16 @@
// Variant count toggle
const count = el('button', {
padding: '4px 6px', borderRadius: '5px',
- border: '1px solid ' + C.mist, background: 'transparent',
+ border: '1px solid ' + BP.hairline, background: 'transparent',
fontFamily: MONO, fontSize: '11px', fontWeight: '600',
- color: C.ash, cursor: 'pointer',
+ color: BP.textDim, cursor: 'pointer',
transition: 'color 0.12s ease, border-color 0.12s ease',
flexShrink: '0', whiteSpace: 'nowrap',
});
count.textContent = '\u00D7' + selectedCount;
count.title = 'Variants: click to change';
- count.addEventListener('mouseenter', () => { count.style.color = C.ink; count.style.borderColor = C.ink; });
- count.addEventListener('mouseleave', () => { count.style.color = C.ash; count.style.borderColor = C.mist; });
+ count.addEventListener('mouseenter', () => { count.style.color = BP.text; count.style.borderColor = BP.text; });
+ count.addEventListener('mouseleave', () => { count.style.color = BP.textDim; count.style.borderColor = BP.hairline; });
count.addEventListener('click', (e) => {
e.stopPropagation();
selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1;
@@ -921,15 +958,15 @@
// Go button
const go = el('button', {
padding: '5px 12px', borderRadius: '6px',
- border: 'none', background: C.brand, color: C.white,
+ border: 'none', background: BP.accent, color: BP.mark,
fontFamily: FONT, fontSize: '12px', fontWeight: '600',
cursor: 'pointer',
- transition: 'background 0.12s ease, transform 0.1s ease',
+ transition: 'filter 0.12s ease, transform 0.1s ease',
flexShrink: '0', whiteSpace: 'nowrap',
});
go.textContent = 'Go \u2192';
- go.addEventListener('mouseenter', () => go.style.background = C.brandHov);
- go.addEventListener('mouseleave', () => go.style.background = C.brand);
+ go.addEventListener('mouseenter', () => go.style.filter = 'brightness(1.1)');
+ go.addEventListener('mouseleave', () => go.style.filter = 'none');
go.addEventListener('mousedown', () => go.style.transform = 'scale(0.97)');
go.addEventListener('mouseup', () => go.style.transform = 'scale(1)');
go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); });
@@ -950,7 +987,7 @@
// Action label
const label = el('span', {
- fontWeight: '600', fontSize: '12px', color: C.ink,
+ fontWeight: '600', fontSize: '12px', color: BP.text,
flexShrink: '0', whiteSpace: 'nowrap',
});
label.textContent = actionLabel();
@@ -961,7 +998,7 @@
// Status
const status = el('span', {
- fontSize: '11px', color: C.ash, whiteSpace: 'nowrap',
+ fontSize: '11px', color: BP.textDim, whiteSpace: 'nowrap',
marginLeft: 'auto',
});
// Variants currently arrive atomically in a single file edit, so a
@@ -976,6 +1013,8 @@
// --- Cycling row ---
+ const TUNE_ICON_SVG = ' ';
+
function buildCyclingRow() {
const row = el('div', {
display: 'flex', alignItems: 'center', gap: '6px',
@@ -994,7 +1033,7 @@
// Counter
const counter = el('span', {
fontFamily: MONO, fontSize: '11px', fontWeight: '500',
- color: C.ash, minWidth: '24px', textAlign: 'center',
+ color: BP.textDim, minWidth: '24px', textAlign: 'center',
});
counter.textContent = visibleVariant + '/' + arrivedVariants;
row.appendChild(counter);
@@ -1005,20 +1044,67 @@
if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
row.appendChild(next);
+ // Tune chip — only when the visible variant exposes params
+ const visParams = parseVariantParams(getVisibleVariantEl());
+ const hasParams = visParams.length > 0;
+ if (hasParams) {
+ const tune = el('button', {
+ display: 'inline-flex', alignItems: 'center', gap: '6px',
+ padding: '4px 10px', borderRadius: '5px',
+ border: '1px solid transparent',
+ background: tuneOpen ? BP.accentSoft : 'transparent',
+ color: tuneOpen ? BP.accent : BP.text,
+ fontFamily: FONT, fontSize: '11px', fontWeight: '500',
+ cursor: 'pointer',
+ transition: 'color 0.12s ease, background 0.12s ease',
+ whiteSpace: 'nowrap',
+ });
+ tune.innerHTML = TUNE_ICON_SVG;
+ const tuneLabel = document.createElement('span');
+ tuneLabel.textContent = 'Tune';
+ tune.appendChild(tuneLabel);
+ const tuneBadge = document.createElement('span');
+ Object.assign(tuneBadge.style, {
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
+ minWidth: '16px', height: '16px', padding: '0 4px',
+ borderRadius: '999px',
+ background: tuneOpen ? C.brand : BP.hairline,
+ color: tuneOpen ? 'oklch(98% 0 0)' : 'inherit',
+ fontFamily: MONO, fontSize: '9.5px', fontWeight: '600',
+ lineHeight: '1',
+ boxSizing: 'border-box',
+ });
+ tuneBadge.textContent = String(visParams.length);
+ tune.appendChild(tuneBadge);
+ tune.title = 'Tune this variant (' + visParams.length + ' knob' + (visParams.length === 1 ? '' : 's') + ')';
+ tune.addEventListener('mouseenter', () => {
+ if (!tuneOpen) tune.style.background = BP.accentSoft;
+ });
+ tune.addEventListener('mouseleave', () => {
+ if (!tuneOpen) tune.style.background = 'transparent';
+ });
+ tune.addEventListener('click', (e) => { e.stopPropagation(); toggleTunePopover(); });
+ tune.dataset.iceqTune = '1';
+ row.appendChild(tune);
+ }
+
// Spacer
row.appendChild(el('div', { flex: '1' }));
- // Accept
+ // Accept — primary action, uses the site's saturated brand magenta
+ // with paper-white text, not the theme-muted BP.accent.
const accept = el('button', {
- padding: '4px 10px', borderRadius: '5px',
- border: 'none', background: C.ink, color: C.white,
+ padding: '5px 14px', borderRadius: '5px',
+ border: 'none', background: C.brand, color: 'oklch(98% 0 0)',
fontFamily: FONT, fontSize: '11px', fontWeight: '600',
- cursor: 'pointer', transition: 'background 0.12s ease',
+ cursor: 'pointer', transition: 'filter 0.12s ease, transform 0.1s ease',
whiteSpace: 'nowrap',
});
accept.textContent = '\u2713 Accept';
- accept.addEventListener('mouseenter', () => accept.style.background = C.brand);
- accept.addEventListener('mouseleave', () => accept.style.background = C.ink);
+ accept.addEventListener('mouseenter', () => accept.style.filter = 'brightness(1.08)');
+ accept.addEventListener('mouseleave', () => accept.style.filter = 'none');
+ accept.addEventListener('mousedown', () => accept.style.transform = 'scale(0.97)');
+ accept.addEventListener('mouseup', () => accept.style.transform = 'scale(1)');
accept.addEventListener('click', (e) => { e.stopPropagation(); handleAccept(); });
if (arrivedVariants === 0) { accept.style.opacity = '0.3'; accept.style.pointerEvents = 'none'; }
row.appendChild(accept);
@@ -1026,14 +1112,14 @@
// Discard
const discard = el('button', {
padding: '4px 6px', borderRadius: '5px',
- border: '1px solid ' + C.mist, background: 'transparent',
- fontFamily: FONT, fontSize: '11px', color: C.ash,
+ border: '1px solid ' + BP.hairline, background: 'transparent',
+ fontFamily: FONT, fontSize: '11px', color: BP.textDim,
cursor: 'pointer', transition: 'color 0.12s ease, border-color 0.12s ease',
});
discard.textContent = '\u2715';
discard.title = 'Discard all variants';
- discard.addEventListener('mouseenter', () => { discard.style.color = C.ink; discard.style.borderColor = C.ink; });
- discard.addEventListener('mouseleave', () => { discard.style.color = C.ash; discard.style.borderColor = C.mist; });
+ discard.addEventListener('mouseenter', () => { discard.style.color = BP.text; discard.style.borderColor = BP.text; });
+ discard.addEventListener('mouseleave', () => { discard.style.color = BP.textDim; discard.style.borderColor = BP.hairline; });
discard.addEventListener('click', (e) => { e.stopPropagation(); handleDiscard(); });
row.appendChild(discard);
@@ -1051,14 +1137,14 @@
});
const spinner = el('div', {
width: '14px', height: '14px', borderRadius: '50%',
- border: '2px solid ' + C.mist,
- borderTopColor: C.brand,
+ border: '2px solid ' + BP.hairline,
+ borderTopColor: BP.accent,
animation: 'impeccable-spin 0.6s linear infinite',
flexShrink: '0',
});
row.appendChild(spinner);
const label = el('span', {
- fontSize: '12px', color: C.ash, fontWeight: '500',
+ fontSize: '12px', color: BP.textDim, fontWeight: '500',
});
label.textContent = 'Applying variant...';
row.appendChild(label);
@@ -1103,14 +1189,26 @@
for (let i = 1; i <= expectedVariants; i++) {
const arrived = i <= arrivedVariants;
const active = i === visibleVariant;
+ // active: solid site-brand magenta 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
+ // magenta chips, especially when all variants had arrived and every
+ // dot wore an accent ring.
+ const dotBg = active ? C.brand
+ : arrived ? BP.textDim
+ : 'transparent';
+ const dotBorder = arrived ? 'none' : '1.5px solid ' + BP.hairline;
const dot = el('div', {
- width: '7px', height: '7px', borderRadius: '50%',
- background: active ? C.brand : (arrived ? C.ash : 'transparent'),
- border: '1.5px solid ' + (arrived ? C.brand : C.mist),
+ width: active ? '8px' : '6px',
+ height: active ? '8px' : '6px',
+ borderRadius: '50%',
+ background: dotBg,
+ border: dotBorder,
+ boxSizing: 'border-box',
transition: 'all 0.2s ' + EASE,
cursor: (clickable && arrived) ? 'pointer' : 'default',
- transform: arrived ? 'scale(1)' : 'scale(0.6)',
- opacity: arrived ? '1' : '0.4',
+ transform: arrived ? 'scale(1)' : 'scale(0.85)',
+ opacity: arrived ? (active ? '1' : '0.6') : '0.4',
});
if (clickable && arrived) {
const idx = i;
@@ -1130,15 +1228,15 @@
function navBtn(text) {
const b = el('button', {
width: '26px', height: '26px', borderRadius: '5px',
- border: '1px solid ' + C.mist, background: 'transparent',
- color: C.ink, fontFamily: FONT, fontSize: '13px',
+ border: '1px solid ' + BP.hairline, background: 'transparent',
+ color: BP.text, fontFamily: FONT, fontSize: '13px',
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'border-color 0.12s ease, background 0.12s ease',
padding: '0', lineHeight: '1',
});
b.textContent = text;
- b.addEventListener('mouseenter', () => { b.style.borderColor = C.ink; });
- b.addEventListener('mouseleave', () => { b.style.borderColor = C.mist; });
+ b.addEventListener('mouseenter', () => { b.style.borderColor = BP.text; });
+ b.addEventListener('mouseleave', () => { b.style.borderColor = BP.hairline; });
return b;
}
@@ -1158,6 +1256,7 @@
// ---------------------------------------------------------------------------
function initActionPicker() {
+ const P = barPaletteForTheme(detectPageTheme());
pickerEl = document.createElement('div');
pickerEl.id = PREFIX + '-picker';
Object.assign(pickerEl.style, {
@@ -1166,12 +1265,14 @@
transform: 'scale(0.96) translateY(4px)',
transformOrigin: 'bottom left',
transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE,
- background: C.paperSolid,
- border: '1px solid ' + C.mist,
+ background: P.surface,
+ border: '1px solid ' + P.hairline,
borderRadius: '10px',
boxShadow: '0 8px 30px oklch(0% 0 0 / 0.10), 0 2px 6px oklch(0% 0 0 / 0.06)',
padding: '6px',
fontFamily: FONT,
+ backdropFilter: 'blur(10px)',
+ WebkitBackdropFilter: 'blur(10px)',
});
// Build the chip grid
@@ -1181,22 +1282,32 @@
ACTIONS.forEach(action => {
const chip = el('button', {
- padding: '6px 8px', borderRadius: '6px',
+ display: 'flex', flexDirection: 'column', alignItems: 'center',
+ gap: '4px',
+ padding: '8px 6px', borderRadius: '6px',
border: 'none',
- background: action.value === selectedAction ? C.brandSoft : 'transparent',
- color: action.value === selectedAction ? C.brand : C.ink,
+ background: action.value === selectedAction ? P.accentSoft : 'transparent',
+ color: action.value === selectedAction ? P.accent : P.text,
fontFamily: FONT, fontSize: '11px', fontWeight: '500',
cursor: 'pointer',
transition: 'background 0.1s ease, color 0.1s ease',
textAlign: 'center', whiteSpace: 'nowrap',
});
- chip.textContent = action.label;
+ const iconWrap = el('span', {
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ height: '20px', opacity: '0.9',
+ });
+ iconWrap.innerHTML = ICONS[action.value] || '';
+ const labelEl = el('span', { lineHeight: '1' });
+ labelEl.textContent = action.label;
+ chip.appendChild(iconWrap);
+ chip.appendChild(labelEl);
chip.dataset.action = action.value;
chip.addEventListener('mouseenter', () => {
- if (action.value !== selectedAction) chip.style.background = C.brandSoft;
+ if (action.value !== selectedAction) chip.style.background = P.accentSoft;
});
chip.addEventListener('mouseleave', () => {
- chip.style.background = action.value === selectedAction ? C.brandSoft : 'transparent';
+ chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent';
});
chip.addEventListener('click', (e) => {
e.stopPropagation();
@@ -1209,19 +1320,24 @@
pickerEl.appendChild(grid);
document.body.appendChild(pickerEl);
+
+ // Cache the palette on the picker so toggleActionPicker's state refresh
+ // uses the same theme-aware colors when it repaints chips.
+ pickerEl.__iceq_palette = P;
}
function toggleActionPicker() {
if (pickerEl.style.display !== 'none') { hideActionPicker(); return; }
// Rebuild chips to reflect current selection
+ const P = pickerEl.__iceq_palette || barPaletteForTheme(detectPageTheme());
pickerEl.querySelectorAll('button').forEach(chip => {
const isActive = chip.dataset.action === selectedAction;
- chip.style.background = isActive ? C.brandSoft : 'transparent';
- chip.style.color = isActive ? C.brand : C.ink;
+ chip.style.background = isActive ? P.accentSoft : 'transparent';
+ chip.style.color = isActive ? P.accent : P.text;
});
// Position above the bar
const barRect = barEl.getBoundingClientRect();
- const pickerH = 90; // approximate
+ const pickerH = 170; // approximate; grows with icon + label rows
let top = barRect.top - pickerH - 6;
if (top < 8) top = barRect.bottom + 6;
Object.assign(pickerEl.style, {
@@ -1241,6 +1357,399 @@
setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180);
}
+ // ---------------------------------------------------------------------------
+ // Params panel (per-variant coarse controls)
+ //
+ // Variants may declare a parameter manifest via a JSON attribute on the
+ // variant wrapper:
+ //
+ //
+ //
+ // The panel docks to the right edge of the outline during CYCLING and
+ // exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped
+ // CSS can respond instantly without regeneration:
+ //
+ // range / numeric toggle → CSS var (`--p-
`) used via var(--p-foo, N)
+ // steps / boolean toggle → data-p- attribute used via :scope[data-p-foo="..."]
+ //
+ // On variant switch, values reset to that variant's declared defaults.
+ // On accept, current values are sent in the event payload so the agent
+ // can bake them into the source-file write.
+ // ---------------------------------------------------------------------------
+
+ 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 tuneOpen = false; // whether the Tune popover is open right now
+
+ // Theme-aware Tune popover. Appears as a drawer that slides out from the
+ // contextual bar's bar-facing edge (below if the bar sits below the
+ // element, above otherwise). Same width as the bar. Auto-wraps to extra
+ // rows when the knobs exceed one row. The bar's border-radius on the
+ // popover side goes flat while open so the two shapes read as one.
+ let paramsPanelPalette = null;
+
+ function initParamsPanel() {
+ paramsPanelPalette = barPaletteForTheme(detectPageTheme());
+ 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
+ // display toggle, no opacity toggle, no transform trickery. The mask
+ // hides everything initially; as it grows, content is revealed from
+ // the bar edge outward.
+ paramsPanelEl = document.createElement('div');
+ paramsPanelEl.id = PREFIX + '-params-panel';
+ Object.assign(paramsPanelEl.style, {
+ position: 'fixed', zIndex: String(Z.bar - 1),
+ background: P.surfaceDeep,
+ color: P.text,
+ fontFamily: FONT,
+ padding: '14px 18px',
+ boxSizing: 'border-box',
+ borderRadius: '0 0 10px 10px',
+ pointerEvents: 'none',
+ backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
+
+ // clip-path is the same conceptual reveal as mask but with rock-solid
+ // transition support across engines. Closed state clips from the far
+ // edge; open = inset(0) shows everything.
+ clipPath: 'inset(0 0 100% 0)',
+ 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
+ // top-left when first shown.
+ top: '-9999px', left: '-9999px', width: '0',
+ });
+
+ paramsPanelBody = el('div', {
+ display: 'grid',
+ gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
+ gap: '12px 16px',
+ });
+
+ paramsPanelEl.appendChild(paramsPanelBody);
+ document.body.appendChild(paramsPanelEl);
+ paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
+ }
+
+ function getVisibleVariantEl() {
+ if (!currentSessionId) return null;
+ const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
+ if (!wrapper) return null;
+ return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
+ }
+
+ function parseVariantParams(variantEl) {
+ if (!variantEl) return [];
+ const raw = variantEl.getAttribute('data-impeccable-params');
+ if (!raw) return [];
+ try {
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) ? parsed : [];
+ } catch (err) {
+ console.warn('[impeccable] Invalid data-impeccable-params JSON:', err.message);
+ return [];
+ }
+ }
+
+ function applyParamValue(variantEl, param, value) {
+ if (!variantEl) return;
+ const attr = 'data-p-' + param.id;
+ if (param.kind === 'range') {
+ variantEl.style.setProperty('--p-' + param.id, String(value));
+ } else if (param.kind === 'toggle') {
+ const on = !!value;
+ variantEl.style.setProperty('--p-' + param.id, on ? '1' : '0');
+ if (on) variantEl.setAttribute(attr, 'on');
+ else variantEl.removeAttribute(attr);
+ } else if (param.kind === 'steps') {
+ variantEl.setAttribute(attr, String(value));
+ }
+ }
+
+ function applyParamDefaults(variantEl, params) {
+ paramsCurrentValues = {};
+ for (const p of params) {
+ paramsCurrentValues[p.id] = p.default;
+ applyParamValue(variantEl, p, p.default);
+ }
+ }
+
+ function formatRangeValue(input) {
+ const max = parseFloat(input.max), min = parseFloat(input.min);
+ const v = parseFloat(input.value);
+ if (!isFinite(v)) return input.value;
+ return (max - min) <= 2 ? v.toFixed(2) : String(Math.round(v));
+ }
+
+ function buildParamsPanel(variantEl, params) {
+ const P = paramsPanelPalette || barPaletteForTheme(detectPageTheme());
+ paramsPanelBody.innerHTML = '';
+ for (const p of params) {
+ const row = el('div', { display: 'flex', flexDirection: 'column', gap: '6px' });
+ const labelRow = el('div', {
+ display: 'flex', justifyContent: 'space-between',
+ alignItems: 'baseline', gap: '8px',
+ });
+ const lbl = el('span', {
+ fontSize: '10.5px', fontWeight: '600', color: P.text,
+ letterSpacing: '0.03em',
+ });
+ lbl.textContent = p.label || p.id;
+ labelRow.appendChild(lbl);
+ const readout = el('span', {
+ fontSize: '10.5px', color: P.textDim,
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
+ });
+ labelRow.appendChild(readout);
+ row.appendChild(labelRow);
+
+ if (p.kind === 'range') {
+ const input = document.createElement('input');
+ input.type = 'range';
+ input.min = String(p.min != null ? p.min : 0);
+ input.max = String(p.max != null ? p.max : 1);
+ input.step = String(p.step != null ? p.step : 0.05);
+ input.value = String(p.default);
+ Object.assign(input.style, {
+ width: '100%', accentColor: C.brand, cursor: 'pointer',
+ });
+ readout.textContent = formatRangeValue(input);
+ input.addEventListener('input', (e) => {
+ e.stopPropagation();
+ const v = parseFloat(input.value);
+ paramsCurrentValues[p.id] = v;
+ readout.textContent = formatRangeValue(input);
+ applyParamValue(variantEl, p, v);
+ });
+ row.appendChild(input);
+ } else if (p.kind === 'toggle') {
+ const initial = !!p.default;
+ readout.textContent = initial ? 'On' : 'Off';
+ const track = el('button', {
+ position: 'relative', width: '36px', height: '20px',
+ borderRadius: '10px', border: 'none', padding: '0',
+ cursor: 'pointer',
+ background: initial ? C.brand : P.hairline,
+ transition: 'background 0.15s ease',
+ alignSelf: 'flex-start',
+ });
+ const knob = el('span', {
+ position: 'absolute', top: '2px',
+ left: initial ? '18px' : '2px',
+ width: '16px', height: '16px', borderRadius: '50%',
+ background: 'oklch(98% 0 0)',
+ transition: 'left 0.18s ' + EASE,
+ boxShadow: '0 1px 2px oklch(0% 0 0 / 0.2)',
+ });
+ track.appendChild(knob);
+ track.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const next = !paramsCurrentValues[p.id];
+ paramsCurrentValues[p.id] = next;
+ track.style.background = next ? C.brand : P.hairline;
+ knob.style.left = next ? '18px' : '2px';
+ readout.textContent = next ? 'On' : 'Off';
+ applyParamValue(variantEl, p, next);
+ });
+ row.appendChild(track);
+ } else if (p.kind === 'steps') {
+ const opts = (p.options || []).map(o =>
+ typeof o === 'string' ? { value: o, label: o } : o
+ );
+ const activeOpt = opts.find(o => o.value === p.default) || opts[0];
+ readout.textContent = activeOpt ? activeOpt.label : String(p.default);
+ const segRow = el('div', {
+ display: 'grid',
+ gridTemplateColumns: 'repeat(' + opts.length + ', 1fr)',
+ gap: '1px', padding: '2px',
+ background: P.hairline, borderRadius: '5px',
+ });
+ const segBtns = [];
+ opts.forEach(o => {
+ const active = o.value === p.default;
+ const b = el('button', {
+ padding: '5px 4px', border: 'none', borderRadius: '3px',
+ background: active ? C.brand : 'transparent',
+ color: active ? 'oklch(98% 0 0)' : P.text,
+ fontFamily: FONT, fontSize: '10.5px', fontWeight: '500',
+ cursor: 'pointer', whiteSpace: 'nowrap',
+ transition: 'background 0.1s ease, color 0.1s ease',
+ });
+ b.textContent = o.label;
+ b.addEventListener('click', (e) => {
+ e.stopPropagation();
+ paramsCurrentValues[p.id] = o.value;
+ readout.textContent = o.label;
+ segBtns.forEach(({ btn, val }) => {
+ const on = val === o.value;
+ btn.style.background = on ? C.brand : 'transparent';
+ btn.style.color = on ? 'oklch(98% 0 0)' : P.text;
+ });
+ applyParamValue(variantEl, p, o.value);
+ });
+ segRow.appendChild(b);
+ segBtns.push({ btn: b, val: o.value });
+ });
+ row.appendChild(segRow);
+ }
+
+ paramsPanelBody.appendChild(row);
+ }
+ }
+
+ // Decide which way the popover opens: away from the picked element. If the
+ // bar landed below the element, popover slides DOWN from the bar's bottom.
+ // If the bar landed above, popover slides UP from the bar's top.
+ function popoverDirection() {
+ if (!barEl || !selectedElement) return 'below';
+ const br = barEl.getBoundingClientRect();
+ const er = selectedElement.getBoundingClientRect();
+ return br.top >= er.bottom - 4 ? 'below' : 'above';
+ }
+
+ // The popover overlaps the bar by OVERLAP px on the bar-facing side. With
+ // popover z-index below bar, that overlap sits behind bar (invisible) and
+ // reinforces the "tucked behind" feel. Padding compensates so the real
+ // content starts flush with bar's outer edge.
+ const TUNE_OVERLAP = 6;
+
+ // Closed clip-path depends on direction: for 'below' clip from the far
+ // (bottom) edge so the reveal grows downward from the bar; for 'above'
+ // clip from the top edge so the reveal grows upward from the bar.
+ function closedClipPath(direction) {
+ return direction === 'below' ? 'inset(0 0 100% 0)' : 'inset(100% 0 0 0)';
+ }
+
+ function setClipPath(value, withTransition) {
+ const saved = paramsPanelEl.style.transition;
+ if (!withTransition) paramsPanelEl.style.transition = 'none';
+ paramsPanelEl.style.clipPath = value;
+ if (!withTransition) {
+ void paramsPanelEl.offsetHeight;
+ paramsPanelEl.style.transition = saved;
+ }
+ }
+
+ function positionParamsPanel() {
+ if (!paramsPanelEl || !barEl || barEl.style.display === 'none') return;
+ const br = barEl.getBoundingClientRect();
+ const direction = popoverDirection();
+ const prevDirection = paramsPanelEl.dataset.tuneDirection;
+
+ // top/left/width are NOT in the transition list, so they snap instantly.
+ paramsPanelEl.style.left = br.left + 'px';
+ paramsPanelEl.style.width = br.width + 'px';
+
+ if (direction === 'below') {
+ paramsPanelEl.style.top = (br.bottom - TUNE_OVERLAP) + 'px';
+ paramsPanelEl.style.borderRadius = '0 0 10px 10px';
+ paramsPanelEl.style.paddingTop = (14 + TUNE_OVERLAP) + 'px';
+ paramsPanelEl.style.paddingBottom = '14px';
+ } else {
+ const ih = paramsPanelEl.offsetHeight || 80;
+ paramsPanelEl.style.top = (br.top - ih + TUNE_OVERLAP) + 'px';
+ paramsPanelEl.style.borderRadius = '10px 10px 0 0';
+ paramsPanelEl.style.paddingTop = '14px';
+ paramsPanelEl.style.paddingBottom = (14 + TUNE_OVERLAP) + 'px';
+ }
+ paramsPanelEl.dataset.tuneDirection = direction;
+
+ // If currently closed and direction flipped (or first-time setup),
+ // snap the clip-path to the new direction's closed pose without
+ // transitioning (so the clip doesn't slide across the element).
+ if (!tuneOpen && (!prevDirection || prevDirection !== direction)) {
+ setClipPath(closedClipPath(direction), false);
+ }
+ }
+
+ function showParamsPanel() {
+ if (!paramsPanelEl) return;
+ positionParamsPanel();
+ paramsPanelEl.style.pointerEvents = 'auto';
+ // rAF so the positioning paint commits before the transition fires.
+ requestAnimationFrame(() => {
+ setClipPath('inset(0 0 0 0)', true);
+ });
+ }
+
+ function hideParamsPanel() {
+ if (!paramsPanelEl) return;
+ paramsPanelEl.style.pointerEvents = 'none';
+ const direction = paramsPanelEl.dataset.tuneDirection || 'below';
+ setClipPath(closedClipPath(direction), true);
+ }
+
+ // Build/rebuild the panel's contents for the current variant AND apply
+ // its defaults to the variant wrapper (so scoped CSS responds even before
+ // the user opens the popover). Visibility is governed by tuneOpen.
+ function refreshParamsPanel() {
+ if (state !== 'CYCLING') {
+ paramsCurrentValues = {};
+ tuneOpen = false;
+ hideParamsPanel();
+ return;
+ }
+ const variantEl = getVisibleVariantEl();
+ const params = parseVariantParams(variantEl);
+ if (!variantEl || params.length === 0) {
+ paramsCurrentValues = {};
+ tuneOpen = false;
+ hideParamsPanel();
+ return;
+ }
+ applyParamDefaults(variantEl, params);
+ buildParamsPanel(variantEl, params);
+ if (tuneOpen) {
+ // If already visible (variant cycled while open), refresh in place
+ // instead of re-running the clip-path animation.
+ const alreadyVisible = paramsPanelEl.style.display === 'block'
+ && paramsPanelEl.style.opacity === '1';
+ if (alreadyVisible) positionParamsPanel();
+ else showParamsPanel();
+ } else {
+ hideParamsPanel();
+ }
+ }
+
+ function toggleTunePopover() {
+ if (tuneOpen) { closeTunePopover(); return; }
+ openTunePopover();
+ }
+
+ function openTunePopover() {
+ if (state !== 'CYCLING') return;
+ const variantEl = getVisibleVariantEl();
+ const params = parseVariantParams(variantEl);
+ if (!variantEl || params.length === 0) return;
+ // Build fresh to ensure the current variant's controls are shown.
+ applyParamDefaults(variantEl, params);
+ buildParamsPanel(variantEl, params);
+ tuneOpen = true;
+ showParamsPanel();
+ // Kill the bar's shadow on the popover-facing side so the dark popover
+ // doesn't pick up a bright glow line.
+ if (barEl) {
+ const direction = paramsPanelEl?.dataset.tuneDirection || 'below';
+ barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN;
+ }
+ // Re-render the bar so the Tune chip picks up the active styling.
+ updateBarContent('cycling');
+ }
+
+ function closeTunePopover() {
+ tuneOpen = false;
+ hideParamsPanel();
+ if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT;
+ if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') {
+ updateBarContent('cycling');
+ }
+ }
+
// ---------------------------------------------------------------------------
// Variant cycling in DOM
// ---------------------------------------------------------------------------
@@ -1253,6 +1762,10 @@
if (!v) continue;
child.style.display = (v === String(num)) ? '' : 'none';
}
+ // 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();
}
/**
@@ -1316,6 +1829,7 @@
state = 'CYCLING';
hideShaderOverlay();
updateBarContent('cycling');
+ refreshParamsPanel();
saveSession();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
@@ -1329,7 +1843,7 @@
const next = visibleVariant + dir;
if (next < 1 || next > arrivedVariants) return;
visibleVariant = next;
- showVariantInDOM(currentSessionId, next);
+ showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself
updateSelectedElement();
updateBarContent('cycling');
saveSession();
@@ -1540,6 +2054,7 @@
state = 'CYCLING';
hideShaderOverlay();
updateBarContent('cycling');
+ refreshParamsPanel();
} else if (state === 'GENERATING') {
updateBarContent('generating');
}
@@ -1560,6 +2075,7 @@
if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') {
positionBar();
showHighlight(selectedElement);
+ if (tuneOpen) positionParamsPanel();
}
if (annotActive) positionAnnotOverlay(selectedElement);
// Shader overlay (via debug P toggle or generation) is repositioned
@@ -1606,6 +2122,7 @@
if (state === 'GENERATING') {
state = 'CYCLING';
updateBarContent('cycling');
+ refreshParamsPanel();
}
break;
}
@@ -1691,6 +2208,10 @@
if (pickerEl?.style.display !== 'none' && !own(e.target)) {
hideActionPicker();
}
+ // Close Tune popover on outside click (anything outside panel + bar)
+ if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) {
+ closeTunePopover();
+ }
// In CONFIGURING: click outside the bar and selected element returns to PICKING
if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) {
hideBar();
@@ -2258,7 +2779,11 @@ void main() {
function handleAccept() {
if (!currentSessionId || arrivedVariants === 0) return;
- sendEvent({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) });
+ const acceptPayload = { type: 'accept', id: currentSessionId, variantId: String(visibleVariant) };
+ if (Object.keys(paramsCurrentValues).length > 0) {
+ acceptPayload.paramValues = { ...paramsCurrentValues };
+ }
+ sendEvent(acceptPayload);
markSessionHandled();
// The accepted variant is already the only visible child of the wrapper
@@ -2477,6 +3002,11 @@ void main() {
state = arrivedVariants >= expectedVariants ? 'CYCLING' : 'GENERATING';
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
startScrollTracking();
+ // Build the params panel for the restored visible variant. Previously
+ // this was missed on page-reload resume: showVariantInDOM above fires
+ // refreshParamsPanel, but state was still IDLE at that moment so it
+ // hid. Now that state is CYCLING, re-fire.
+ if (state === 'CYCLING') refreshParamsPanel();
saveSession();
// Start observing for more variants AFTER initial setup
@@ -2522,6 +3052,11 @@ void main() {
// dark pages. This keeps the bar from fighting with the host design.
function detectPageTheme() {
try {
+ // Dev override: set localStorage 'impeccable-dev-theme' to 'light' or
+ // 'dark' to preview the opposite palette without actually changing the
+ // page bg. Used for screenshots and theme QA.
+ const override = localStorage.getItem('impeccable-dev-theme');
+ if (override === 'light' || override === 'dark') return override;
const bg = getComputedStyle(document.body).backgroundColor
|| getComputedStyle(document.documentElement).backgroundColor;
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
@@ -2538,6 +3073,7 @@ void main() {
// Light bar on dark page
return {
surface: 'oklch(98% 0 0 / 0.92)',
+ surfaceDeep: 'oklch(92% 0.005 60 / 0.96)', // slightly deeper, faint warm
hairline: 'oklch(70% 0 0 / 0.35)',
text: 'oklch(15% 0 0)',
textDim: 'oklch(45% 0 0)',
@@ -2552,6 +3088,7 @@ void main() {
// deeper so the rounded-right shape reads as a clear sculpted mark.
return {
surface: 'oklch(26% 0 0 / 0.94)',
+ surfaceDeep: 'oklch(18% 0 0 / 0.96)', // darker sand for Tune popover
hairline: 'oklch(42% 0 0 / 0.5)',
text: 'oklch(96% 0 0)',
textDim: 'oklch(72% 0 0)',
@@ -2883,6 +3420,7 @@ void main() {
if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; }
if (barEl) { barEl.remove(); barEl = null; }
if (pickerEl) { pickerEl.remove(); pickerEl = null; }
+ if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; }
if (evtSource) { evtSource.close(); evtSource = null; }
document.removeEventListener('mousemove', handleMouseMove, true);
document.removeEventListener('click', handleClick, true);
@@ -3956,6 +4494,7 @@ void main() {
initAnnotOverlay();
initBar();
initActionPicker();
+ initParamsPanel();
initGlobalBar();
initDesignPanel();
document.addEventListener('mousemove', handleMouseMove, true);
diff --git a/.rovodev/skills/impeccable/scripts/live-poll.mjs b/.rovodev/skills/impeccable/scripts/live-poll.mjs
index 9dd87e3ea..5cece1a43 100644
--- a/.rovodev/skills/impeccable/scripts/live-poll.mjs
+++ b/.rovodev/skills/impeccable/scripts/live-poll.mjs
@@ -145,6 +145,11 @@ Options:
const scriptArgs = event.type === 'discard'
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
+ if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
+ // Pass through a JSON blob; the shell-safe wrap uses single quotes because
+ // values are finite {id, number|string|boolean} pairs from a validated payload.
+ scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
+ }
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
diff --git a/.rovodev/skills/impeccable/scripts/live-server.mjs b/.rovodev/skills/impeccable/scripts/live-server.mjs
index e5fb05bfa..2ab33fd22 100644
--- a/.rovodev/skills/impeccable/scripts/live-server.mjs
+++ b/.rovodev/skills/impeccable/scripts/live-server.mjs
@@ -147,6 +147,11 @@ function validateEvent(msg) {
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
+ if (msg.paramValues !== undefined) {
+ if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
+ return 'accept: paramValues must be an object';
+ }
+ }
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
diff --git a/.trae-cn/skills/impeccable/reference/colorize.md b/.trae-cn/skills/impeccable/reference/colorize.md
index de74ac95f..1bd52004f 100644
--- a/.trae-cn/skills/impeccable/reference/colorize.md
+++ b/.trae-cn/skills/impeccable/reference/colorize.md
@@ -142,3 +142,13 @@ Test that colorization improves the experience:
- **Not overwhelming**: Is color balanced and purposeful?
Remember: Color is emotional and powerful. Use it to create warmth, guide attention, communicate meaning, and express personality. But restraint and strategy matter more than saturation and variety. Be colorful, but be intentional.
+
+## Live-mode signature params
+
+When invoked from live mode, each variant MUST declare a `color-amount` param so the user can dial between a restrained accent and a drenched surface without regeneration. Author the variant's CSS against `var(--p-color-amount, 0.5)` — typically as the alpha multiplier on backgrounds, or as a scaling factor on the chroma axis in an OKLCH expression. 0 = neutral/monochrome, 1 = full saturation / dominant coverage.
+
+```json
+{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"}
+```
+
+Layer 1-2 variant-specific params on top: palette selection (`steps` with named options), temperature warmth, or tint vs. true color. See `reference/live.md` for the full params contract.
diff --git a/.trae-cn/skills/impeccable/reference/layout.md b/.trae-cn/skills/impeccable/reference/layout.md
index 981ace8a1..5e8022081 100644
--- a/.trae-cn/skills/impeccable/reference/layout.md
+++ b/.trae-cn/skills/impeccable/reference/layout.md
@@ -119,3 +119,23 @@ Create a systematic plan:
- **Responsiveness**: Does the layout adapt gracefully across screen sizes?
Remember: Space is the most underused design tool. A layout with the right rhythm and hierarchy can make even simple content feel polished and intentional.
+
+## Live-mode signature params
+
+Each variant MUST declare a `density` param. Drive all spacing tokens in the variant's scoped CSS through `calc(var(--p-density, 1) * )` — paddings, gaps, column widths. Users slide from airy to packed and see layout re-breathe with no regeneration.
+
+```json
+{"id":"density","kind":"range","min":0.6,"max":1.4,"step":0.05,"default":1,"label":"Density"}
+```
+
+For variants whose topology genuinely changes (stacked vs. side-by-side, grid vs. bento), use a `steps` param whose scoped CSS branches via `:scope[data-p-structure="X"]`. One structure param + one density param is a powerful combo; resist adding a third.
+
+```json
+{"id":"structure","kind":"steps","default":"grid","label":"Structure","options":[
+ {"value":"stacked","label":"Stacked"},
+ {"value":"grid","label":"Grid"},
+ {"value":"bento","label":"Bento"}
+]}
+```
+
+See `reference/live.md` for the full params contract.
diff --git a/.trae-cn/skills/impeccable/reference/live.md b/.trae-cn/skills/impeccable/reference/live.md
index 87a6acc2b..25ce29189 100644
--- a/.trae-cn/skills/impeccable/reference/live.md
+++ b/.trae-cn/skills/impeccable/reference/live.md
@@ -179,7 +179,56 @@ The first variant has no `display: none` (visible by default). All others do. If
One edit, all variants — the browser's MutationObserver picks everything up in one pass.
-### 7. Signal done
+### 7. Parameters (optional, 2-5 per variant)
+
+Each variant can expose coarse knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against.
+
+**When to use.** Any time the variant has a meaningful axis the user might want to dial in: color amount, density, motion intensity, scale ratio. Not micro-level margin tweaks; those defeat the point.
+
+**Budget scales with the element's visual weight, not the user's curiosity.** Knobs need real estate to produce noticeably different output; slapping three on a small element just crowds the panel without making anything feel tunable.
+
+- **Leaf / tiny** — a single button, icon, input, bare heading, solitary paragraph: **0 params.** A slider can't meaningfully reshape one element; just use variants.
+- **Small composition** — labeled input, simple card, short callout (≤ ~5 visual children): **0-1 params.** Only add one if it's a clear dominant axis (e.g. density on a card with visible internal rhythm).
+- **Medium composition** — section component, nav cluster, dense card, short feature block (6-15 visual children): **2 params.**
+- **Large composition** — hero section, full page region, spread layout, anything with strong internal structure (16+ visual children or multiple sub-sections): **3-4 params.**
+
+When in doubt, fewer. The user can always ask for more variants to explore an axis you didn't expose as a knob. Count by visual children, not by DOM-node depth; a deeply-nested-but-visually-simple card still counts as small.
+
+**How to declare.** Put a JSON manifest on the variant wrapper:
+
+```html
+
+ ...variant content...
+
+```
+
+**Three kinds:**
+
+- `range` — smooth slider. Drives a CSS custom property `--p-` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`.
+- `steps` — segmented radio. Drives a data attribute `data-p-` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`.
+- `toggle` — on/off switch. Drives BOTH a CSS var (`--p-: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`.
+
+**Signature params per action.** Each action has one or two signature params that MUST be exposed when the variant can meaningfully express them. Check the action's reference file for the list. Layer 1-2 variant-specific params on top.
+
+**Reset on variant switch.** User dials density on v1, flips to v2, v2 starts at v2's declared defaults. Known limitation; preservation across variants may land later.
+
+**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment:
+
+```html
+
+```
+
+The carbonize cleanup step (see below) reads that comment and bakes the chosen values into the final CSS. For `steps`/`toggle` attribute selectors: keep only the branch matching the chosen value, drop the others, collapse `:scope[data-p-density="packed"] .grid` to a semantic class rule. For `range` vars: either substitute the literal or keep the var with the chosen value as its new default.
+
+### 8. Signal done
```bash
node .trae-cn/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
@@ -244,11 +293,11 @@ When `_acceptResult.carbonize === true`, the accepted variant was stitched into
Do these five steps in the current thread, synchronously, before the next poll. Do not poll again until the file is clean.
-1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `` and `` and contains a `');
+ if (paramValues && Object.keys(paramValues).length > 0) {
+ // Preserve the user's knob positions for the carbonize-cleanup agent
+ // to bake into the final CSS when it collapses scoped rules.
+ replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
+ }
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
diff --git a/.trae-cn/skills/impeccable/scripts/live-browser.js b/.trae-cn/skills/impeccable/scripts/live-browser.js
index 116e1efa6..66efd7243 100644
--- a/.trae-cn/skills/impeccable/scripts/live-browser.js
+++ b/.trae-cn/skills/impeccable/scripts/live-browser.js
@@ -30,10 +30,13 @@
// Design tokens
// ---------------------------------------------------------------------------
+ // Brand magenta is pinned to the site token (--color-accent in main.css)
+ // so Accept / knobs / cycle-dots match the site's accent, not a washed
+ // theme-adjusted one.
const C = {
- brand: 'oklch(55% 0.25 350)',
- brandHov: 'oklch(48% 0.25 350)',
- brandSoft: 'oklch(55% 0.25 350 / 0.12)',
+ brand: 'oklch(60% 0.25 350)',
+ brandHov: 'oklch(52% 0.25 350)',
+ brandSoft: 'oklch(60% 0.25 350 / 0.15)',
ink: 'oklch(15% 0.01 350)',
ash: 'oklch(55% 0 0)',
paper: 'oklch(98% 0.005 350 / 0.92)',
@@ -60,6 +63,25 @@
'html', 'head', 'body', 'script', 'style', 'link', 'meta', 'noscript', 'br', 'wbr',
]);
+ // 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.
+ 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: ` `,
+ bolder: ` `,
+ quieter: ` `,
+ distill: ` `,
+ polish: ` `,
+ typeset: ` `,
+ colorize: ` `,
+ layout: ` `,
+ adapt: ` `,
+ animate: ` `,
+ delight: ` `,
+ overdrive: ` `,
+ };
+
const ACTIONS = [
{ value: 'impeccable', label: 'Freeform' },
{ value: 'bolder', label: 'Bolder' },
@@ -756,7 +778,21 @@
// The Bar — one floating element, three modes
// ---------------------------------------------------------------------------
+ // Contextual-bar palette. Cached at init so every build*Row reads a
+ // consistent set of colors; detectPageTheme runs once rather than on every
+ // phase transition.
+ let BP = null;
+
+ // Bar shadow variants. The default projects down + subtle around. When
+ // the Tune popover opens below the bar, a downward shadow lands on the
+ // dark popover and reads as a bright ghost line. We swap to UP-only while
+ // tune is open below so the popover's top edge is clean.
+ const BAR_SHADOW_DEFAULT = '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)';
+ const BAR_SHADOW_UP = '0 -4px 20px oklch(0% 0 0 / 0.08), 0 -1px 3px oklch(0% 0 0 / 0.06)';
+ const BAR_SHADOW_DOWN = BAR_SHADOW_DEFAULT;
+
function initBar() {
+ BP = barPaletteForTheme(detectPageTheme());
barEl = document.createElement('div');
barEl.id = PREFIX + '-bar';
Object.assign(barEl.style, {
@@ -764,15 +800,15 @@
display: 'none', opacity: '0',
transform: 'translateY(6px)',
transition: 'opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
- // Visual
- background: C.paper,
+ background: BP.surface,
backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
- border: '1px solid ' + C.mist,
+ border: '1px solid ' + BP.hairline,
borderRadius: '10px',
- boxShadow: '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)',
- fontFamily: FONT, fontSize: '13px', color: C.ink,
+ boxShadow: BAR_SHADOW_DEFAULT,
+ transition: 'box-shadow 0.2s ease, opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
+ fontFamily: FONT, fontSize: '13px', color: BP.text,
padding: '6px',
- maxWidth: '460px', minWidth: '320px',
+ maxWidth: '520px', minWidth: '320px',
});
document.body.appendChild(barEl);
}
@@ -824,14 +860,15 @@
barEl.style.transform = 'translateY(6px)';
setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250);
hideActionPicker();
+ closeTunePopover();
}
function updateBarContent(mode) {
if (!barEl || barEl.style.display === 'none') return;
barEl.innerHTML = '';
- // Reset bar styling
- barEl.style.background = C.paper;
- barEl.style.border = '1px solid ' + C.mist;
+ // Reset bar styling to the theme-aware palette
+ barEl.style.background = BP.surface;
+ barEl.style.border = '1px solid ' + BP.hairline;
if (mode === 'configure') barEl.appendChild(buildConfigureRow());
else if (mode === 'generating') barEl.appendChild(buildGeneratingRow());
else if (mode === 'cycling') barEl.appendChild(buildCyclingRow());
@@ -854,15 +891,15 @@
const pill = el('button', {
display: 'inline-flex', alignItems: 'center', gap: '4px',
padding: '5px 10px', borderRadius: '6px',
- background: C.ink, color: C.white,
+ background: BP.mark, color: BP.markText,
fontFamily: FONT, fontSize: '12px', fontWeight: '500',
border: 'none', cursor: 'pointer',
transition: 'background 0.12s ease, transform 0.1s ease',
whiteSpace: 'nowrap', flexShrink: '0',
});
pill.textContent = actionLabel() + ' \u25BE';
- pill.addEventListener('mouseenter', () => pill.style.background = C.brandHov);
- pill.addEventListener('mouseleave', () => pill.style.background = C.ink);
+ pill.addEventListener('mouseenter', () => pill.style.background = BP.accent);
+ pill.addEventListener('mouseleave', () => pill.style.background = BP.mark);
pill.addEventListener('mousedown', () => pill.style.transform = 'scale(0.97)');
pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)');
pill.addEventListener('click', (e) => { e.stopPropagation(); toggleActionPicker(); });
@@ -877,13 +914,13 @@
flex: '1', minWidth: '0',
padding: '5px 8px', borderRadius: '6px',
border: '1px solid transparent', background: 'transparent',
- fontFamily: FONT, fontSize: '12px', color: C.ink,
+ fontFamily: FONT, fontSize: '12px', color: BP.text,
outline: 'none',
transition: 'border-color 0.15s ease, background 0.15s ease',
});
input.addEventListener('focus', () => {
- input.style.borderColor = C.mist;
- input.style.background = C.white;
+ input.style.borderColor = BP.hairline;
+ input.style.background = BP.accentSoft;
});
input.addEventListener('blur', () => {
input.style.borderColor = 'transparent';
@@ -901,16 +938,16 @@
// Variant count toggle
const count = el('button', {
padding: '4px 6px', borderRadius: '5px',
- border: '1px solid ' + C.mist, background: 'transparent',
+ border: '1px solid ' + BP.hairline, background: 'transparent',
fontFamily: MONO, fontSize: '11px', fontWeight: '600',
- color: C.ash, cursor: 'pointer',
+ color: BP.textDim, cursor: 'pointer',
transition: 'color 0.12s ease, border-color 0.12s ease',
flexShrink: '0', whiteSpace: 'nowrap',
});
count.textContent = '\u00D7' + selectedCount;
count.title = 'Variants: click to change';
- count.addEventListener('mouseenter', () => { count.style.color = C.ink; count.style.borderColor = C.ink; });
- count.addEventListener('mouseleave', () => { count.style.color = C.ash; count.style.borderColor = C.mist; });
+ count.addEventListener('mouseenter', () => { count.style.color = BP.text; count.style.borderColor = BP.text; });
+ count.addEventListener('mouseleave', () => { count.style.color = BP.textDim; count.style.borderColor = BP.hairline; });
count.addEventListener('click', (e) => {
e.stopPropagation();
selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1;
@@ -921,15 +958,15 @@
// Go button
const go = el('button', {
padding: '5px 12px', borderRadius: '6px',
- border: 'none', background: C.brand, color: C.white,
+ border: 'none', background: BP.accent, color: BP.mark,
fontFamily: FONT, fontSize: '12px', fontWeight: '600',
cursor: 'pointer',
- transition: 'background 0.12s ease, transform 0.1s ease',
+ transition: 'filter 0.12s ease, transform 0.1s ease',
flexShrink: '0', whiteSpace: 'nowrap',
});
go.textContent = 'Go \u2192';
- go.addEventListener('mouseenter', () => go.style.background = C.brandHov);
- go.addEventListener('mouseleave', () => go.style.background = C.brand);
+ go.addEventListener('mouseenter', () => go.style.filter = 'brightness(1.1)');
+ go.addEventListener('mouseleave', () => go.style.filter = 'none');
go.addEventListener('mousedown', () => go.style.transform = 'scale(0.97)');
go.addEventListener('mouseup', () => go.style.transform = 'scale(1)');
go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); });
@@ -950,7 +987,7 @@
// Action label
const label = el('span', {
- fontWeight: '600', fontSize: '12px', color: C.ink,
+ fontWeight: '600', fontSize: '12px', color: BP.text,
flexShrink: '0', whiteSpace: 'nowrap',
});
label.textContent = actionLabel();
@@ -961,7 +998,7 @@
// Status
const status = el('span', {
- fontSize: '11px', color: C.ash, whiteSpace: 'nowrap',
+ fontSize: '11px', color: BP.textDim, whiteSpace: 'nowrap',
marginLeft: 'auto',
});
// Variants currently arrive atomically in a single file edit, so a
@@ -976,6 +1013,8 @@
// --- Cycling row ---
+ const TUNE_ICON_SVG = ' ';
+
function buildCyclingRow() {
const row = el('div', {
display: 'flex', alignItems: 'center', gap: '6px',
@@ -994,7 +1033,7 @@
// Counter
const counter = el('span', {
fontFamily: MONO, fontSize: '11px', fontWeight: '500',
- color: C.ash, minWidth: '24px', textAlign: 'center',
+ color: BP.textDim, minWidth: '24px', textAlign: 'center',
});
counter.textContent = visibleVariant + '/' + arrivedVariants;
row.appendChild(counter);
@@ -1005,20 +1044,67 @@
if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
row.appendChild(next);
+ // Tune chip — only when the visible variant exposes params
+ const visParams = parseVariantParams(getVisibleVariantEl());
+ const hasParams = visParams.length > 0;
+ if (hasParams) {
+ const tune = el('button', {
+ display: 'inline-flex', alignItems: 'center', gap: '6px',
+ padding: '4px 10px', borderRadius: '5px',
+ border: '1px solid transparent',
+ background: tuneOpen ? BP.accentSoft : 'transparent',
+ color: tuneOpen ? BP.accent : BP.text,
+ fontFamily: FONT, fontSize: '11px', fontWeight: '500',
+ cursor: 'pointer',
+ transition: 'color 0.12s ease, background 0.12s ease',
+ whiteSpace: 'nowrap',
+ });
+ tune.innerHTML = TUNE_ICON_SVG;
+ const tuneLabel = document.createElement('span');
+ tuneLabel.textContent = 'Tune';
+ tune.appendChild(tuneLabel);
+ const tuneBadge = document.createElement('span');
+ Object.assign(tuneBadge.style, {
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
+ minWidth: '16px', height: '16px', padding: '0 4px',
+ borderRadius: '999px',
+ background: tuneOpen ? C.brand : BP.hairline,
+ color: tuneOpen ? 'oklch(98% 0 0)' : 'inherit',
+ fontFamily: MONO, fontSize: '9.5px', fontWeight: '600',
+ lineHeight: '1',
+ boxSizing: 'border-box',
+ });
+ tuneBadge.textContent = String(visParams.length);
+ tune.appendChild(tuneBadge);
+ tune.title = 'Tune this variant (' + visParams.length + ' knob' + (visParams.length === 1 ? '' : 's') + ')';
+ tune.addEventListener('mouseenter', () => {
+ if (!tuneOpen) tune.style.background = BP.accentSoft;
+ });
+ tune.addEventListener('mouseleave', () => {
+ if (!tuneOpen) tune.style.background = 'transparent';
+ });
+ tune.addEventListener('click', (e) => { e.stopPropagation(); toggleTunePopover(); });
+ tune.dataset.iceqTune = '1';
+ row.appendChild(tune);
+ }
+
// Spacer
row.appendChild(el('div', { flex: '1' }));
- // Accept
+ // Accept — primary action, uses the site's saturated brand magenta
+ // with paper-white text, not the theme-muted BP.accent.
const accept = el('button', {
- padding: '4px 10px', borderRadius: '5px',
- border: 'none', background: C.ink, color: C.white,
+ padding: '5px 14px', borderRadius: '5px',
+ border: 'none', background: C.brand, color: 'oklch(98% 0 0)',
fontFamily: FONT, fontSize: '11px', fontWeight: '600',
- cursor: 'pointer', transition: 'background 0.12s ease',
+ cursor: 'pointer', transition: 'filter 0.12s ease, transform 0.1s ease',
whiteSpace: 'nowrap',
});
accept.textContent = '\u2713 Accept';
- accept.addEventListener('mouseenter', () => accept.style.background = C.brand);
- accept.addEventListener('mouseleave', () => accept.style.background = C.ink);
+ accept.addEventListener('mouseenter', () => accept.style.filter = 'brightness(1.08)');
+ accept.addEventListener('mouseleave', () => accept.style.filter = 'none');
+ accept.addEventListener('mousedown', () => accept.style.transform = 'scale(0.97)');
+ accept.addEventListener('mouseup', () => accept.style.transform = 'scale(1)');
accept.addEventListener('click', (e) => { e.stopPropagation(); handleAccept(); });
if (arrivedVariants === 0) { accept.style.opacity = '0.3'; accept.style.pointerEvents = 'none'; }
row.appendChild(accept);
@@ -1026,14 +1112,14 @@
// Discard
const discard = el('button', {
padding: '4px 6px', borderRadius: '5px',
- border: '1px solid ' + C.mist, background: 'transparent',
- fontFamily: FONT, fontSize: '11px', color: C.ash,
+ border: '1px solid ' + BP.hairline, background: 'transparent',
+ fontFamily: FONT, fontSize: '11px', color: BP.textDim,
cursor: 'pointer', transition: 'color 0.12s ease, border-color 0.12s ease',
});
discard.textContent = '\u2715';
discard.title = 'Discard all variants';
- discard.addEventListener('mouseenter', () => { discard.style.color = C.ink; discard.style.borderColor = C.ink; });
- discard.addEventListener('mouseleave', () => { discard.style.color = C.ash; discard.style.borderColor = C.mist; });
+ discard.addEventListener('mouseenter', () => { discard.style.color = BP.text; discard.style.borderColor = BP.text; });
+ discard.addEventListener('mouseleave', () => { discard.style.color = BP.textDim; discard.style.borderColor = BP.hairline; });
discard.addEventListener('click', (e) => { e.stopPropagation(); handleDiscard(); });
row.appendChild(discard);
@@ -1051,14 +1137,14 @@
});
const spinner = el('div', {
width: '14px', height: '14px', borderRadius: '50%',
- border: '2px solid ' + C.mist,
- borderTopColor: C.brand,
+ border: '2px solid ' + BP.hairline,
+ borderTopColor: BP.accent,
animation: 'impeccable-spin 0.6s linear infinite',
flexShrink: '0',
});
row.appendChild(spinner);
const label = el('span', {
- fontSize: '12px', color: C.ash, fontWeight: '500',
+ fontSize: '12px', color: BP.textDim, fontWeight: '500',
});
label.textContent = 'Applying variant...';
row.appendChild(label);
@@ -1103,14 +1189,26 @@
for (let i = 1; i <= expectedVariants; i++) {
const arrived = i <= arrivedVariants;
const active = i === visibleVariant;
+ // active: solid site-brand magenta 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
+ // magenta chips, especially when all variants had arrived and every
+ // dot wore an accent ring.
+ const dotBg = active ? C.brand
+ : arrived ? BP.textDim
+ : 'transparent';
+ const dotBorder = arrived ? 'none' : '1.5px solid ' + BP.hairline;
const dot = el('div', {
- width: '7px', height: '7px', borderRadius: '50%',
- background: active ? C.brand : (arrived ? C.ash : 'transparent'),
- border: '1.5px solid ' + (arrived ? C.brand : C.mist),
+ width: active ? '8px' : '6px',
+ height: active ? '8px' : '6px',
+ borderRadius: '50%',
+ background: dotBg,
+ border: dotBorder,
+ boxSizing: 'border-box',
transition: 'all 0.2s ' + EASE,
cursor: (clickable && arrived) ? 'pointer' : 'default',
- transform: arrived ? 'scale(1)' : 'scale(0.6)',
- opacity: arrived ? '1' : '0.4',
+ transform: arrived ? 'scale(1)' : 'scale(0.85)',
+ opacity: arrived ? (active ? '1' : '0.6') : '0.4',
});
if (clickable && arrived) {
const idx = i;
@@ -1130,15 +1228,15 @@
function navBtn(text) {
const b = el('button', {
width: '26px', height: '26px', borderRadius: '5px',
- border: '1px solid ' + C.mist, background: 'transparent',
- color: C.ink, fontFamily: FONT, fontSize: '13px',
+ border: '1px solid ' + BP.hairline, background: 'transparent',
+ color: BP.text, fontFamily: FONT, fontSize: '13px',
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'border-color 0.12s ease, background 0.12s ease',
padding: '0', lineHeight: '1',
});
b.textContent = text;
- b.addEventListener('mouseenter', () => { b.style.borderColor = C.ink; });
- b.addEventListener('mouseleave', () => { b.style.borderColor = C.mist; });
+ b.addEventListener('mouseenter', () => { b.style.borderColor = BP.text; });
+ b.addEventListener('mouseleave', () => { b.style.borderColor = BP.hairline; });
return b;
}
@@ -1158,6 +1256,7 @@
// ---------------------------------------------------------------------------
function initActionPicker() {
+ const P = barPaletteForTheme(detectPageTheme());
pickerEl = document.createElement('div');
pickerEl.id = PREFIX + '-picker';
Object.assign(pickerEl.style, {
@@ -1166,12 +1265,14 @@
transform: 'scale(0.96) translateY(4px)',
transformOrigin: 'bottom left',
transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE,
- background: C.paperSolid,
- border: '1px solid ' + C.mist,
+ background: P.surface,
+ border: '1px solid ' + P.hairline,
borderRadius: '10px',
boxShadow: '0 8px 30px oklch(0% 0 0 / 0.10), 0 2px 6px oklch(0% 0 0 / 0.06)',
padding: '6px',
fontFamily: FONT,
+ backdropFilter: 'blur(10px)',
+ WebkitBackdropFilter: 'blur(10px)',
});
// Build the chip grid
@@ -1181,22 +1282,32 @@
ACTIONS.forEach(action => {
const chip = el('button', {
- padding: '6px 8px', borderRadius: '6px',
+ display: 'flex', flexDirection: 'column', alignItems: 'center',
+ gap: '4px',
+ padding: '8px 6px', borderRadius: '6px',
border: 'none',
- background: action.value === selectedAction ? C.brandSoft : 'transparent',
- color: action.value === selectedAction ? C.brand : C.ink,
+ background: action.value === selectedAction ? P.accentSoft : 'transparent',
+ color: action.value === selectedAction ? P.accent : P.text,
fontFamily: FONT, fontSize: '11px', fontWeight: '500',
cursor: 'pointer',
transition: 'background 0.1s ease, color 0.1s ease',
textAlign: 'center', whiteSpace: 'nowrap',
});
- chip.textContent = action.label;
+ const iconWrap = el('span', {
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ height: '20px', opacity: '0.9',
+ });
+ iconWrap.innerHTML = ICONS[action.value] || '';
+ const labelEl = el('span', { lineHeight: '1' });
+ labelEl.textContent = action.label;
+ chip.appendChild(iconWrap);
+ chip.appendChild(labelEl);
chip.dataset.action = action.value;
chip.addEventListener('mouseenter', () => {
- if (action.value !== selectedAction) chip.style.background = C.brandSoft;
+ if (action.value !== selectedAction) chip.style.background = P.accentSoft;
});
chip.addEventListener('mouseleave', () => {
- chip.style.background = action.value === selectedAction ? C.brandSoft : 'transparent';
+ chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent';
});
chip.addEventListener('click', (e) => {
e.stopPropagation();
@@ -1209,19 +1320,24 @@
pickerEl.appendChild(grid);
document.body.appendChild(pickerEl);
+
+ // Cache the palette on the picker so toggleActionPicker's state refresh
+ // uses the same theme-aware colors when it repaints chips.
+ pickerEl.__iceq_palette = P;
}
function toggleActionPicker() {
if (pickerEl.style.display !== 'none') { hideActionPicker(); return; }
// Rebuild chips to reflect current selection
+ const P = pickerEl.__iceq_palette || barPaletteForTheme(detectPageTheme());
pickerEl.querySelectorAll('button').forEach(chip => {
const isActive = chip.dataset.action === selectedAction;
- chip.style.background = isActive ? C.brandSoft : 'transparent';
- chip.style.color = isActive ? C.brand : C.ink;
+ chip.style.background = isActive ? P.accentSoft : 'transparent';
+ chip.style.color = isActive ? P.accent : P.text;
});
// Position above the bar
const barRect = barEl.getBoundingClientRect();
- const pickerH = 90; // approximate
+ const pickerH = 170; // approximate; grows with icon + label rows
let top = barRect.top - pickerH - 6;
if (top < 8) top = barRect.bottom + 6;
Object.assign(pickerEl.style, {
@@ -1241,6 +1357,399 @@
setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180);
}
+ // ---------------------------------------------------------------------------
+ // Params panel (per-variant coarse controls)
+ //
+ // Variants may declare a parameter manifest via a JSON attribute on the
+ // variant wrapper:
+ //
+ //
+ //
+ // The panel docks to the right edge of the outline during CYCLING and
+ // exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped
+ // CSS can respond instantly without regeneration:
+ //
+ // range / numeric toggle → CSS var (`--p-
`) used via var(--p-foo, N)
+ // steps / boolean toggle → data-p- attribute used via :scope[data-p-foo="..."]
+ //
+ // On variant switch, values reset to that variant's declared defaults.
+ // On accept, current values are sent in the event payload so the agent
+ // can bake them into the source-file write.
+ // ---------------------------------------------------------------------------
+
+ 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 tuneOpen = false; // whether the Tune popover is open right now
+
+ // Theme-aware Tune popover. Appears as a drawer that slides out from the
+ // contextual bar's bar-facing edge (below if the bar sits below the
+ // element, above otherwise). Same width as the bar. Auto-wraps to extra
+ // rows when the knobs exceed one row. The bar's border-radius on the
+ // popover side goes flat while open so the two shapes read as one.
+ let paramsPanelPalette = null;
+
+ function initParamsPanel() {
+ paramsPanelPalette = barPaletteForTheme(detectPageTheme());
+ 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
+ // display toggle, no opacity toggle, no transform trickery. The mask
+ // hides everything initially; as it grows, content is revealed from
+ // the bar edge outward.
+ paramsPanelEl = document.createElement('div');
+ paramsPanelEl.id = PREFIX + '-params-panel';
+ Object.assign(paramsPanelEl.style, {
+ position: 'fixed', zIndex: String(Z.bar - 1),
+ background: P.surfaceDeep,
+ color: P.text,
+ fontFamily: FONT,
+ padding: '14px 18px',
+ boxSizing: 'border-box',
+ borderRadius: '0 0 10px 10px',
+ pointerEvents: 'none',
+ backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
+
+ // clip-path is the same conceptual reveal as mask but with rock-solid
+ // transition support across engines. Closed state clips from the far
+ // edge; open = inset(0) shows everything.
+ clipPath: 'inset(0 0 100% 0)',
+ 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
+ // top-left when first shown.
+ top: '-9999px', left: '-9999px', width: '0',
+ });
+
+ paramsPanelBody = el('div', {
+ display: 'grid',
+ gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
+ gap: '12px 16px',
+ });
+
+ paramsPanelEl.appendChild(paramsPanelBody);
+ document.body.appendChild(paramsPanelEl);
+ paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
+ }
+
+ function getVisibleVariantEl() {
+ if (!currentSessionId) return null;
+ const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
+ if (!wrapper) return null;
+ return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
+ }
+
+ function parseVariantParams(variantEl) {
+ if (!variantEl) return [];
+ const raw = variantEl.getAttribute('data-impeccable-params');
+ if (!raw) return [];
+ try {
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) ? parsed : [];
+ } catch (err) {
+ console.warn('[impeccable] Invalid data-impeccable-params JSON:', err.message);
+ return [];
+ }
+ }
+
+ function applyParamValue(variantEl, param, value) {
+ if (!variantEl) return;
+ const attr = 'data-p-' + param.id;
+ if (param.kind === 'range') {
+ variantEl.style.setProperty('--p-' + param.id, String(value));
+ } else if (param.kind === 'toggle') {
+ const on = !!value;
+ variantEl.style.setProperty('--p-' + param.id, on ? '1' : '0');
+ if (on) variantEl.setAttribute(attr, 'on');
+ else variantEl.removeAttribute(attr);
+ } else if (param.kind === 'steps') {
+ variantEl.setAttribute(attr, String(value));
+ }
+ }
+
+ function applyParamDefaults(variantEl, params) {
+ paramsCurrentValues = {};
+ for (const p of params) {
+ paramsCurrentValues[p.id] = p.default;
+ applyParamValue(variantEl, p, p.default);
+ }
+ }
+
+ function formatRangeValue(input) {
+ const max = parseFloat(input.max), min = parseFloat(input.min);
+ const v = parseFloat(input.value);
+ if (!isFinite(v)) return input.value;
+ return (max - min) <= 2 ? v.toFixed(2) : String(Math.round(v));
+ }
+
+ function buildParamsPanel(variantEl, params) {
+ const P = paramsPanelPalette || barPaletteForTheme(detectPageTheme());
+ paramsPanelBody.innerHTML = '';
+ for (const p of params) {
+ const row = el('div', { display: 'flex', flexDirection: 'column', gap: '6px' });
+ const labelRow = el('div', {
+ display: 'flex', justifyContent: 'space-between',
+ alignItems: 'baseline', gap: '8px',
+ });
+ const lbl = el('span', {
+ fontSize: '10.5px', fontWeight: '600', color: P.text,
+ letterSpacing: '0.03em',
+ });
+ lbl.textContent = p.label || p.id;
+ labelRow.appendChild(lbl);
+ const readout = el('span', {
+ fontSize: '10.5px', color: P.textDim,
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
+ });
+ labelRow.appendChild(readout);
+ row.appendChild(labelRow);
+
+ if (p.kind === 'range') {
+ const input = document.createElement('input');
+ input.type = 'range';
+ input.min = String(p.min != null ? p.min : 0);
+ input.max = String(p.max != null ? p.max : 1);
+ input.step = String(p.step != null ? p.step : 0.05);
+ input.value = String(p.default);
+ Object.assign(input.style, {
+ width: '100%', accentColor: C.brand, cursor: 'pointer',
+ });
+ readout.textContent = formatRangeValue(input);
+ input.addEventListener('input', (e) => {
+ e.stopPropagation();
+ const v = parseFloat(input.value);
+ paramsCurrentValues[p.id] = v;
+ readout.textContent = formatRangeValue(input);
+ applyParamValue(variantEl, p, v);
+ });
+ row.appendChild(input);
+ } else if (p.kind === 'toggle') {
+ const initial = !!p.default;
+ readout.textContent = initial ? 'On' : 'Off';
+ const track = el('button', {
+ position: 'relative', width: '36px', height: '20px',
+ borderRadius: '10px', border: 'none', padding: '0',
+ cursor: 'pointer',
+ background: initial ? C.brand : P.hairline,
+ transition: 'background 0.15s ease',
+ alignSelf: 'flex-start',
+ });
+ const knob = el('span', {
+ position: 'absolute', top: '2px',
+ left: initial ? '18px' : '2px',
+ width: '16px', height: '16px', borderRadius: '50%',
+ background: 'oklch(98% 0 0)',
+ transition: 'left 0.18s ' + EASE,
+ boxShadow: '0 1px 2px oklch(0% 0 0 / 0.2)',
+ });
+ track.appendChild(knob);
+ track.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const next = !paramsCurrentValues[p.id];
+ paramsCurrentValues[p.id] = next;
+ track.style.background = next ? C.brand : P.hairline;
+ knob.style.left = next ? '18px' : '2px';
+ readout.textContent = next ? 'On' : 'Off';
+ applyParamValue(variantEl, p, next);
+ });
+ row.appendChild(track);
+ } else if (p.kind === 'steps') {
+ const opts = (p.options || []).map(o =>
+ typeof o === 'string' ? { value: o, label: o } : o
+ );
+ const activeOpt = opts.find(o => o.value === p.default) || opts[0];
+ readout.textContent = activeOpt ? activeOpt.label : String(p.default);
+ const segRow = el('div', {
+ display: 'grid',
+ gridTemplateColumns: 'repeat(' + opts.length + ', 1fr)',
+ gap: '1px', padding: '2px',
+ background: P.hairline, borderRadius: '5px',
+ });
+ const segBtns = [];
+ opts.forEach(o => {
+ const active = o.value === p.default;
+ const b = el('button', {
+ padding: '5px 4px', border: 'none', borderRadius: '3px',
+ background: active ? C.brand : 'transparent',
+ color: active ? 'oklch(98% 0 0)' : P.text,
+ fontFamily: FONT, fontSize: '10.5px', fontWeight: '500',
+ cursor: 'pointer', whiteSpace: 'nowrap',
+ transition: 'background 0.1s ease, color 0.1s ease',
+ });
+ b.textContent = o.label;
+ b.addEventListener('click', (e) => {
+ e.stopPropagation();
+ paramsCurrentValues[p.id] = o.value;
+ readout.textContent = o.label;
+ segBtns.forEach(({ btn, val }) => {
+ const on = val === o.value;
+ btn.style.background = on ? C.brand : 'transparent';
+ btn.style.color = on ? 'oklch(98% 0 0)' : P.text;
+ });
+ applyParamValue(variantEl, p, o.value);
+ });
+ segRow.appendChild(b);
+ segBtns.push({ btn: b, val: o.value });
+ });
+ row.appendChild(segRow);
+ }
+
+ paramsPanelBody.appendChild(row);
+ }
+ }
+
+ // Decide which way the popover opens: away from the picked element. If the
+ // bar landed below the element, popover slides DOWN from the bar's bottom.
+ // If the bar landed above, popover slides UP from the bar's top.
+ function popoverDirection() {
+ if (!barEl || !selectedElement) return 'below';
+ const br = barEl.getBoundingClientRect();
+ const er = selectedElement.getBoundingClientRect();
+ return br.top >= er.bottom - 4 ? 'below' : 'above';
+ }
+
+ // The popover overlaps the bar by OVERLAP px on the bar-facing side. With
+ // popover z-index below bar, that overlap sits behind bar (invisible) and
+ // reinforces the "tucked behind" feel. Padding compensates so the real
+ // content starts flush with bar's outer edge.
+ const TUNE_OVERLAP = 6;
+
+ // Closed clip-path depends on direction: for 'below' clip from the far
+ // (bottom) edge so the reveal grows downward from the bar; for 'above'
+ // clip from the top edge so the reveal grows upward from the bar.
+ function closedClipPath(direction) {
+ return direction === 'below' ? 'inset(0 0 100% 0)' : 'inset(100% 0 0 0)';
+ }
+
+ function setClipPath(value, withTransition) {
+ const saved = paramsPanelEl.style.transition;
+ if (!withTransition) paramsPanelEl.style.transition = 'none';
+ paramsPanelEl.style.clipPath = value;
+ if (!withTransition) {
+ void paramsPanelEl.offsetHeight;
+ paramsPanelEl.style.transition = saved;
+ }
+ }
+
+ function positionParamsPanel() {
+ if (!paramsPanelEl || !barEl || barEl.style.display === 'none') return;
+ const br = barEl.getBoundingClientRect();
+ const direction = popoverDirection();
+ const prevDirection = paramsPanelEl.dataset.tuneDirection;
+
+ // top/left/width are NOT in the transition list, so they snap instantly.
+ paramsPanelEl.style.left = br.left + 'px';
+ paramsPanelEl.style.width = br.width + 'px';
+
+ if (direction === 'below') {
+ paramsPanelEl.style.top = (br.bottom - TUNE_OVERLAP) + 'px';
+ paramsPanelEl.style.borderRadius = '0 0 10px 10px';
+ paramsPanelEl.style.paddingTop = (14 + TUNE_OVERLAP) + 'px';
+ paramsPanelEl.style.paddingBottom = '14px';
+ } else {
+ const ih = paramsPanelEl.offsetHeight || 80;
+ paramsPanelEl.style.top = (br.top - ih + TUNE_OVERLAP) + 'px';
+ paramsPanelEl.style.borderRadius = '10px 10px 0 0';
+ paramsPanelEl.style.paddingTop = '14px';
+ paramsPanelEl.style.paddingBottom = (14 + TUNE_OVERLAP) + 'px';
+ }
+ paramsPanelEl.dataset.tuneDirection = direction;
+
+ // If currently closed and direction flipped (or first-time setup),
+ // snap the clip-path to the new direction's closed pose without
+ // transitioning (so the clip doesn't slide across the element).
+ if (!tuneOpen && (!prevDirection || prevDirection !== direction)) {
+ setClipPath(closedClipPath(direction), false);
+ }
+ }
+
+ function showParamsPanel() {
+ if (!paramsPanelEl) return;
+ positionParamsPanel();
+ paramsPanelEl.style.pointerEvents = 'auto';
+ // rAF so the positioning paint commits before the transition fires.
+ requestAnimationFrame(() => {
+ setClipPath('inset(0 0 0 0)', true);
+ });
+ }
+
+ function hideParamsPanel() {
+ if (!paramsPanelEl) return;
+ paramsPanelEl.style.pointerEvents = 'none';
+ const direction = paramsPanelEl.dataset.tuneDirection || 'below';
+ setClipPath(closedClipPath(direction), true);
+ }
+
+ // Build/rebuild the panel's contents for the current variant AND apply
+ // its defaults to the variant wrapper (so scoped CSS responds even before
+ // the user opens the popover). Visibility is governed by tuneOpen.
+ function refreshParamsPanel() {
+ if (state !== 'CYCLING') {
+ paramsCurrentValues = {};
+ tuneOpen = false;
+ hideParamsPanel();
+ return;
+ }
+ const variantEl = getVisibleVariantEl();
+ const params = parseVariantParams(variantEl);
+ if (!variantEl || params.length === 0) {
+ paramsCurrentValues = {};
+ tuneOpen = false;
+ hideParamsPanel();
+ return;
+ }
+ applyParamDefaults(variantEl, params);
+ buildParamsPanel(variantEl, params);
+ if (tuneOpen) {
+ // If already visible (variant cycled while open), refresh in place
+ // instead of re-running the clip-path animation.
+ const alreadyVisible = paramsPanelEl.style.display === 'block'
+ && paramsPanelEl.style.opacity === '1';
+ if (alreadyVisible) positionParamsPanel();
+ else showParamsPanel();
+ } else {
+ hideParamsPanel();
+ }
+ }
+
+ function toggleTunePopover() {
+ if (tuneOpen) { closeTunePopover(); return; }
+ openTunePopover();
+ }
+
+ function openTunePopover() {
+ if (state !== 'CYCLING') return;
+ const variantEl = getVisibleVariantEl();
+ const params = parseVariantParams(variantEl);
+ if (!variantEl || params.length === 0) return;
+ // Build fresh to ensure the current variant's controls are shown.
+ applyParamDefaults(variantEl, params);
+ buildParamsPanel(variantEl, params);
+ tuneOpen = true;
+ showParamsPanel();
+ // Kill the bar's shadow on the popover-facing side so the dark popover
+ // doesn't pick up a bright glow line.
+ if (barEl) {
+ const direction = paramsPanelEl?.dataset.tuneDirection || 'below';
+ barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN;
+ }
+ // Re-render the bar so the Tune chip picks up the active styling.
+ updateBarContent('cycling');
+ }
+
+ function closeTunePopover() {
+ tuneOpen = false;
+ hideParamsPanel();
+ if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT;
+ if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') {
+ updateBarContent('cycling');
+ }
+ }
+
// ---------------------------------------------------------------------------
// Variant cycling in DOM
// ---------------------------------------------------------------------------
@@ -1253,6 +1762,10 @@
if (!v) continue;
child.style.display = (v === String(num)) ? '' : 'none';
}
+ // 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();
}
/**
@@ -1316,6 +1829,7 @@
state = 'CYCLING';
hideShaderOverlay();
updateBarContent('cycling');
+ refreshParamsPanel();
saveSession();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
@@ -1329,7 +1843,7 @@
const next = visibleVariant + dir;
if (next < 1 || next > arrivedVariants) return;
visibleVariant = next;
- showVariantInDOM(currentSessionId, next);
+ showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself
updateSelectedElement();
updateBarContent('cycling');
saveSession();
@@ -1540,6 +2054,7 @@
state = 'CYCLING';
hideShaderOverlay();
updateBarContent('cycling');
+ refreshParamsPanel();
} else if (state === 'GENERATING') {
updateBarContent('generating');
}
@@ -1560,6 +2075,7 @@
if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') {
positionBar();
showHighlight(selectedElement);
+ if (tuneOpen) positionParamsPanel();
}
if (annotActive) positionAnnotOverlay(selectedElement);
// Shader overlay (via debug P toggle or generation) is repositioned
@@ -1606,6 +2122,7 @@
if (state === 'GENERATING') {
state = 'CYCLING';
updateBarContent('cycling');
+ refreshParamsPanel();
}
break;
}
@@ -1691,6 +2208,10 @@
if (pickerEl?.style.display !== 'none' && !own(e.target)) {
hideActionPicker();
}
+ // Close Tune popover on outside click (anything outside panel + bar)
+ if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) {
+ closeTunePopover();
+ }
// In CONFIGURING: click outside the bar and selected element returns to PICKING
if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) {
hideBar();
@@ -2258,7 +2779,11 @@ void main() {
function handleAccept() {
if (!currentSessionId || arrivedVariants === 0) return;
- sendEvent({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) });
+ const acceptPayload = { type: 'accept', id: currentSessionId, variantId: String(visibleVariant) };
+ if (Object.keys(paramsCurrentValues).length > 0) {
+ acceptPayload.paramValues = { ...paramsCurrentValues };
+ }
+ sendEvent(acceptPayload);
markSessionHandled();
// The accepted variant is already the only visible child of the wrapper
@@ -2477,6 +3002,11 @@ void main() {
state = arrivedVariants >= expectedVariants ? 'CYCLING' : 'GENERATING';
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
startScrollTracking();
+ // Build the params panel for the restored visible variant. Previously
+ // this was missed on page-reload resume: showVariantInDOM above fires
+ // refreshParamsPanel, but state was still IDLE at that moment so it
+ // hid. Now that state is CYCLING, re-fire.
+ if (state === 'CYCLING') refreshParamsPanel();
saveSession();
// Start observing for more variants AFTER initial setup
@@ -2522,6 +3052,11 @@ void main() {
// dark pages. This keeps the bar from fighting with the host design.
function detectPageTheme() {
try {
+ // Dev override: set localStorage 'impeccable-dev-theme' to 'light' or
+ // 'dark' to preview the opposite palette without actually changing the
+ // page bg. Used for screenshots and theme QA.
+ const override = localStorage.getItem('impeccable-dev-theme');
+ if (override === 'light' || override === 'dark') return override;
const bg = getComputedStyle(document.body).backgroundColor
|| getComputedStyle(document.documentElement).backgroundColor;
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
@@ -2538,6 +3073,7 @@ void main() {
// Light bar on dark page
return {
surface: 'oklch(98% 0 0 / 0.92)',
+ surfaceDeep: 'oklch(92% 0.005 60 / 0.96)', // slightly deeper, faint warm
hairline: 'oklch(70% 0 0 / 0.35)',
text: 'oklch(15% 0 0)',
textDim: 'oklch(45% 0 0)',
@@ -2552,6 +3088,7 @@ void main() {
// deeper so the rounded-right shape reads as a clear sculpted mark.
return {
surface: 'oklch(26% 0 0 / 0.94)',
+ surfaceDeep: 'oklch(18% 0 0 / 0.96)', // darker sand for Tune popover
hairline: 'oklch(42% 0 0 / 0.5)',
text: 'oklch(96% 0 0)',
textDim: 'oklch(72% 0 0)',
@@ -2883,6 +3420,7 @@ void main() {
if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; }
if (barEl) { barEl.remove(); barEl = null; }
if (pickerEl) { pickerEl.remove(); pickerEl = null; }
+ if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; }
if (evtSource) { evtSource.close(); evtSource = null; }
document.removeEventListener('mousemove', handleMouseMove, true);
document.removeEventListener('click', handleClick, true);
@@ -3956,6 +4494,7 @@ void main() {
initAnnotOverlay();
initBar();
initActionPicker();
+ initParamsPanel();
initGlobalBar();
initDesignPanel();
document.addEventListener('mousemove', handleMouseMove, true);
diff --git a/.trae-cn/skills/impeccable/scripts/live-poll.mjs b/.trae-cn/skills/impeccable/scripts/live-poll.mjs
index 9dd87e3ea..5cece1a43 100644
--- a/.trae-cn/skills/impeccable/scripts/live-poll.mjs
+++ b/.trae-cn/skills/impeccable/scripts/live-poll.mjs
@@ -145,6 +145,11 @@ Options:
const scriptArgs = event.type === 'discard'
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
+ if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
+ // Pass through a JSON blob; the shell-safe wrap uses single quotes because
+ // values are finite {id, number|string|boolean} pairs from a validated payload.
+ scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
+ }
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
diff --git a/.trae-cn/skills/impeccable/scripts/live-server.mjs b/.trae-cn/skills/impeccable/scripts/live-server.mjs
index e5fb05bfa..2ab33fd22 100644
--- a/.trae-cn/skills/impeccable/scripts/live-server.mjs
+++ b/.trae-cn/skills/impeccable/scripts/live-server.mjs
@@ -147,6 +147,11 @@ function validateEvent(msg) {
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
+ if (msg.paramValues !== undefined) {
+ if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
+ return 'accept: paramValues must be an object';
+ }
+ }
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
diff --git a/.trae/skills/impeccable/reference/colorize.md b/.trae/skills/impeccable/reference/colorize.md
index de74ac95f..1bd52004f 100644
--- a/.trae/skills/impeccable/reference/colorize.md
+++ b/.trae/skills/impeccable/reference/colorize.md
@@ -142,3 +142,13 @@ Test that colorization improves the experience:
- **Not overwhelming**: Is color balanced and purposeful?
Remember: Color is emotional and powerful. Use it to create warmth, guide attention, communicate meaning, and express personality. But restraint and strategy matter more than saturation and variety. Be colorful, but be intentional.
+
+## Live-mode signature params
+
+When invoked from live mode, each variant MUST declare a `color-amount` param so the user can dial between a restrained accent and a drenched surface without regeneration. Author the variant's CSS against `var(--p-color-amount, 0.5)` — typically as the alpha multiplier on backgrounds, or as a scaling factor on the chroma axis in an OKLCH expression. 0 = neutral/monochrome, 1 = full saturation / dominant coverage.
+
+```json
+{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"}
+```
+
+Layer 1-2 variant-specific params on top: palette selection (`steps` with named options), temperature warmth, or tint vs. true color. See `reference/live.md` for the full params contract.
diff --git a/.trae/skills/impeccable/reference/layout.md b/.trae/skills/impeccable/reference/layout.md
index 981ace8a1..5e8022081 100644
--- a/.trae/skills/impeccable/reference/layout.md
+++ b/.trae/skills/impeccable/reference/layout.md
@@ -119,3 +119,23 @@ Create a systematic plan:
- **Responsiveness**: Does the layout adapt gracefully across screen sizes?
Remember: Space is the most underused design tool. A layout with the right rhythm and hierarchy can make even simple content feel polished and intentional.
+
+## Live-mode signature params
+
+Each variant MUST declare a `density` param. Drive all spacing tokens in the variant's scoped CSS through `calc(var(--p-density, 1) * )` — paddings, gaps, column widths. Users slide from airy to packed and see layout re-breathe with no regeneration.
+
+```json
+{"id":"density","kind":"range","min":0.6,"max":1.4,"step":0.05,"default":1,"label":"Density"}
+```
+
+For variants whose topology genuinely changes (stacked vs. side-by-side, grid vs. bento), use a `steps` param whose scoped CSS branches via `:scope[data-p-structure="X"]`. One structure param + one density param is a powerful combo; resist adding a third.
+
+```json
+{"id":"structure","kind":"steps","default":"grid","label":"Structure","options":[
+ {"value":"stacked","label":"Stacked"},
+ {"value":"grid","label":"Grid"},
+ {"value":"bento","label":"Bento"}
+]}
+```
+
+See `reference/live.md` for the full params contract.
diff --git a/.trae/skills/impeccable/reference/live.md b/.trae/skills/impeccable/reference/live.md
index 15ccead5c..8791ab55b 100644
--- a/.trae/skills/impeccable/reference/live.md
+++ b/.trae/skills/impeccable/reference/live.md
@@ -179,7 +179,56 @@ The first variant has no `display: none` (visible by default). All others do. If
One edit, all variants — the browser's MutationObserver picks everything up in one pass.
-### 7. Signal done
+### 7. Parameters (optional, 2-5 per variant)
+
+Each variant can expose coarse knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against.
+
+**When to use.** Any time the variant has a meaningful axis the user might want to dial in: color amount, density, motion intensity, scale ratio. Not micro-level margin tweaks; those defeat the point.
+
+**Budget scales with the element's visual weight, not the user's curiosity.** Knobs need real estate to produce noticeably different output; slapping three on a small element just crowds the panel without making anything feel tunable.
+
+- **Leaf / tiny** — a single button, icon, input, bare heading, solitary paragraph: **0 params.** A slider can't meaningfully reshape one element; just use variants.
+- **Small composition** — labeled input, simple card, short callout (≤ ~5 visual children): **0-1 params.** Only add one if it's a clear dominant axis (e.g. density on a card with visible internal rhythm).
+- **Medium composition** — section component, nav cluster, dense card, short feature block (6-15 visual children): **2 params.**
+- **Large composition** — hero section, full page region, spread layout, anything with strong internal structure (16+ visual children or multiple sub-sections): **3-4 params.**
+
+When in doubt, fewer. The user can always ask for more variants to explore an axis you didn't expose as a knob. Count by visual children, not by DOM-node depth; a deeply-nested-but-visually-simple card still counts as small.
+
+**How to declare.** Put a JSON manifest on the variant wrapper:
+
+```html
+
+ ...variant content...
+
+```
+
+**Three kinds:**
+
+- `range` — smooth slider. Drives a CSS custom property `--p-` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`.
+- `steps` — segmented radio. Drives a data attribute `data-p-` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`.
+- `toggle` — on/off switch. Drives BOTH a CSS var (`--p-: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`.
+
+**Signature params per action.** Each action has one or two signature params that MUST be exposed when the variant can meaningfully express them. Check the action's reference file for the list. Layer 1-2 variant-specific params on top.
+
+**Reset on variant switch.** User dials density on v1, flips to v2, v2 starts at v2's declared defaults. Known limitation; preservation across variants may land later.
+
+**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment:
+
+```html
+
+```
+
+The carbonize cleanup step (see below) reads that comment and bakes the chosen values into the final CSS. For `steps`/`toggle` attribute selectors: keep only the branch matching the chosen value, drop the others, collapse `:scope[data-p-density="packed"] .grid` to a semantic class rule. For `range` vars: either substitute the literal or keep the var with the chosen value as its new default.
+
+### 8. Signal done
```bash
node .trae/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
@@ -244,11 +293,11 @@ When `_acceptResult.carbonize === true`, the accepted variant was stitched into
Do these five steps in the current thread, synchronously, before the next poll. Do not poll again until the file is clean.
-1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `` and `` and contains a `');
+ if (paramValues && Object.keys(paramValues).length > 0) {
+ // Preserve the user's knob positions for the carbonize-cleanup agent
+ // to bake into the final CSS when it collapses scoped rules.
+ replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
+ }
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
diff --git a/.trae/skills/impeccable/scripts/live-browser.js b/.trae/skills/impeccable/scripts/live-browser.js
index 116e1efa6..66efd7243 100644
--- a/.trae/skills/impeccable/scripts/live-browser.js
+++ b/.trae/skills/impeccable/scripts/live-browser.js
@@ -30,10 +30,13 @@
// Design tokens
// ---------------------------------------------------------------------------
+ // Brand magenta is pinned to the site token (--color-accent in main.css)
+ // so Accept / knobs / cycle-dots match the site's accent, not a washed
+ // theme-adjusted one.
const C = {
- brand: 'oklch(55% 0.25 350)',
- brandHov: 'oklch(48% 0.25 350)',
- brandSoft: 'oklch(55% 0.25 350 / 0.12)',
+ brand: 'oklch(60% 0.25 350)',
+ brandHov: 'oklch(52% 0.25 350)',
+ brandSoft: 'oklch(60% 0.25 350 / 0.15)',
ink: 'oklch(15% 0.01 350)',
ash: 'oklch(55% 0 0)',
paper: 'oklch(98% 0.005 350 / 0.92)',
@@ -60,6 +63,25 @@
'html', 'head', 'body', 'script', 'style', 'link', 'meta', 'noscript', 'br', 'wbr',
]);
+ // 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.
+ 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: ` `,
+ bolder: ` `,
+ quieter: ` `,
+ distill: ` `,
+ polish: ` `,
+ typeset: ` `,
+ colorize: ` `,
+ layout: ` `,
+ adapt: ` `,
+ animate: ` `,
+ delight: ` `,
+ overdrive: ` `,
+ };
+
const ACTIONS = [
{ value: 'impeccable', label: 'Freeform' },
{ value: 'bolder', label: 'Bolder' },
@@ -756,7 +778,21 @@
// The Bar — one floating element, three modes
// ---------------------------------------------------------------------------
+ // Contextual-bar palette. Cached at init so every build*Row reads a
+ // consistent set of colors; detectPageTheme runs once rather than on every
+ // phase transition.
+ let BP = null;
+
+ // Bar shadow variants. The default projects down + subtle around. When
+ // the Tune popover opens below the bar, a downward shadow lands on the
+ // dark popover and reads as a bright ghost line. We swap to UP-only while
+ // tune is open below so the popover's top edge is clean.
+ const BAR_SHADOW_DEFAULT = '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)';
+ const BAR_SHADOW_UP = '0 -4px 20px oklch(0% 0 0 / 0.08), 0 -1px 3px oklch(0% 0 0 / 0.06)';
+ const BAR_SHADOW_DOWN = BAR_SHADOW_DEFAULT;
+
function initBar() {
+ BP = barPaletteForTheme(detectPageTheme());
barEl = document.createElement('div');
barEl.id = PREFIX + '-bar';
Object.assign(barEl.style, {
@@ -764,15 +800,15 @@
display: 'none', opacity: '0',
transform: 'translateY(6px)',
transition: 'opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
- // Visual
- background: C.paper,
+ background: BP.surface,
backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
- border: '1px solid ' + C.mist,
+ border: '1px solid ' + BP.hairline,
borderRadius: '10px',
- boxShadow: '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)',
- fontFamily: FONT, fontSize: '13px', color: C.ink,
+ boxShadow: BAR_SHADOW_DEFAULT,
+ transition: 'box-shadow 0.2s ease, opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
+ fontFamily: FONT, fontSize: '13px', color: BP.text,
padding: '6px',
- maxWidth: '460px', minWidth: '320px',
+ maxWidth: '520px', minWidth: '320px',
});
document.body.appendChild(barEl);
}
@@ -824,14 +860,15 @@
barEl.style.transform = 'translateY(6px)';
setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250);
hideActionPicker();
+ closeTunePopover();
}
function updateBarContent(mode) {
if (!barEl || barEl.style.display === 'none') return;
barEl.innerHTML = '';
- // Reset bar styling
- barEl.style.background = C.paper;
- barEl.style.border = '1px solid ' + C.mist;
+ // Reset bar styling to the theme-aware palette
+ barEl.style.background = BP.surface;
+ barEl.style.border = '1px solid ' + BP.hairline;
if (mode === 'configure') barEl.appendChild(buildConfigureRow());
else if (mode === 'generating') barEl.appendChild(buildGeneratingRow());
else if (mode === 'cycling') barEl.appendChild(buildCyclingRow());
@@ -854,15 +891,15 @@
const pill = el('button', {
display: 'inline-flex', alignItems: 'center', gap: '4px',
padding: '5px 10px', borderRadius: '6px',
- background: C.ink, color: C.white,
+ background: BP.mark, color: BP.markText,
fontFamily: FONT, fontSize: '12px', fontWeight: '500',
border: 'none', cursor: 'pointer',
transition: 'background 0.12s ease, transform 0.1s ease',
whiteSpace: 'nowrap', flexShrink: '0',
});
pill.textContent = actionLabel() + ' \u25BE';
- pill.addEventListener('mouseenter', () => pill.style.background = C.brandHov);
- pill.addEventListener('mouseleave', () => pill.style.background = C.ink);
+ pill.addEventListener('mouseenter', () => pill.style.background = BP.accent);
+ pill.addEventListener('mouseleave', () => pill.style.background = BP.mark);
pill.addEventListener('mousedown', () => pill.style.transform = 'scale(0.97)');
pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)');
pill.addEventListener('click', (e) => { e.stopPropagation(); toggleActionPicker(); });
@@ -877,13 +914,13 @@
flex: '1', minWidth: '0',
padding: '5px 8px', borderRadius: '6px',
border: '1px solid transparent', background: 'transparent',
- fontFamily: FONT, fontSize: '12px', color: C.ink,
+ fontFamily: FONT, fontSize: '12px', color: BP.text,
outline: 'none',
transition: 'border-color 0.15s ease, background 0.15s ease',
});
input.addEventListener('focus', () => {
- input.style.borderColor = C.mist;
- input.style.background = C.white;
+ input.style.borderColor = BP.hairline;
+ input.style.background = BP.accentSoft;
});
input.addEventListener('blur', () => {
input.style.borderColor = 'transparent';
@@ -901,16 +938,16 @@
// Variant count toggle
const count = el('button', {
padding: '4px 6px', borderRadius: '5px',
- border: '1px solid ' + C.mist, background: 'transparent',
+ border: '1px solid ' + BP.hairline, background: 'transparent',
fontFamily: MONO, fontSize: '11px', fontWeight: '600',
- color: C.ash, cursor: 'pointer',
+ color: BP.textDim, cursor: 'pointer',
transition: 'color 0.12s ease, border-color 0.12s ease',
flexShrink: '0', whiteSpace: 'nowrap',
});
count.textContent = '\u00D7' + selectedCount;
count.title = 'Variants: click to change';
- count.addEventListener('mouseenter', () => { count.style.color = C.ink; count.style.borderColor = C.ink; });
- count.addEventListener('mouseleave', () => { count.style.color = C.ash; count.style.borderColor = C.mist; });
+ count.addEventListener('mouseenter', () => { count.style.color = BP.text; count.style.borderColor = BP.text; });
+ count.addEventListener('mouseleave', () => { count.style.color = BP.textDim; count.style.borderColor = BP.hairline; });
count.addEventListener('click', (e) => {
e.stopPropagation();
selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1;
@@ -921,15 +958,15 @@
// Go button
const go = el('button', {
padding: '5px 12px', borderRadius: '6px',
- border: 'none', background: C.brand, color: C.white,
+ border: 'none', background: BP.accent, color: BP.mark,
fontFamily: FONT, fontSize: '12px', fontWeight: '600',
cursor: 'pointer',
- transition: 'background 0.12s ease, transform 0.1s ease',
+ transition: 'filter 0.12s ease, transform 0.1s ease',
flexShrink: '0', whiteSpace: 'nowrap',
});
go.textContent = 'Go \u2192';
- go.addEventListener('mouseenter', () => go.style.background = C.brandHov);
- go.addEventListener('mouseleave', () => go.style.background = C.brand);
+ go.addEventListener('mouseenter', () => go.style.filter = 'brightness(1.1)');
+ go.addEventListener('mouseleave', () => go.style.filter = 'none');
go.addEventListener('mousedown', () => go.style.transform = 'scale(0.97)');
go.addEventListener('mouseup', () => go.style.transform = 'scale(1)');
go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); });
@@ -950,7 +987,7 @@
// Action label
const label = el('span', {
- fontWeight: '600', fontSize: '12px', color: C.ink,
+ fontWeight: '600', fontSize: '12px', color: BP.text,
flexShrink: '0', whiteSpace: 'nowrap',
});
label.textContent = actionLabel();
@@ -961,7 +998,7 @@
// Status
const status = el('span', {
- fontSize: '11px', color: C.ash, whiteSpace: 'nowrap',
+ fontSize: '11px', color: BP.textDim, whiteSpace: 'nowrap',
marginLeft: 'auto',
});
// Variants currently arrive atomically in a single file edit, so a
@@ -976,6 +1013,8 @@
// --- Cycling row ---
+ const TUNE_ICON_SVG = ' ';
+
function buildCyclingRow() {
const row = el('div', {
display: 'flex', alignItems: 'center', gap: '6px',
@@ -994,7 +1033,7 @@
// Counter
const counter = el('span', {
fontFamily: MONO, fontSize: '11px', fontWeight: '500',
- color: C.ash, minWidth: '24px', textAlign: 'center',
+ color: BP.textDim, minWidth: '24px', textAlign: 'center',
});
counter.textContent = visibleVariant + '/' + arrivedVariants;
row.appendChild(counter);
@@ -1005,20 +1044,67 @@
if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
row.appendChild(next);
+ // Tune chip — only when the visible variant exposes params
+ const visParams = parseVariantParams(getVisibleVariantEl());
+ const hasParams = visParams.length > 0;
+ if (hasParams) {
+ const tune = el('button', {
+ display: 'inline-flex', alignItems: 'center', gap: '6px',
+ padding: '4px 10px', borderRadius: '5px',
+ border: '1px solid transparent',
+ background: tuneOpen ? BP.accentSoft : 'transparent',
+ color: tuneOpen ? BP.accent : BP.text,
+ fontFamily: FONT, fontSize: '11px', fontWeight: '500',
+ cursor: 'pointer',
+ transition: 'color 0.12s ease, background 0.12s ease',
+ whiteSpace: 'nowrap',
+ });
+ tune.innerHTML = TUNE_ICON_SVG;
+ const tuneLabel = document.createElement('span');
+ tuneLabel.textContent = 'Tune';
+ tune.appendChild(tuneLabel);
+ const tuneBadge = document.createElement('span');
+ Object.assign(tuneBadge.style, {
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
+ minWidth: '16px', height: '16px', padding: '0 4px',
+ borderRadius: '999px',
+ background: tuneOpen ? C.brand : BP.hairline,
+ color: tuneOpen ? 'oklch(98% 0 0)' : 'inherit',
+ fontFamily: MONO, fontSize: '9.5px', fontWeight: '600',
+ lineHeight: '1',
+ boxSizing: 'border-box',
+ });
+ tuneBadge.textContent = String(visParams.length);
+ tune.appendChild(tuneBadge);
+ tune.title = 'Tune this variant (' + visParams.length + ' knob' + (visParams.length === 1 ? '' : 's') + ')';
+ tune.addEventListener('mouseenter', () => {
+ if (!tuneOpen) tune.style.background = BP.accentSoft;
+ });
+ tune.addEventListener('mouseleave', () => {
+ if (!tuneOpen) tune.style.background = 'transparent';
+ });
+ tune.addEventListener('click', (e) => { e.stopPropagation(); toggleTunePopover(); });
+ tune.dataset.iceqTune = '1';
+ row.appendChild(tune);
+ }
+
// Spacer
row.appendChild(el('div', { flex: '1' }));
- // Accept
+ // Accept — primary action, uses the site's saturated brand magenta
+ // with paper-white text, not the theme-muted BP.accent.
const accept = el('button', {
- padding: '4px 10px', borderRadius: '5px',
- border: 'none', background: C.ink, color: C.white,
+ padding: '5px 14px', borderRadius: '5px',
+ border: 'none', background: C.brand, color: 'oklch(98% 0 0)',
fontFamily: FONT, fontSize: '11px', fontWeight: '600',
- cursor: 'pointer', transition: 'background 0.12s ease',
+ cursor: 'pointer', transition: 'filter 0.12s ease, transform 0.1s ease',
whiteSpace: 'nowrap',
});
accept.textContent = '\u2713 Accept';
- accept.addEventListener('mouseenter', () => accept.style.background = C.brand);
- accept.addEventListener('mouseleave', () => accept.style.background = C.ink);
+ accept.addEventListener('mouseenter', () => accept.style.filter = 'brightness(1.08)');
+ accept.addEventListener('mouseleave', () => accept.style.filter = 'none');
+ accept.addEventListener('mousedown', () => accept.style.transform = 'scale(0.97)');
+ accept.addEventListener('mouseup', () => accept.style.transform = 'scale(1)');
accept.addEventListener('click', (e) => { e.stopPropagation(); handleAccept(); });
if (arrivedVariants === 0) { accept.style.opacity = '0.3'; accept.style.pointerEvents = 'none'; }
row.appendChild(accept);
@@ -1026,14 +1112,14 @@
// Discard
const discard = el('button', {
padding: '4px 6px', borderRadius: '5px',
- border: '1px solid ' + C.mist, background: 'transparent',
- fontFamily: FONT, fontSize: '11px', color: C.ash,
+ border: '1px solid ' + BP.hairline, background: 'transparent',
+ fontFamily: FONT, fontSize: '11px', color: BP.textDim,
cursor: 'pointer', transition: 'color 0.12s ease, border-color 0.12s ease',
});
discard.textContent = '\u2715';
discard.title = 'Discard all variants';
- discard.addEventListener('mouseenter', () => { discard.style.color = C.ink; discard.style.borderColor = C.ink; });
- discard.addEventListener('mouseleave', () => { discard.style.color = C.ash; discard.style.borderColor = C.mist; });
+ discard.addEventListener('mouseenter', () => { discard.style.color = BP.text; discard.style.borderColor = BP.text; });
+ discard.addEventListener('mouseleave', () => { discard.style.color = BP.textDim; discard.style.borderColor = BP.hairline; });
discard.addEventListener('click', (e) => { e.stopPropagation(); handleDiscard(); });
row.appendChild(discard);
@@ -1051,14 +1137,14 @@
});
const spinner = el('div', {
width: '14px', height: '14px', borderRadius: '50%',
- border: '2px solid ' + C.mist,
- borderTopColor: C.brand,
+ border: '2px solid ' + BP.hairline,
+ borderTopColor: BP.accent,
animation: 'impeccable-spin 0.6s linear infinite',
flexShrink: '0',
});
row.appendChild(spinner);
const label = el('span', {
- fontSize: '12px', color: C.ash, fontWeight: '500',
+ fontSize: '12px', color: BP.textDim, fontWeight: '500',
});
label.textContent = 'Applying variant...';
row.appendChild(label);
@@ -1103,14 +1189,26 @@
for (let i = 1; i <= expectedVariants; i++) {
const arrived = i <= arrivedVariants;
const active = i === visibleVariant;
+ // active: solid site-brand magenta 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
+ // magenta chips, especially when all variants had arrived and every
+ // dot wore an accent ring.
+ const dotBg = active ? C.brand
+ : arrived ? BP.textDim
+ : 'transparent';
+ const dotBorder = arrived ? 'none' : '1.5px solid ' + BP.hairline;
const dot = el('div', {
- width: '7px', height: '7px', borderRadius: '50%',
- background: active ? C.brand : (arrived ? C.ash : 'transparent'),
- border: '1.5px solid ' + (arrived ? C.brand : C.mist),
+ width: active ? '8px' : '6px',
+ height: active ? '8px' : '6px',
+ borderRadius: '50%',
+ background: dotBg,
+ border: dotBorder,
+ boxSizing: 'border-box',
transition: 'all 0.2s ' + EASE,
cursor: (clickable && arrived) ? 'pointer' : 'default',
- transform: arrived ? 'scale(1)' : 'scale(0.6)',
- opacity: arrived ? '1' : '0.4',
+ transform: arrived ? 'scale(1)' : 'scale(0.85)',
+ opacity: arrived ? (active ? '1' : '0.6') : '0.4',
});
if (clickable && arrived) {
const idx = i;
@@ -1130,15 +1228,15 @@
function navBtn(text) {
const b = el('button', {
width: '26px', height: '26px', borderRadius: '5px',
- border: '1px solid ' + C.mist, background: 'transparent',
- color: C.ink, fontFamily: FONT, fontSize: '13px',
+ border: '1px solid ' + BP.hairline, background: 'transparent',
+ color: BP.text, fontFamily: FONT, fontSize: '13px',
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'border-color 0.12s ease, background 0.12s ease',
padding: '0', lineHeight: '1',
});
b.textContent = text;
- b.addEventListener('mouseenter', () => { b.style.borderColor = C.ink; });
- b.addEventListener('mouseleave', () => { b.style.borderColor = C.mist; });
+ b.addEventListener('mouseenter', () => { b.style.borderColor = BP.text; });
+ b.addEventListener('mouseleave', () => { b.style.borderColor = BP.hairline; });
return b;
}
@@ -1158,6 +1256,7 @@
// ---------------------------------------------------------------------------
function initActionPicker() {
+ const P = barPaletteForTheme(detectPageTheme());
pickerEl = document.createElement('div');
pickerEl.id = PREFIX + '-picker';
Object.assign(pickerEl.style, {
@@ -1166,12 +1265,14 @@
transform: 'scale(0.96) translateY(4px)',
transformOrigin: 'bottom left',
transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE,
- background: C.paperSolid,
- border: '1px solid ' + C.mist,
+ background: P.surface,
+ border: '1px solid ' + P.hairline,
borderRadius: '10px',
boxShadow: '0 8px 30px oklch(0% 0 0 / 0.10), 0 2px 6px oklch(0% 0 0 / 0.06)',
padding: '6px',
fontFamily: FONT,
+ backdropFilter: 'blur(10px)',
+ WebkitBackdropFilter: 'blur(10px)',
});
// Build the chip grid
@@ -1181,22 +1282,32 @@
ACTIONS.forEach(action => {
const chip = el('button', {
- padding: '6px 8px', borderRadius: '6px',
+ display: 'flex', flexDirection: 'column', alignItems: 'center',
+ gap: '4px',
+ padding: '8px 6px', borderRadius: '6px',
border: 'none',
- background: action.value === selectedAction ? C.brandSoft : 'transparent',
- color: action.value === selectedAction ? C.brand : C.ink,
+ background: action.value === selectedAction ? P.accentSoft : 'transparent',
+ color: action.value === selectedAction ? P.accent : P.text,
fontFamily: FONT, fontSize: '11px', fontWeight: '500',
cursor: 'pointer',
transition: 'background 0.1s ease, color 0.1s ease',
textAlign: 'center', whiteSpace: 'nowrap',
});
- chip.textContent = action.label;
+ const iconWrap = el('span', {
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ height: '20px', opacity: '0.9',
+ });
+ iconWrap.innerHTML = ICONS[action.value] || '';
+ const labelEl = el('span', { lineHeight: '1' });
+ labelEl.textContent = action.label;
+ chip.appendChild(iconWrap);
+ chip.appendChild(labelEl);
chip.dataset.action = action.value;
chip.addEventListener('mouseenter', () => {
- if (action.value !== selectedAction) chip.style.background = C.brandSoft;
+ if (action.value !== selectedAction) chip.style.background = P.accentSoft;
});
chip.addEventListener('mouseleave', () => {
- chip.style.background = action.value === selectedAction ? C.brandSoft : 'transparent';
+ chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent';
});
chip.addEventListener('click', (e) => {
e.stopPropagation();
@@ -1209,19 +1320,24 @@
pickerEl.appendChild(grid);
document.body.appendChild(pickerEl);
+
+ // Cache the palette on the picker so toggleActionPicker's state refresh
+ // uses the same theme-aware colors when it repaints chips.
+ pickerEl.__iceq_palette = P;
}
function toggleActionPicker() {
if (pickerEl.style.display !== 'none') { hideActionPicker(); return; }
// Rebuild chips to reflect current selection
+ const P = pickerEl.__iceq_palette || barPaletteForTheme(detectPageTheme());
pickerEl.querySelectorAll('button').forEach(chip => {
const isActive = chip.dataset.action === selectedAction;
- chip.style.background = isActive ? C.brandSoft : 'transparent';
- chip.style.color = isActive ? C.brand : C.ink;
+ chip.style.background = isActive ? P.accentSoft : 'transparent';
+ chip.style.color = isActive ? P.accent : P.text;
});
// Position above the bar
const barRect = barEl.getBoundingClientRect();
- const pickerH = 90; // approximate
+ const pickerH = 170; // approximate; grows with icon + label rows
let top = barRect.top - pickerH - 6;
if (top < 8) top = barRect.bottom + 6;
Object.assign(pickerEl.style, {
@@ -1241,6 +1357,399 @@
setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180);
}
+ // ---------------------------------------------------------------------------
+ // Params panel (per-variant coarse controls)
+ //
+ // Variants may declare a parameter manifest via a JSON attribute on the
+ // variant wrapper:
+ //
+ //
+ //
+ // The panel docks to the right edge of the outline during CYCLING and
+ // exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped
+ // CSS can respond instantly without regeneration:
+ //
+ // range / numeric toggle → CSS var (`--p-
`) used via var(--p-foo, N)
+ // steps / boolean toggle → data-p- attribute used via :scope[data-p-foo="..."]
+ //
+ // On variant switch, values reset to that variant's declared defaults.
+ // On accept, current values are sent in the event payload so the agent
+ // can bake them into the source-file write.
+ // ---------------------------------------------------------------------------
+
+ 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 tuneOpen = false; // whether the Tune popover is open right now
+
+ // Theme-aware Tune popover. Appears as a drawer that slides out from the
+ // contextual bar's bar-facing edge (below if the bar sits below the
+ // element, above otherwise). Same width as the bar. Auto-wraps to extra
+ // rows when the knobs exceed one row. The bar's border-radius on the
+ // popover side goes flat while open so the two shapes read as one.
+ let paramsPanelPalette = null;
+
+ function initParamsPanel() {
+ paramsPanelPalette = barPaletteForTheme(detectPageTheme());
+ 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
+ // display toggle, no opacity toggle, no transform trickery. The mask
+ // hides everything initially; as it grows, content is revealed from
+ // the bar edge outward.
+ paramsPanelEl = document.createElement('div');
+ paramsPanelEl.id = PREFIX + '-params-panel';
+ Object.assign(paramsPanelEl.style, {
+ position: 'fixed', zIndex: String(Z.bar - 1),
+ background: P.surfaceDeep,
+ color: P.text,
+ fontFamily: FONT,
+ padding: '14px 18px',
+ boxSizing: 'border-box',
+ borderRadius: '0 0 10px 10px',
+ pointerEvents: 'none',
+ backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
+
+ // clip-path is the same conceptual reveal as mask but with rock-solid
+ // transition support across engines. Closed state clips from the far
+ // edge; open = inset(0) shows everything.
+ clipPath: 'inset(0 0 100% 0)',
+ 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
+ // top-left when first shown.
+ top: '-9999px', left: '-9999px', width: '0',
+ });
+
+ paramsPanelBody = el('div', {
+ display: 'grid',
+ gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
+ gap: '12px 16px',
+ });
+
+ paramsPanelEl.appendChild(paramsPanelBody);
+ document.body.appendChild(paramsPanelEl);
+ paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
+ }
+
+ function getVisibleVariantEl() {
+ if (!currentSessionId) return null;
+ const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
+ if (!wrapper) return null;
+ return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
+ }
+
+ function parseVariantParams(variantEl) {
+ if (!variantEl) return [];
+ const raw = variantEl.getAttribute('data-impeccable-params');
+ if (!raw) return [];
+ try {
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) ? parsed : [];
+ } catch (err) {
+ console.warn('[impeccable] Invalid data-impeccable-params JSON:', err.message);
+ return [];
+ }
+ }
+
+ function applyParamValue(variantEl, param, value) {
+ if (!variantEl) return;
+ const attr = 'data-p-' + param.id;
+ if (param.kind === 'range') {
+ variantEl.style.setProperty('--p-' + param.id, String(value));
+ } else if (param.kind === 'toggle') {
+ const on = !!value;
+ variantEl.style.setProperty('--p-' + param.id, on ? '1' : '0');
+ if (on) variantEl.setAttribute(attr, 'on');
+ else variantEl.removeAttribute(attr);
+ } else if (param.kind === 'steps') {
+ variantEl.setAttribute(attr, String(value));
+ }
+ }
+
+ function applyParamDefaults(variantEl, params) {
+ paramsCurrentValues = {};
+ for (const p of params) {
+ paramsCurrentValues[p.id] = p.default;
+ applyParamValue(variantEl, p, p.default);
+ }
+ }
+
+ function formatRangeValue(input) {
+ const max = parseFloat(input.max), min = parseFloat(input.min);
+ const v = parseFloat(input.value);
+ if (!isFinite(v)) return input.value;
+ return (max - min) <= 2 ? v.toFixed(2) : String(Math.round(v));
+ }
+
+ function buildParamsPanel(variantEl, params) {
+ const P = paramsPanelPalette || barPaletteForTheme(detectPageTheme());
+ paramsPanelBody.innerHTML = '';
+ for (const p of params) {
+ const row = el('div', { display: 'flex', flexDirection: 'column', gap: '6px' });
+ const labelRow = el('div', {
+ display: 'flex', justifyContent: 'space-between',
+ alignItems: 'baseline', gap: '8px',
+ });
+ const lbl = el('span', {
+ fontSize: '10.5px', fontWeight: '600', color: P.text,
+ letterSpacing: '0.03em',
+ });
+ lbl.textContent = p.label || p.id;
+ labelRow.appendChild(lbl);
+ const readout = el('span', {
+ fontSize: '10.5px', color: P.textDim,
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
+ });
+ labelRow.appendChild(readout);
+ row.appendChild(labelRow);
+
+ if (p.kind === 'range') {
+ const input = document.createElement('input');
+ input.type = 'range';
+ input.min = String(p.min != null ? p.min : 0);
+ input.max = String(p.max != null ? p.max : 1);
+ input.step = String(p.step != null ? p.step : 0.05);
+ input.value = String(p.default);
+ Object.assign(input.style, {
+ width: '100%', accentColor: C.brand, cursor: 'pointer',
+ });
+ readout.textContent = formatRangeValue(input);
+ input.addEventListener('input', (e) => {
+ e.stopPropagation();
+ const v = parseFloat(input.value);
+ paramsCurrentValues[p.id] = v;
+ readout.textContent = formatRangeValue(input);
+ applyParamValue(variantEl, p, v);
+ });
+ row.appendChild(input);
+ } else if (p.kind === 'toggle') {
+ const initial = !!p.default;
+ readout.textContent = initial ? 'On' : 'Off';
+ const track = el('button', {
+ position: 'relative', width: '36px', height: '20px',
+ borderRadius: '10px', border: 'none', padding: '0',
+ cursor: 'pointer',
+ background: initial ? C.brand : P.hairline,
+ transition: 'background 0.15s ease',
+ alignSelf: 'flex-start',
+ });
+ const knob = el('span', {
+ position: 'absolute', top: '2px',
+ left: initial ? '18px' : '2px',
+ width: '16px', height: '16px', borderRadius: '50%',
+ background: 'oklch(98% 0 0)',
+ transition: 'left 0.18s ' + EASE,
+ boxShadow: '0 1px 2px oklch(0% 0 0 / 0.2)',
+ });
+ track.appendChild(knob);
+ track.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const next = !paramsCurrentValues[p.id];
+ paramsCurrentValues[p.id] = next;
+ track.style.background = next ? C.brand : P.hairline;
+ knob.style.left = next ? '18px' : '2px';
+ readout.textContent = next ? 'On' : 'Off';
+ applyParamValue(variantEl, p, next);
+ });
+ row.appendChild(track);
+ } else if (p.kind === 'steps') {
+ const opts = (p.options || []).map(o =>
+ typeof o === 'string' ? { value: o, label: o } : o
+ );
+ const activeOpt = opts.find(o => o.value === p.default) || opts[0];
+ readout.textContent = activeOpt ? activeOpt.label : String(p.default);
+ const segRow = el('div', {
+ display: 'grid',
+ gridTemplateColumns: 'repeat(' + opts.length + ', 1fr)',
+ gap: '1px', padding: '2px',
+ background: P.hairline, borderRadius: '5px',
+ });
+ const segBtns = [];
+ opts.forEach(o => {
+ const active = o.value === p.default;
+ const b = el('button', {
+ padding: '5px 4px', border: 'none', borderRadius: '3px',
+ background: active ? C.brand : 'transparent',
+ color: active ? 'oklch(98% 0 0)' : P.text,
+ fontFamily: FONT, fontSize: '10.5px', fontWeight: '500',
+ cursor: 'pointer', whiteSpace: 'nowrap',
+ transition: 'background 0.1s ease, color 0.1s ease',
+ });
+ b.textContent = o.label;
+ b.addEventListener('click', (e) => {
+ e.stopPropagation();
+ paramsCurrentValues[p.id] = o.value;
+ readout.textContent = o.label;
+ segBtns.forEach(({ btn, val }) => {
+ const on = val === o.value;
+ btn.style.background = on ? C.brand : 'transparent';
+ btn.style.color = on ? 'oklch(98% 0 0)' : P.text;
+ });
+ applyParamValue(variantEl, p, o.value);
+ });
+ segRow.appendChild(b);
+ segBtns.push({ btn: b, val: o.value });
+ });
+ row.appendChild(segRow);
+ }
+
+ paramsPanelBody.appendChild(row);
+ }
+ }
+
+ // Decide which way the popover opens: away from the picked element. If the
+ // bar landed below the element, popover slides DOWN from the bar's bottom.
+ // If the bar landed above, popover slides UP from the bar's top.
+ function popoverDirection() {
+ if (!barEl || !selectedElement) return 'below';
+ const br = barEl.getBoundingClientRect();
+ const er = selectedElement.getBoundingClientRect();
+ return br.top >= er.bottom - 4 ? 'below' : 'above';
+ }
+
+ // The popover overlaps the bar by OVERLAP px on the bar-facing side. With
+ // popover z-index below bar, that overlap sits behind bar (invisible) and
+ // reinforces the "tucked behind" feel. Padding compensates so the real
+ // content starts flush with bar's outer edge.
+ const TUNE_OVERLAP = 6;
+
+ // Closed clip-path depends on direction: for 'below' clip from the far
+ // (bottom) edge so the reveal grows downward from the bar; for 'above'
+ // clip from the top edge so the reveal grows upward from the bar.
+ function closedClipPath(direction) {
+ return direction === 'below' ? 'inset(0 0 100% 0)' : 'inset(100% 0 0 0)';
+ }
+
+ function setClipPath(value, withTransition) {
+ const saved = paramsPanelEl.style.transition;
+ if (!withTransition) paramsPanelEl.style.transition = 'none';
+ paramsPanelEl.style.clipPath = value;
+ if (!withTransition) {
+ void paramsPanelEl.offsetHeight;
+ paramsPanelEl.style.transition = saved;
+ }
+ }
+
+ function positionParamsPanel() {
+ if (!paramsPanelEl || !barEl || barEl.style.display === 'none') return;
+ const br = barEl.getBoundingClientRect();
+ const direction = popoverDirection();
+ const prevDirection = paramsPanelEl.dataset.tuneDirection;
+
+ // top/left/width are NOT in the transition list, so they snap instantly.
+ paramsPanelEl.style.left = br.left + 'px';
+ paramsPanelEl.style.width = br.width + 'px';
+
+ if (direction === 'below') {
+ paramsPanelEl.style.top = (br.bottom - TUNE_OVERLAP) + 'px';
+ paramsPanelEl.style.borderRadius = '0 0 10px 10px';
+ paramsPanelEl.style.paddingTop = (14 + TUNE_OVERLAP) + 'px';
+ paramsPanelEl.style.paddingBottom = '14px';
+ } else {
+ const ih = paramsPanelEl.offsetHeight || 80;
+ paramsPanelEl.style.top = (br.top - ih + TUNE_OVERLAP) + 'px';
+ paramsPanelEl.style.borderRadius = '10px 10px 0 0';
+ paramsPanelEl.style.paddingTop = '14px';
+ paramsPanelEl.style.paddingBottom = (14 + TUNE_OVERLAP) + 'px';
+ }
+ paramsPanelEl.dataset.tuneDirection = direction;
+
+ // If currently closed and direction flipped (or first-time setup),
+ // snap the clip-path to the new direction's closed pose without
+ // transitioning (so the clip doesn't slide across the element).
+ if (!tuneOpen && (!prevDirection || prevDirection !== direction)) {
+ setClipPath(closedClipPath(direction), false);
+ }
+ }
+
+ function showParamsPanel() {
+ if (!paramsPanelEl) return;
+ positionParamsPanel();
+ paramsPanelEl.style.pointerEvents = 'auto';
+ // rAF so the positioning paint commits before the transition fires.
+ requestAnimationFrame(() => {
+ setClipPath('inset(0 0 0 0)', true);
+ });
+ }
+
+ function hideParamsPanel() {
+ if (!paramsPanelEl) return;
+ paramsPanelEl.style.pointerEvents = 'none';
+ const direction = paramsPanelEl.dataset.tuneDirection || 'below';
+ setClipPath(closedClipPath(direction), true);
+ }
+
+ // Build/rebuild the panel's contents for the current variant AND apply
+ // its defaults to the variant wrapper (so scoped CSS responds even before
+ // the user opens the popover). Visibility is governed by tuneOpen.
+ function refreshParamsPanel() {
+ if (state !== 'CYCLING') {
+ paramsCurrentValues = {};
+ tuneOpen = false;
+ hideParamsPanel();
+ return;
+ }
+ const variantEl = getVisibleVariantEl();
+ const params = parseVariantParams(variantEl);
+ if (!variantEl || params.length === 0) {
+ paramsCurrentValues = {};
+ tuneOpen = false;
+ hideParamsPanel();
+ return;
+ }
+ applyParamDefaults(variantEl, params);
+ buildParamsPanel(variantEl, params);
+ if (tuneOpen) {
+ // If already visible (variant cycled while open), refresh in place
+ // instead of re-running the clip-path animation.
+ const alreadyVisible = paramsPanelEl.style.display === 'block'
+ && paramsPanelEl.style.opacity === '1';
+ if (alreadyVisible) positionParamsPanel();
+ else showParamsPanel();
+ } else {
+ hideParamsPanel();
+ }
+ }
+
+ function toggleTunePopover() {
+ if (tuneOpen) { closeTunePopover(); return; }
+ openTunePopover();
+ }
+
+ function openTunePopover() {
+ if (state !== 'CYCLING') return;
+ const variantEl = getVisibleVariantEl();
+ const params = parseVariantParams(variantEl);
+ if (!variantEl || params.length === 0) return;
+ // Build fresh to ensure the current variant's controls are shown.
+ applyParamDefaults(variantEl, params);
+ buildParamsPanel(variantEl, params);
+ tuneOpen = true;
+ showParamsPanel();
+ // Kill the bar's shadow on the popover-facing side so the dark popover
+ // doesn't pick up a bright glow line.
+ if (barEl) {
+ const direction = paramsPanelEl?.dataset.tuneDirection || 'below';
+ barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN;
+ }
+ // Re-render the bar so the Tune chip picks up the active styling.
+ updateBarContent('cycling');
+ }
+
+ function closeTunePopover() {
+ tuneOpen = false;
+ hideParamsPanel();
+ if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT;
+ if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') {
+ updateBarContent('cycling');
+ }
+ }
+
// ---------------------------------------------------------------------------
// Variant cycling in DOM
// ---------------------------------------------------------------------------
@@ -1253,6 +1762,10 @@
if (!v) continue;
child.style.display = (v === String(num)) ? '' : 'none';
}
+ // 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();
}
/**
@@ -1316,6 +1829,7 @@
state = 'CYCLING';
hideShaderOverlay();
updateBarContent('cycling');
+ refreshParamsPanel();
saveSession();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
@@ -1329,7 +1843,7 @@
const next = visibleVariant + dir;
if (next < 1 || next > arrivedVariants) return;
visibleVariant = next;
- showVariantInDOM(currentSessionId, next);
+ showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself
updateSelectedElement();
updateBarContent('cycling');
saveSession();
@@ -1540,6 +2054,7 @@
state = 'CYCLING';
hideShaderOverlay();
updateBarContent('cycling');
+ refreshParamsPanel();
} else if (state === 'GENERATING') {
updateBarContent('generating');
}
@@ -1560,6 +2075,7 @@
if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') {
positionBar();
showHighlight(selectedElement);
+ if (tuneOpen) positionParamsPanel();
}
if (annotActive) positionAnnotOverlay(selectedElement);
// Shader overlay (via debug P toggle or generation) is repositioned
@@ -1606,6 +2122,7 @@
if (state === 'GENERATING') {
state = 'CYCLING';
updateBarContent('cycling');
+ refreshParamsPanel();
}
break;
}
@@ -1691,6 +2208,10 @@
if (pickerEl?.style.display !== 'none' && !own(e.target)) {
hideActionPicker();
}
+ // Close Tune popover on outside click (anything outside panel + bar)
+ if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) {
+ closeTunePopover();
+ }
// In CONFIGURING: click outside the bar and selected element returns to PICKING
if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) {
hideBar();
@@ -2258,7 +2779,11 @@ void main() {
function handleAccept() {
if (!currentSessionId || arrivedVariants === 0) return;
- sendEvent({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) });
+ const acceptPayload = { type: 'accept', id: currentSessionId, variantId: String(visibleVariant) };
+ if (Object.keys(paramsCurrentValues).length > 0) {
+ acceptPayload.paramValues = { ...paramsCurrentValues };
+ }
+ sendEvent(acceptPayload);
markSessionHandled();
// The accepted variant is already the only visible child of the wrapper
@@ -2477,6 +3002,11 @@ void main() {
state = arrivedVariants >= expectedVariants ? 'CYCLING' : 'GENERATING';
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
startScrollTracking();
+ // Build the params panel for the restored visible variant. Previously
+ // this was missed on page-reload resume: showVariantInDOM above fires
+ // refreshParamsPanel, but state was still IDLE at that moment so it
+ // hid. Now that state is CYCLING, re-fire.
+ if (state === 'CYCLING') refreshParamsPanel();
saveSession();
// Start observing for more variants AFTER initial setup
@@ -2522,6 +3052,11 @@ void main() {
// dark pages. This keeps the bar from fighting with the host design.
function detectPageTheme() {
try {
+ // Dev override: set localStorage 'impeccable-dev-theme' to 'light' or
+ // 'dark' to preview the opposite palette without actually changing the
+ // page bg. Used for screenshots and theme QA.
+ const override = localStorage.getItem('impeccable-dev-theme');
+ if (override === 'light' || override === 'dark') return override;
const bg = getComputedStyle(document.body).backgroundColor
|| getComputedStyle(document.documentElement).backgroundColor;
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
@@ -2538,6 +3073,7 @@ void main() {
// Light bar on dark page
return {
surface: 'oklch(98% 0 0 / 0.92)',
+ surfaceDeep: 'oklch(92% 0.005 60 / 0.96)', // slightly deeper, faint warm
hairline: 'oklch(70% 0 0 / 0.35)',
text: 'oklch(15% 0 0)',
textDim: 'oklch(45% 0 0)',
@@ -2552,6 +3088,7 @@ void main() {
// deeper so the rounded-right shape reads as a clear sculpted mark.
return {
surface: 'oklch(26% 0 0 / 0.94)',
+ surfaceDeep: 'oklch(18% 0 0 / 0.96)', // darker sand for Tune popover
hairline: 'oklch(42% 0 0 / 0.5)',
text: 'oklch(96% 0 0)',
textDim: 'oklch(72% 0 0)',
@@ -2883,6 +3420,7 @@ void main() {
if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; }
if (barEl) { barEl.remove(); barEl = null; }
if (pickerEl) { pickerEl.remove(); pickerEl = null; }
+ if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; }
if (evtSource) { evtSource.close(); evtSource = null; }
document.removeEventListener('mousemove', handleMouseMove, true);
document.removeEventListener('click', handleClick, true);
@@ -3956,6 +4494,7 @@ void main() {
initAnnotOverlay();
initBar();
initActionPicker();
+ initParamsPanel();
initGlobalBar();
initDesignPanel();
document.addEventListener('mousemove', handleMouseMove, true);
diff --git a/.trae/skills/impeccable/scripts/live-poll.mjs b/.trae/skills/impeccable/scripts/live-poll.mjs
index 9dd87e3ea..5cece1a43 100644
--- a/.trae/skills/impeccable/scripts/live-poll.mjs
+++ b/.trae/skills/impeccable/scripts/live-poll.mjs
@@ -145,6 +145,11 @@ Options:
const scriptArgs = event.type === 'discard'
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
+ if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
+ // Pass through a JSON blob; the shell-safe wrap uses single quotes because
+ // values are finite {id, number|string|boolean} pairs from a validated payload.
+ scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
+ }
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
diff --git a/.trae/skills/impeccable/scripts/live-server.mjs b/.trae/skills/impeccable/scripts/live-server.mjs
index e5fb05bfa..2ab33fd22 100644
--- a/.trae/skills/impeccable/scripts/live-server.mjs
+++ b/.trae/skills/impeccable/scripts/live-server.mjs
@@ -147,6 +147,11 @@ function validateEvent(msg) {
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
+ if (msg.paramValues !== undefined) {
+ if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
+ return 'accept: paramValues must be an object';
+ }
+ }
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
diff --git a/content/site/partials/header.html b/content/site/partials/header.html
index 87fdc4193..0d7d796c2 100644
--- a/content/site/partials/header.html
+++ b/content/site/partials/header.html
@@ -9,11 +9,12 @@
Home
Docs
Anti-Patterns
- Visual Mode
+ Live
+ Overlay
-
diff --git a/content/site/skills/arrange.md b/content/site/skills/arrange.md
deleted file mode 100644
index 5144ee508..000000000
--- a/content/site/skills/arrange.md
+++ /dev/null
@@ -1,41 +0,0 @@
----
-tagline: "Fix layout, spacing, and visual rhythm."
----
-
-## When to use it
-
-`/arrange` is for pages where nothing is technically wrong but nothing is breathing either. Equal padding everywhere, monotonous card grids, content that runs edge to edge, hierarchy that relies on size alone. Reach for it when a layout "feels off" and you cannot articulate why.
-
-Good triggers: "everything feels crowded", "it reads like a wall", "I do not know where to look first".
-
-## How it works
-
-The skill runs through five layout dimensions:
-
-1. **Spacing**: is the spacing scale consistent or are there random 13px gaps, are related elements grouped tightly with generous space between groups, is there any rhythm at all.
-2. **Visual hierarchy**: does the eye land on the primary action within 2 seconds, is the hierarchy doing real work or is everything shouting.
-3. **Grid and structure**: is there an underlying grid or is the layout random, are elements aligned to baselines.
-4. **Rhythm**: does the page alternate between tight and generous spacing, or is everything uniform.
-5. **Density**: is the layout cramped or is it wasteful, does density match the content type.
-
-Fixes usually involve rebuilding the spacing scale, introducing asymmetry, collapsing monotonous grids into a mixed layout with hero and supporting elements, and giving the primary action real space.
-
-## Try it
-
-```
-/arrange the settings page
-```
-
-Typical changes:
-
-- Spacing scale unified to 8 / 16 / 24 / 48 / 96px
-- Section breaks at 48px, row gaps at 16px, form field groups at 8px
-- Primary actions pulled out of the form flow with 32px buffer
-- Decorative borders removed, replaced with spacing-driven grouping
-- Sidebar and main column proportions rebalanced (280 / flex vs 25 / 75)
-
-## Pitfalls
-
-- **Confusing arrange with distill.** If the problem is too many things, run `/distill` first. Arrange is for arranging what is already the right set.
-- **Expecting it to rescue a broken grid.** If the page has no grid at all, arrange will build one. Just know that the diff is going to be larger than you expect.
-- **Ignoring the hierarchy verdict.** If arrange says "nothing is primary", no amount of spacing work fixes that. You need a content decision, not a layout tweak.
diff --git a/content/site/skills/audit.md b/content/site/skills/audit.md
index 9d7b68ba9..a5d5c3b69 100644
--- a/content/site/skills/audit.md
+++ b/content/site/skills/audit.md
@@ -2,6 +2,55 @@
tagline: "Five-dimension technical quality check with P0 to P3 severity."
---
+
+
+
+
+
/impeccable audit the checkout flow
+
src/checkout/**
+
+
+ 2.6
+ / 4
+
+
+
+
+ Accessibility
+
+ 2 / 4
+
+
+ Performance
+
+ 3 / 4
+
+
+ Theming
+
+ 2.5 / 4
+
+
+ Responsive
+
+ 3 / 4
+
+
+ Anti-patterns
+
+ 2.8 / 4
+
+
+
+ P02
+ P15
+ P28
+ P314
+
+
+
Five dimensions scored 0 to 4, each finding tagged P0 (blocks release) to P3 (polish). Audit documents; it doesn't fix. Route the findings into /impeccable harden, /impeccable polish, or /impeccable optimize.
+
+
## When to use it
`/impeccable audit` is the technical counterpart to `/impeccable critique`. Where `/impeccable critique` asks "does this feel right", `/impeccable audit` asks "does this hold up". It runs accessibility, performance, theming, responsive design, and anti-pattern checks against the implementation, scores each dimension 0 to 4, and produces a plan with P0 to P3 severity ratings.
diff --git a/content/site/skills/craft.md b/content/site/skills/craft.md
index cec545ca6..cc3707687 100644
--- a/content/site/skills/craft.md
+++ b/content/site/skills/craft.md
@@ -2,6 +2,32 @@
tagline: "Shape the design, then build it, all in one flow."
---
+
+
+
+ 01
+ Shape
+ Discovery interview. Purpose, users, constraints, direction.
+
+
+ 02
+ Load references
+ Spatial, typography, motion, color, interaction.
+
+
+ 03
+ Build
+ Structure, hierarchy, type, color, states, motion, responsive.
+
+
+ 04
+ Iterate visually
+ Check in browser, refine until it matches the brief.
+
+
+
Every phase is non-skippable. The discovery step is where most AI output fails: by the time code exists, the thinking is locked in.
+
+
## When to use it
`/impeccable craft` is the end-to-end build command. Give it a feature description and it runs the whole pipeline: structured discovery, reference loading, implementation, visual iteration. Use it when you are starting a new feature from zero and want the whole workflow in one invocation.
diff --git a/content/site/skills/critique.md b/content/site/skills/critique.md
index 4381803b5..5a01c7a5d 100644
--- a/content/site/skills/critique.md
+++ b/content/site/skills/critique.md
@@ -2,6 +2,72 @@
tagline: "A design review with scoring, persona tests, and automated detection."
---
+
+
+
+
+ AI slop verdict
+ FAIL
+
+
gradient-text · ai-color-palette · nested-cards
+
+
+
+
Heuristics (Nielsen)
+
+
+ Visibility of status
+ 3
+
+
+ Match with real world
+ 2
+
+
+ Consistency & standards
+ 2
+
+
+ Error prevention
+ 3
+
+
+ Recognition over recall
+ 1
+
+
+
+
+
Personas
+
+
+
+ The evaluator
+ Comparing us to two alternatives on a Tuesday evening.
+
+
2 / 4
+
+
+
+ The returning user
+ Knows the product, on mobile, in a hurry.
+
+
3 / 4
+
+
+
+ The skeptic
+ Has seen every SaaS landing and is bored.
+
+
1 / 4
+
+
+
+
+
+
The two passes (LLM design review plus the deterministic detector) merge into one prioritized list. What's working, what to fix, and the provocative questions worth answering before shipping.
+
+
## When to use it
Reach for `/impeccable critique` when you want an honest second opinion on something you already built. Not "does it work" but "is it any good". The skill scores your interface against Nielsen's 10 heuristics, runs cognitive load checks, tests through persona lenses, and cross-references an automated detector for 25 concrete anti-patterns.
diff --git a/content/site/skills/document.md b/content/site/skills/document.md
new file mode 100644
index 000000000..63d668b46
--- /dev/null
+++ b/content/site/skills/document.md
@@ -0,0 +1,114 @@
+---
+tagline: "Generate a spec-compliant DESIGN.md that captures your visual system so every AI agent stays on-brand."
+---
+
+
+
+
+
+
+ 01
+ Overview
+
+
Creative North Star: "The Editorial Sanctuary." Quiet type, generous air, one committed accent.
+
+
+
+ 02
+ Colors
+
+
+
+
+
+
+
+
+
+
+ 03
+ Typography
+
+
+ Aa
+ Cormorant Garamond · Instrument Sans
+
+
+
+
+ 04
+ Elevation
+
+
Flat by default. Shadows appear only as a response to state.
+
+
+
+ 05
+ Components
+
+
+ Subscribe
+ filter
+ card
+
+
+
+
+ 06
+ Do's and Don'ts
+
+
+ Tint neutrals toward the accent hue.
+ Gradient text for emphasis.
+
+
+
+
The six sections are fixed, in a fixed order, with fixed names. Alongside, DESIGN.json ships as a machine-readable sidecar for the Live Mode design panel.
+
+
+## When to use it
+
+Run `/impeccable document` once you have enough of a visual system to document: colors, typography, at least a button and a card. The command scans your codebase, extracts the tokens and component patterns it finds, and writes a `DESIGN.md` at the project root that follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/), six sections in a fixed order, interoperable with every other DESIGN.md-aware tool.
+
+Reach for it when:
+
+- **You just ran `/impeccable teach`** and `PRODUCT.md` now exists. Document is the matching visual-side file.
+- **A command nudged you toward it.** Live, craft, and polish all read DESIGN.md. If it is missing, the skill suggests running document first.
+- **The design has drifted** from an older DESIGN.md and the file no longer describes the live system.
+- **Before a large redesign**, to capture current state as a reference for the next direction.
+
+For projects with no code yet (fresh `teach` run, nothing built), there is a seed mode: `/impeccable document --seed` asks five quick strategic questions (color strategy, type direction, motion energy, references, anti-references) and writes a scaffold. Re-run in scan mode once there is code.
+
+## How it works
+
+The scan pass finds design assets in priority order: CSS custom properties, Tailwind config, CSS-in-JS themes, design token files, component source, the global stylesheet, and finally computed styles from the live rendered output if a browser is available. It auto-extracts everything it can, then asks one grouped question for the parts that need creative input: the **Creative North Star** (a single named metaphor for the whole system, like "The Editorial Sanctuary"), descriptive color names, the elevation philosophy, and the component character.
+
+Output is a DESIGN.md with exactly six sections: Overview, Colors, Typography, Elevation, Components, Do's and Don'ts. Headers are fixed character-for-character so the file is parseable by other tools. Alongside it, `DESIGN.json` is written as a machine-readable sidecar. That sidecar is what the live-mode design panel uses to render *this project's* actual button, input, nav, and card tiles instead of a generic approximation.
+
+Every other command reads DESIGN.md on invocation. Variants, polishes, audits, and new features inherit the visual system without being told.
+
+## Try it
+
+```
+/impeccable document
+```
+
+On a project with tokens already defined, this takes about two minutes: the scan finds your palette and type stack, you pick a North Star from 2 or 3 options, confirm descriptive color names ("Deep Muted Teal-Navy", not "blue-800"), and the file lands at the project root.
+
+On a fresh project:
+
+```
+/impeccable document --seed
+```
+
+Five questions, about five minutes. The file is a scaffold, marked with a `` comment so it is honest about what it is. Re-run without the flag once you have implemented tokens.
+
+## Pitfalls
+
+- **Running it too early.** On a project with no implemented tokens, seed mode is right. Do not fabricate a full spec the code cannot back up. A fake DESIGN.md is worse than no DESIGN.md.
+- **Treating DESIGN.md as documentation for humans only.** It is primarily for the AI. Every other command reads it. The format's forcefulness ("never", "always", Named Rules) is intentional.
+- **Adding a Layout / Motion / Responsive top-level section.** The spec has six sections, in a fixed order, with fixed names. Fold layout or motion content into Overview (philosophy-level rules) or Components (per-component behavior).
+- **Overwriting an existing DESIGN.md silently.** Document always confirms first. If you want to start fresh, rename the existing file out of the way or explicitly tell the skill to overwrite.
diff --git a/content/site/skills/extract.md b/content/site/skills/extract.md
index e8325a3a8..0fb4d97dc 100644
--- a/content/site/skills/extract.md
+++ b/content/site/skills/extract.md
@@ -2,6 +2,27 @@
tagline: "Pull reusable components, tokens, and patterns into the design system."
---
+
+
+
+ 01
+ Discover drift
+ Repeated hex values, button variants, spacing scales, text styles.
+
+
+ 02
+ Propose primitives
+ Token names, component APIs with variant + size, text styles.
+
+
+ 03
+ Migrate call sites
+ Replace duplicated CSS with the new primitives. No orphan code left behind.
+
+
+
The skill only extracts what's used three or more times with the same intent. Two usages are not a pattern, and migration always happens in the same pass.
+
+
## When to use it
`/impeccable extract` is for the moment your codebase has accidentally become a design system. Repeated button styles in 12 places. Three variants of the same card. Hex colors scattered throughout. Hand-rolled spacing that accidentally matches a scale. Reach for it when you want to consolidate this drift into reusable primitives.
diff --git a/content/site/skills/impeccable.md b/content/site/skills/impeccable.md
index d8f3474a8..768f58e97 100644
--- a/content/site/skills/impeccable.md
+++ b/content/site/skills/impeccable.md
@@ -4,21 +4,28 @@ tagline: "The design intelligence behind every command."
## When to use it
-`/impeccable` is the home command. Call it directly when you want freeform design work with the full guidebook loaded, without having to pick a specialized command. It is the fallback you reach for when none of the 20 specialists (`audit`, `polish`, `critique`, and the rest) map cleanly onto what you're trying to do.
+`/impeccable` is the home command. Call it directly when you want freeform design work with the full guidebook loaded, without picking a specialized command. It is the fallback you reach for when none of the 23 specialists (`audit`, `polish`, `critique`, and the rest) map cleanly onto what you are trying to do.
Reach for `/impeccable` directly when:
-- **You're not sure which command fits.** Describe what you want in plain English and let the skill pick the right approach.
-- **The work spans multiple disciplines.** "Redo this hero section" touches layout, type, color, and motion. One command can't own that.
+- **You are not sure which command fits.** Describe what you want in plain English and let the skill pick the right approach.
+- **The work spans multiple disciplines.** "Redo this hero section" touches layout, type, color, and motion. One command cannot own that.
- **You want the full design intelligence without constraints.** Every reference file loaded, every anti-pattern checked, no pre-set workflow.
-For more structured flows, reach for the specialized commands in the sidebar. `/impeccable craft` runs the full shape-then-build pipeline, `/impeccable shape` produces a design brief before any code is written, and the evaluation and refinement commands (`audit`, `critique`, `polish`, `typeset`, etc.) each own a specific slice of the work.
+For structured flows, reach for the specialized commands in the sidebar. Run `/impeccable teach` first on any new project to establish PRODUCT.md and DESIGN.md. `/impeccable craft` chains a discovery interview into a full build with live visual iteration. `/impeccable shape` produces a design brief without touching code. `/impeccable live` gives you a browser picker with three variants per element. The evaluation and refinement commands (`audit`, `critique`, `polish`, `typeset`, `layout`, `colorize`, and the rest) each own a specific slice of the work.
## How it works
Most AI-generated UIs fail the same way: generic fonts, purple gradients, card grids on card grids, glassmorphism everywhere. `/impeccable` gives your AI a strong point of view. It loads an opinionated design handbook plus a long list of anti-patterns, then pushes the model to commit to a specific aesthetic direction before writing a single line of code.
-The skill has a **Context Gathering Protocol** built in. It will not design anything until it knows who uses the product, what they're trying to do, and how the interface should feel. On first use in a project, it runs the `teach` flow automatically: a short interview about your brand, audience, and aesthetic direction, saved to `PRODUCT.md` so every future command reads it without asking again.
+Two files at your project root shape everything the skill does:
+
+- **`PRODUCT.md`** carries register (brand vs product), target users, brand personality, anti-references, design principles. Answers "who, what, why".
+- **`DESIGN.md`** carries colors, typography, elevation, components, do's and don'ts, in the six-section Google Stitch format. Answers "how it looks".
+
+Every command reads both files before generating. **Register** is the load-bearing switch. Brand (marketing, landing, portfolio, where design IS the product) and product (app UI, dashboards, tools, where design SERVES the product) have different defaults for type, motion, color, and density. Specifying it once in PRODUCT.md means `/impeccable typeset` will not push editorial-magazine fonts on a dashboard, and will not push product-fluent defaults on a campaign page. See the [brand vs product tutorial](/tutorials/brand-vs-product) for how the two diverge.
+
+On first use in a project, the skill runs the `teach` flow automatically: a short interview that writes PRODUCT.md and then delegates to `/impeccable document` for DESIGN.md. Future commands read the files without asking again.
## Try it
@@ -30,11 +37,19 @@ The skill has a **Context Gathering Protocol** built in. It will not design anyt
/impeccable build me a pricing page for a developer tool
```
-Both prompts are vague on purpose. `/impeccable` will pick a strong aesthetic direction, commit to non-default fonts, avoid the AI color palette, and make the kind of specific choices that a designer would make. No command name to pick first, no step-by-step workflow to follow.
+Both prompts are vague on purpose. `/impeccable` will pick a strong aesthetic direction consistent with your register, commit to non-default fonts, avoid the AI color palette, and make the kind of specific choices that a designer would make. No command name to pick first, no step-by-step workflow to follow.
+
+For visual iteration in the browser rather than chat:
+
+```
+/impeccable live
+```
+
+Pick any element on your running dev server. Drop a comment or stroke. Get three production-quality variants hot-swapped in via HMR. Accept the one you want and it writes back to source.
## Pin commands back as shortcuts
-v3.0 consolidated 18 standalone skills into a single `/impeccable` with sub-commands. If you miss the short form of a specific command, pin it back:
+v3.0 consolidated 18 standalone skills into a single `/impeccable` with 23 sub-commands. If you miss the short form of a specific command, pin it back:
```
/impeccable pin critique
@@ -53,6 +68,7 @@ To remove: `/impeccable unpin critique`. Pins live as directories prefixed with
## Pitfalls
-- **Treating it like a style guide.** It is an opinionated design partner, not a linter. The defaults exist to raise the floor, not to overrule your judgment. If you have a real reason to push back (brand guideline, accessibility constraint, user research that says otherwise), push back and explain why. The skill will work with you. What produces worse output is ignoring the opinion without a reason.
+- **Treating it like a style guide.** It is an opinionated design partner, not a linter. The defaults exist to raise the floor, not to overrule your judgment. If you have a real reason to push back (brand guideline, accessibility constraint, user research), push back and explain why. The skill will work with you. What produces worse output is ignoring the opinion without a reason.
- **Expecting it to fix existing code.** `/impeccable` is for creation. For refinement, reach for `/impeccable polish`, `/impeccable distill`, or `/impeccable critique` instead.
- **Running it before `teach` has had a chance to save context.** On a fresh project it will interview you mid-flight, which is fine but slower. Running `/impeccable teach` explicitly as your very first command is a tiny bit smoother.
+- **Skipping the register question.** Brand and product defaults diverge enough that running on the wrong register produces subtly off output. If `PRODUCT.md` has no `## Register` field (legacy), run `/impeccable teach` to add it.
diff --git a/content/site/skills/live.md b/content/site/skills/live.md
new file mode 100644
index 000000000..523a237a1
--- /dev/null
+++ b/content/site/skills/live.md
@@ -0,0 +1,76 @@
+---
+tagline: "Iterate on UI in the browser. Pick an element, drop a comment, get three variants. Accept one and it writes to source."
+---
+
+
+
+
+
+
+
+ localhost:3000
+
+
+
+
No. 04
+
Letters, occasionally .
+
A postcard from the editor, about once a month. No tracking pixels, no "just checking in."
+
Send me one
+
+
+
+ ‹
+ 2 / 3
+ ›
+
+ Accept
+
+
+ /
+ Pick
+
+ ✕
+
+
+
+
Live Mode mid-cycle: the picker outlines the element you chose, the context bar shows which variant you're on, and the global bar stays pinned to the bottom. Accept on this one writes Variant 2 back to source.
+
+
+## When to use it
+
+Reach for `/impeccable live` when you want to iterate on something visually the way you would in a design tool, but keep production code as the output. The canvas-like flow of Figma without the round trip to an implementation step.
+
+Use it for:
+
+- **Exploring directions on a real element.** A hero section, a newsletter card, a pricing tier. Three genuinely different takes, side by side, on the actual page with the actual context.
+- **Polishing a piece of UI that is almost right.** You know what feels off but cannot quite say it. Pick the element, scribble "more playful" or draw a stroke through the bit that bugs you, hit Go.
+- **A quick A/B between two directions your team is debating.** Generate variants, accept nothing, walk away. The point was the comparison.
+
+It is NOT for new greenfield features (reach for `/impeccable craft`) or whole-page redesigns (reach for `/impeccable` or a specialized refine command).
+
+## How it works
+
+One command brings up a picker overlay on top of your running dev server. You pick any element. A small context bar appears next to it. Type a freeform description or pick one of the action chips (`bolder`, `quieter`, `distill`, `polish`, `typeset`, `colorize`, `layout`, `animate`, `delight`, `overdrive`). Optionally drop comment pins or draw strokes directly on the element first, and the skill reads those as intent.
+
+Hit Go. Three **production-quality variants** get generated, each anchored to a genuinely different design archetype (not three riffs on color) and hot-swapped into the page via your framework's HMR. Cycle through them with arrow keys. Accept one and the variant is written back to source. Discard all three and the original stays.
+
+It supports Vite, Next.js (including monorepos), SvelteKit, Astro, Nuxt, and plain static HTML. If your dev server has a strict Content Security Policy, the first-run setup detects it and offers a one-time, dev-only patch so the picker can load. `DESIGN.md` wins on visual decisions, `PRODUCT.md` wins on voice: if you have both, variants stay on-brand without being told.
+
+## Try it
+
+```
+/impeccable live
+```
+
+Open your dev server URL, pick the newsletter signup card, click the `delight` chip, hit Go. You will get three variants that vary across personality dimensions (a stamp-and-postcard feel, a typographic-surprise version, an illustrated-accent one), not three riffs on the same treatment.
+
+Or pick a hero, type "more editorial, less SaaS", hit Go. The three variants anchor to different editorial archetypes (broadsheet masthead, catalog-style spec rows, oversized-glyph poster) rather than three shades of the same idea.
+
+Stop live mode when you are done: say "stop live mode", close the tab, or hit the exit button on the picker bar.
+
+## Pitfalls
+
+- **Running it on a page that is still half-written.** Live variant generation needs context. If the element has placeholder copy, generic Lorem ipsum, or pre-stylesheet default formatting, variants will reflect that. Fill the content first.
+- **Expecting it to make macro decisions.** Live mode iterates on a single picked element. For "redo the entire pricing page", reach for `/impeccable` or `/impeccable craft` instead.
+- **Ignoring the fallback messages.** If the element lives in a generated file (a compiled template, a build output), the picker says so explicitly and offers to route the accept into true source. Do not force the accept into the generated file: the next build will wipe it.
+- **Running it without PRODUCT.md or DESIGN.md when you care about brand fit.** Live will still generate, but the variants will lean toward generic defaults. Run `/impeccable teach` and `/impeccable document` first if the result needs to sound like your product.
diff --git a/content/site/skills/normalize.md b/content/site/skills/normalize.md
deleted file mode 100644
index 30187f9f1..000000000
--- a/content/site/skills/normalize.md
+++ /dev/null
@@ -1,41 +0,0 @@
----
-tagline: "Pull drifted UI back into the design system."
----
-
-## When to use it
-
-`/normalize` is for the page that looks almost right but is not. Hard-coded colors where tokens should be, one-off spacing, a custom button that should have been the shared button, component markup that drifted during a rushed feature. Use it when consistency has decayed and you want to pull the feature back in line with the design system.
-
-Good triggers: "this page uses a different button", "the spacing feels wrong compared to the rest of the app", "why is this blue different from the other blue".
-
-## How it works
-
-The skill audits the target against the design system, then fixes the drift:
-
-1. **Tokens**: find every hard-coded color, spacing, radius, or shadow. Replace with the right token. Flag cases where no token fits (usually a sign the token system needs extending).
-2. **Components**: find custom implementations of things the library already provides. Replace with the shared component, preserving functionality.
-3. **Patterns**: find layouts that deviate from the standard patterns (form layouts, card grids, page headers). Align with the convention.
-4. **Spacing and rhythm**: align to the spacing scale, fix any random pixel values.
-5. **Typography**: make sure type styles come from the system, not one-off font-size/weight combinations.
-
-Normalize is conservative. It does not redesign. It makes the feature match what the rest of the app already does.
-
-## Try it
-
-```
-/normalize the billing page
-```
-
-Typical diff:
-
-- 14 hard-coded hex values replaced with `var(--color-*)` tokens
-- 6 one-off spacing values (13px, 27px, 11px) aligned to 8 / 16 / 24 / 32
-- Custom "Upgrade" button replaced with `` from the shared library
-- Form layout restructured to use the shared `FormRow` component
-- Font weight for section headings unified (was mixing 500 and 600 across similar headings)
-
-## Pitfalls
-
-- **Using normalize to redesign.** If you find yourself wanting to change how things look, not just make them consistent, you want `/polish` or `/arrange`. Normalize enforces consistency with what exists.
-- **Skipping the token gap analysis.** When normalize flags "no token fits", that is signal. Extend the token system before shipping, or the drift will come back.
-- **Running normalize on a prototype.** If the feature is still exploring, consistency is premature optimization. Normalize ships-ready features.
diff --git a/content/site/skills/shape.md b/content/site/skills/shape.md
index b593e30f5..7f51beedc 100644
--- a/content/site/skills/shape.md
+++ b/content/site/skills/shape.md
@@ -2,6 +2,39 @@
tagline: "Think before you build. Produce a design brief through discovery, not guesswork."
---
+
+
+
+
+
+ Purpose
+ Let committed subscribers change what they get without losing them to unsubscribe.
+
+
+ User
+ Rushed, on mobile, mid-meeting. Reading fast, low patience.
+
+
+ Content
+ 4 digest types, 2 cadences, one opt-out-all at the bottom.
+
+
+ Feeling
+ Calm, trustworthy, no dark patterns.
+
+
+ Constraints
+ Mobile-first. WCAG AA contrast. One column, no modals.
+
+
+
+
+
A shape brief is a compass, not a spec. It captures intent, not UI. Implementation skills read it before writing a line of code.
+
+
## When to use it
`/impeccable shape` is where a feature starts. Before anyone writes code, before anyone argues about the hero treatment, before anyone picks a font. Use it to force a discovery conversation about purpose, users, content, and constraints, then capture the answers as a design brief the implementation skills can lean on.
diff --git a/content/site/skills/teach.md b/content/site/skills/teach.md
index 698fd7894..c57da2eca 100644
--- a/content/site/skills/teach.md
+++ b/content/site/skills/teach.md
@@ -1,3 +1,70 @@
---
tagline: "Teach Impeccable who your product is for, once per project."
---
+
+
+
+
+
+
+ Register
+ Product. Design serves the task.
+
+
+ Users
+ SREs on call, reading fast, often in the dark.
+
+
+ Brand voice
+ Calm, clinical, no hype.
+
+
+ Anti-references
+ Purple gradients. Glassmorphism. "Boost your productivity."
+
+
+
+
+
A finished PRODUCT.md. Strategy only: who, what, why. No colors, no fonts, no pixel values, those live in DESIGN.md.
+
+
+## When to use it
+
+Run `/impeccable teach` once at the start of a project. It is the onramp. Without it, every other command will produce design that is technically competent but generically toned: stock SaaS voice, safe-default fonts, the AI color palette. With it, every command reads your answers before it generates.
+
+Reach for it when:
+
+- **You just installed Impeccable in a new project.** First thing to run. Other commands will nudge you toward it if you skip.
+- **The project's brand direction has shifted.** New positioning, new audience, new voice. Re-run `teach` and the updated context flows through every command.
+- **Another command said "no design context found"** and stopped. That is the signal: run teach, then resume.
+
+## How it works
+
+Teach writes two complementary files at the project root:
+
+- **`PRODUCT.md`** is the strategic file. Register (brand or product), target users, product purpose, brand personality, anti-references, design principles, accessibility needs. Answers "who, what, why".
+- **`DESIGN.md`** is the visual file. Colors, typography, elevation, components, do's and don'ts. Answers "how it looks". Written by the delegated `/impeccable document` command, which teach invokes at the end.
+
+The flow scans the codebase first (README, package.json, components, tokens, brand assets) and forms a **register hypothesis**: brand (landing, marketing, portfolio, where design IS the product) or product (app UI, dashboards, tools, where design SERVES the product). Register is the first question, because it shapes every downstream answer: typography defaults, motion energy, color strategy, the reference set commands like `/impeccable typeset` pull from. After register, teach asks only what it could not infer: users, personality in three real words, references and anti-references, accessibility requirements.
+
+PRODUCT.md is strategic only. No colors, no fonts, no pixel values. Those live in DESIGN.md. Keeping the two files separate is deliberate: strategy can stay stable while the visual system evolves.
+
+## Try it
+
+```
+/impeccable teach
+```
+
+Expect a 5 to 8 minute interview. The first question is usually about register; the rest are short. Teach will quote back what it inferred from your code ("from the routes, this looks like a product surface, match?") so you are confirming, not starting from scratch.
+
+At the end, teach offers to run `/impeccable document` for you. Say yes unless you have a specific reason to hold off. A real DESIGN.md is what keeps variants, polishes, and audits on-brand.
+
+## Pitfalls
+
+- **Skipping it to "just try a command quickly".** Every other command will interview you mid-flight instead. Running teach first is faster, not slower.
+- **Giving generic answers.** "Modern and clean" is not useful. "Warm, mechanical, opinionated" is. Be specific. Be willing to disagree with safe defaults.
+- **Treating PRODUCT.md as immutable.** The file is yours. If teach put something in there that is not quite right, edit it. Every command reads the current file.
+- **Listing only adjectives for references.** Brands, products, printed objects: named, not described. "Klim Type Foundry specimen pages", not "technical and clean". Anti-references should be equally specific.
diff --git a/content/site/tutorials/brand-vs-product.md b/content/site/tutorials/brand-vs-product.md
new file mode 100644
index 000000000..6aa7c5b11
--- /dev/null
+++ b/content/site/tutorials/brand-vs-product.md
@@ -0,0 +1,130 @@
+---
+title: Brand vs product, pick a register
+tagline: "Two worlds, two sets of defaults. Pick the right one and every command downstream benefits."
+order: 3
+description: "Impeccable treats brand work (landing pages, campaigns, portfolios) and product work (app UI, dashboards, tools) as different worlds with different defaults. Learn how to pick a register and how it shapes every command that reads it."
+---
+
+## See the divergence
+
+Same element, one register each. A newsletter signup, twice.
+
+
+
+
+
+ Brand
+ Editorial-magazine
+
+
+
No. 04 · Dispatch
+
Letters, occasionally.
+
A postcard from the editor, once a month. No tracking pixels, no "just checking in."
+
Send me one
+
+
+ Serif display, italic display weight
+ Drenched in the primary hue
+ Monospaced kicker, editorial voice
+
+
+
+
+ Product
+ Utility / app shell
+
+
+
Newsletter
+
Subscribe to updates
+
Product changes and release notes, once a month. Unsubscribe at any time.
+
Subscribe
+
+
+ Neutral sans, semibold for hierarchy
+ Restrained palette, accent only on state
+ Short, scannable, mobile-readable copy
+
+
+
+
The table below lists what's different. This is what it looks like at the pixel.
+
+
+## Why register matters
+
+Every design task belongs to one of two worlds:
+
+- **Brand** is where design IS the product. Marketing sites, landing pages, portfolios, long-form content, campaign surfaces. Distinctiveness is the bar. Fonts, motion, density, and color all push toward "this looks like nothing else in the category."
+- **Product** is where design SERVES the product. App UI, admin, dashboards, tools. Earned familiarity is the bar. Fluent users of Linear, Figma, Notion, Raycast, or Stripe should trust the output on sight.
+
+If you ask the same AI to design a dashboard and a campaign page without naming which world, you'll get the average of the two. Brand surfaces will feel too careful. Product surfaces will feel too precious. Register is how Impeccable avoids that.
+
+Impeccable tracks register as a single field in `PRODUCT.md`:
+
+```markdown
+## Register
+
+product
+```
+
+That is it: a bare value, `brand` or `product`. Every command that does register-sensitive work (`typeset`, `animate`, `colorize`, `layout`, `bolder`, `quieter`, `delight`) loads a different reference file based on what it finds here.
+
+## How the two worlds diverge
+
+This is not an exhaustive list, the full divergence lives in the `brand.md` and `product.md` reference files, but the shape of the difference:
+
+| Dimension | Brand | Product |
+|---|---|---|
+| **Type lanes** | Editorial-magazine, luxury, brutalist, consumer-warm, tech-minimal, all available. Swing. | Tighter set: neutral sans + optional mono, sized for dense reading, fluid type reserved for marketing surfaces. |
+| **Motion** | Choreographed entrances, scroll-driven sequences, decorative moments earn their place. | Restrained. State changes only. Animation serves feedback, not atmosphere. |
+| **Color** | Full palette, Committed, or Drenched are all on the table. | Restrained by default. Accents carry meaning; color is not decoration. |
+| **Density** | Whatever the narrative wants. Generous whitespace or packed rule-divided columns both valid. | Comfortable to dense. Every pixel earns its place. |
+| **References** | Real-world, from the right lane. *Klim specimen pages* or *Broadsheet masthead*, not "modern SaaS". | Category best-tool. *Linear*, *Figma*, *Notion*, *Raycast*, *Stripe*. |
+
+The same command, `/impeccable typeset`, pulls from different fonts in the two worlds. The same command, `/impeccable animate`, picks different motion vocabularies. The same command, `/impeccable layout`, assumes different density defaults. You do not re-learn the command: you answer the register question once, and the command adapts.
+
+## Step 1. Decide or inherit
+
+If you haven't run `/impeccable teach` yet, run it now. The first question is about register:
+
+```
+/impeccable teach
+```
+
+Teach scans your codebase first and forms a hypothesis: routes like `/`, `/pricing`, `/blog`, hero sections, scroll-driven content point toward brand. Routes like `/app`, `/dashboard`, `/settings`, forms and tables point toward product. It leads with the hypothesis rather than starting cold:
+
+> From the codebase, this looks like a product surface, does that match your intent, or should we treat it differently?
+
+If the project genuinely spans both (a product with a big marketing landing), teach asks which register describes the **primary** surface. Register is per-project, not per-page, but you can override it per task when needed.
+
+## Step 2. Verify the register landed
+
+Open `PRODUCT.md` and look for the `## Register` section. It should carry a bare value, not prose:
+
+```markdown
+## Register
+
+brand
+```
+
+If the section is missing (you're on an older `PRODUCT.md` from pre-v3.0), re-run `/impeccable teach`. It will detect the gap and add the field without re-interviewing you on everything else.
+
+## Step 3. Override per task when you need to
+
+Most of the time, register is set once and forgotten. But a product project might occasionally need a single brand surface (a launch landing, an investor one-pager) without flipping the whole project.
+
+You have two options:
+
+- **Name it in the brief.** "`/impeccable craft a launch landing for v2, brand register for this one page.`" The skill honors the override for that task only.
+- **Set a per-surface register.** If the override is lasting, add a short note in `PRODUCT.md` under an explicit section: `## Register overrides: /launch is brand.` Commands that read PRODUCT.md will respect it.
+
+## What to try next
+
+- Run a command that is register-sensitive and watch the divergence: `/impeccable typeset the pricing page` on a product project vs. a brand project will pick different type families, different scale ratios, and different pairings.
+- Pair with [getting started](/tutorials/getting-started) if you haven't installed Impeccable yet.
+- Reach for `/impeccable document` after teach to capture the visual side (colors, components) into DESIGN.md.
+
+## Common issues
+
+- **Register keeps slipping the wrong way.** If you set `product` but commands keep producing brand-feeling output, check that `PRODUCT.md` is at the project root and the `## Register` section has a bare value (no prose, no explanation, just the word). Commands can only read what is there.
+- **The hypothesis teach formed is wrong.** Disagree in the answer. Teach is asking, not telling.
+- **A project is genuinely 50/50.** Pick the primary surface, then use per-task overrides for the minority one. Trying to average the two in PRODUCT.md produces worse output than committing to one.
diff --git a/content/site/tutorials/critique-with-overlay.md b/content/site/tutorials/critique-with-overlay.md
index 7bb71304f..76e2f119a 100644
--- a/content/site/tutorials/critique-with-overlay.md
+++ b/content/site/tutorials/critique-with-overlay.md
@@ -1,7 +1,7 @@
---
title: Critique with the visual overlay
tagline: "Use /impeccable critique plus the browser overlay to review a live page with ground truth."
-order: 2
+order: 4
description: "Run a full design critique that combines LLM assessment, the automated detector, and a live browser overlay so you can see exactly which elements trigger which anti-patterns on the page you're looking at."
---
@@ -35,7 +35,7 @@ It labels the tab it opens with `[LLM]` in the title so you can tell which one i
### What the automated detector does
-The second assessment runs `npx impeccable detect` against the page. This is deterministic: 25 specific pattern checks that fire or do not fire. Gradient text, purple palettes, side-tab borders, nested cards, line length problems, low contrast, tiny body text, and the rest.
+The second assessment runs `npx impeccable detect` against the page. This is deterministic: around thirty specific pattern checks that fire or do not fire. Gradient text, purple palettes, side-tab borders, nested cards, line length problems, low contrast, tiny body text, and the rest. The [full catalog](/anti-patterns) lists every rule and which layer (CLI, browser, or LLM-only) catches it.
You get back a JSON list of every finding with its element selector, the rule that fired, and a short description.
@@ -117,6 +117,7 @@ If something still fires, fix it or write a suppression comment explaining why t
## What to try next
+- [Iterate on the critique findings with Live Mode](/tutorials/iterate-live). Pick the element critique flagged, drop a comment, get three redirections hot-swapped in place, and write the accepted one back to source.
- `/impeccable audit the same page` to catch the implementation issues critique does not cover (accessibility, performance, theming).
- `/impeccable polish` if the critique report is clean and you want the last-mile refinement pass.
- `/impeccable distill` if critique flagged "too busy" or "cognitive load". Distill removes what should not be there.
diff --git a/content/site/tutorials/getting-started.md b/content/site/tutorials/getting-started.md
index 220f80940..37d1289cb 100644
--- a/content/site/tutorials/getting-started.md
+++ b/content/site/tutorials/getting-started.md
@@ -7,7 +7,7 @@ description: "Install Impeccable, run /impeccable teach once to establish projec
## What you'll build
-You will end this tutorial with Impeccable installed in your project, a `PRODUCT.md` file that captures your brand and audience, and one hand-polished page that went through a polish pass. Total time: about five minutes.
+You will end this tutorial with Impeccable installed in your project, a `PRODUCT.md` plus `DESIGN.md` pair that captures your brand, audience, and visual system, and one hand-polished page that went through a polish pass. Total time: about ten minutes.
## Prerequisites
@@ -16,7 +16,7 @@ You will end this tutorial with Impeccable installed in your project, a `PRODUCT
## How Impeccable works
-Impeccable installs as a single agent skill called `impeccable`. You access all 20 design commands through it:
+Impeccable installs as a single agent skill called `impeccable`. You access all 23 sub-commands through it:
```
/impeccable
@@ -24,7 +24,7 @@ Impeccable installs as a single agent skill called `impeccable`. You access all
For example: `/impeccable polish the pricing page`, or `/impeccable audit the checkout`. Type `/impeccable` alone to see the full list.
-If you use a command often, pin it with `/impeccable pin ` to create a standalone shortcut (e.g., `/impeccable pin audit` gives you `/audit` directly).
+If you use a command often, pin it with `/impeccable pin ` to create a standalone shortcut (for example, `/impeccable pin audit` gives you `/audit` directly).
## Step 1. Install
@@ -46,18 +46,27 @@ Run:
/impeccable teach
```
-The skill will ask you a handful of questions:
+The first question is about **register**: is this a brand surface (marketing site, landing page, portfolio, where design IS the product) or a product surface (app UI, dashboard, tools, where design SERVES the product)? Register shapes every downstream default, from type lanes to motion energy. See [brand vs product](/tutorials/brand-vs-product) for how the two diverge. Teach will form a hypothesis from your codebase and ask you to confirm, rather than starting cold.
+
+Then a handful of shorter questions:
- **Who is this product for?** Be specific. Not "users" but "solo founders evaluating a new tool on their phone between meetings".
- **What is the brand voice in three words?** Pick real words. "Warm and mechanical and opinionated" is better than "modern and clean".
-- **What should the interface feel like?** Concrete adjectives. "Calm, trustworthy, fast" or "playful, bold, a little chaotic".
-- **Any visual references?** Screenshots, sites, design systems you admire.
-- **Anti-references?** Things the product should explicitly not look like.
+- **Any visual references?** Named brands, products, or printed objects, not adjectives. "Klim Type Foundry specimen pages", not "technical and clean".
+- **Anti-references?** Things the product should explicitly not look like, equally named.
-Answer in your own words. The skill writes a `PRODUCT.md` file with the answers. Every future command run reads it automatically.
+Answer in your own words. The skill writes `PRODUCT.md` with the answers. Every future command run reads it automatically.
Open `PRODUCT.md` and read what it wrote. Edit anything that does not feel right. The file is yours.
+## Step 2.5. Capture the visual system
+
+At the end of `/impeccable teach`, the skill offers to run `/impeccable document` for you. Say yes. It scans your tokens (CSS custom properties, Tailwind config, CSS-in-JS themes), extracts colors and typography, asks one grouped question for the parts that need creative input (a Creative North Star, descriptive color names), and writes a `DESIGN.md` that follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/).
+
+On a fresh project with no tokens yet, document runs in seed mode: five quick questions about color strategy, type direction, and motion energy, and writes a scaffold you can refresh once there is code.
+
+`PRODUCT.md` carries strategy (who, what, why). `DESIGN.md` carries visuals (colors, typography, components). Every command reads both before generating.
+
## Step 3. Polish something
Pick a page that already exists. An about page, a settings screen, a pricing table, anything. Run:
@@ -83,9 +92,11 @@ Review the diff. If something does not feel right, ask the model to explain the
## What to try next
+- [Iterate visually with Live Mode](/tutorials/iterate-live) opens a browser picker on your dev server, generates three production-quality variants per element, and writes the accepted one back to source.
- `/impeccable critique the landing page` runs a full design review with scoring, persona tests, and automated detection. It is the best way to find what to fix next.
- `/impeccable audit the checkout` runs accessibility, performance, theming, responsive, and anti-pattern checks against the implementation. Useful before shipping.
- `/impeccable craft a pricing page for enterprise customers` runs the full shape-then-build flow on a brand new feature.
+- **Pin your favorites.** If you reach for one command constantly, `/impeccable pin audit` makes `/audit` work as a standalone shortcut without reversing the consolidation.
- `/impeccable redo this hero section` works too. Any description after `/impeccable` applies the design principles to the task.
## Common issues
diff --git a/content/site/tutorials/iterate-live.md b/content/site/tutorials/iterate-live.md
new file mode 100644
index 000000000..471357788
--- /dev/null
+++ b/content/site/tutorials/iterate-live.md
@@ -0,0 +1,123 @@
+---
+title: Iterate on UI with Live Mode
+tagline: "Pick an element, generate three variants, accept one. Canvas-like iteration without leaving your code."
+order: 2
+description: "Use /impeccable live to visually iterate on a real element in your dev server: pick, annotate, generate three variants, accept the one you want, and have it written back to source."
+---
+
+## What you'll build
+
+You will use `/impeccable live` on your dev server to iterate on a single piece of UI (a hero, a card, a section) and end with one of three AI-generated variants written back to source as real code. You'll see the canvas-style picking, annotation, and three-up cycling flow.
+
+Total time: about ten minutes. Most of that is picking what to iterate on.
+
+## Prerequisites
+
+- Impeccable installed (see [getting started](/tutorials/getting-started) if you have not). Run `/impeccable teach` first if you haven't yet: variants lean on `PRODUCT.md` and `DESIGN.md` for brand fit.
+- A running dev server with HMR (Vite, Next.js, SvelteKit, Astro, Nuxt, Bun) OR a static HTML file open in a browser.
+- A page with at least one piece of UI you'd like to iterate on. A newsletter card, a hero, a pricing tier, something small enough to hold in your head.
+
+## Step 1. Start live mode
+
+From your harness, run:
+
+```
+/impeccable live
+```
+
+The skill starts a small local helper server on port 8400 and injects a `
-
-
-