From 2341fe3637d93392febc61961e20fd052f1443f7 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Wed, 22 Apr 2026 18:17:17 -0700 Subject: [PATCH] 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) --- .../skills/impeccable/reference/colorize.md | 10 + .agents/skills/impeccable/reference/layout.md | 20 + .agents/skills/impeccable/reference/live.md | 59 +- .../skills/impeccable/reference/typeset.md | 12 + .../skills/impeccable/scripts/live-accept.mjs | 16 +- .../skills/impeccable/scripts/live-browser.js | 667 ++++++++- .../skills/impeccable/scripts/live-poll.mjs | 5 + .../skills/impeccable/scripts/live-server.mjs | 5 + .../skills/impeccable/reference/colorize.md | 10 + .claude/skills/impeccable/reference/layout.md | 20 + .claude/skills/impeccable/reference/live.md | 59 +- .../skills/impeccable/reference/typeset.md | 12 + .../skills/impeccable/scripts/live-accept.mjs | 16 +- .../skills/impeccable/scripts/live-browser.js | 667 ++++++++- .../skills/impeccable/scripts/live-poll.mjs | 5 + .../skills/impeccable/scripts/live-server.mjs | 5 + .../skills/impeccable/reference/colorize.md | 10 + .cursor/skills/impeccable/reference/layout.md | 20 + .cursor/skills/impeccable/reference/live.md | 59 +- .../skills/impeccable/reference/typeset.md | 12 + .../skills/impeccable/scripts/live-accept.mjs | 16 +- .../skills/impeccable/scripts/live-browser.js | 667 ++++++++- .../skills/impeccable/scripts/live-poll.mjs | 5 + .../skills/impeccable/scripts/live-server.mjs | 5 + .../skills/impeccable/reference/colorize.md | 10 + .gemini/skills/impeccable/reference/layout.md | 20 + .gemini/skills/impeccable/reference/live.md | 59 +- .../skills/impeccable/reference/typeset.md | 12 + .../skills/impeccable/scripts/live-accept.mjs | 16 +- .../skills/impeccable/scripts/live-browser.js | 667 ++++++++- .../skills/impeccable/scripts/live-poll.mjs | 5 + .../skills/impeccable/scripts/live-server.mjs | 5 + .../skills/impeccable/reference/colorize.md | 10 + .github/skills/impeccable/reference/layout.md | 20 + .github/skills/impeccable/reference/live.md | 59 +- .../skills/impeccable/reference/typeset.md | 12 + .../skills/impeccable/scripts/live-accept.mjs | 16 +- .../skills/impeccable/scripts/live-browser.js | 667 ++++++++- .../skills/impeccable/scripts/live-poll.mjs | 5 + .../skills/impeccable/scripts/live-server.mjs | 5 + .kiro/skills/impeccable/reference/colorize.md | 10 + .kiro/skills/impeccable/reference/layout.md | 20 + .kiro/skills/impeccable/reference/live.md | 59 +- .kiro/skills/impeccable/reference/typeset.md | 12 + .../skills/impeccable/scripts/live-accept.mjs | 16 +- .../skills/impeccable/scripts/live-browser.js | 667 ++++++++- .kiro/skills/impeccable/scripts/live-poll.mjs | 5 + .../skills/impeccable/scripts/live-server.mjs | 5 + .../skills/impeccable/reference/colorize.md | 10 + .../skills/impeccable/reference/layout.md | 20 + .opencode/skills/impeccable/reference/live.md | 59 +- .../skills/impeccable/reference/typeset.md | 12 + .../skills/impeccable/scripts/live-accept.mjs | 16 +- .../skills/impeccable/scripts/live-browser.js | 667 ++++++++- .../skills/impeccable/scripts/live-poll.mjs | 5 + .../skills/impeccable/scripts/live-server.mjs | 5 + .pi/skills/impeccable/reference/colorize.md | 10 + .pi/skills/impeccable/reference/layout.md | 20 + .pi/skills/impeccable/reference/live.md | 59 +- .pi/skills/impeccable/reference/typeset.md | 12 + .pi/skills/impeccable/scripts/live-accept.mjs | 16 +- .pi/skills/impeccable/scripts/live-browser.js | 667 ++++++++- .pi/skills/impeccable/scripts/live-poll.mjs | 5 + .pi/skills/impeccable/scripts/live-server.mjs | 5 + .../skills/impeccable/reference/colorize.md | 10 + .../skills/impeccable/reference/layout.md | 20 + .rovodev/skills/impeccable/reference/live.md | 59 +- .../skills/impeccable/reference/typeset.md | 12 + .../skills/impeccable/scripts/live-accept.mjs | 16 +- .../skills/impeccable/scripts/live-browser.js | 667 ++++++++- .../skills/impeccable/scripts/live-poll.mjs | 5 + .../skills/impeccable/scripts/live-server.mjs | 5 + .../skills/impeccable/reference/colorize.md | 10 + .../skills/impeccable/reference/layout.md | 20 + .trae-cn/skills/impeccable/reference/live.md | 59 +- .../skills/impeccable/reference/typeset.md | 12 + .../skills/impeccable/scripts/live-accept.mjs | 16 +- .../skills/impeccable/scripts/live-browser.js | 667 ++++++++- .../skills/impeccable/scripts/live-poll.mjs | 5 + .../skills/impeccable/scripts/live-server.mjs | 5 + .trae/skills/impeccable/reference/colorize.md | 10 + .trae/skills/impeccable/reference/layout.md | 20 + .trae/skills/impeccable/reference/live.md | 59 +- .trae/skills/impeccable/reference/typeset.md | 12 + .../skills/impeccable/scripts/live-accept.mjs | 16 +- .../skills/impeccable/scripts/live-browser.js | 667 ++++++++- .trae/skills/impeccable/scripts/live-poll.mjs | 5 + .../skills/impeccable/scripts/live-server.mjs | 5 + content/site/partials/header.html | 7 +- content/site/skills/arrange.md | 41 - content/site/skills/audit.md | 49 + content/site/skills/craft.md | 26 + content/site/skills/critique.md | 66 + content/site/skills/document.md | 114 ++ content/site/skills/extract.md | 21 + content/site/skills/impeccable.md | 32 +- content/site/skills/live.md | 76 + content/site/skills/normalize.md | 41 - content/site/skills/shape.md | 33 + content/site/skills/teach.md | 67 + content/site/tutorials/brand-vs-product.md | 130 ++ .../site/tutorials/critique-with-overlay.md | 5 +- content/site/tutorials/getting-started.md | 27 +- content/site/tutorials/iterate-live.md | 123 ++ public/css/docs-visuals.css | 1257 +++++++++++++++++ public/css/sub-pages.css | 13 +- public/css/workflow.css | 8 + public/index.html | 15 +- public/js/data.js | 2 +- public/privacy.html | 4 +- scripts/build-sub-pages.js | 18 +- .../skills/impeccable/reference/colorize.md | 10 + source/skills/impeccable/reference/layout.md | 20 + source/skills/impeccable/reference/live.md | 59 +- source/skills/impeccable/reference/typeset.md | 12 + .../skills/impeccable/scripts/live-accept.mjs | 16 +- .../skills/impeccable/scripts/live-browser.js | 667 ++++++++- .../skills/impeccable/scripts/live-poll.mjs | 5 + .../skills/impeccable/scripts/live-server.mjs | 5 + 119 files changed, 10730 insertions(+), 973 deletions(-) delete mode 100644 content/site/skills/arrange.md create mode 100644 content/site/skills/document.md create mode 100644 content/site/skills/live.md delete mode 100644 content/site/skills/normalize.md create mode 100644 content/site/tutorials/brand-vs-product.md create mode 100644 content/site/tutorials/iterate-live.md create mode 100644 public/css/docs-visuals.css 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 - + - 18k + 21k
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." +--- + +
+
+
+ DESIGN.md + Google Stitch format +
+
+
+ 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 +
+ +
+
+
+ 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."

