feat(live): tune popover, theme-aware bar/picker, action icons, params system

Adds a coarse-controls ("Tune") popover that slides out from behind the
contextual bar via clip-path, showing 2-4 per-variant knobs (range / steps /
toggle) driven by a `data-impeccable-params` manifest. Range/toggle drive
CSS custom properties on the variant wrapper; steps toggle a data
attribute. Values reset per variant; on accept, current values are passed
through live-poll to live-accept as an `impeccable-param-values` comment
for the carbonize cleanup step to bake in.

Other live-UI work in this change:
- Theme-aware palette (barPaletteForTheme) now drives the contextual bar,
  action picker, and tune popover. Dark sand on light pages, paper on
  dark. Detection has a localStorage dev override for QA.
- Action picker chips get inline SVG icons (wand / bars / funnel / sparkle /
  type ramp / circles / grid / devices / curve / star / bolt) stacked
  above the label; selection state recolors via currentColor.
- Accept button switched to saturated site magenta with paper text.
- Cycle dots reworked: solid accent for active, neutral for arrived,
  hairline ring for pending. No more magenta-on-gray noise.
- Tune chip sits in the cycling row with a count pill badge; open state
  uses accentSoft bg + accent text (no ad-hoc white border).
- Popover uses the bar's palette with a deeper surface (surfaceDeep),
  lives behind the bar via z-index so a 6px overlap reads as tucked under
  it, and animates with clip-path inset() for reliable slide behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-04-22 18:17:17 -07:00