+ +
+ + + +
+
+

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 `
diff --git a/scripts/build-sub-pages.js b/scripts/build-sub-pages.js index 5265b7e60..59aaf0b81 100644 --- a/scripts/build-sub-pages.js +++ b/scripts/build-sub-pages.js @@ -262,7 +262,7 @@ function renderSkillsOverviewMain(skillsByCategory, allSkills) { const tagline = skill.editorial?.frontmatter?.tagline || skill.description; const shortTagline = tagline.length > 140 ? tagline.slice(0, 137) + '...' : tagline; const rel = COMMAND_RELATIONSHIPS[skill.id] || {}; - const isBeta = skill.id === 'overdrive'; + const isBeta = skill.id === 'live'; let metaHtml = ''; if (rel.pairs) { @@ -333,7 +333,13 @@ ${rowsHtml}
  • /impeccable teach - One-time project setup. Runs automatically on first use. + One-time project setup. Writes PRODUCT.md, offers DESIGN.md next. + +
  • +
  • + + /impeccable document + Generate a spec-compliant DESIGN.md that captures your visual system.
  • @@ -343,9 +349,9 @@ ${rowsHtml}
  • - - /impeccable extract - Pull reusable components and tokens into the design system. + + /impeccable live + Pick an element, get three variants, accept one. Writes back to source.
  • @@ -641,7 +647,7 @@ ${rules.map(renderRuleCard).join('\n')}

    ${totalRules} rules

    Anti-patterns

    -

    The full catalog of patterns /impeccable teaches against. ${detectedCount} are caught by a deterministic detector (npx impeccable detect or the browser extension). ${llmCount} can only be flagged by /impeccable critique's LLM review pass. Want to see them live on real pages? Try Visual Mode.

    +

    The full catalog of patterns /impeccable teaches against. ${detectedCount} are caught by a deterministic detector (npx impeccable detect or the browser extension). ${llmCount} can only be flagged by /impeccable critique's LLM review pass. Want to see them live on real pages? Try Visual Mode, or iterate past them on your own dev server with Live Mode.

    diff --git a/source/skills/impeccable/reference/colorize.md b/source/skills/impeccable/reference/colorize.md index 8e66eb71d..07ae3c3e1 100644 --- a/source/skills/impeccable/reference/colorize.md +++ b/source/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/source/skills/impeccable/reference/layout.md b/source/skills/impeccable/reference/layout.md index 981ace8a1..5e8022081 100644 --- a/source/skills/impeccable/reference/layout.md +++ b/source/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/source/skills/impeccable/reference/live.md b/source/skills/impeccable/reference/live.md index 95ef8094f..e836d7c57 100644 --- a/source/skills/impeccable/reference/live.md +++ b/source/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 {{scripts_path}}/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/source/skills/impeccable/scripts/live-browser.js b/source/skills/impeccable/scripts/live-browser.js index 116e1efa6..66efd7243 100644 --- a/source/skills/impeccable/scripts/live-browser.js +++ b/source/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/source/skills/impeccable/scripts/live-poll.mjs b/source/skills/impeccable/scripts/live-poll.mjs index 9dd87e3ea..5cece1a43 100644 --- a/source/skills/impeccable/scripts/live-poll.mjs +++ b/source/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/source/skills/impeccable/scripts/live-server.mjs b/source/skills/impeccable/scripts/live-server.mjs index e5fb05bfa..2ab33fd22 100644 --- a/source/skills/impeccable/scripts/live-server.mjs +++ b/source/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';