co-authored by Claude Opus 4.7
parent 59769e316f
commit 2341fe3637
119 changed files with 10730 additions and 973 deletions
@@ -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.
@@ -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) * <base>)` — 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.
+54 -5
View File
@@ -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
<div data-impeccable-variant="1" data-impeccable-params='[
{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"},
{"id":"density","kind":"steps","default":"snug","label":"Density","options":[
{"value":"airy","label":"Airy"},
{"value":"snug","label":"Snug"},
{"value":"packed","label":"Packed"}
]},
{"id":"serif","kind":"toggle","default":false,"label":"Serif display"}
]'>
...variant content...
</div>
```
**Three kinds:**
- `range` — smooth slider. Drives a CSS custom property `--p-<id>` 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-<id>` 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-<id>: 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
<!-- impeccable-param-values SESSION_ID: {"color-amount":0.7,"density":"packed"} -->
```
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 `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element.
1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values — read it first; it drives steps 3 and 4 below.
2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `public/css/workflow.css` for this repo, or the component's co-located CSS file for a Vite/Next project — pick whichever already owns styling for the surrounding element).
3. **Rewrite `@scope ([data-impeccable-variant="N"])` selectors** to target real, semantic classes on the accepted HTML. Example: `@scope ([data-impeccable-variant="2"]) { .v2-label { … } }` becomes `.why-visual--v2 .v2-label { … }` if the accepted element already carries `.why-visual--v2`, or pick/add a suitable class if it doesn't.
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it.
5. **Delete the inline `<style>` block and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one — those are dead code now.
3. **Bake in parameter values while rewriting selectors.** For `@scope ([data-impeccable-variant="N"])` wrappers: retarget to real, semantic classes on the accepted HTML (`.why-visual--v2 .v2-label { … }`). For `:scope[data-p-<id>="VALUE"]` selectors: keep only the branch matching the chosen value from the param-values comment; drop the others (they're dead after accept). For `var(--p-<id>, DEFAULT)` in the CSS: either substitute the literal value, or if the param is still useful as a knob going forward, leave the var and update its initial declaration to the chosen value.
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it. Drop `data-impeccable-params` and any `data-p-*` attributes from it — those are live-mode plumbing, not source.
5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one — those are dead code now.
Then poll again.
@@ -110,3 +110,15 @@ Build a clear type scale:
- **Accessibility**: Does text meet WCAG contrast ratios? Is it zoomable to 200%?
Remember: Typography is the foundation of interface design — it carries the majority of information. Getting it right is the highest-leverage improvement you can make.
## Live-mode signature params
Each variant MUST declare a `scale` param controlling the hierarchy ratio. Express all font sizes in the variant's scoped CSS through `calc(var(--p-scale, 1) * <base>)` or, better, scale the type ramp via `clamp(min, calc(var(--p-scale, 1) * Npx), max)`. Users slide from subdued to commanding.
```json
{"id":"scale","kind":"range","min":0.85,"max":1.3,"step":0.05,"default":1,"label":"Scale"}
```
Where the variant riffs on a specific pairing, expose the pairing choice as a `steps` param (e.g. "serif display + sans body" vs. "mono display + sans body" vs. "all-sans"). Each branch routes through `:scope[data-p-pairing="X"]` selectors in scoped CSS.
See `reference/live.md` for the full params contract.
@@ -45,11 +45,18 @@ Output (JSON):
const id = argVal(args, '--id');
const variantNum = argVal(args, '--variant');
const paramValuesRaw = argVal(args, '--param-values');
const isDiscard = args.includes('--discard');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); }
let paramValues = null;
if (paramValuesRaw) {
try { paramValues = JSON.parse(paramValuesRaw); }
catch { paramValues = null; } // malformed blob: skip the comment rather than failing the accept
}
// Find the file containing this session's markers
const found = findSessionFile(id, process.cwd());
if (!found) {
@@ -78,7 +85,7 @@ Output (JSON):
const result = handleDiscard(id, lines, targetFile);
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
} else {
const result = handleAccept(id, variantNum, lines, targetFile);
const result = handleAccept(id, variantNum, lines, targetFile, paramValues);
// Single-line attention-grabber when cleanup is required. The full
// five-step checklist lives in reference/live.md (loaded once per
// session); repeating it per-event would waste tokens.
@@ -116,7 +123,7 @@ function handleDiscard(id, lines, targetFile) {
// Accept
// ---------------------------------------------------------------------------
function handleAccept(id, variantNum, lines, targetFile) {
function handleAccept(id, variantNum, lines, targetFile, paramValues) {
const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' };
@@ -149,6 +156,11 @@ function handleAccept(id, variantNum, lines, targetFile) {
replacement.push(indent + cssLine.trimStart());
}
replacement.push(indent + '</style>');
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);
}
@@ -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: `<svg ${ICON_ATTRS}><path d="M4 20l4-1L18 9l-3-3L5 16z"/><path d="M14 7l3 3"/></svg>`,
bolder: `<svg ${ICON_ATTRS}><rect x="6" y="12" width="4" height="7" rx="0.5"/><rect x="14" y="5" width="4" height="14" rx="0.5"/></svg>`,
quieter: `<svg ${ICON_ATTRS}><rect x="6" y="5" width="4" height="14" rx="0.5"/><rect x="14" y="12" width="4" height="7" rx="0.5"/></svg>`,
distill: `<svg ${ICON_ATTRS}><path d="M4 5h16l-6 8v7l-4-2v-5z"/></svg>`,
polish: `<svg ${ICON_ATTRS}><path d="M15 3l1 3 3 1-3 1-1 3-1-3-3-1 3-1z"/><path d="M7 13l0.6 1.8 1.8 0.6-1.8 0.6-0.6 1.8-0.6-1.8-1.8-0.6 1.8-0.6z"/></svg>`,
typeset: `<svg ${ICON_ATTRS}><path d="M5 6h14" stroke-width="2.6"/><path d="M5 12h9" stroke-width="1.9"/><path d="M5 18h5" stroke-width="1.3"/></svg>`,
colorize: `<svg ${ICON_ATTRS}><circle cx="9" cy="10" r="5"/><circle cx="15" cy="10" r="5"/><circle cx="12" cy="15" r="5"/></svg>`,
layout: `<svg ${ICON_ATTRS}><rect x="3" y="4" width="8" height="16" rx="0.5"/><rect x="13" y="4" width="8" height="7" rx="0.5"/><rect x="13" y="13" width="8" height="7" rx="0.5"/></svg>`,
adapt: `<svg ${ICON_ATTRS}><rect x="2.5" y="5" width="12" height="11" rx="1"/><line x1="2.5" y1="19" x2="14.5" y2="19"/><rect x="16.5" y="8" width="5" height="11" rx="1"/></svg>`,
animate: `<svg ${ICON_ATTRS}><path d="M3 18c4-4 6-10 10-10"/><path d="M13 8c3 0 5 5 8 10"/><circle cx="13" cy="8" r="1.6" fill="currentColor" stroke="none"/></svg>`,
delight: `<svg ${ICON_ATTRS}><path d="M12 3l2 6 6 2-6 2-2 6-2-6-6-2 6-2z"/></svg>`,
overdrive: `<svg ${ICON_ATTRS}><path d="M13 3L5 13h5l-1 8 9-12h-6z"/></svg>`,
};
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 = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" style="flex-shrink:0"><line x1="4" y1="8" x2="20" y2="8"/><circle cx="14" cy="8" r="2.4" fill="currentColor" stroke="none"/><line x1="4" y1="16" x2="20" y2="16"/><circle cx="10" cy="16" r="2.4" fill="currentColor" stroke="none"/></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:
//
// <div data-impeccable-variant="1"
// data-impeccable-params='[{"id":"density","kind":"steps",...}]'>
//
// 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-<id>`) used via var(--p-foo, N)
// steps / boolean toggle → data-p-<id> 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);
@@ -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(' ')}`,
@@ -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';