mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-17 08:36:25 +03:00
feat(live): tune popover, theme-aware bar/picker, action icons, params system
Adds a coarse-controls ("Tune") popover that slides out from behind the
contextual bar via clip-path, showing 2-4 per-variant knobs (range / steps /
toggle) driven by a `data-impeccable-params` manifest. Range/toggle drive
CSS custom properties on the variant wrapper; steps toggle a data
attribute. Values reset per variant; on accept, current values are passed
through live-poll to live-accept as an `impeccable-param-values` comment
for the carbonize cleanup step to bake in.
Other live-UI work in this change:
- Theme-aware palette (barPaletteForTheme) now drives the contextual bar,
action picker, and tune popover. Dark sand on light pages, paper on
dark. Detection has a localStorage dev override for QA.
- Action picker chips get inline SVG icons (wand / bars / funnel / sparkle /
type ramp / circles / grid / devices / curve / star / bolt) stacked
above the label; selection state recolors via currentColor.
- Accept button switched to saturated site magenta with paper text.
- Cycle dots reworked: solid accent for active, neutral for arrived,
hairline ring for pending. No more magenta-on-gray noise.
- Tune chip sits in the cycling row with a count pill badge; open state
uses accentSoft bg + accent text (no ad-hoc white border).
- Popover uses the bar's palette with a deeper surface (surfaceDeep),
lives behind the bar via z-index so a 6px overlap reads as tucked under
it, and animates with clip-path inset() for reliable slide behavior.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
59769e316f
commit
2341fe3637
@@ -142,3 +142,13 @@ Test that colorization improves the experience:
|
||||
- **Not overwhelming**: Is color balanced and purposeful?
|
||||
|
||||
Remember: Color is emotional and powerful. Use it to create warmth, guide attention, communicate meaning, and express personality. But restraint and strategy matter more than saturation and variety. Be colorful, but be intentional.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
When invoked from live mode, each variant MUST declare a `color-amount` param so the user can dial between a restrained accent and a drenched surface without regeneration. Author the variant's CSS against `var(--p-color-amount, 0.5)` — typically as the alpha multiplier on backgrounds, or as a scaling factor on the chroma axis in an OKLCH expression. 0 = neutral/monochrome, 1 = full saturation / dominant coverage.
|
||||
|
||||
```json
|
||||
{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"}
|
||||
```
|
||||
|
||||
Layer 1-2 variant-specific params on top: palette selection (`steps` with named options), temperature warmth, or tint vs. true color. See `reference/live.md` for the full params contract.
|
||||
|
||||
@@ -119,3 +119,23 @@ Create a systematic plan:
|
||||
- **Responsiveness**: Does the layout adapt gracefully across screen sizes?
|
||||
|
||||
Remember: Space is the most underused design tool. A layout with the right rhythm and hierarchy can make even simple content feel polished and intentional.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
Each variant MUST declare a `density` param. Drive all spacing tokens in the variant's scoped CSS through `calc(var(--p-density, 1) * <base>)` — paddings, gaps, column widths. Users slide from airy to packed and see layout re-breathe with no regeneration.
|
||||
|
||||
```json
|
||||
{"id":"density","kind":"range","min":0.6,"max":1.4,"step":0.05,"default":1,"label":"Density"}
|
||||
```
|
||||
|
||||
For variants whose topology genuinely changes (stacked vs. side-by-side, grid vs. bento), use a `steps` param whose scoped CSS branches via `:scope[data-p-structure="X"]`. One structure param + one density param is a powerful combo; resist adding a third.
|
||||
|
||||
```json
|
||||
{"id":"structure","kind":"steps","default":"grid","label":"Structure","options":[
|
||||
{"value":"stacked","label":"Stacked"},
|
||||
{"value":"grid","label":"Grid"},
|
||||
{"value":"bento","label":"Bento"}
|
||||
]}
|
||||
```
|
||||
|
||||
See `reference/live.md` for the full params contract.
|
||||
|
||||
@@ -179,7 +179,56 @@ The first variant has no `display: none` (visible by default). All others do. If
|
||||
|
||||
One edit, all variants — the browser's MutationObserver picks everything up in one pass.
|
||||
|
||||
### 7. Signal done
|
||||
### 7. Parameters (optional, 2-5 per variant)
|
||||
|
||||
Each variant can expose coarse knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against.
|
||||
|
||||
**When to use.** Any time the variant has a meaningful axis the user might want to dial in: color amount, density, motion intensity, scale ratio. Not micro-level margin tweaks; those defeat the point.
|
||||
|
||||
**Budget scales with the element's visual weight, not the user's curiosity.** Knobs need real estate to produce noticeably different output; slapping three on a small element just crowds the panel without making anything feel tunable.
|
||||
|
||||
- **Leaf / tiny** — a single button, icon, input, bare heading, solitary paragraph: **0 params.** A slider can't meaningfully reshape one element; just use variants.
|
||||
- **Small composition** — labeled input, simple card, short callout (≤ ~5 visual children): **0-1 params.** Only add one if it's a clear dominant axis (e.g. density on a card with visible internal rhythm).
|
||||
- **Medium composition** — section component, nav cluster, dense card, short feature block (6-15 visual children): **2 params.**
|
||||
- **Large composition** — hero section, full page region, spread layout, anything with strong internal structure (16+ visual children or multiple sub-sections): **3-4 params.**
|
||||
|
||||
When in doubt, fewer. The user can always ask for more variants to explore an axis you didn't expose as a knob. Count by visual children, not by DOM-node depth; a deeply-nested-but-visually-simple card still counts as small.
|
||||
|
||||
**How to declare.** Put a JSON manifest on the variant wrapper:
|
||||
|
||||
```html
|
||||
<div data-impeccable-variant="1" data-impeccable-params='[
|
||||
{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"},
|
||||
{"id":"density","kind":"steps","default":"snug","label":"Density","options":[
|
||||
{"value":"airy","label":"Airy"},
|
||||
{"value":"snug","label":"Snug"},
|
||||
{"value":"packed","label":"Packed"}
|
||||
]},
|
||||
{"id":"serif","kind":"toggle","default":false,"label":"Serif display"}
|
||||
]'>
|
||||
...variant content...
|
||||
</div>
|
||||
```
|
||||
|
||||
**Three kinds:**
|
||||
|
||||
- `range` — smooth slider. Drives a CSS custom property `--p-<id>` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`.
|
||||
- `steps` — segmented radio. Drives a data attribute `data-p-<id>` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`.
|
||||
- `toggle` — on/off switch. Drives BOTH a CSS var (`--p-<id>: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`.
|
||||
|
||||
**Signature params per action.** Each action has one or two signature params that MUST be exposed when the variant can meaningfully express them. Check the action's reference file for the list. Layer 1-2 variant-specific params on top.
|
||||
|
||||
**Reset on variant switch.** User dials density on v1, flips to v2, v2 starts at v2's declared defaults. Known limitation; preservation across variants may land later.
|
||||
|
||||
**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment:
|
||||
|
||||
```html
|
||||
<!-- impeccable-param-values SESSION_ID: {"color-amount":0.7,"density":"packed"} -->
|
||||
```
|
||||
|
||||
The carbonize cleanup step (see below) reads that comment and bakes the chosen values into the final CSS. For `steps`/`toggle` attribute selectors: keep only the branch matching the chosen value, drop the others, collapse `:scope[data-p-density="packed"] .grid` to a semantic class rule. For `range` vars: either substitute the literal or keep the var with the chosen value as its new default.
|
||||
|
||||
### 8. Signal done
|
||||
|
||||
```bash
|
||||
node .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 `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element.
|
||||
1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values — read it first; it drives steps 3 and 4 below.
|
||||
2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `public/css/workflow.css` for this repo, or the component's co-located CSS file for a Vite/Next project — pick whichever already owns styling for the surrounding element).
|
||||
3. **Rewrite `@scope ([data-impeccable-variant="N"])` selectors** to target real, semantic classes on the accepted HTML. Example: `@scope ([data-impeccable-variant="2"]) { .v2-label { … } }` becomes `.why-visual--v2 .v2-label { … }` if the accepted element already carries `.why-visual--v2`, or pick/add a suitable class if it doesn't.
|
||||
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it.
|
||||
5. **Delete the inline `<style>` block and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one — those are dead code now.
|
||||
3. **Bake in parameter values while rewriting selectors.** For `@scope ([data-impeccable-variant="N"])` wrappers: retarget to real, semantic classes on the accepted HTML (`.why-visual--v2 .v2-label { … }`). For `:scope[data-p-<id>="VALUE"]` selectors: keep only the branch matching the chosen value from the param-values comment; drop the others (they're dead after accept). For `var(--p-<id>, DEFAULT)` in the CSS: either substitute the literal value, or if the param is still useful as a knob going forward, leave the var and update its initial declaration to the chosen value.
|
||||
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it. Drop `data-impeccable-params` and any `data-p-*` attributes from it — those are live-mode plumbing, not source.
|
||||
5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one — those are dead code now.
|
||||
|
||||
Then poll again.
|
||||
|
||||
|
||||
@@ -110,3 +110,15 @@ Build a clear type scale:
|
||||
- **Accessibility**: Does text meet WCAG contrast ratios? Is it zoomable to 200%?
|
||||
|
||||
Remember: Typography is the foundation of interface design — it carries the majority of information. Getting it right is the highest-leverage improvement you can make.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
Each variant MUST declare a `scale` param controlling the hierarchy ratio. Express all font sizes in the variant's scoped CSS through `calc(var(--p-scale, 1) * <base>)` or, better, scale the type ramp via `clamp(min, calc(var(--p-scale, 1) * Npx), max)`. Users slide from subdued to commanding.
|
||||
|
||||
```json
|
||||
{"id":"scale","kind":"range","min":0.85,"max":1.3,"step":0.05,"default":1,"label":"Scale"}
|
||||
```
|
||||
|
||||
Where the variant riffs on a specific pairing, expose the pairing choice as a `steps` param (e.g. "serif display + sans body" vs. "mono display + sans body" vs. "all-sans"). Each branch routes through `:scope[data-p-pairing="X"]` selectors in scoped CSS.
|
||||
|
||||
See `reference/live.md` for the full params contract.
|
||||
|
||||
@@ -45,11 +45,18 @@ Output (JSON):
|
||||
|
||||
const id = argVal(args, '--id');
|
||||
const variantNum = argVal(args, '--variant');
|
||||
const paramValuesRaw = argVal(args, '--param-values');
|
||||
const isDiscard = args.includes('--discard');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); }
|
||||
|
||||
let paramValues = null;
|
||||
if (paramValuesRaw) {
|
||||
try { paramValues = JSON.parse(paramValuesRaw); }
|
||||
catch { paramValues = null; } // malformed blob: skip the comment rather than failing the accept
|
||||
}
|
||||
|
||||
// Find the file containing this session's markers
|
||||
const found = findSessionFile(id, process.cwd());
|
||||
if (!found) {
|
||||
@@ -78,7 +85,7 @@ Output (JSON):
|
||||
const result = handleDiscard(id, lines, targetFile);
|
||||
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
|
||||
} else {
|
||||
const result = handleAccept(id, variantNum, lines, targetFile);
|
||||
const result = handleAccept(id, variantNum, lines, targetFile, paramValues);
|
||||
// Single-line attention-grabber when cleanup is required. The full
|
||||
// five-step checklist lives in reference/live.md (loaded once per
|
||||
// session); repeating it per-event would waste tokens.
|
||||
@@ -116,7 +123,7 @@ function handleDiscard(id, lines, targetFile) {
|
||||
// Accept
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function handleAccept(id, variantNum, lines, targetFile) {
|
||||
function handleAccept(id, variantNum, lines, targetFile, paramValues) {
|
||||
const block = findMarkerBlock(id, lines);
|
||||
if (!block) return { handled: false, error: 'Markers not found' };
|
||||
|
||||
@@ -149,6 +156,11 @@ function handleAccept(id, variantNum, lines, targetFile) {
|
||||
replacement.push(indent + cssLine.trimStart());
|
||||
}
|
||||
replacement.push(indent + '</style>');
|
||||
if (paramValues && Object.keys(paramValues).length > 0) {
|
||||
// Preserve the user's knob positions for the carbonize-cleanup agent
|
||||
// to bake into the final CSS when it collapses scoped rules.
|
||||
replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
|
||||
}
|
||||
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,10 +30,13 @@
|
||||
// Design tokens
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Brand magenta is pinned to the site token (--color-accent in main.css)
|
||||
// so Accept / knobs / cycle-dots match the site's accent, not a washed
|
||||
// theme-adjusted one.
|
||||
const C = {
|
||||
brand: 'oklch(55% 0.25 350)',
|
||||
brandHov: 'oklch(48% 0.25 350)',
|
||||
brandSoft: 'oklch(55% 0.25 350 / 0.12)',
|
||||
brand: 'oklch(60% 0.25 350)',
|
||||
brandHov: 'oklch(52% 0.25 350)',
|
||||
brandSoft: 'oklch(60% 0.25 350 / 0.15)',
|
||||
ink: 'oklch(15% 0.01 350)',
|
||||
ash: 'oklch(55% 0 0)',
|
||||
paper: 'oklch(98% 0.005 350 / 0.92)',
|
||||
@@ -60,6 +63,25 @@
|
||||
'html', 'head', 'body', 'script', 'style', 'link', 'meta', 'noscript', 'br', 'wbr',
|
||||
]);
|
||||
|
||||
// SVG icons stack above each chip label. All strokes use currentColor so the
|
||||
// icon recolors to C.brand when its chip is selected. 20x20 render, 24-viewBox,
|
||||
// 1.5 stroke — visually consistent with the Foundation grid on the homepage.
|
||||
const ICON_ATTRS = 'width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="display:block"';
|
||||
const ICONS = {
|
||||
impeccable: `<svg ${ICON_ATTRS}><path d="M4 20l4-1L18 9l-3-3L5 16z"/><path d="M14 7l3 3"/></svg>`,
|
||||
bolder: `<svg ${ICON_ATTRS}><rect x="6" y="12" width="4" height="7" rx="0.5"/><rect x="14" y="5" width="4" height="14" rx="0.5"/></svg>`,
|
||||
quieter: `<svg ${ICON_ATTRS}><rect x="6" y="5" width="4" height="14" rx="0.5"/><rect x="14" y="12" width="4" height="7" rx="0.5"/></svg>`,
|
||||
distill: `<svg ${ICON_ATTRS}><path d="M4 5h16l-6 8v7l-4-2v-5z"/></svg>`,
|
||||
polish: `<svg ${ICON_ATTRS}><path d="M15 3l1 3 3 1-3 1-1 3-1-3-3-1 3-1z"/><path d="M7 13l0.6 1.8 1.8 0.6-1.8 0.6-0.6 1.8-0.6-1.8-1.8-0.6 1.8-0.6z"/></svg>`,
|
||||
typeset: `<svg ${ICON_ATTRS}><path d="M5 6h14" stroke-width="2.6"/><path d="M5 12h9" stroke-width="1.9"/><path d="M5 18h5" stroke-width="1.3"/></svg>`,
|
||||
colorize: `<svg ${ICON_ATTRS}><circle cx="9" cy="10" r="5"/><circle cx="15" cy="10" r="5"/><circle cx="12" cy="15" r="5"/></svg>`,
|
||||
layout: `<svg ${ICON_ATTRS}><rect x="3" y="4" width="8" height="16" rx="0.5"/><rect x="13" y="4" width="8" height="7" rx="0.5"/><rect x="13" y="13" width="8" height="7" rx="0.5"/></svg>`,
|
||||
adapt: `<svg ${ICON_ATTRS}><rect x="2.5" y="5" width="12" height="11" rx="1"/><line x1="2.5" y1="19" x2="14.5" y2="19"/><rect x="16.5" y="8" width="5" height="11" rx="1"/></svg>`,
|
||||
animate: `<svg ${ICON_ATTRS}><path d="M3 18c4-4 6-10 10-10"/><path d="M13 8c3 0 5 5 8 10"/><circle cx="13" cy="8" r="1.6" fill="currentColor" stroke="none"/></svg>`,
|
||||
delight: `<svg ${ICON_ATTRS}><path d="M12 3l2 6 6 2-6 2-2 6-2-6-6-2 6-2z"/></svg>`,
|
||||
overdrive: `<svg ${ICON_ATTRS}><path d="M13 3L5 13h5l-1 8 9-12h-6z"/></svg>`,
|
||||
};
|
||||
|
||||
const ACTIONS = [
|
||||
{ value: 'impeccable', label: 'Freeform' },
|
||||
{ value: 'bolder', label: 'Bolder' },
|
||||
@@ -756,7 +778,21 @@
|
||||
// The Bar — one floating element, three modes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Contextual-bar palette. Cached at init so every build*Row reads a
|
||||
// consistent set of colors; detectPageTheme runs once rather than on every
|
||||
// phase transition.
|
||||
let BP = null;
|
||||
|
||||
// Bar shadow variants. The default projects down + subtle around. When
|
||||
// the Tune popover opens below the bar, a downward shadow lands on the
|
||||
// dark popover and reads as a bright ghost line. We swap to UP-only while
|
||||
// tune is open below so the popover's top edge is clean.
|
||||
const BAR_SHADOW_DEFAULT = '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)';
|
||||
const BAR_SHADOW_UP = '0 -4px 20px oklch(0% 0 0 / 0.08), 0 -1px 3px oklch(0% 0 0 / 0.06)';
|
||||
const BAR_SHADOW_DOWN = BAR_SHADOW_DEFAULT;
|
||||
|
||||
function initBar() {
|
||||
BP = barPaletteForTheme(detectPageTheme());
|
||||
barEl = document.createElement('div');
|
||||
barEl.id = PREFIX + '-bar';
|
||||
Object.assign(barEl.style, {
|
||||
@@ -764,15 +800,15 @@
|
||||
display: 'none', opacity: '0',
|
||||
transform: 'translateY(6px)',
|
||||
transition: 'opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
|
||||
// Visual
|
||||
background: C.paper,
|
||||
background: BP.surface,
|
||||
backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
|
||||
border: '1px solid ' + C.mist,
|
||||
border: '1px solid ' + BP.hairline,
|
||||
borderRadius: '10px',
|
||||
boxShadow: '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)',
|
||||
fontFamily: FONT, fontSize: '13px', color: C.ink,
|
||||
boxShadow: BAR_SHADOW_DEFAULT,
|
||||
transition: 'box-shadow 0.2s ease, opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
|
||||
fontFamily: FONT, fontSize: '13px', color: BP.text,
|
||||
padding: '6px',
|
||||
maxWidth: '460px', minWidth: '320px',
|
||||
maxWidth: '520px', minWidth: '320px',
|
||||
});
|
||||
document.body.appendChild(barEl);
|
||||
}
|
||||
@@ -824,14 +860,15 @@
|
||||
barEl.style.transform = 'translateY(6px)';
|
||||
setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250);
|
||||
hideActionPicker();
|
||||
closeTunePopover();
|
||||
}
|
||||
|
||||
function updateBarContent(mode) {
|
||||
if (!barEl || barEl.style.display === 'none') return;
|
||||
barEl.innerHTML = '';
|
||||
// Reset bar styling
|
||||
barEl.style.background = C.paper;
|
||||
barEl.style.border = '1px solid ' + C.mist;
|
||||
// Reset bar styling to the theme-aware palette
|
||||
barEl.style.background = BP.surface;
|
||||
barEl.style.border = '1px solid ' + BP.hairline;
|
||||
if (mode === 'configure') barEl.appendChild(buildConfigureRow());
|
||||
else if (mode === 'generating') barEl.appendChild(buildGeneratingRow());
|
||||
else if (mode === 'cycling') barEl.appendChild(buildCyclingRow());
|
||||
@@ -854,15 +891,15 @@
|
||||
const pill = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
||||
padding: '5px 10px', borderRadius: '6px',
|
||||
background: C.ink, color: C.white,
|
||||
background: BP.mark, color: BP.markText,
|
||||
fontFamily: FONT, fontSize: '12px', fontWeight: '500',
|
||||
border: 'none', cursor: 'pointer',
|
||||
transition: 'background 0.12s ease, transform 0.1s ease',
|
||||
whiteSpace: 'nowrap', flexShrink: '0',
|
||||
});
|
||||
pill.textContent = actionLabel() + ' \u25BE';
|
||||
pill.addEventListener('mouseenter', () => pill.style.background = C.brandHov);
|
||||
pill.addEventListener('mouseleave', () => pill.style.background = C.ink);
|
||||
pill.addEventListener('mouseenter', () => pill.style.background = BP.accent);
|
||||
pill.addEventListener('mouseleave', () => pill.style.background = BP.mark);
|
||||
pill.addEventListener('mousedown', () => pill.style.transform = 'scale(0.97)');
|
||||
pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)');
|
||||
pill.addEventListener('click', (e) => { e.stopPropagation(); toggleActionPicker(); });
|
||||
@@ -877,13 +914,13 @@
|
||||
flex: '1', minWidth: '0',
|
||||
padding: '5px 8px', borderRadius: '6px',
|
||||
border: '1px solid transparent', background: 'transparent',
|
||||
fontFamily: FONT, fontSize: '12px', color: C.ink,
|
||||
fontFamily: FONT, fontSize: '12px', color: BP.text,
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.15s ease, background 0.15s ease',
|
||||
});
|
||||
input.addEventListener('focus', () => {
|
||||
input.style.borderColor = C.mist;
|
||||
input.style.background = C.white;
|
||||
input.style.borderColor = BP.hairline;
|
||||
input.style.background = BP.accentSoft;
|
||||
});
|
||||
input.addEventListener('blur', () => {
|
||||
input.style.borderColor = 'transparent';
|
||||
@@ -901,16 +938,16 @@
|
||||
// Variant count toggle
|
||||
const count = el('button', {
|
||||
padding: '4px 6px', borderRadius: '5px',
|
||||
border: '1px solid ' + C.mist, background: 'transparent',
|
||||
border: '1px solid ' + BP.hairline, background: 'transparent',
|
||||
fontFamily: MONO, fontSize: '11px', fontWeight: '600',
|
||||
color: C.ash, cursor: 'pointer',
|
||||
color: BP.textDim, cursor: 'pointer',
|
||||
transition: 'color 0.12s ease, border-color 0.12s ease',
|
||||
flexShrink: '0', whiteSpace: 'nowrap',
|
||||
});
|
||||
count.textContent = '\u00D7' + selectedCount;
|
||||
count.title = 'Variants: click to change';
|
||||
count.addEventListener('mouseenter', () => { count.style.color = C.ink; count.style.borderColor = C.ink; });
|
||||
count.addEventListener('mouseleave', () => { count.style.color = C.ash; count.style.borderColor = C.mist; });
|
||||
count.addEventListener('mouseenter', () => { count.style.color = BP.text; count.style.borderColor = BP.text; });
|
||||
count.addEventListener('mouseleave', () => { count.style.color = BP.textDim; count.style.borderColor = BP.hairline; });
|
||||
count.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1;
|
||||
@@ -921,15 +958,15 @@
|
||||
// Go button
|
||||
const go = el('button', {
|
||||
padding: '5px 12px', borderRadius: '6px',
|
||||
border: 'none', background: C.brand, color: C.white,
|
||||
border: 'none', background: BP.accent, color: BP.mark,
|
||||
fontFamily: FONT, fontSize: '12px', fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.12s ease, transform 0.1s ease',
|
||||
transition: 'filter 0.12s ease, transform 0.1s ease',
|
||||
flexShrink: '0', whiteSpace: 'nowrap',
|
||||
});
|
||||
go.textContent = 'Go \u2192';
|
||||
go.addEventListener('mouseenter', () => go.style.background = C.brandHov);
|
||||
go.addEventListener('mouseleave', () => go.style.background = C.brand);
|
||||
go.addEventListener('mouseenter', () => go.style.filter = 'brightness(1.1)');
|
||||
go.addEventListener('mouseleave', () => go.style.filter = 'none');
|
||||
go.addEventListener('mousedown', () => go.style.transform = 'scale(0.97)');
|
||||
go.addEventListener('mouseup', () => go.style.transform = 'scale(1)');
|
||||
go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); });
|
||||
@@ -950,7 +987,7 @@
|
||||
|
||||
// Action label
|
||||
const label = el('span', {
|
||||
fontWeight: '600', fontSize: '12px', color: C.ink,
|
||||
fontWeight: '600', fontSize: '12px', color: BP.text,
|
||||
flexShrink: '0', whiteSpace: 'nowrap',
|
||||
});
|
||||
label.textContent = actionLabel();
|
||||
@@ -961,7 +998,7 @@
|
||||
|
||||
// Status
|
||||
const status = el('span', {
|
||||
fontSize: '11px', color: C.ash, whiteSpace: 'nowrap',
|
||||
fontSize: '11px', color: BP.textDim, whiteSpace: 'nowrap',
|
||||
marginLeft: 'auto',
|
||||
});
|
||||
// Variants currently arrive atomically in a single file edit, so a
|
||||
@@ -976,6 +1013,8 @@
|
||||
|
||||
// --- Cycling row ---
|
||||
|
||||
const TUNE_ICON_SVG = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" style="flex-shrink:0"><line x1="4" y1="8" x2="20" y2="8"/><circle cx="14" cy="8" r="2.4" fill="currentColor" stroke="none"/><line x1="4" y1="16" x2="20" y2="16"/><circle cx="10" cy="16" r="2.4" fill="currentColor" stroke="none"/></svg>';
|
||||
|
||||
function buildCyclingRow() {
|
||||
const row = el('div', {
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
@@ -994,7 +1033,7 @@
|
||||
// Counter
|
||||
const counter = el('span', {
|
||||
fontFamily: MONO, fontSize: '11px', fontWeight: '500',
|
||||
color: C.ash, minWidth: '24px', textAlign: 'center',
|
||||
color: BP.textDim, minWidth: '24px', textAlign: 'center',
|
||||
});
|
||||
counter.textContent = visibleVariant + '/' + arrivedVariants;
|
||||
row.appendChild(counter);
|
||||
@@ -1005,20 +1044,67 @@
|
||||
if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
|
||||
row.appendChild(next);
|
||||
|
||||
// Tune chip — only when the visible variant exposes params
|
||||
const visParams = parseVariantParams(getVisibleVariantEl());
|
||||
const hasParams = visParams.length > 0;
|
||||
if (hasParams) {
|
||||
const tune = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '6px',
|
||||
padding: '4px 10px', borderRadius: '5px',
|
||||
border: '1px solid transparent',
|
||||
background: tuneOpen ? BP.accentSoft : 'transparent',
|
||||
color: tuneOpen ? BP.accent : BP.text,
|
||||
fontFamily: FONT, fontSize: '11px', fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'color 0.12s ease, background 0.12s ease',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
tune.innerHTML = TUNE_ICON_SVG;
|
||||
const tuneLabel = document.createElement('span');
|
||||
tuneLabel.textContent = 'Tune';
|
||||
tune.appendChild(tuneLabel);
|
||||
const tuneBadge = document.createElement('span');
|
||||
Object.assign(tuneBadge.style, {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
minWidth: '16px', height: '16px', padding: '0 4px',
|
||||
borderRadius: '999px',
|
||||
background: tuneOpen ? C.brand : BP.hairline,
|
||||
color: tuneOpen ? 'oklch(98% 0 0)' : 'inherit',
|
||||
fontFamily: MONO, fontSize: '9.5px', fontWeight: '600',
|
||||
lineHeight: '1',
|
||||
boxSizing: 'border-box',
|
||||
});
|
||||
tuneBadge.textContent = String(visParams.length);
|
||||
tune.appendChild(tuneBadge);
|
||||
tune.title = 'Tune this variant (' + visParams.length + ' knob' + (visParams.length === 1 ? '' : 's') + ')';
|
||||
tune.addEventListener('mouseenter', () => {
|
||||
if (!tuneOpen) tune.style.background = BP.accentSoft;
|
||||
});
|
||||
tune.addEventListener('mouseleave', () => {
|
||||
if (!tuneOpen) tune.style.background = 'transparent';
|
||||
});
|
||||
tune.addEventListener('click', (e) => { e.stopPropagation(); toggleTunePopover(); });
|
||||
tune.dataset.iceqTune = '1';
|
||||
row.appendChild(tune);
|
||||
}
|
||||
|
||||
// Spacer
|
||||
row.appendChild(el('div', { flex: '1' }));
|
||||
|
||||
// Accept
|
||||
// Accept — primary action, uses the site's saturated brand magenta
|
||||
// with paper-white text, not the theme-muted BP.accent.
|
||||
const accept = el('button', {
|
||||
padding: '4px 10px', borderRadius: '5px',
|
||||
border: 'none', background: C.ink, color: C.white,
|
||||
padding: '5px 14px', borderRadius: '5px',
|
||||
border: 'none', background: C.brand, color: 'oklch(98% 0 0)',
|
||||
fontFamily: FONT, fontSize: '11px', fontWeight: '600',
|
||||
cursor: 'pointer', transition: 'background 0.12s ease',
|
||||
cursor: 'pointer', transition: 'filter 0.12s ease, transform 0.1s ease',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
accept.textContent = '\u2713 Accept';
|
||||
accept.addEventListener('mouseenter', () => accept.style.background = C.brand);
|
||||
accept.addEventListener('mouseleave', () => accept.style.background = C.ink);
|
||||
accept.addEventListener('mouseenter', () => accept.style.filter = 'brightness(1.08)');
|
||||
accept.addEventListener('mouseleave', () => accept.style.filter = 'none');
|
||||
accept.addEventListener('mousedown', () => accept.style.transform = 'scale(0.97)');
|
||||
accept.addEventListener('mouseup', () => accept.style.transform = 'scale(1)');
|
||||
accept.addEventListener('click', (e) => { e.stopPropagation(); handleAccept(); });
|
||||
if (arrivedVariants === 0) { accept.style.opacity = '0.3'; accept.style.pointerEvents = 'none'; }
|
||||
row.appendChild(accept);
|
||||
@@ -1026,14 +1112,14 @@
|
||||
// Discard
|
||||
const discard = el('button', {
|
||||
padding: '4px 6px', borderRadius: '5px',
|
||||
border: '1px solid ' + C.mist, background: 'transparent',
|
||||
fontFamily: FONT, fontSize: '11px', color: C.ash,
|
||||
border: '1px solid ' + BP.hairline, background: 'transparent',
|
||||
fontFamily: FONT, fontSize: '11px', color: BP.textDim,
|
||||
cursor: 'pointer', transition: 'color 0.12s ease, border-color 0.12s ease',
|
||||
});
|
||||
discard.textContent = '\u2715';
|
||||
discard.title = 'Discard all variants';
|
||||
discard.addEventListener('mouseenter', () => { discard.style.color = C.ink; discard.style.borderColor = C.ink; });
|
||||
discard.addEventListener('mouseleave', () => { discard.style.color = C.ash; discard.style.borderColor = C.mist; });
|
||||
discard.addEventListener('mouseenter', () => { discard.style.color = BP.text; discard.style.borderColor = BP.text; });
|
||||
discard.addEventListener('mouseleave', () => { discard.style.color = BP.textDim; discard.style.borderColor = BP.hairline; });
|
||||
discard.addEventListener('click', (e) => { e.stopPropagation(); handleDiscard(); });
|
||||
row.appendChild(discard);
|
||||
|
||||
@@ -1051,14 +1137,14 @@
|
||||
});
|
||||
const spinner = el('div', {
|
||||
width: '14px', height: '14px', borderRadius: '50%',
|
||||
border: '2px solid ' + C.mist,
|
||||
borderTopColor: C.brand,
|
||||
border: '2px solid ' + BP.hairline,
|
||||
borderTopColor: BP.accent,
|
||||
animation: 'impeccable-spin 0.6s linear infinite',
|
||||
flexShrink: '0',
|
||||
});
|
||||
row.appendChild(spinner);
|
||||
const label = el('span', {
|
||||
fontSize: '12px', color: C.ash, fontWeight: '500',
|
||||
fontSize: '12px', color: BP.textDim, fontWeight: '500',
|
||||
});
|
||||
label.textContent = 'Applying variant...';
|
||||
row.appendChild(label);
|
||||
@@ -1103,14 +1189,26 @@
|
||||
for (let i = 1; i <= expectedVariants; i++) {
|
||||
const arrived = i <= arrivedVariants;
|
||||
const active = i === visibleVariant;
|
||||
// active: solid site-brand magenta dot. arrived+inactive: muted neutral.
|
||||
// pending (not yet arrived): faint outline ring. No borders on arrived
|
||||
// dots — the previous "accent ring + ash fill" combo read as noisy
|
||||
// magenta chips, especially when all variants had arrived and every
|
||||
// dot wore an accent ring.
|
||||
const dotBg = active ? C.brand
|
||||
: arrived ? BP.textDim
|
||||
: 'transparent';
|
||||
const dotBorder = arrived ? 'none' : '1.5px solid ' + BP.hairline;
|
||||
const dot = el('div', {
|
||||
width: '7px', height: '7px', borderRadius: '50%',
|
||||
background: active ? C.brand : (arrived ? C.ash : 'transparent'),
|
||||
border: '1.5px solid ' + (arrived ? C.brand : C.mist),
|
||||
width: active ? '8px' : '6px',
|
||||
height: active ? '8px' : '6px',
|
||||
borderRadius: '50%',
|
||||
background: dotBg,
|
||||
border: dotBorder,
|
||||
boxSizing: 'border-box',
|
||||
transition: 'all 0.2s ' + EASE,
|
||||
cursor: (clickable && arrived) ? 'pointer' : 'default',
|
||||
transform: arrived ? 'scale(1)' : 'scale(0.6)',
|
||||
opacity: arrived ? '1' : '0.4',
|
||||
transform: arrived ? 'scale(1)' : 'scale(0.85)',
|
||||
opacity: arrived ? (active ? '1' : '0.6') : '0.4',
|
||||
});
|
||||
if (clickable && arrived) {
|
||||
const idx = i;
|
||||
@@ -1130,15 +1228,15 @@
|
||||
function navBtn(text) {
|
||||
const b = el('button', {
|
||||
width: '26px', height: '26px', borderRadius: '5px',
|
||||
border: '1px solid ' + C.mist, background: 'transparent',
|
||||
color: C.ink, fontFamily: FONT, fontSize: '13px',
|
||||
border: '1px solid ' + BP.hairline, background: 'transparent',
|
||||
color: BP.text, fontFamily: FONT, fontSize: '13px',
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'border-color 0.12s ease, background 0.12s ease',
|
||||
padding: '0', lineHeight: '1',
|
||||
});
|
||||
b.textContent = text;
|
||||
b.addEventListener('mouseenter', () => { b.style.borderColor = C.ink; });
|
||||
b.addEventListener('mouseleave', () => { b.style.borderColor = C.mist; });
|
||||
b.addEventListener('mouseenter', () => { b.style.borderColor = BP.text; });
|
||||
b.addEventListener('mouseleave', () => { b.style.borderColor = BP.hairline; });
|
||||
return b;
|
||||
}
|
||||
|
||||
@@ -1158,6 +1256,7 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initActionPicker() {
|
||||
const P = barPaletteForTheme(detectPageTheme());
|
||||
pickerEl = document.createElement('div');
|
||||
pickerEl.id = PREFIX + '-picker';
|
||||
Object.assign(pickerEl.style, {
|
||||
@@ -1166,12 +1265,14 @@
|
||||
transform: 'scale(0.96) translateY(4px)',
|
||||
transformOrigin: 'bottom left',
|
||||
transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE,
|
||||
background: C.paperSolid,
|
||||
border: '1px solid ' + C.mist,
|
||||
background: P.surface,
|
||||
border: '1px solid ' + P.hairline,
|
||||
borderRadius: '10px',
|
||||
boxShadow: '0 8px 30px oklch(0% 0 0 / 0.10), 0 2px 6px oklch(0% 0 0 / 0.06)',
|
||||
padding: '6px',
|
||||
fontFamily: FONT,
|
||||
backdropFilter: 'blur(10px)',
|
||||
WebkitBackdropFilter: 'blur(10px)',
|
||||
});
|
||||
|
||||
// Build the chip grid
|
||||
@@ -1181,22 +1282,32 @@
|
||||
|
||||
ACTIONS.forEach(action => {
|
||||
const chip = el('button', {
|
||||
padding: '6px 8px', borderRadius: '6px',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '8px 6px', borderRadius: '6px',
|
||||
border: 'none',
|
||||
background: action.value === selectedAction ? C.brandSoft : 'transparent',
|
||||
color: action.value === selectedAction ? C.brand : C.ink,
|
||||
background: action.value === selectedAction ? P.accentSoft : 'transparent',
|
||||
color: action.value === selectedAction ? P.accent : P.text,
|
||||
fontFamily: FONT, fontSize: '11px', fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.1s ease, color 0.1s ease',
|
||||
textAlign: 'center', whiteSpace: 'nowrap',
|
||||
});
|
||||
chip.textContent = action.label;
|
||||
const iconWrap = el('span', {
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
height: '20px', opacity: '0.9',
|
||||
});
|
||||
iconWrap.innerHTML = ICONS[action.value] || '';
|
||||
const labelEl = el('span', { lineHeight: '1' });
|
||||
labelEl.textContent = action.label;
|
||||
chip.appendChild(iconWrap);
|
||||
chip.appendChild(labelEl);
|
||||
chip.dataset.action = action.value;
|
||||
chip.addEventListener('mouseenter', () => {
|
||||
if (action.value !== selectedAction) chip.style.background = C.brandSoft;
|
||||
if (action.value !== selectedAction) chip.style.background = P.accentSoft;
|
||||
});
|
||||
chip.addEventListener('mouseleave', () => {
|
||||
chip.style.background = action.value === selectedAction ? C.brandSoft : 'transparent';
|
||||
chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent';
|
||||
});
|
||||
chip.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -1209,19 +1320,24 @@
|
||||
|
||||
pickerEl.appendChild(grid);
|
||||
document.body.appendChild(pickerEl);
|
||||
|
||||
// Cache the palette on the picker so toggleActionPicker's state refresh
|
||||
// uses the same theme-aware colors when it repaints chips.
|
||||
pickerEl.__iceq_palette = P;
|
||||
}
|
||||
|
||||
function toggleActionPicker() {
|
||||
if (pickerEl.style.display !== 'none') { hideActionPicker(); return; }
|
||||
// Rebuild chips to reflect current selection
|
||||
const P = pickerEl.__iceq_palette || barPaletteForTheme(detectPageTheme());
|
||||
pickerEl.querySelectorAll('button').forEach(chip => {
|
||||
const isActive = chip.dataset.action === selectedAction;
|
||||
chip.style.background = isActive ? C.brandSoft : 'transparent';
|
||||
chip.style.color = isActive ? C.brand : C.ink;
|
||||
chip.style.background = isActive ? P.accentSoft : 'transparent';
|
||||
chip.style.color = isActive ? P.accent : P.text;
|
||||
});
|
||||
// Position above the bar
|
||||
const barRect = barEl.getBoundingClientRect();
|
||||
const pickerH = 90; // approximate
|
||||
const pickerH = 170; // approximate; grows with icon + label rows
|
||||
let top = barRect.top - pickerH - 6;
|
||||
if (top < 8) top = barRect.bottom + 6;
|
||||
Object.assign(pickerEl.style, {
|
||||
@@ -1241,6 +1357,399 @@
|
||||
setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Params panel (per-variant coarse controls)
|
||||
//
|
||||
// Variants may declare a parameter manifest via a JSON attribute on the
|
||||
// variant wrapper:
|
||||
//
|
||||
// <div data-impeccable-variant="1"
|
||||
// data-impeccable-params='[{"id":"density","kind":"steps",...}]'>
|
||||
//
|
||||
// The panel docks to the right edge of the outline during CYCLING and
|
||||
// exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped
|
||||
// CSS can respond instantly without regeneration:
|
||||
//
|
||||
// range / numeric toggle → CSS var (`--p-<id>`) used via var(--p-foo, N)
|
||||
// steps / boolean toggle → data-p-<id> attribute used via :scope[data-p-foo="..."]
|
||||
//
|
||||
// On variant switch, values reset to that variant's declared defaults.
|
||||
// On accept, current values are sent in the event payload so the agent
|
||||
// can bake them into the source-file write.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide)
|
||||
let paramsPanelInner = null; // translating content (carries bg, padding, knobs)
|
||||
let paramsPanelBody = null; // grid holding the knob cells
|
||||
let paramsCurrentValues = {}; // {paramId: value} — mirror of the visible variant's live values
|
||||
let tuneOpen = false; // whether the Tune popover is open right now
|
||||
|
||||
// Theme-aware Tune popover. Appears as a drawer that slides out from the
|
||||
// contextual bar's bar-facing edge (below if the bar sits below the
|
||||
// element, above otherwise). Same width as the bar. Auto-wraps to extra
|
||||
// rows when the knobs exceed one row. The bar's border-radius on the
|
||||
// popover side goes flat while open so the two shapes read as one.
|
||||
let paramsPanelPalette = null;
|
||||
|
||||
function initParamsPanel() {
|
||||
paramsPanelPalette = barPaletteForTheme(detectPageTheme());
|
||||
const P = paramsPanelPalette;
|
||||
|
||||
// Single element, always in the DOM. The slide animation is a CSS mask
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis — no
|
||||
// display toggle, no opacity toggle, no transform trickery. The mask
|
||||
// hides everything initially; as it grows, content is revealed from
|
||||
// the bar edge outward.
|
||||
paramsPanelEl = document.createElement('div');
|
||||
paramsPanelEl.id = PREFIX + '-params-panel';
|
||||
Object.assign(paramsPanelEl.style, {
|
||||
position: 'fixed', zIndex: String(Z.bar - 1),
|
||||
background: P.surfaceDeep,
|
||||
color: P.text,
|
||||
fontFamily: FONT,
|
||||
padding: '14px 18px',
|
||||
boxSizing: 'border-box',
|
||||
borderRadius: '0 0 10px 10px',
|
||||
pointerEvents: 'none',
|
||||
backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
|
||||
|
||||
// clip-path is the same conceptual reveal as mask but with rock-solid
|
||||
// transition support across engines. Closed state clips from the far
|
||||
// edge; open = inset(0) shows everything.
|
||||
clipPath: 'inset(0 0 100% 0)',
|
||||
transition: 'clip-path 0.44s ' + EASE,
|
||||
|
||||
// Park off-screen until positionParamsPanel places it. These are NOT
|
||||
// in the transition list, so they snap instantly — no fly-in from the
|
||||
// top-left when first shown.
|
||||
top: '-9999px', left: '-9999px', width: '0',
|
||||
});
|
||||
|
||||
paramsPanelBody = el('div', {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
|
||||
gap: '12px 16px',
|
||||
});
|
||||
|
||||
paramsPanelEl.appendChild(paramsPanelBody);
|
||||
document.body.appendChild(paramsPanelEl);
|
||||
paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
|
||||
}
|
||||
|
||||
function getVisibleVariantEl() {
|
||||
if (!currentSessionId) return null;
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
|
||||
function parseVariantParams(variantEl) {
|
||||
if (!variantEl) return [];
|
||||
const raw = variantEl.getAttribute('data-impeccable-params');
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] Invalid data-impeccable-params JSON:', err.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function applyParamValue(variantEl, param, value) {
|
||||
if (!variantEl) return;
|
||||
const attr = 'data-p-' + param.id;
|
||||
if (param.kind === 'range') {
|
||||
variantEl.style.setProperty('--p-' + param.id, String(value));
|
||||
} else if (param.kind === 'toggle') {
|
||||
const on = !!value;
|
||||
variantEl.style.setProperty('--p-' + param.id, on ? '1' : '0');
|
||||
if (on) variantEl.setAttribute(attr, 'on');
|
||||
else variantEl.removeAttribute(attr);
|
||||
} else if (param.kind === 'steps') {
|
||||
variantEl.setAttribute(attr, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
function applyParamDefaults(variantEl, params) {
|
||||
paramsCurrentValues = {};
|
||||
for (const p of params) {
|
||||
paramsCurrentValues[p.id] = p.default;
|
||||
applyParamValue(variantEl, p, p.default);
|
||||
}
|
||||
}
|
||||
|
||||
function formatRangeValue(input) {
|
||||
const max = parseFloat(input.max), min = parseFloat(input.min);
|
||||
const v = parseFloat(input.value);
|
||||
if (!isFinite(v)) return input.value;
|
||||
return (max - min) <= 2 ? v.toFixed(2) : String(Math.round(v));
|
||||
}
|
||||
|
||||
function buildParamsPanel(variantEl, params) {
|
||||
const P = paramsPanelPalette || barPaletteForTheme(detectPageTheme());
|
||||
paramsPanelBody.innerHTML = '';
|
||||
for (const p of params) {
|
||||
const row = el('div', { display: 'flex', flexDirection: 'column', gap: '6px' });
|
||||
const labelRow = el('div', {
|
||||
display: 'flex', justifyContent: 'space-between',
|
||||
alignItems: 'baseline', gap: '8px',
|
||||
});
|
||||
const lbl = el('span', {
|
||||
fontSize: '10.5px', fontWeight: '600', color: P.text,
|
||||
letterSpacing: '0.03em',
|
||||
});
|
||||
lbl.textContent = p.label || p.id;
|
||||
labelRow.appendChild(lbl);
|
||||
const readout = el('span', {
|
||||
fontSize: '10.5px', color: P.textDim,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
});
|
||||
labelRow.appendChild(readout);
|
||||
row.appendChild(labelRow);
|
||||
|
||||
if (p.kind === 'range') {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'range';
|
||||
input.min = String(p.min != null ? p.min : 0);
|
||||
input.max = String(p.max != null ? p.max : 1);
|
||||
input.step = String(p.step != null ? p.step : 0.05);
|
||||
input.value = String(p.default);
|
||||
Object.assign(input.style, {
|
||||
width: '100%', accentColor: C.brand, cursor: 'pointer',
|
||||
});
|
||||
readout.textContent = formatRangeValue(input);
|
||||
input.addEventListener('input', (e) => {
|
||||
e.stopPropagation();
|
||||
const v = parseFloat(input.value);
|
||||
paramsCurrentValues[p.id] = v;
|
||||
readout.textContent = formatRangeValue(input);
|
||||
applyParamValue(variantEl, p, v);
|
||||
});
|
||||
row.appendChild(input);
|
||||
} else if (p.kind === 'toggle') {
|
||||
const initial = !!p.default;
|
||||
readout.textContent = initial ? 'On' : 'Off';
|
||||
const track = el('button', {
|
||||
position: 'relative', width: '36px', height: '20px',
|
||||
borderRadius: '10px', border: 'none', padding: '0',
|
||||
cursor: 'pointer',
|
||||
background: initial ? C.brand : P.hairline,
|
||||
transition: 'background 0.15s ease',
|
||||
alignSelf: 'flex-start',
|
||||
});
|
||||
const knob = el('span', {
|
||||
position: 'absolute', top: '2px',
|
||||
left: initial ? '18px' : '2px',
|
||||
width: '16px', height: '16px', borderRadius: '50%',
|
||||
background: 'oklch(98% 0 0)',
|
||||
transition: 'left 0.18s ' + EASE,
|
||||
boxShadow: '0 1px 2px oklch(0% 0 0 / 0.2)',
|
||||
});
|
||||
track.appendChild(knob);
|
||||
track.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const next = !paramsCurrentValues[p.id];
|
||||
paramsCurrentValues[p.id] = next;
|
||||
track.style.background = next ? C.brand : P.hairline;
|
||||
knob.style.left = next ? '18px' : '2px';
|
||||
readout.textContent = next ? 'On' : 'Off';
|
||||
applyParamValue(variantEl, p, next);
|
||||
});
|
||||
row.appendChild(track);
|
||||
} else if (p.kind === 'steps') {
|
||||
const opts = (p.options || []).map(o =>
|
||||
typeof o === 'string' ? { value: o, label: o } : o
|
||||
);
|
||||
const activeOpt = opts.find(o => o.value === p.default) || opts[0];
|
||||
readout.textContent = activeOpt ? activeOpt.label : String(p.default);
|
||||
const segRow = el('div', {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(' + opts.length + ', 1fr)',
|
||||
gap: '1px', padding: '2px',
|
||||
background: P.hairline, borderRadius: '5px',
|
||||
});
|
||||
const segBtns = [];
|
||||
opts.forEach(o => {
|
||||
const active = o.value === p.default;
|
||||
const b = el('button', {
|
||||
padding: '5px 4px', border: 'none', borderRadius: '3px',
|
||||
background: active ? C.brand : 'transparent',
|
||||
color: active ? 'oklch(98% 0 0)' : P.text,
|
||||
fontFamily: FONT, fontSize: '10.5px', fontWeight: '500',
|
||||
cursor: 'pointer', whiteSpace: 'nowrap',
|
||||
transition: 'background 0.1s ease, color 0.1s ease',
|
||||
});
|
||||
b.textContent = o.label;
|
||||
b.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
paramsCurrentValues[p.id] = o.value;
|
||||
readout.textContent = o.label;
|
||||
segBtns.forEach(({ btn, val }) => {
|
||||
const on = val === o.value;
|
||||
btn.style.background = on ? C.brand : 'transparent';
|
||||
btn.style.color = on ? 'oklch(98% 0 0)' : P.text;
|
||||
});
|
||||
applyParamValue(variantEl, p, o.value);
|
||||
});
|
||||
segRow.appendChild(b);
|
||||
segBtns.push({ btn: b, val: o.value });
|
||||
});
|
||||
row.appendChild(segRow);
|
||||
}
|
||||
|
||||
paramsPanelBody.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
// Decide which way the popover opens: away from the picked element. If the
|
||||
// bar landed below the element, popover slides DOWN from the bar's bottom.
|
||||
// If the bar landed above, popover slides UP from the bar's top.
|
||||
function popoverDirection() {
|
||||
if (!barEl || !selectedElement) return 'below';
|
||||
const br = barEl.getBoundingClientRect();
|
||||
const er = selectedElement.getBoundingClientRect();
|
||||
return br.top >= er.bottom - 4 ? 'below' : 'above';
|
||||
}
|
||||
|
||||
// The popover overlaps the bar by OVERLAP px on the bar-facing side. With
|
||||
// popover z-index below bar, that overlap sits behind bar (invisible) and
|
||||
// reinforces the "tucked behind" feel. Padding compensates so the real
|
||||
// content starts flush with bar's outer edge.
|
||||
const TUNE_OVERLAP = 6;
|
||||
|
||||
// Closed clip-path depends on direction: for 'below' clip from the far
|
||||
// (bottom) edge so the reveal grows downward from the bar; for 'above'
|
||||
// clip from the top edge so the reveal grows upward from the bar.
|
||||
function closedClipPath(direction) {
|
||||
return direction === 'below' ? 'inset(0 0 100% 0)' : 'inset(100% 0 0 0)';
|
||||
}
|
||||
|
||||
function setClipPath(value, withTransition) {
|
||||
const saved = paramsPanelEl.style.transition;
|
||||
if (!withTransition) paramsPanelEl.style.transition = 'none';
|
||||
paramsPanelEl.style.clipPath = value;
|
||||
if (!withTransition) {
|
||||
void paramsPanelEl.offsetHeight;
|
||||
paramsPanelEl.style.transition = saved;
|
||||
}
|
||||
}
|
||||
|
||||
function positionParamsPanel() {
|
||||
if (!paramsPanelEl || !barEl || barEl.style.display === 'none') return;
|
||||
const br = barEl.getBoundingClientRect();
|
||||
const direction = popoverDirection();
|
||||
const prevDirection = paramsPanelEl.dataset.tuneDirection;
|
||||
|
||||
// top/left/width are NOT in the transition list, so they snap instantly.
|
||||
paramsPanelEl.style.left = br.left + 'px';
|
||||
paramsPanelEl.style.width = br.width + 'px';
|
||||
|
||||
if (direction === 'below') {
|
||||
paramsPanelEl.style.top = (br.bottom - TUNE_OVERLAP) + 'px';
|
||||
paramsPanelEl.style.borderRadius = '0 0 10px 10px';
|
||||
paramsPanelEl.style.paddingTop = (14 + TUNE_OVERLAP) + 'px';
|
||||
paramsPanelEl.style.paddingBottom = '14px';
|
||||
} else {
|
||||
const ih = paramsPanelEl.offsetHeight || 80;
|
||||
paramsPanelEl.style.top = (br.top - ih + TUNE_OVERLAP) + 'px';
|
||||
paramsPanelEl.style.borderRadius = '10px 10px 0 0';
|
||||
paramsPanelEl.style.paddingTop = '14px';
|
||||
paramsPanelEl.style.paddingBottom = (14 + TUNE_OVERLAP) + 'px';
|
||||
}
|
||||
paramsPanelEl.dataset.tuneDirection = direction;
|
||||
|
||||
// If currently closed and direction flipped (or first-time setup),
|
||||
// snap the clip-path to the new direction's closed pose without
|
||||
// transitioning (so the clip doesn't slide across the element).
|
||||
if (!tuneOpen && (!prevDirection || prevDirection !== direction)) {
|
||||
setClipPath(closedClipPath(direction), false);
|
||||
}
|
||||
}
|
||||
|
||||
function showParamsPanel() {
|
||||
if (!paramsPanelEl) return;
|
||||
positionParamsPanel();
|
||||
paramsPanelEl.style.pointerEvents = 'auto';
|
||||
// rAF so the positioning paint commits before the transition fires.
|
||||
requestAnimationFrame(() => {
|
||||
setClipPath('inset(0 0 0 0)', true);
|
||||
});
|
||||
}
|
||||
|
||||
function hideParamsPanel() {
|
||||
if (!paramsPanelEl) return;
|
||||
paramsPanelEl.style.pointerEvents = 'none';
|
||||
const direction = paramsPanelEl.dataset.tuneDirection || 'below';
|
||||
setClipPath(closedClipPath(direction), true);
|
||||
}
|
||||
|
||||
// Build/rebuild the panel's contents for the current variant AND apply
|
||||
// its defaults to the variant wrapper (so scoped CSS responds even before
|
||||
// the user opens the popover). Visibility is governed by tuneOpen.
|
||||
function refreshParamsPanel() {
|
||||
if (state !== 'CYCLING') {
|
||||
paramsCurrentValues = {};
|
||||
tuneOpen = false;
|
||||
hideParamsPanel();
|
||||
return;
|
||||
}
|
||||
const variantEl = getVisibleVariantEl();
|
||||
const params = parseVariantParams(variantEl);
|
||||
if (!variantEl || params.length === 0) {
|
||||
paramsCurrentValues = {};
|
||||
tuneOpen = false;
|
||||
hideParamsPanel();
|
||||
return;
|
||||
}
|
||||
applyParamDefaults(variantEl, params);
|
||||
buildParamsPanel(variantEl, params);
|
||||
if (tuneOpen) {
|
||||
// If already visible (variant cycled while open), refresh in place
|
||||
// instead of re-running the clip-path animation.
|
||||
const alreadyVisible = paramsPanelEl.style.display === 'block'
|
||||
&& paramsPanelEl.style.opacity === '1';
|
||||
if (alreadyVisible) positionParamsPanel();
|
||||
else showParamsPanel();
|
||||
} else {
|
||||
hideParamsPanel();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTunePopover() {
|
||||
if (tuneOpen) { closeTunePopover(); return; }
|
||||
openTunePopover();
|
||||
}
|
||||
|
||||
function openTunePopover() {
|
||||
if (state !== 'CYCLING') return;
|
||||
const variantEl = getVisibleVariantEl();
|
||||
const params = parseVariantParams(variantEl);
|
||||
if (!variantEl || params.length === 0) return;
|
||||
// Build fresh to ensure the current variant's controls are shown.
|
||||
applyParamDefaults(variantEl, params);
|
||||
buildParamsPanel(variantEl, params);
|
||||
tuneOpen = true;
|
||||
showParamsPanel();
|
||||
// Kill the bar's shadow on the popover-facing side so the dark popover
|
||||
// doesn't pick up a bright glow line.
|
||||
if (barEl) {
|
||||
const direction = paramsPanelEl?.dataset.tuneDirection || 'below';
|
||||
barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN;
|
||||
}
|
||||
// Re-render the bar so the Tune chip picks up the active styling.
|
||||
updateBarContent('cycling');
|
||||
}
|
||||
|
||||
function closeTunePopover() {
|
||||
tuneOpen = false;
|
||||
hideParamsPanel();
|
||||
if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT;
|
||||
if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') {
|
||||
updateBarContent('cycling');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variant cycling in DOM
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1253,6 +1762,10 @@
|
||||
if (!v) continue;
|
||||
child.style.display = (v === String(num)) ? '' : 'none';
|
||||
}
|
||||
// Unconditional refresh — covers first-reveal (no-op if state isn't
|
||||
// CYCLING yet, the subsequent CYCLING transition triggers its own
|
||||
// refresh) and every cycle step.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1316,6 +1829,7 @@
|
||||
state = 'CYCLING';
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
refreshParamsPanel();
|
||||
saveSession();
|
||||
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
|
||||
})
|
||||
@@ -1329,7 +1843,7 @@
|
||||
const next = visibleVariant + dir;
|
||||
if (next < 1 || next > arrivedVariants) return;
|
||||
visibleVariant = next;
|
||||
showVariantInDOM(currentSessionId, next);
|
||||
showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself
|
||||
updateSelectedElement();
|
||||
updateBarContent('cycling');
|
||||
saveSession();
|
||||
@@ -1540,6 +2054,7 @@
|
||||
state = 'CYCLING';
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
refreshParamsPanel();
|
||||
} else if (state === 'GENERATING') {
|
||||
updateBarContent('generating');
|
||||
}
|
||||
@@ -1560,6 +2075,7 @@
|
||||
if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') {
|
||||
positionBar();
|
||||
showHighlight(selectedElement);
|
||||
if (tuneOpen) positionParamsPanel();
|
||||
}
|
||||
if (annotActive) positionAnnotOverlay(selectedElement);
|
||||
// Shader overlay (via debug P toggle or generation) is repositioned
|
||||
@@ -1606,6 +2122,7 @@
|
||||
if (state === 'GENERATING') {
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
refreshParamsPanel();
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -1691,6 +2208,10 @@
|
||||
if (pickerEl?.style.display !== 'none' && !own(e.target)) {
|
||||
hideActionPicker();
|
||||
}
|
||||
// Close Tune popover on outside click (anything outside panel + bar)
|
||||
if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) {
|
||||
closeTunePopover();
|
||||
}
|
||||
// In CONFIGURING: click outside the bar and selected element returns to PICKING
|
||||
if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) {
|
||||
hideBar();
|
||||
@@ -2258,7 +2779,11 @@ void main() {
|
||||
|
||||
function handleAccept() {
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
sendEvent({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) });
|
||||
const acceptPayload = { type: 'accept', id: currentSessionId, variantId: String(visibleVariant) };
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
sendEvent(acceptPayload);
|
||||
markSessionHandled();
|
||||
|
||||
// The accepted variant is already the only visible child of the wrapper
|
||||
@@ -2477,6 +3002,11 @@ void main() {
|
||||
state = arrivedVariants >= expectedVariants ? 'CYCLING' : 'GENERATING';
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -2522,6 +3052,11 @@ void main() {
|
||||
// dark pages. This keeps the bar from fighting with the host design.
|
||||
function detectPageTheme() {
|
||||
try {
|
||||
// Dev override: set localStorage 'impeccable-dev-theme' to 'light' or
|
||||
// 'dark' to preview the opposite palette without actually changing the
|
||||
// page bg. Used for screenshots and theme QA.
|
||||
const override = localStorage.getItem('impeccable-dev-theme');
|
||||
if (override === 'light' || override === 'dark') return override;
|
||||
const bg = getComputedStyle(document.body).backgroundColor
|
||||
|| getComputedStyle(document.documentElement).backgroundColor;
|
||||
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
||||
@@ -2538,6 +3073,7 @@ void main() {
|
||||
// Light bar on dark page
|
||||
return {
|
||||
surface: 'oklch(98% 0 0 / 0.92)',
|
||||
surfaceDeep: 'oklch(92% 0.005 60 / 0.96)', // slightly deeper, faint warm
|
||||
hairline: 'oklch(70% 0 0 / 0.35)',
|
||||
text: 'oklch(15% 0 0)',
|
||||
textDim: 'oklch(45% 0 0)',
|
||||
@@ -2552,6 +3088,7 @@ void main() {
|
||||
// deeper so the rounded-right shape reads as a clear sculpted mark.
|
||||
return {
|
||||
surface: 'oklch(26% 0 0 / 0.94)',
|
||||
surfaceDeep: 'oklch(18% 0 0 / 0.96)', // darker sand for Tune popover
|
||||
hairline: 'oklch(42% 0 0 / 0.5)',
|
||||
text: 'oklch(96% 0 0)',
|
||||
textDim: 'oklch(72% 0 0)',
|
||||
@@ -2883,6 +3420,7 @@ void main() {
|
||||
if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; }
|
||||
if (barEl) { barEl.remove(); barEl = null; }
|
||||
if (pickerEl) { pickerEl.remove(); pickerEl = null; }
|
||||
if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; }
|
||||
if (evtSource) { evtSource.close(); evtSource = null; }
|
||||
document.removeEventListener('mousemove', handleMouseMove, true);
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
@@ -3956,6 +4494,7 @@ void main() {
|
||||
initAnnotOverlay();
|
||||
initBar();
|
||||
initActionPicker();
|
||||
initParamsPanel();
|
||||
initGlobalBar();
|
||||
initDesignPanel();
|
||||
document.addEventListener('mousemove', handleMouseMove, true);
|
||||
|
||||
@@ -145,6 +145,11 @@ Options:
|
||||
const scriptArgs = event.type === 'discard'
|
||||
? ['--id', event.id, '--discard']
|
||||
: ['--id', event.id, '--variant', event.variantId];
|
||||
if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
|
||||
// Pass through a JSON blob; the shell-safe wrap uses single quotes because
|
||||
// values are finite {id, number|string|boolean} pairs from a validated payload.
|
||||
scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
|
||||
}
|
||||
try {
|
||||
const out = execSync(
|
||||
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
|
||||
|
||||
@@ -147,6 +147,11 @@ function validateEvent(msg) {
|
||||
case 'accept':
|
||||
if (!msg.id) return 'accept: missing id';
|
||||
if (!msg.variantId) return 'accept: missing variantId';
|
||||
if (msg.paramValues !== undefined) {
|
||||
if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
|
||||
return 'accept: paramValues must be an object';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
case 'discard':
|
||||
return msg.id ? null : 'discard: missing id';
|
||||
|
||||
@@ -142,3 +142,13 @@ Test that colorization improves the experience:
|
||||
- **Not overwhelming**: Is color balanced and purposeful?
|
||||
|
||||
Remember: Color is emotional and powerful. Use it to create warmth, guide attention, communicate meaning, and express personality. But restraint and strategy matter more than saturation and variety. Be colorful, but be intentional.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
When invoked from live mode, each variant MUST declare a `color-amount` param so the user can dial between a restrained accent and a drenched surface without regeneration. Author the variant's CSS against `var(--p-color-amount, 0.5)` — typically as the alpha multiplier on backgrounds, or as a scaling factor on the chroma axis in an OKLCH expression. 0 = neutral/monochrome, 1 = full saturation / dominant coverage.
|
||||
|
||||
```json
|
||||
{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"}
|
||||
```
|
||||
|
||||
Layer 1-2 variant-specific params on top: palette selection (`steps` with named options), temperature warmth, or tint vs. true color. See `reference/live.md` for the full params contract.
|
||||
|
||||
@@ -119,3 +119,23 @@ Create a systematic plan:
|
||||
- **Responsiveness**: Does the layout adapt gracefully across screen sizes?
|
||||
|
||||
Remember: Space is the most underused design tool. A layout with the right rhythm and hierarchy can make even simple content feel polished and intentional.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
Each variant MUST declare a `density` param. Drive all spacing tokens in the variant's scoped CSS through `calc(var(--p-density, 1) * <base>)` — paddings, gaps, column widths. Users slide from airy to packed and see layout re-breathe with no regeneration.
|
||||
|
||||
```json
|
||||
{"id":"density","kind":"range","min":0.6,"max":1.4,"step":0.05,"default":1,"label":"Density"}
|
||||
```
|
||||
|
||||
For variants whose topology genuinely changes (stacked vs. side-by-side, grid vs. bento), use a `steps` param whose scoped CSS branches via `:scope[data-p-structure="X"]`. One structure param + one density param is a powerful combo; resist adding a third.
|
||||
|
||||
```json
|
||||
{"id":"structure","kind":"steps","default":"grid","label":"Structure","options":[
|
||||
{"value":"stacked","label":"Stacked"},
|
||||
{"value":"grid","label":"Grid"},
|
||||
{"value":"bento","label":"Bento"}
|
||||
]}
|
||||
```
|
||||
|
||||
See `reference/live.md` for the full params contract.
|
||||
|
||||
@@ -179,7 +179,56 @@ The first variant has no `display: none` (visible by default). All others do. If
|
||||
|
||||
One edit, all variants — the browser's MutationObserver picks everything up in one pass.
|
||||
|
||||
### 7. Signal done
|
||||
### 7. Parameters (optional, 2-5 per variant)
|
||||
|
||||
Each variant can expose coarse knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against.
|
||||
|
||||
**When to use.** Any time the variant has a meaningful axis the user might want to dial in: color amount, density, motion intensity, scale ratio. Not micro-level margin tweaks; those defeat the point.
|
||||
|
||||
**Budget scales with the element's visual weight, not the user's curiosity.** Knobs need real estate to produce noticeably different output; slapping three on a small element just crowds the panel without making anything feel tunable.
|
||||
|
||||
- **Leaf / tiny** — a single button, icon, input, bare heading, solitary paragraph: **0 params.** A slider can't meaningfully reshape one element; just use variants.
|
||||
- **Small composition** — labeled input, simple card, short callout (≤ ~5 visual children): **0-1 params.** Only add one if it's a clear dominant axis (e.g. density on a card with visible internal rhythm).
|
||||
- **Medium composition** — section component, nav cluster, dense card, short feature block (6-15 visual children): **2 params.**
|
||||
- **Large composition** — hero section, full page region, spread layout, anything with strong internal structure (16+ visual children or multiple sub-sections): **3-4 params.**
|
||||
|
||||
When in doubt, fewer. The user can always ask for more variants to explore an axis you didn't expose as a knob. Count by visual children, not by DOM-node depth; a deeply-nested-but-visually-simple card still counts as small.
|
||||
|
||||
**How to declare.** Put a JSON manifest on the variant wrapper:
|
||||
|
||||
```html
|
||||
<div data-impeccable-variant="1" data-impeccable-params='[
|
||||
{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"},
|
||||
{"id":"density","kind":"steps","default":"snug","label":"Density","options":[
|
||||
{"value":"airy","label":"Airy"},
|
||||
{"value":"snug","label":"Snug"},
|
||||
{"value":"packed","label":"Packed"}
|
||||
]},
|
||||
{"id":"serif","kind":"toggle","default":false,"label":"Serif display"}
|
||||
]'>
|
||||
...variant content...
|
||||
</div>
|
||||
```
|
||||
|
||||
**Three kinds:**
|
||||
|
||||
- `range` — smooth slider. Drives a CSS custom property `--p-<id>` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`.
|
||||
- `steps` — segmented radio. Drives a data attribute `data-p-<id>` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`.
|
||||
- `toggle` — on/off switch. Drives BOTH a CSS var (`--p-<id>: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`.
|
||||
|
||||
**Signature params per action.** Each action has one or two signature params that MUST be exposed when the variant can meaningfully express them. Check the action's reference file for the list. Layer 1-2 variant-specific params on top.
|
||||
|
||||
**Reset on variant switch.** User dials density on v1, flips to v2, v2 starts at v2's declared defaults. Known limitation; preservation across variants may land later.
|
||||
|
||||
**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment:
|
||||
|
||||
```html
|
||||
<!-- impeccable-param-values SESSION_ID: {"color-amount":0.7,"density":"packed"} -->
|
||||
```
|
||||
|
||||
The carbonize cleanup step (see below) reads that comment and bakes the chosen values into the final CSS. For `steps`/`toggle` attribute selectors: keep only the branch matching the chosen value, drop the others, collapse `:scope[data-p-density="packed"] .grid` to a semantic class rule. For `range` vars: either substitute the literal or keep the var with the chosen value as its new default.
|
||||
|
||||
### 8. Signal done
|
||||
|
||||
```bash
|
||||
node .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 `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element.
|
||||
1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values — read it first; it drives steps 3 and 4 below.
|
||||
2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `public/css/workflow.css` for this repo, or the component's co-located CSS file for a Vite/Next project — pick whichever already owns styling for the surrounding element).
|
||||
3. **Rewrite `@scope ([data-impeccable-variant="N"])` selectors** to target real, semantic classes on the accepted HTML. Example: `@scope ([data-impeccable-variant="2"]) { .v2-label { … } }` becomes `.why-visual--v2 .v2-label { … }` if the accepted element already carries `.why-visual--v2`, or pick/add a suitable class if it doesn't.
|
||||
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it.
|
||||
5. **Delete the inline `<style>` block and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one — those are dead code now.
|
||||
3. **Bake in parameter values while rewriting selectors.** For `@scope ([data-impeccable-variant="N"])` wrappers: retarget to real, semantic classes on the accepted HTML (`.why-visual--v2 .v2-label { … }`). For `:scope[data-p-<id>="VALUE"]` selectors: keep only the branch matching the chosen value from the param-values comment; drop the others (they're dead after accept). For `var(--p-<id>, DEFAULT)` in the CSS: either substitute the literal value, or if the param is still useful as a knob going forward, leave the var and update its initial declaration to the chosen value.
|
||||
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it. Drop `data-impeccable-params` and any `data-p-*` attributes from it — those are live-mode plumbing, not source.
|
||||
5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one — those are dead code now.
|
||||
|
||||
Then poll again.
|
||||
|
||||
|
||||
@@ -110,3 +110,15 @@ Build a clear type scale:
|
||||
- **Accessibility**: Does text meet WCAG contrast ratios? Is it zoomable to 200%?
|
||||
|
||||
Remember: Typography is the foundation of interface design — it carries the majority of information. Getting it right is the highest-leverage improvement you can make.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
Each variant MUST declare a `scale` param controlling the hierarchy ratio. Express all font sizes in the variant's scoped CSS through `calc(var(--p-scale, 1) * <base>)` or, better, scale the type ramp via `clamp(min, calc(var(--p-scale, 1) * Npx), max)`. Users slide from subdued to commanding.
|
||||
|
||||
```json
|
||||
{"id":"scale","kind":"range","min":0.85,"max":1.3,"step":0.05,"default":1,"label":"Scale"}
|
||||
```
|
||||
|
||||
Where the variant riffs on a specific pairing, expose the pairing choice as a `steps` param (e.g. "serif display + sans body" vs. "mono display + sans body" vs. "all-sans"). Each branch routes through `:scope[data-p-pairing="X"]` selectors in scoped CSS.
|
||||
|
||||
See `reference/live.md` for the full params contract.
|
||||
|
||||
@@ -45,11 +45,18 @@ Output (JSON):
|
||||
|
||||
const id = argVal(args, '--id');
|
||||
const variantNum = argVal(args, '--variant');
|
||||
const paramValuesRaw = argVal(args, '--param-values');
|
||||
const isDiscard = args.includes('--discard');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); }
|
||||
|
||||
let paramValues = null;
|
||||
if (paramValuesRaw) {
|
||||
try { paramValues = JSON.parse(paramValuesRaw); }
|
||||
catch { paramValues = null; } // malformed blob: skip the comment rather than failing the accept
|
||||
}
|
||||
|
||||
// Find the file containing this session's markers
|
||||
const found = findSessionFile(id, process.cwd());
|
||||
if (!found) {
|
||||
@@ -78,7 +85,7 @@ Output (JSON):
|
||||
const result = handleDiscard(id, lines, targetFile);
|
||||
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
|
||||
} else {
|
||||
const result = handleAccept(id, variantNum, lines, targetFile);
|
||||
const result = handleAccept(id, variantNum, lines, targetFile, paramValues);
|
||||
// Single-line attention-grabber when cleanup is required. The full
|
||||
// five-step checklist lives in reference/live.md (loaded once per
|
||||
// session); repeating it per-event would waste tokens.
|
||||
@@ -116,7 +123,7 @@ function handleDiscard(id, lines, targetFile) {
|
||||
// Accept
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function handleAccept(id, variantNum, lines, targetFile) {
|
||||
function handleAccept(id, variantNum, lines, targetFile, paramValues) {
|
||||
const block = findMarkerBlock(id, lines);
|
||||
if (!block) return { handled: false, error: 'Markers not found' };
|
||||
|
||||
@@ -149,6 +156,11 @@ function handleAccept(id, variantNum, lines, targetFile) {
|
||||
replacement.push(indent + cssLine.trimStart());
|
||||
}
|
||||
replacement.push(indent + '</style>');
|
||||
if (paramValues && Object.keys(paramValues).length > 0) {
|
||||
// Preserve the user's knob positions for the carbonize-cleanup agent
|
||||
// to bake into the final CSS when it collapses scoped rules.
|
||||
replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
|
||||
}
|
||||
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,10 +30,13 @@
|
||||
// Design tokens
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Brand magenta is pinned to the site token (--color-accent in main.css)
|
||||
// so Accept / knobs / cycle-dots match the site's accent, not a washed
|
||||
// theme-adjusted one.
|
||||
const C = {
|
||||
brand: 'oklch(55% 0.25 350)',
|
||||
brandHov: 'oklch(48% 0.25 350)',
|
||||
brandSoft: 'oklch(55% 0.25 350 / 0.12)',
|
||||
brand: 'oklch(60% 0.25 350)',
|
||||
brandHov: 'oklch(52% 0.25 350)',
|
||||
brandSoft: 'oklch(60% 0.25 350 / 0.15)',
|
||||
ink: 'oklch(15% 0.01 350)',
|
||||
ash: 'oklch(55% 0 0)',
|
||||
paper: 'oklch(98% 0.005 350 / 0.92)',
|
||||
@@ -60,6 +63,25 @@
|
||||
'html', 'head', 'body', 'script', 'style', 'link', 'meta', 'noscript', 'br', 'wbr',
|
||||
]);
|
||||
|
||||
// SVG icons stack above each chip label. All strokes use currentColor so the
|
||||
// icon recolors to C.brand when its chip is selected. 20x20 render, 24-viewBox,
|
||||
// 1.5 stroke — visually consistent with the Foundation grid on the homepage.
|
||||
const ICON_ATTRS = 'width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="display:block"';
|
||||
const ICONS = {
|
||||
impeccable: `<svg ${ICON_ATTRS}><path d="M4 20l4-1L18 9l-3-3L5 16z"/><path d="M14 7l3 3"/></svg>`,
|
||||
bolder: `<svg ${ICON_ATTRS}><rect x="6" y="12" width="4" height="7" rx="0.5"/><rect x="14" y="5" width="4" height="14" rx="0.5"/></svg>`,
|
||||
quieter: `<svg ${ICON_ATTRS}><rect x="6" y="5" width="4" height="14" rx="0.5"/><rect x="14" y="12" width="4" height="7" rx="0.5"/></svg>`,
|
||||
distill: `<svg ${ICON_ATTRS}><path d="M4 5h16l-6 8v7l-4-2v-5z"/></svg>`,
|
||||
polish: `<svg ${ICON_ATTRS}><path d="M15 3l1 3 3 1-3 1-1 3-1-3-3-1 3-1z"/><path d="M7 13l0.6 1.8 1.8 0.6-1.8 0.6-0.6 1.8-0.6-1.8-1.8-0.6 1.8-0.6z"/></svg>`,
|
||||
typeset: `<svg ${ICON_ATTRS}><path d="M5 6h14" stroke-width="2.6"/><path d="M5 12h9" stroke-width="1.9"/><path d="M5 18h5" stroke-width="1.3"/></svg>`,
|
||||
colorize: `<svg ${ICON_ATTRS}><circle cx="9" cy="10" r="5"/><circle cx="15" cy="10" r="5"/><circle cx="12" cy="15" r="5"/></svg>`,
|
||||
layout: `<svg ${ICON_ATTRS}><rect x="3" y="4" width="8" height="16" rx="0.5"/><rect x="13" y="4" width="8" height="7" rx="0.5"/><rect x="13" y="13" width="8" height="7" rx="0.5"/></svg>`,
|
||||
adapt: `<svg ${ICON_ATTRS}><rect x="2.5" y="5" width="12" height="11" rx="1"/><line x1="2.5" y1="19" x2="14.5" y2="19"/><rect x="16.5" y="8" width="5" height="11" rx="1"/></svg>`,
|
||||
animate: `<svg ${ICON_ATTRS}><path d="M3 18c4-4 6-10 10-10"/><path d="M13 8c3 0 5 5 8 10"/><circle cx="13" cy="8" r="1.6" fill="currentColor" stroke="none"/></svg>`,
|
||||
delight: `<svg ${ICON_ATTRS}><path d="M12 3l2 6 6 2-6 2-2 6-2-6-6-2 6-2z"/></svg>`,
|
||||
overdrive: `<svg ${ICON_ATTRS}><path d="M13 3L5 13h5l-1 8 9-12h-6z"/></svg>`,
|
||||
};
|
||||
|
||||
const ACTIONS = [
|
||||
{ value: 'impeccable', label: 'Freeform' },
|
||||
{ value: 'bolder', label: 'Bolder' },
|
||||
@@ -756,7 +778,21 @@
|
||||
// The Bar — one floating element, three modes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Contextual-bar palette. Cached at init so every build*Row reads a
|
||||
// consistent set of colors; detectPageTheme runs once rather than on every
|
||||
// phase transition.
|
||||
let BP = null;
|
||||
|
||||
// Bar shadow variants. The default projects down + subtle around. When
|
||||
// the Tune popover opens below the bar, a downward shadow lands on the
|
||||
// dark popover and reads as a bright ghost line. We swap to UP-only while
|
||||
// tune is open below so the popover's top edge is clean.
|
||||
const BAR_SHADOW_DEFAULT = '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)';
|
||||
const BAR_SHADOW_UP = '0 -4px 20px oklch(0% 0 0 / 0.08), 0 -1px 3px oklch(0% 0 0 / 0.06)';
|
||||
const BAR_SHADOW_DOWN = BAR_SHADOW_DEFAULT;
|
||||
|
||||
function initBar() {
|
||||
BP = barPaletteForTheme(detectPageTheme());
|
||||
barEl = document.createElement('div');
|
||||
barEl.id = PREFIX + '-bar';
|
||||
Object.assign(barEl.style, {
|
||||
@@ -764,15 +800,15 @@
|
||||
display: 'none', opacity: '0',
|
||||
transform: 'translateY(6px)',
|
||||
transition: 'opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
|
||||
// Visual
|
||||
background: C.paper,
|
||||
background: BP.surface,
|
||||
backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
|
||||
border: '1px solid ' + C.mist,
|
||||
border: '1px solid ' + BP.hairline,
|
||||
borderRadius: '10px',
|
||||
boxShadow: '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)',
|
||||
fontFamily: FONT, fontSize: '13px', color: C.ink,
|
||||
boxShadow: BAR_SHADOW_DEFAULT,
|
||||
transition: 'box-shadow 0.2s ease, opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
|
||||
fontFamily: FONT, fontSize: '13px', color: BP.text,
|
||||
padding: '6px',
|
||||
maxWidth: '460px', minWidth: '320px',
|
||||
maxWidth: '520px', minWidth: '320px',
|
||||
});
|
||||
document.body.appendChild(barEl);
|
||||
}
|
||||
@@ -824,14 +860,15 @@
|
||||
barEl.style.transform = 'translateY(6px)';
|
||||
setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250);
|
||||
hideActionPicker();
|
||||
closeTunePopover();
|
||||
}
|
||||
|
||||
function updateBarContent(mode) {
|
||||
if (!barEl || barEl.style.display === 'none') return;
|
||||
barEl.innerHTML = '';
|
||||
// Reset bar styling
|
||||
barEl.style.background = C.paper;
|
||||
barEl.style.border = '1px solid ' + C.mist;
|
||||
// Reset bar styling to the theme-aware palette
|
||||
barEl.style.background = BP.surface;
|
||||
barEl.style.border = '1px solid ' + BP.hairline;
|
||||
if (mode === 'configure') barEl.appendChild(buildConfigureRow());
|
||||
else if (mode === 'generating') barEl.appendChild(buildGeneratingRow());
|
||||
else if (mode === 'cycling') barEl.appendChild(buildCyclingRow());
|
||||
@@ -854,15 +891,15 @@
|
||||
const pill = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
||||
padding: '5px 10px', borderRadius: '6px',
|
||||
background: C.ink, color: C.white,
|
||||
background: BP.mark, color: BP.markText,
|
||||
fontFamily: FONT, fontSize: '12px', fontWeight: '500',
|
||||
border: 'none', cursor: 'pointer',
|
||||
transition: 'background 0.12s ease, transform 0.1s ease',
|
||||
whiteSpace: 'nowrap', flexShrink: '0',
|
||||
});
|
||||
pill.textContent = actionLabel() + ' \u25BE';
|
||||
pill.addEventListener('mouseenter', () => pill.style.background = C.brandHov);
|
||||
pill.addEventListener('mouseleave', () => pill.style.background = C.ink);
|
||||
pill.addEventListener('mouseenter', () => pill.style.background = BP.accent);
|
||||
pill.addEventListener('mouseleave', () => pill.style.background = BP.mark);
|
||||
pill.addEventListener('mousedown', () => pill.style.transform = 'scale(0.97)');
|
||||
pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)');
|
||||
pill.addEventListener('click', (e) => { e.stopPropagation(); toggleActionPicker(); });
|
||||
@@ -877,13 +914,13 @@
|
||||
flex: '1', minWidth: '0',
|
||||
padding: '5px 8px', borderRadius: '6px',
|
||||
border: '1px solid transparent', background: 'transparent',
|
||||
fontFamily: FONT, fontSize: '12px', color: C.ink,
|
||||
fontFamily: FONT, fontSize: '12px', color: BP.text,
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.15s ease, background 0.15s ease',
|
||||
});
|
||||
input.addEventListener('focus', () => {
|
||||
input.style.borderColor = C.mist;
|
||||
input.style.background = C.white;
|
||||
input.style.borderColor = BP.hairline;
|
||||
input.style.background = BP.accentSoft;
|
||||
});
|
||||
input.addEventListener('blur', () => {
|
||||
input.style.borderColor = 'transparent';
|
||||
@@ -901,16 +938,16 @@
|
||||
// Variant count toggle
|
||||
const count = el('button', {
|
||||
padding: '4px 6px', borderRadius: '5px',
|
||||
border: '1px solid ' + C.mist, background: 'transparent',
|
||||
border: '1px solid ' + BP.hairline, background: 'transparent',
|
||||
fontFamily: MONO, fontSize: '11px', fontWeight: '600',
|
||||
color: C.ash, cursor: 'pointer',
|
||||
color: BP.textDim, cursor: 'pointer',
|
||||
transition: 'color 0.12s ease, border-color 0.12s ease',
|
||||
flexShrink: '0', whiteSpace: 'nowrap',
|
||||
});
|
||||
count.textContent = '\u00D7' + selectedCount;
|
||||
count.title = 'Variants: click to change';
|
||||
count.addEventListener('mouseenter', () => { count.style.color = C.ink; count.style.borderColor = C.ink; });
|
||||
count.addEventListener('mouseleave', () => { count.style.color = C.ash; count.style.borderColor = C.mist; });
|
||||
count.addEventListener('mouseenter', () => { count.style.color = BP.text; count.style.borderColor = BP.text; });
|
||||
count.addEventListener('mouseleave', () => { count.style.color = BP.textDim; count.style.borderColor = BP.hairline; });
|
||||
count.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1;
|
||||
@@ -921,15 +958,15 @@
|
||||
// Go button
|
||||
const go = el('button', {
|
||||
padding: '5px 12px', borderRadius: '6px',
|
||||
border: 'none', background: C.brand, color: C.white,
|
||||
border: 'none', background: BP.accent, color: BP.mark,
|
||||
fontFamily: FONT, fontSize: '12px', fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.12s ease, transform 0.1s ease',
|
||||
transition: 'filter 0.12s ease, transform 0.1s ease',
|
||||
flexShrink: '0', whiteSpace: 'nowrap',
|
||||
});
|
||||
go.textContent = 'Go \u2192';
|
||||
go.addEventListener('mouseenter', () => go.style.background = C.brandHov);
|
||||
go.addEventListener('mouseleave', () => go.style.background = C.brand);
|
||||
go.addEventListener('mouseenter', () => go.style.filter = 'brightness(1.1)');
|
||||
go.addEventListener('mouseleave', () => go.style.filter = 'none');
|
||||
go.addEventListener('mousedown', () => go.style.transform = 'scale(0.97)');
|
||||
go.addEventListener('mouseup', () => go.style.transform = 'scale(1)');
|
||||
go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); });
|
||||
@@ -950,7 +987,7 @@
|
||||
|
||||
// Action label
|
||||
const label = el('span', {
|
||||
fontWeight: '600', fontSize: '12px', color: C.ink,
|
||||
fontWeight: '600', fontSize: '12px', color: BP.text,
|
||||
flexShrink: '0', whiteSpace: 'nowrap',
|
||||
});
|
||||
label.textContent = actionLabel();
|
||||
@@ -961,7 +998,7 @@
|
||||
|
||||
// Status
|
||||
const status = el('span', {
|
||||
fontSize: '11px', color: C.ash, whiteSpace: 'nowrap',
|
||||
fontSize: '11px', color: BP.textDim, whiteSpace: 'nowrap',
|
||||
marginLeft: 'auto',
|
||||
});
|
||||
// Variants currently arrive atomically in a single file edit, so a
|
||||
@@ -976,6 +1013,8 @@
|
||||
|
||||
// --- Cycling row ---
|
||||
|
||||
const TUNE_ICON_SVG = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" style="flex-shrink:0"><line x1="4" y1="8" x2="20" y2="8"/><circle cx="14" cy="8" r="2.4" fill="currentColor" stroke="none"/><line x1="4" y1="16" x2="20" y2="16"/><circle cx="10" cy="16" r="2.4" fill="currentColor" stroke="none"/></svg>';
|
||||
|
||||
function buildCyclingRow() {
|
||||
const row = el('div', {
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
@@ -994,7 +1033,7 @@
|
||||
// Counter
|
||||
const counter = el('span', {
|
||||
fontFamily: MONO, fontSize: '11px', fontWeight: '500',
|
||||
color: C.ash, minWidth: '24px', textAlign: 'center',
|
||||
color: BP.textDim, minWidth: '24px', textAlign: 'center',
|
||||
});
|
||||
counter.textContent = visibleVariant + '/' + arrivedVariants;
|
||||
row.appendChild(counter);
|
||||
@@ -1005,20 +1044,67 @@
|
||||
if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
|
||||
row.appendChild(next);
|
||||
|
||||
// Tune chip — only when the visible variant exposes params
|
||||
const visParams = parseVariantParams(getVisibleVariantEl());
|
||||
const hasParams = visParams.length > 0;
|
||||
if (hasParams) {
|
||||
const tune = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '6px',
|
||||
padding: '4px 10px', borderRadius: '5px',
|
||||
border: '1px solid transparent',
|
||||
background: tuneOpen ? BP.accentSoft : 'transparent',
|
||||
color: tuneOpen ? BP.accent : BP.text,
|
||||
fontFamily: FONT, fontSize: '11px', fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'color 0.12s ease, background 0.12s ease',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
tune.innerHTML = TUNE_ICON_SVG;
|
||||
const tuneLabel = document.createElement('span');
|
||||
tuneLabel.textContent = 'Tune';
|
||||
tune.appendChild(tuneLabel);
|
||||
const tuneBadge = document.createElement('span');
|
||||
Object.assign(tuneBadge.style, {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
minWidth: '16px', height: '16px', padding: '0 4px',
|
||||
borderRadius: '999px',
|
||||
background: tuneOpen ? C.brand : BP.hairline,
|
||||
color: tuneOpen ? 'oklch(98% 0 0)' : 'inherit',
|
||||
fontFamily: MONO, fontSize: '9.5px', fontWeight: '600',
|
||||
lineHeight: '1',
|
||||
boxSizing: 'border-box',
|
||||
});
|
||||
tuneBadge.textContent = String(visParams.length);
|
||||
tune.appendChild(tuneBadge);
|
||||
tune.title = 'Tune this variant (' + visParams.length + ' knob' + (visParams.length === 1 ? '' : 's') + ')';
|
||||
tune.addEventListener('mouseenter', () => {
|
||||
if (!tuneOpen) tune.style.background = BP.accentSoft;
|
||||
});
|
||||
tune.addEventListener('mouseleave', () => {
|
||||
if (!tuneOpen) tune.style.background = 'transparent';
|
||||
});
|
||||
tune.addEventListener('click', (e) => { e.stopPropagation(); toggleTunePopover(); });
|
||||
tune.dataset.iceqTune = '1';
|
||||
row.appendChild(tune);
|
||||
}
|
||||
|
||||
// Spacer
|
||||
row.appendChild(el('div', { flex: '1' }));
|
||||
|
||||
// Accept
|
||||
// Accept — primary action, uses the site's saturated brand magenta
|
||||
// with paper-white text, not the theme-muted BP.accent.
|
||||
const accept = el('button', {
|
||||
padding: '4px 10px', borderRadius: '5px',
|
||||
border: 'none', background: C.ink, color: C.white,
|
||||
padding: '5px 14px', borderRadius: '5px',
|
||||
border: 'none', background: C.brand, color: 'oklch(98% 0 0)',
|
||||
fontFamily: FONT, fontSize: '11px', fontWeight: '600',
|
||||
cursor: 'pointer', transition: 'background 0.12s ease',
|
||||
cursor: 'pointer', transition: 'filter 0.12s ease, transform 0.1s ease',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
accept.textContent = '\u2713 Accept';
|
||||
accept.addEventListener('mouseenter', () => accept.style.background = C.brand);
|
||||
accept.addEventListener('mouseleave', () => accept.style.background = C.ink);
|
||||
accept.addEventListener('mouseenter', () => accept.style.filter = 'brightness(1.08)');
|
||||
accept.addEventListener('mouseleave', () => accept.style.filter = 'none');
|
||||
accept.addEventListener('mousedown', () => accept.style.transform = 'scale(0.97)');
|
||||
accept.addEventListener('mouseup', () => accept.style.transform = 'scale(1)');
|
||||
accept.addEventListener('click', (e) => { e.stopPropagation(); handleAccept(); });
|
||||
if (arrivedVariants === 0) { accept.style.opacity = '0.3'; accept.style.pointerEvents = 'none'; }
|
||||
row.appendChild(accept);
|
||||
@@ -1026,14 +1112,14 @@
|
||||
// Discard
|
||||
const discard = el('button', {
|
||||
padding: '4px 6px', borderRadius: '5px',
|
||||
border: '1px solid ' + C.mist, background: 'transparent',
|
||||
fontFamily: FONT, fontSize: '11px', color: C.ash,
|
||||
border: '1px solid ' + BP.hairline, background: 'transparent',
|
||||
fontFamily: FONT, fontSize: '11px', color: BP.textDim,
|
||||
cursor: 'pointer', transition: 'color 0.12s ease, border-color 0.12s ease',
|
||||
});
|
||||
discard.textContent = '\u2715';
|
||||
discard.title = 'Discard all variants';
|
||||
discard.addEventListener('mouseenter', () => { discard.style.color = C.ink; discard.style.borderColor = C.ink; });
|
||||
discard.addEventListener('mouseleave', () => { discard.style.color = C.ash; discard.style.borderColor = C.mist; });
|
||||
discard.addEventListener('mouseenter', () => { discard.style.color = BP.text; discard.style.borderColor = BP.text; });
|
||||
discard.addEventListener('mouseleave', () => { discard.style.color = BP.textDim; discard.style.borderColor = BP.hairline; });
|
||||
discard.addEventListener('click', (e) => { e.stopPropagation(); handleDiscard(); });
|
||||
row.appendChild(discard);
|
||||
|
||||
@@ -1051,14 +1137,14 @@
|
||||
});
|
||||
const spinner = el('div', {
|
||||
width: '14px', height: '14px', borderRadius: '50%',
|
||||
border: '2px solid ' + C.mist,
|
||||
borderTopColor: C.brand,
|
||||
border: '2px solid ' + BP.hairline,
|
||||
borderTopColor: BP.accent,
|
||||
animation: 'impeccable-spin 0.6s linear infinite',
|
||||
flexShrink: '0',
|
||||
});
|
||||
row.appendChild(spinner);
|
||||
const label = el('span', {
|
||||
fontSize: '12px', color: C.ash, fontWeight: '500',
|
||||
fontSize: '12px', color: BP.textDim, fontWeight: '500',
|
||||
});
|
||||
label.textContent = 'Applying variant...';
|
||||
row.appendChild(label);
|
||||
@@ -1103,14 +1189,26 @@
|
||||
for (let i = 1; i <= expectedVariants; i++) {
|
||||
const arrived = i <= arrivedVariants;
|
||||
const active = i === visibleVariant;
|
||||
// active: solid site-brand magenta dot. arrived+inactive: muted neutral.
|
||||
// pending (not yet arrived): faint outline ring. No borders on arrived
|
||||
// dots — the previous "accent ring + ash fill" combo read as noisy
|
||||
// magenta chips, especially when all variants had arrived and every
|
||||
// dot wore an accent ring.
|
||||
const dotBg = active ? C.brand
|
||||
: arrived ? BP.textDim
|
||||
: 'transparent';
|
||||
const dotBorder = arrived ? 'none' : '1.5px solid ' + BP.hairline;
|
||||
const dot = el('div', {
|
||||
width: '7px', height: '7px', borderRadius: '50%',
|
||||
background: active ? C.brand : (arrived ? C.ash : 'transparent'),
|
||||
border: '1.5px solid ' + (arrived ? C.brand : C.mist),
|
||||
width: active ? '8px' : '6px',
|
||||
height: active ? '8px' : '6px',
|
||||
borderRadius: '50%',
|
||||
background: dotBg,
|
||||
border: dotBorder,
|
||||
boxSizing: 'border-box',
|
||||
transition: 'all 0.2s ' + EASE,
|
||||
cursor: (clickable && arrived) ? 'pointer' : 'default',
|
||||
transform: arrived ? 'scale(1)' : 'scale(0.6)',
|
||||
opacity: arrived ? '1' : '0.4',
|
||||
transform: arrived ? 'scale(1)' : 'scale(0.85)',
|
||||
opacity: arrived ? (active ? '1' : '0.6') : '0.4',
|
||||
});
|
||||
if (clickable && arrived) {
|
||||
const idx = i;
|
||||
@@ -1130,15 +1228,15 @@
|
||||
function navBtn(text) {
|
||||
const b = el('button', {
|
||||
width: '26px', height: '26px', borderRadius: '5px',
|
||||
border: '1px solid ' + C.mist, background: 'transparent',
|
||||
color: C.ink, fontFamily: FONT, fontSize: '13px',
|
||||
border: '1px solid ' + BP.hairline, background: 'transparent',
|
||||
color: BP.text, fontFamily: FONT, fontSize: '13px',
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'border-color 0.12s ease, background 0.12s ease',
|
||||
padding: '0', lineHeight: '1',
|
||||
});
|
||||
b.textContent = text;
|
||||
b.addEventListener('mouseenter', () => { b.style.borderColor = C.ink; });
|
||||
b.addEventListener('mouseleave', () => { b.style.borderColor = C.mist; });
|
||||
b.addEventListener('mouseenter', () => { b.style.borderColor = BP.text; });
|
||||
b.addEventListener('mouseleave', () => { b.style.borderColor = BP.hairline; });
|
||||
return b;
|
||||
}
|
||||
|
||||
@@ -1158,6 +1256,7 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initActionPicker() {
|
||||
const P = barPaletteForTheme(detectPageTheme());
|
||||
pickerEl = document.createElement('div');
|
||||
pickerEl.id = PREFIX + '-picker';
|
||||
Object.assign(pickerEl.style, {
|
||||
@@ -1166,12 +1265,14 @@
|
||||
transform: 'scale(0.96) translateY(4px)',
|
||||
transformOrigin: 'bottom left',
|
||||
transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE,
|
||||
background: C.paperSolid,
|
||||
border: '1px solid ' + C.mist,
|
||||
background: P.surface,
|
||||
border: '1px solid ' + P.hairline,
|
||||
borderRadius: '10px',
|
||||
boxShadow: '0 8px 30px oklch(0% 0 0 / 0.10), 0 2px 6px oklch(0% 0 0 / 0.06)',
|
||||
padding: '6px',
|
||||
fontFamily: FONT,
|
||||
backdropFilter: 'blur(10px)',
|
||||
WebkitBackdropFilter: 'blur(10px)',
|
||||
});
|
||||
|
||||
// Build the chip grid
|
||||
@@ -1181,22 +1282,32 @@
|
||||
|
||||
ACTIONS.forEach(action => {
|
||||
const chip = el('button', {
|
||||
padding: '6px 8px', borderRadius: '6px',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '8px 6px', borderRadius: '6px',
|
||||
border: 'none',
|
||||
background: action.value === selectedAction ? C.brandSoft : 'transparent',
|
||||
color: action.value === selectedAction ? C.brand : C.ink,
|
||||
background: action.value === selectedAction ? P.accentSoft : 'transparent',
|
||||
color: action.value === selectedAction ? P.accent : P.text,
|
||||
fontFamily: FONT, fontSize: '11px', fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.1s ease, color 0.1s ease',
|
||||
textAlign: 'center', whiteSpace: 'nowrap',
|
||||
});
|
||||
chip.textContent = action.label;
|
||||
const iconWrap = el('span', {
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
height: '20px', opacity: '0.9',
|
||||
});
|
||||
iconWrap.innerHTML = ICONS[action.value] || '';
|
||||
const labelEl = el('span', { lineHeight: '1' });
|
||||
labelEl.textContent = action.label;
|
||||
chip.appendChild(iconWrap);
|
||||
chip.appendChild(labelEl);
|
||||
chip.dataset.action = action.value;
|
||||
chip.addEventListener('mouseenter', () => {
|
||||
if (action.value !== selectedAction) chip.style.background = C.brandSoft;
|
||||
if (action.value !== selectedAction) chip.style.background = P.accentSoft;
|
||||
});
|
||||
chip.addEventListener('mouseleave', () => {
|
||||
chip.style.background = action.value === selectedAction ? C.brandSoft : 'transparent';
|
||||
chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent';
|
||||
});
|
||||
chip.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -1209,19 +1320,24 @@
|
||||
|
||||
pickerEl.appendChild(grid);
|
||||
document.body.appendChild(pickerEl);
|
||||
|
||||
// Cache the palette on the picker so toggleActionPicker's state refresh
|
||||
// uses the same theme-aware colors when it repaints chips.
|
||||
pickerEl.__iceq_palette = P;
|
||||
}
|
||||
|
||||
function toggleActionPicker() {
|
||||
if (pickerEl.style.display !== 'none') { hideActionPicker(); return; }
|
||||
// Rebuild chips to reflect current selection
|
||||
const P = pickerEl.__iceq_palette || barPaletteForTheme(detectPageTheme());
|
||||
pickerEl.querySelectorAll('button').forEach(chip => {
|
||||
const isActive = chip.dataset.action === selectedAction;
|
||||
chip.style.background = isActive ? C.brandSoft : 'transparent';
|
||||
chip.style.color = isActive ? C.brand : C.ink;
|
||||
chip.style.background = isActive ? P.accentSoft : 'transparent';
|
||||
chip.style.color = isActive ? P.accent : P.text;
|
||||
});
|
||||
// Position above the bar
|
||||
const barRect = barEl.getBoundingClientRect();
|
||||
const pickerH = 90; // approximate
|
||||
const pickerH = 170; // approximate; grows with icon + label rows
|
||||
let top = barRect.top - pickerH - 6;
|
||||
if (top < 8) top = barRect.bottom + 6;
|
||||
Object.assign(pickerEl.style, {
|
||||
@@ -1241,6 +1357,399 @@
|
||||
setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Params panel (per-variant coarse controls)
|
||||
//
|
||||
// Variants may declare a parameter manifest via a JSON attribute on the
|
||||
// variant wrapper:
|
||||
//
|
||||
// <div data-impeccable-variant="1"
|
||||
// data-impeccable-params='[{"id":"density","kind":"steps",...}]'>
|
||||
//
|
||||
// The panel docks to the right edge of the outline during CYCLING and
|
||||
// exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped
|
||||
// CSS can respond instantly without regeneration:
|
||||
//
|
||||
// range / numeric toggle → CSS var (`--p-<id>`) used via var(--p-foo, N)
|
||||
// steps / boolean toggle → data-p-<id> attribute used via :scope[data-p-foo="..."]
|
||||
//
|
||||
// On variant switch, values reset to that variant's declared defaults.
|
||||
// On accept, current values are sent in the event payload so the agent
|
||||
// can bake them into the source-file write.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide)
|
||||
let paramsPanelInner = null; // translating content (carries bg, padding, knobs)
|
||||
let paramsPanelBody = null; // grid holding the knob cells
|
||||
let paramsCurrentValues = {}; // {paramId: value} — mirror of the visible variant's live values
|
||||
let tuneOpen = false; // whether the Tune popover is open right now
|
||||
|
||||
// Theme-aware Tune popover. Appears as a drawer that slides out from the
|
||||
// contextual bar's bar-facing edge (below if the bar sits below the
|
||||
// element, above otherwise). Same width as the bar. Auto-wraps to extra
|
||||
// rows when the knobs exceed one row. The bar's border-radius on the
|
||||
// popover side goes flat while open so the two shapes read as one.
|
||||
let paramsPanelPalette = null;
|
||||
|
||||
function initParamsPanel() {
|
||||
paramsPanelPalette = barPaletteForTheme(detectPageTheme());
|
||||
const P = paramsPanelPalette;
|
||||
|
||||
// Single element, always in the DOM. The slide animation is a CSS mask
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis — no
|
||||
// display toggle, no opacity toggle, no transform trickery. The mask
|
||||
// hides everything initially; as it grows, content is revealed from
|
||||
// the bar edge outward.
|
||||
paramsPanelEl = document.createElement('div');
|
||||
paramsPanelEl.id = PREFIX + '-params-panel';
|
||||
Object.assign(paramsPanelEl.style, {
|
||||
position: 'fixed', zIndex: String(Z.bar - 1),
|
||||
background: P.surfaceDeep,
|
||||
color: P.text,
|
||||
fontFamily: FONT,
|
||||
padding: '14px 18px',
|
||||
boxSizing: 'border-box',
|
||||
borderRadius: '0 0 10px 10px',
|
||||
pointerEvents: 'none',
|
||||
backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
|
||||
|
||||
// clip-path is the same conceptual reveal as mask but with rock-solid
|
||||
// transition support across engines. Closed state clips from the far
|
||||
// edge; open = inset(0) shows everything.
|
||||
clipPath: 'inset(0 0 100% 0)',
|
||||
transition: 'clip-path 0.44s ' + EASE,
|
||||
|
||||
// Park off-screen until positionParamsPanel places it. These are NOT
|
||||
// in the transition list, so they snap instantly — no fly-in from the
|
||||
// top-left when first shown.
|
||||
top: '-9999px', left: '-9999px', width: '0',
|
||||
});
|
||||
|
||||
paramsPanelBody = el('div', {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
|
||||
gap: '12px 16px',
|
||||
});
|
||||
|
||||
paramsPanelEl.appendChild(paramsPanelBody);
|
||||
document.body.appendChild(paramsPanelEl);
|
||||
paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
|
||||
}
|
||||
|
||||
function getVisibleVariantEl() {
|
||||
if (!currentSessionId) return null;
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
|
||||
function parseVariantParams(variantEl) {
|
||||
if (!variantEl) return [];
|
||||
const raw = variantEl.getAttribute('data-impeccable-params');
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] Invalid data-impeccable-params JSON:', err.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function applyParamValue(variantEl, param, value) {
|
||||
if (!variantEl) return;
|
||||
const attr = 'data-p-' + param.id;
|
||||
if (param.kind === 'range') {
|
||||
variantEl.style.setProperty('--p-' + param.id, String(value));
|
||||
} else if (param.kind === 'toggle') {
|
||||
const on = !!value;
|
||||
variantEl.style.setProperty('--p-' + param.id, on ? '1' : '0');
|
||||
if (on) variantEl.setAttribute(attr, 'on');
|
||||
else variantEl.removeAttribute(attr);
|
||||
} else if (param.kind === 'steps') {
|
||||
variantEl.setAttribute(attr, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
function applyParamDefaults(variantEl, params) {
|
||||
paramsCurrentValues = {};
|
||||
for (const p of params) {
|
||||
paramsCurrentValues[p.id] = p.default;
|
||||
applyParamValue(variantEl, p, p.default);
|
||||
}
|
||||
}
|
||||
|
||||
function formatRangeValue(input) {
|
||||
const max = parseFloat(input.max), min = parseFloat(input.min);
|
||||
const v = parseFloat(input.value);
|
||||
if (!isFinite(v)) return input.value;
|
||||
return (max - min) <= 2 ? v.toFixed(2) : String(Math.round(v));
|
||||
}
|
||||
|
||||
function buildParamsPanel(variantEl, params) {
|
||||
const P = paramsPanelPalette || barPaletteForTheme(detectPageTheme());
|
||||
paramsPanelBody.innerHTML = '';
|
||||
for (const p of params) {
|
||||
const row = el('div', { display: 'flex', flexDirection: 'column', gap: '6px' });
|
||||
const labelRow = el('div', {
|
||||
display: 'flex', justifyContent: 'space-between',
|
||||
alignItems: 'baseline', gap: '8px',
|
||||
});
|
||||
const lbl = el('span', {
|
||||
fontSize: '10.5px', fontWeight: '600', color: P.text,
|
||||
letterSpacing: '0.03em',
|
||||
});
|
||||
lbl.textContent = p.label || p.id;
|
||||
labelRow.appendChild(lbl);
|
||||
const readout = el('span', {
|
||||
fontSize: '10.5px', color: P.textDim,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
});
|
||||
labelRow.appendChild(readout);
|
||||
row.appendChild(labelRow);
|
||||
|
||||
if (p.kind === 'range') {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'range';
|
||||
input.min = String(p.min != null ? p.min : 0);
|
||||
input.max = String(p.max != null ? p.max : 1);
|
||||
input.step = String(p.step != null ? p.step : 0.05);
|
||||
input.value = String(p.default);
|
||||
Object.assign(input.style, {
|
||||
width: '100%', accentColor: C.brand, cursor: 'pointer',
|
||||
});
|
||||
readout.textContent = formatRangeValue(input);
|
||||
input.addEventListener('input', (e) => {
|
||||
e.stopPropagation();
|
||||
const v = parseFloat(input.value);
|
||||
paramsCurrentValues[p.id] = v;
|
||||
readout.textContent = formatRangeValue(input);
|
||||
applyParamValue(variantEl, p, v);
|
||||
});
|
||||
row.appendChild(input);
|
||||
} else if (p.kind === 'toggle') {
|
||||
const initial = !!p.default;
|
||||
readout.textContent = initial ? 'On' : 'Off';
|
||||
const track = el('button', {
|
||||
position: 'relative', width: '36px', height: '20px',
|
||||
borderRadius: '10px', border: 'none', padding: '0',
|
||||
cursor: 'pointer',
|
||||
background: initial ? C.brand : P.hairline,
|
||||
transition: 'background 0.15s ease',
|
||||
alignSelf: 'flex-start',
|
||||
});
|
||||
const knob = el('span', {
|
||||
position: 'absolute', top: '2px',
|
||||
left: initial ? '18px' : '2px',
|
||||
width: '16px', height: '16px', borderRadius: '50%',
|
||||
background: 'oklch(98% 0 0)',
|
||||
transition: 'left 0.18s ' + EASE,
|
||||
boxShadow: '0 1px 2px oklch(0% 0 0 / 0.2)',
|
||||
});
|
||||
track.appendChild(knob);
|
||||
track.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const next = !paramsCurrentValues[p.id];
|
||||
paramsCurrentValues[p.id] = next;
|
||||
track.style.background = next ? C.brand : P.hairline;
|
||||
knob.style.left = next ? '18px' : '2px';
|
||||
readout.textContent = next ? 'On' : 'Off';
|
||||
applyParamValue(variantEl, p, next);
|
||||
});
|
||||
row.appendChild(track);
|
||||
} else if (p.kind === 'steps') {
|
||||
const opts = (p.options || []).map(o =>
|
||||
typeof o === 'string' ? { value: o, label: o } : o
|
||||
);
|
||||
const activeOpt = opts.find(o => o.value === p.default) || opts[0];
|
||||
readout.textContent = activeOpt ? activeOpt.label : String(p.default);
|
||||
const segRow = el('div', {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(' + opts.length + ', 1fr)',
|
||||
gap: '1px', padding: '2px',
|
||||
background: P.hairline, borderRadius: '5px',
|
||||
});
|
||||
const segBtns = [];
|
||||
opts.forEach(o => {
|
||||
const active = o.value === p.default;
|
||||
const b = el('button', {
|
||||
padding: '5px 4px', border: 'none', borderRadius: '3px',
|
||||
background: active ? C.brand : 'transparent',
|
||||
color: active ? 'oklch(98% 0 0)' : P.text,
|
||||
fontFamily: FONT, fontSize: '10.5px', fontWeight: '500',
|
||||
cursor: 'pointer', whiteSpace: 'nowrap',
|
||||
transition: 'background 0.1s ease, color 0.1s ease',
|
||||
});
|
||||
b.textContent = o.label;
|
||||
b.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
paramsCurrentValues[p.id] = o.value;
|
||||
readout.textContent = o.label;
|
||||
segBtns.forEach(({ btn, val }) => {
|
||||
const on = val === o.value;
|
||||
btn.style.background = on ? C.brand : 'transparent';
|
||||
btn.style.color = on ? 'oklch(98% 0 0)' : P.text;
|
||||
});
|
||||
applyParamValue(variantEl, p, o.value);
|
||||
});
|
||||
segRow.appendChild(b);
|
||||
segBtns.push({ btn: b, val: o.value });
|
||||
});
|
||||
row.appendChild(segRow);
|
||||
}
|
||||
|
||||
paramsPanelBody.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
// Decide which way the popover opens: away from the picked element. If the
|
||||
// bar landed below the element, popover slides DOWN from the bar's bottom.
|
||||
// If the bar landed above, popover slides UP from the bar's top.
|
||||
function popoverDirection() {
|
||||
if (!barEl || !selectedElement) return 'below';
|
||||
const br = barEl.getBoundingClientRect();
|
||||
const er = selectedElement.getBoundingClientRect();
|
||||
return br.top >= er.bottom - 4 ? 'below' : 'above';
|
||||
}
|
||||
|
||||
// The popover overlaps the bar by OVERLAP px on the bar-facing side. With
|
||||
// popover z-index below bar, that overlap sits behind bar (invisible) and
|
||||
// reinforces the "tucked behind" feel. Padding compensates so the real
|
||||
// content starts flush with bar's outer edge.
|
||||
const TUNE_OVERLAP = 6;
|
||||
|
||||
// Closed clip-path depends on direction: for 'below' clip from the far
|
||||
// (bottom) edge so the reveal grows downward from the bar; for 'above'
|
||||
// clip from the top edge so the reveal grows upward from the bar.
|
||||
function closedClipPath(direction) {
|
||||
return direction === 'below' ? 'inset(0 0 100% 0)' : 'inset(100% 0 0 0)';
|
||||
}
|
||||
|
||||
function setClipPath(value, withTransition) {
|
||||
const saved = paramsPanelEl.style.transition;
|
||||
if (!withTransition) paramsPanelEl.style.transition = 'none';
|
||||
paramsPanelEl.style.clipPath = value;
|
||||
if (!withTransition) {
|
||||
void paramsPanelEl.offsetHeight;
|
||||
paramsPanelEl.style.transition = saved;
|
||||
}
|
||||
}
|
||||
|
||||
function positionParamsPanel() {
|
||||
if (!paramsPanelEl || !barEl || barEl.style.display === 'none') return;
|
||||
const br = barEl.getBoundingClientRect();
|
||||
const direction = popoverDirection();
|
||||
const prevDirection = paramsPanelEl.dataset.tuneDirection;
|
||||
|
||||
// top/left/width are NOT in the transition list, so they snap instantly.
|
||||
paramsPanelEl.style.left = br.left + 'px';
|
||||
paramsPanelEl.style.width = br.width + 'px';
|
||||
|
||||
if (direction === 'below') {
|
||||
paramsPanelEl.style.top = (br.bottom - TUNE_OVERLAP) + 'px';
|
||||
paramsPanelEl.style.borderRadius = '0 0 10px 10px';
|
||||
paramsPanelEl.style.paddingTop = (14 + TUNE_OVERLAP) + 'px';
|
||||
paramsPanelEl.style.paddingBottom = '14px';
|
||||
} else {
|
||||
const ih = paramsPanelEl.offsetHeight || 80;
|
||||
paramsPanelEl.style.top = (br.top - ih + TUNE_OVERLAP) + 'px';
|
||||
paramsPanelEl.style.borderRadius = '10px 10px 0 0';
|
||||
paramsPanelEl.style.paddingTop = '14px';
|
||||
paramsPanelEl.style.paddingBottom = (14 + TUNE_OVERLAP) + 'px';
|
||||
}
|
||||
paramsPanelEl.dataset.tuneDirection = direction;
|
||||
|
||||
// If currently closed and direction flipped (or first-time setup),
|
||||
// snap the clip-path to the new direction's closed pose without
|
||||
// transitioning (so the clip doesn't slide across the element).
|
||||
if (!tuneOpen && (!prevDirection || prevDirection !== direction)) {
|
||||
setClipPath(closedClipPath(direction), false);
|
||||
}
|
||||
}
|
||||
|
||||
function showParamsPanel() {
|
||||
if (!paramsPanelEl) return;
|
||||
positionParamsPanel();
|
||||
paramsPanelEl.style.pointerEvents = 'auto';
|
||||
// rAF so the positioning paint commits before the transition fires.
|
||||
requestAnimationFrame(() => {
|
||||
setClipPath('inset(0 0 0 0)', true);
|
||||
});
|
||||
}
|
||||
|
||||
function hideParamsPanel() {
|
||||
if (!paramsPanelEl) return;
|
||||
paramsPanelEl.style.pointerEvents = 'none';
|
||||
const direction = paramsPanelEl.dataset.tuneDirection || 'below';
|
||||
setClipPath(closedClipPath(direction), true);
|
||||
}
|
||||
|
||||
// Build/rebuild the panel's contents for the current variant AND apply
|
||||
// its defaults to the variant wrapper (so scoped CSS responds even before
|
||||
// the user opens the popover). Visibility is governed by tuneOpen.
|
||||
function refreshParamsPanel() {
|
||||
if (state !== 'CYCLING') {
|
||||
paramsCurrentValues = {};
|
||||
tuneOpen = false;
|
||||
hideParamsPanel();
|
||||
return;
|
||||
}
|
||||
const variantEl = getVisibleVariantEl();
|
||||
const params = parseVariantParams(variantEl);
|
||||
if (!variantEl || params.length === 0) {
|
||||
paramsCurrentValues = {};
|
||||
tuneOpen = false;
|
||||
hideParamsPanel();
|
||||
return;
|
||||
}
|
||||
applyParamDefaults(variantEl, params);
|
||||
buildParamsPanel(variantEl, params);
|
||||
if (tuneOpen) {
|
||||
// If already visible (variant cycled while open), refresh in place
|
||||
// instead of re-running the clip-path animation.
|
||||
const alreadyVisible = paramsPanelEl.style.display === 'block'
|
||||
&& paramsPanelEl.style.opacity === '1';
|
||||
if (alreadyVisible) positionParamsPanel();
|
||||
else showParamsPanel();
|
||||
} else {
|
||||
hideParamsPanel();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTunePopover() {
|
||||
if (tuneOpen) { closeTunePopover(); return; }
|
||||
openTunePopover();
|
||||
}
|
||||
|
||||
function openTunePopover() {
|
||||
if (state !== 'CYCLING') return;
|
||||
const variantEl = getVisibleVariantEl();
|
||||
const params = parseVariantParams(variantEl);
|
||||
if (!variantEl || params.length === 0) return;
|
||||
// Build fresh to ensure the current variant's controls are shown.
|
||||
applyParamDefaults(variantEl, params);
|
||||
buildParamsPanel(variantEl, params);
|
||||
tuneOpen = true;
|
||||
showParamsPanel();
|
||||
// Kill the bar's shadow on the popover-facing side so the dark popover
|
||||
// doesn't pick up a bright glow line.
|
||||
if (barEl) {
|
||||
const direction = paramsPanelEl?.dataset.tuneDirection || 'below';
|
||||
barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN;
|
||||
}
|
||||
// Re-render the bar so the Tune chip picks up the active styling.
|
||||
updateBarContent('cycling');
|
||||
}
|
||||
|
||||
function closeTunePopover() {
|
||||
tuneOpen = false;
|
||||
hideParamsPanel();
|
||||
if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT;
|
||||
if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') {
|
||||
updateBarContent('cycling');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variant cycling in DOM
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1253,6 +1762,10 @@
|
||||
if (!v) continue;
|
||||
child.style.display = (v === String(num)) ? '' : 'none';
|
||||
}
|
||||
// Unconditional refresh — covers first-reveal (no-op if state isn't
|
||||
// CYCLING yet, the subsequent CYCLING transition triggers its own
|
||||
// refresh) and every cycle step.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1316,6 +1829,7 @@
|
||||
state = 'CYCLING';
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
refreshParamsPanel();
|
||||
saveSession();
|
||||
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
|
||||
})
|
||||
@@ -1329,7 +1843,7 @@
|
||||
const next = visibleVariant + dir;
|
||||
if (next < 1 || next > arrivedVariants) return;
|
||||
visibleVariant = next;
|
||||
showVariantInDOM(currentSessionId, next);
|
||||
showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself
|
||||
updateSelectedElement();
|
||||
updateBarContent('cycling');
|
||||
saveSession();
|
||||
@@ -1540,6 +2054,7 @@
|
||||
state = 'CYCLING';
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
refreshParamsPanel();
|
||||
} else if (state === 'GENERATING') {
|
||||
updateBarContent('generating');
|
||||
}
|
||||
@@ -1560,6 +2075,7 @@
|
||||
if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') {
|
||||
positionBar();
|
||||
showHighlight(selectedElement);
|
||||
if (tuneOpen) positionParamsPanel();
|
||||
}
|
||||
if (annotActive) positionAnnotOverlay(selectedElement);
|
||||
// Shader overlay (via debug P toggle or generation) is repositioned
|
||||
@@ -1606,6 +2122,7 @@
|
||||
if (state === 'GENERATING') {
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
refreshParamsPanel();
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -1691,6 +2208,10 @@
|
||||
if (pickerEl?.style.display !== 'none' && !own(e.target)) {
|
||||
hideActionPicker();
|
||||
}
|
||||
// Close Tune popover on outside click (anything outside panel + bar)
|
||||
if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) {
|
||||
closeTunePopover();
|
||||
}
|
||||
// In CONFIGURING: click outside the bar and selected element returns to PICKING
|
||||
if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) {
|
||||
hideBar();
|
||||
@@ -2258,7 +2779,11 @@ void main() {
|
||||
|
||||
function handleAccept() {
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
sendEvent({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) });
|
||||
const acceptPayload = { type: 'accept', id: currentSessionId, variantId: String(visibleVariant) };
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
sendEvent(acceptPayload);
|
||||
markSessionHandled();
|
||||
|
||||
// The accepted variant is already the only visible child of the wrapper
|
||||
@@ -2477,6 +3002,11 @@ void main() {
|
||||
state = arrivedVariants >= expectedVariants ? 'CYCLING' : 'GENERATING';
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -2522,6 +3052,11 @@ void main() {
|
||||
// dark pages. This keeps the bar from fighting with the host design.
|
||||
function detectPageTheme() {
|
||||
try {
|
||||
// Dev override: set localStorage 'impeccable-dev-theme' to 'light' or
|
||||
// 'dark' to preview the opposite palette without actually changing the
|
||||
// page bg. Used for screenshots and theme QA.
|
||||
const override = localStorage.getItem('impeccable-dev-theme');
|
||||
if (override === 'light' || override === 'dark') return override;
|
||||
const bg = getComputedStyle(document.body).backgroundColor
|
||||
|| getComputedStyle(document.documentElement).backgroundColor;
|
||||
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
||||
@@ -2538,6 +3073,7 @@ void main() {
|
||||
// Light bar on dark page
|
||||
return {
|
||||
surface: 'oklch(98% 0 0 / 0.92)',
|
||||
surfaceDeep: 'oklch(92% 0.005 60 / 0.96)', // slightly deeper, faint warm
|
||||
hairline: 'oklch(70% 0 0 / 0.35)',
|
||||
text: 'oklch(15% 0 0)',
|
||||
textDim: 'oklch(45% 0 0)',
|
||||
@@ -2552,6 +3088,7 @@ void main() {
|
||||
// deeper so the rounded-right shape reads as a clear sculpted mark.
|
||||
return {
|
||||
surface: 'oklch(26% 0 0 / 0.94)',
|
||||
surfaceDeep: 'oklch(18% 0 0 / 0.96)', // darker sand for Tune popover
|
||||
hairline: 'oklch(42% 0 0 / 0.5)',
|
||||
text: 'oklch(96% 0 0)',
|
||||
textDim: 'oklch(72% 0 0)',
|
||||
@@ -2883,6 +3420,7 @@ void main() {
|
||||
if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; }
|
||||
if (barEl) { barEl.remove(); barEl = null; }
|
||||
if (pickerEl) { pickerEl.remove(); pickerEl = null; }
|
||||
if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; }
|
||||
if (evtSource) { evtSource.close(); evtSource = null; }
|
||||
document.removeEventListener('mousemove', handleMouseMove, true);
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
@@ -3956,6 +4494,7 @@ void main() {
|
||||
initAnnotOverlay();
|
||||
initBar();
|
||||
initActionPicker();
|
||||
initParamsPanel();
|
||||
initGlobalBar();
|
||||
initDesignPanel();
|
||||
document.addEventListener('mousemove', handleMouseMove, true);
|
||||
|
||||
@@ -145,6 +145,11 @@ Options:
|
||||
const scriptArgs = event.type === 'discard'
|
||||
? ['--id', event.id, '--discard']
|
||||
: ['--id', event.id, '--variant', event.variantId];
|
||||
if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
|
||||
// Pass through a JSON blob; the shell-safe wrap uses single quotes because
|
||||
// values are finite {id, number|string|boolean} pairs from a validated payload.
|
||||
scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
|
||||
}
|
||||
try {
|
||||
const out = execSync(
|
||||
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
|
||||
|
||||
@@ -147,6 +147,11 @@ function validateEvent(msg) {
|
||||
case 'accept':
|
||||
if (!msg.id) return 'accept: missing id';
|
||||
if (!msg.variantId) return 'accept: missing variantId';
|
||||
if (msg.paramValues !== undefined) {
|
||||
if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
|
||||
return 'accept: paramValues must be an object';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
case 'discard':
|
||||
return msg.id ? null : 'discard: missing id';
|
||||
|
||||
@@ -142,3 +142,13 @@ Test that colorization improves the experience:
|
||||
- **Not overwhelming**: Is color balanced and purposeful?
|
||||
|
||||
Remember: Color is emotional and powerful. Use it to create warmth, guide attention, communicate meaning, and express personality. But restraint and strategy matter more than saturation and variety. Be colorful, but be intentional.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
When invoked from live mode, each variant MUST declare a `color-amount` param so the user can dial between a restrained accent and a drenched surface without regeneration. Author the variant's CSS against `var(--p-color-amount, 0.5)` — typically as the alpha multiplier on backgrounds, or as a scaling factor on the chroma axis in an OKLCH expression. 0 = neutral/monochrome, 1 = full saturation / dominant coverage.
|
||||
|
||||
```json
|
||||
{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"}
|
||||
```
|
||||
|
||||
Layer 1-2 variant-specific params on top: palette selection (`steps` with named options), temperature warmth, or tint vs. true color. See `reference/live.md` for the full params contract.
|
||||
|
||||
@@ -119,3 +119,23 @@ Create a systematic plan:
|
||||
- **Responsiveness**: Does the layout adapt gracefully across screen sizes?
|
||||
|
||||
Remember: Space is the most underused design tool. A layout with the right rhythm and hierarchy can make even simple content feel polished and intentional.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
Each variant MUST declare a `density` param. Drive all spacing tokens in the variant's scoped CSS through `calc(var(--p-density, 1) * <base>)` — paddings, gaps, column widths. Users slide from airy to packed and see layout re-breathe with no regeneration.
|
||||
|
||||
```json
|
||||
{"id":"density","kind":"range","min":0.6,"max":1.4,"step":0.05,"default":1,"label":"Density"}
|
||||
```
|
||||
|
||||
For variants whose topology genuinely changes (stacked vs. side-by-side, grid vs. bento), use a `steps` param whose scoped CSS branches via `:scope[data-p-structure="X"]`. One structure param + one density param is a powerful combo; resist adding a third.
|
||||
|
||||
```json
|
||||
{"id":"structure","kind":"steps","default":"grid","label":"Structure","options":[
|
||||
{"value":"stacked","label":"Stacked"},
|
||||
{"value":"grid","label":"Grid"},
|
||||
{"value":"bento","label":"Bento"}
|
||||
]}
|
||||
```
|
||||
|
||||
See `reference/live.md` for the full params contract.
|
||||
|
||||
@@ -179,7 +179,56 @@ The first variant has no `display: none` (visible by default). All others do. If
|
||||
|
||||
One edit, all variants — the browser's MutationObserver picks everything up in one pass.
|
||||
|
||||
### 7. Signal done
|
||||
### 7. Parameters (optional, 2-5 per variant)
|
||||
|
||||
Each variant can expose coarse knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against.
|
||||
|
||||
**When to use.** Any time the variant has a meaningful axis the user might want to dial in: color amount, density, motion intensity, scale ratio. Not micro-level margin tweaks; those defeat the point.
|
||||
|
||||
**Budget scales with the element's visual weight, not the user's curiosity.** Knobs need real estate to produce noticeably different output; slapping three on a small element just crowds the panel without making anything feel tunable.
|
||||
|
||||
- **Leaf / tiny** — a single button, icon, input, bare heading, solitary paragraph: **0 params.** A slider can't meaningfully reshape one element; just use variants.
|
||||
- **Small composition** — labeled input, simple card, short callout (≤ ~5 visual children): **0-1 params.** Only add one if it's a clear dominant axis (e.g. density on a card with visible internal rhythm).
|
||||
- **Medium composition** — section component, nav cluster, dense card, short feature block (6-15 visual children): **2 params.**
|
||||
- **Large composition** — hero section, full page region, spread layout, anything with strong internal structure (16+ visual children or multiple sub-sections): **3-4 params.**
|
||||
|
||||
When in doubt, fewer. The user can always ask for more variants to explore an axis you didn't expose as a knob. Count by visual children, not by DOM-node depth; a deeply-nested-but-visually-simple card still counts as small.
|
||||
|
||||
**How to declare.** Put a JSON manifest on the variant wrapper:
|
||||
|
||||
```html
|
||||
<div data-impeccable-variant="1" data-impeccable-params='[
|
||||
{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"},
|
||||
{"id":"density","kind":"steps","default":"snug","label":"Density","options":[
|
||||
{"value":"airy","label":"Airy"},
|
||||
{"value":"snug","label":"Snug"},
|
||||
{"value":"packed","label":"Packed"}
|
||||
]},
|
||||
{"id":"serif","kind":"toggle","default":false,"label":"Serif display"}
|
||||
]'>
|
||||
...variant content...
|
||||
</div>
|
||||
```
|
||||
|
||||
**Three kinds:**
|
||||
|
||||
- `range` — smooth slider. Drives a CSS custom property `--p-<id>` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`.
|
||||
- `steps` — segmented radio. Drives a data attribute `data-p-<id>` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`.
|
||||
- `toggle` — on/off switch. Drives BOTH a CSS var (`--p-<id>: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`.
|
||||
|
||||
**Signature params per action.** Each action has one or two signature params that MUST be exposed when the variant can meaningfully express them. Check the action's reference file for the list. Layer 1-2 variant-specific params on top.
|
||||
|
||||
**Reset on variant switch.** User dials density on v1, flips to v2, v2 starts at v2's declared defaults. Known limitation; preservation across variants may land later.
|
||||
|
||||
**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment:
|
||||
|
||||
```html
|
||||
<!-- impeccable-param-values SESSION_ID: {"color-amount":0.7,"density":"packed"} -->
|
||||
```
|
||||
|
||||
The carbonize cleanup step (see below) reads that comment and bakes the chosen values into the final CSS. For `steps`/`toggle` attribute selectors: keep only the branch matching the chosen value, drop the others, collapse `:scope[data-p-density="packed"] .grid` to a semantic class rule. For `range` vars: either substitute the literal or keep the var with the chosen value as its new default.
|
||||
|
||||
### 8. Signal done
|
||||
|
||||
```bash
|
||||
node .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 `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element.
|
||||
1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values — read it first; it drives steps 3 and 4 below.
|
||||
2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `public/css/workflow.css` for this repo, or the component's co-located CSS file for a Vite/Next project — pick whichever already owns styling for the surrounding element).
|
||||
3. **Rewrite `@scope ([data-impeccable-variant="N"])` selectors** to target real, semantic classes on the accepted HTML. Example: `@scope ([data-impeccable-variant="2"]) { .v2-label { … } }` becomes `.why-visual--v2 .v2-label { … }` if the accepted element already carries `.why-visual--v2`, or pick/add a suitable class if it doesn't.
|
||||
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it.
|
||||
5. **Delete the inline `<style>` block and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one — those are dead code now.
|
||||
3. **Bake in parameter values while rewriting selectors.** For `@scope ([data-impeccable-variant="N"])` wrappers: retarget to real, semantic classes on the accepted HTML (`.why-visual--v2 .v2-label { … }`). For `:scope[data-p-<id>="VALUE"]` selectors: keep only the branch matching the chosen value from the param-values comment; drop the others (they're dead after accept). For `var(--p-<id>, DEFAULT)` in the CSS: either substitute the literal value, or if the param is still useful as a knob going forward, leave the var and update its initial declaration to the chosen value.
|
||||
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it. Drop `data-impeccable-params` and any `data-p-*` attributes from it — those are live-mode plumbing, not source.
|
||||
5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one — those are dead code now.
|
||||
|
||||
Then poll again.
|
||||
|
||||
|
||||
@@ -110,3 +110,15 @@ Build a clear type scale:
|
||||
- **Accessibility**: Does text meet WCAG contrast ratios? Is it zoomable to 200%?
|
||||
|
||||
Remember: Typography is the foundation of interface design — it carries the majority of information. Getting it right is the highest-leverage improvement you can make.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
Each variant MUST declare a `scale` param controlling the hierarchy ratio. Express all font sizes in the variant's scoped CSS through `calc(var(--p-scale, 1) * <base>)` or, better, scale the type ramp via `clamp(min, calc(var(--p-scale, 1) * Npx), max)`. Users slide from subdued to commanding.
|
||||
|
||||
```json
|
||||
{"id":"scale","kind":"range","min":0.85,"max":1.3,"step":0.05,"default":1,"label":"Scale"}
|
||||
```
|
||||
|
||||
Where the variant riffs on a specific pairing, expose the pairing choice as a `steps` param (e.g. "serif display + sans body" vs. "mono display + sans body" vs. "all-sans"). Each branch routes through `:scope[data-p-pairing="X"]` selectors in scoped CSS.
|
||||
|
||||
See `reference/live.md` for the full params contract.
|
||||
|
||||
@@ -45,11 +45,18 @@ Output (JSON):
|
||||
|
||||
const id = argVal(args, '--id');
|
||||
const variantNum = argVal(args, '--variant');
|
||||
const paramValuesRaw = argVal(args, '--param-values');
|
||||
const isDiscard = args.includes('--discard');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); }
|
||||
|
||||
let paramValues = null;
|
||||
if (paramValuesRaw) {
|
||||
try { paramValues = JSON.parse(paramValuesRaw); }
|
||||
catch { paramValues = null; } // malformed blob: skip the comment rather than failing the accept
|
||||
}
|
||||
|
||||
// Find the file containing this session's markers
|
||||
const found = findSessionFile(id, process.cwd());
|
||||
if (!found) {
|
||||
@@ -78,7 +85,7 @@ Output (JSON):
|
||||
const result = handleDiscard(id, lines, targetFile);
|
||||
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
|
||||
} else {
|
||||
const result = handleAccept(id, variantNum, lines, targetFile);
|
||||
const result = handleAccept(id, variantNum, lines, targetFile, paramValues);
|
||||
// Single-line attention-grabber when cleanup is required. The full
|
||||
// five-step checklist lives in reference/live.md (loaded once per
|
||||
// session); repeating it per-event would waste tokens.
|
||||
@@ -116,7 +123,7 @@ function handleDiscard(id, lines, targetFile) {
|
||||
// Accept
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function handleAccept(id, variantNum, lines, targetFile) {
|
||||
function handleAccept(id, variantNum, lines, targetFile, paramValues) {
|
||||
const block = findMarkerBlock(id, lines);
|
||||
if (!block) return { handled: false, error: 'Markers not found' };
|
||||
|
||||
@@ -149,6 +156,11 @@ function handleAccept(id, variantNum, lines, targetFile) {
|
||||
replacement.push(indent + cssLine.trimStart());
|
||||
}
|
||||
replacement.push(indent + '</style>');
|
||||
if (paramValues && Object.keys(paramValues).length > 0) {
|
||||
// Preserve the user's knob positions for the carbonize-cleanup agent
|
||||
// to bake into the final CSS when it collapses scoped rules.
|
||||
replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
|
||||
}
|
||||
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,10 +30,13 @@
|
||||
// Design tokens
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Brand magenta is pinned to the site token (--color-accent in main.css)
|
||||
// so Accept / knobs / cycle-dots match the site's accent, not a washed
|
||||
// theme-adjusted one.
|
||||
const C = {
|
||||
brand: 'oklch(55% 0.25 350)',
|
||||
brandHov: 'oklch(48% 0.25 350)',
|
||||
brandSoft: 'oklch(55% 0.25 350 / 0.12)',
|
||||
brand: 'oklch(60% 0.25 350)',
|
||||
brandHov: 'oklch(52% 0.25 350)',
|
||||
brandSoft: 'oklch(60% 0.25 350 / 0.15)',
|
||||
ink: 'oklch(15% 0.01 350)',
|
||||
ash: 'oklch(55% 0 0)',
|
||||
paper: 'oklch(98% 0.005 350 / 0.92)',
|
||||
@@ -60,6 +63,25 @@
|
||||
'html', 'head', 'body', 'script', 'style', 'link', 'meta', 'noscript', 'br', 'wbr',
|
||||
]);
|
||||
|
||||
// SVG icons stack above each chip label. All strokes use currentColor so the
|
||||
// icon recolors to C.brand when its chip is selected. 20x20 render, 24-viewBox,
|
||||
// 1.5 stroke — visually consistent with the Foundation grid on the homepage.
|
||||
const ICON_ATTRS = 'width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="display:block"';
|
||||
const ICONS = {
|
||||
impeccable: `<svg ${ICON_ATTRS}><path d="M4 20l4-1L18 9l-3-3L5 16z"/><path d="M14 7l3 3"/></svg>`,
|
||||
bolder: `<svg ${ICON_ATTRS}><rect x="6" y="12" width="4" height="7" rx="0.5"/><rect x="14" y="5" width="4" height="14" rx="0.5"/></svg>`,
|
||||
quieter: `<svg ${ICON_ATTRS}><rect x="6" y="5" width="4" height="14" rx="0.5"/><rect x="14" y="12" width="4" height="7" rx="0.5"/></svg>`,
|
||||
distill: `<svg ${ICON_ATTRS}><path d="M4 5h16l-6 8v7l-4-2v-5z"/></svg>`,
|
||||
polish: `<svg ${ICON_ATTRS}><path d="M15 3l1 3 3 1-3 1-1 3-1-3-3-1 3-1z"/><path d="M7 13l0.6 1.8 1.8 0.6-1.8 0.6-0.6 1.8-0.6-1.8-1.8-0.6 1.8-0.6z"/></svg>`,
|
||||
typeset: `<svg ${ICON_ATTRS}><path d="M5 6h14" stroke-width="2.6"/><path d="M5 12h9" stroke-width="1.9"/><path d="M5 18h5" stroke-width="1.3"/></svg>`,
|
||||
colorize: `<svg ${ICON_ATTRS}><circle cx="9" cy="10" r="5"/><circle cx="15" cy="10" r="5"/><circle cx="12" cy="15" r="5"/></svg>`,
|
||||
layout: `<svg ${ICON_ATTRS}><rect x="3" y="4" width="8" height="16" rx="0.5"/><rect x="13" y="4" width="8" height="7" rx="0.5"/><rect x="13" y="13" width="8" height="7" rx="0.5"/></svg>`,
|
||||
adapt: `<svg ${ICON_ATTRS}><rect x="2.5" y="5" width="12" height="11" rx="1"/><line x1="2.5" y1="19" x2="14.5" y2="19"/><rect x="16.5" y="8" width="5" height="11" rx="1"/></svg>`,
|
||||
animate: `<svg ${ICON_ATTRS}><path d="M3 18c4-4 6-10 10-10"/><path d="M13 8c3 0 5 5 8 10"/><circle cx="13" cy="8" r="1.6" fill="currentColor" stroke="none"/></svg>`,
|
||||
delight: `<svg ${ICON_ATTRS}><path d="M12 3l2 6 6 2-6 2-2 6-2-6-6-2 6-2z"/></svg>`,
|
||||
overdrive: `<svg ${ICON_ATTRS}><path d="M13 3L5 13h5l-1 8 9-12h-6z"/></svg>`,
|
||||
};
|
||||
|
||||
const ACTIONS = [
|
||||
{ value: 'impeccable', label: 'Freeform' },
|
||||
{ value: 'bolder', label: 'Bolder' },
|
||||
@@ -756,7 +778,21 @@
|
||||
// The Bar — one floating element, three modes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Contextual-bar palette. Cached at init so every build*Row reads a
|
||||
// consistent set of colors; detectPageTheme runs once rather than on every
|
||||
// phase transition.
|
||||
let BP = null;
|
||||
|
||||
// Bar shadow variants. The default projects down + subtle around. When
|
||||
// the Tune popover opens below the bar, a downward shadow lands on the
|
||||
// dark popover and reads as a bright ghost line. We swap to UP-only while
|
||||
// tune is open below so the popover's top edge is clean.
|
||||
const BAR_SHADOW_DEFAULT = '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)';
|
||||
const BAR_SHADOW_UP = '0 -4px 20px oklch(0% 0 0 / 0.08), 0 -1px 3px oklch(0% 0 0 / 0.06)';
|
||||
const BAR_SHADOW_DOWN = BAR_SHADOW_DEFAULT;
|
||||
|
||||
function initBar() {
|
||||
BP = barPaletteForTheme(detectPageTheme());
|
||||
barEl = document.createElement('div');
|
||||
barEl.id = PREFIX + '-bar';
|
||||
Object.assign(barEl.style, {
|
||||
@@ -764,15 +800,15 @@
|
||||
display: 'none', opacity: '0',
|
||||
transform: 'translateY(6px)',
|
||||
transition: 'opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
|
||||
// Visual
|
||||
background: C.paper,
|
||||
background: BP.surface,
|
||||
backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
|
||||
border: '1px solid ' + C.mist,
|
||||
border: '1px solid ' + BP.hairline,
|
||||
borderRadius: '10px',
|
||||
boxShadow: '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)',
|
||||
fontFamily: FONT, fontSize: '13px', color: C.ink,
|
||||
boxShadow: BAR_SHADOW_DEFAULT,
|
||||
transition: 'box-shadow 0.2s ease, opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
|
||||
fontFamily: FONT, fontSize: '13px', color: BP.text,
|
||||
padding: '6px',
|
||||
maxWidth: '460px', minWidth: '320px',
|
||||
maxWidth: '520px', minWidth: '320px',
|
||||
});
|
||||
document.body.appendChild(barEl);
|
||||
}
|
||||
@@ -824,14 +860,15 @@
|
||||
barEl.style.transform = 'translateY(6px)';
|
||||
setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250);
|
||||
hideActionPicker();
|
||||
closeTunePopover();
|
||||
}
|
||||
|
||||
function updateBarContent(mode) {
|
||||
if (!barEl || barEl.style.display === 'none') return;
|
||||
barEl.innerHTML = '';
|
||||
// Reset bar styling
|
||||
barEl.style.background = C.paper;
|
||||
barEl.style.border = '1px solid ' + C.mist;
|
||||
// Reset bar styling to the theme-aware palette
|
||||
barEl.style.background = BP.surface;
|
||||
barEl.style.border = '1px solid ' + BP.hairline;
|
||||
if (mode === 'configure') barEl.appendChild(buildConfigureRow());
|
||||
else if (mode === 'generating') barEl.appendChild(buildGeneratingRow());
|
||||
else if (mode === 'cycling') barEl.appendChild(buildCyclingRow());
|
||||
@@ -854,15 +891,15 @@
|
||||
const pill = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
||||
padding: '5px 10px', borderRadius: '6px',
|
||||
background: C.ink, color: C.white,
|
||||
background: BP.mark, color: BP.markText,
|
||||
fontFamily: FONT, fontSize: '12px', fontWeight: '500',
|
||||
border: 'none', cursor: 'pointer',
|
||||
transition: 'background 0.12s ease, transform 0.1s ease',
|
||||
whiteSpace: 'nowrap', flexShrink: '0',
|
||||
});
|
||||
pill.textContent = actionLabel() + ' \u25BE';
|
||||
pill.addEventListener('mouseenter', () => pill.style.background = C.brandHov);
|
||||
pill.addEventListener('mouseleave', () => pill.style.background = C.ink);
|
||||
pill.addEventListener('mouseenter', () => pill.style.background = BP.accent);
|
||||
pill.addEventListener('mouseleave', () => pill.style.background = BP.mark);
|
||||
pill.addEventListener('mousedown', () => pill.style.transform = 'scale(0.97)');
|
||||
pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)');
|
||||
pill.addEventListener('click', (e) => { e.stopPropagation(); toggleActionPicker(); });
|
||||
@@ -877,13 +914,13 @@
|
||||
flex: '1', minWidth: '0',
|
||||
padding: '5px 8px', borderRadius: '6px',
|
||||
border: '1px solid transparent', background: 'transparent',
|
||||
fontFamily: FONT, fontSize: '12px', color: C.ink,
|
||||
fontFamily: FONT, fontSize: '12px', color: BP.text,
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.15s ease, background 0.15s ease',
|
||||
});
|
||||
input.addEventListener('focus', () => {
|
||||
input.style.borderColor = C.mist;
|
||||
input.style.background = C.white;
|
||||
input.style.borderColor = BP.hairline;
|
||||
input.style.background = BP.accentSoft;
|
||||
});
|
||||
input.addEventListener('blur', () => {
|
||||
input.style.borderColor = 'transparent';
|
||||
@@ -901,16 +938,16 @@
|
||||
// Variant count toggle
|
||||
const count = el('button', {
|
||||
padding: '4px 6px', borderRadius: '5px',
|
||||
border: '1px solid ' + C.mist, background: 'transparent',
|
||||
border: '1px solid ' + BP.hairline, background: 'transparent',
|
||||
fontFamily: MONO, fontSize: '11px', fontWeight: '600',
|
||||
color: C.ash, cursor: 'pointer',
|
||||
color: BP.textDim, cursor: 'pointer',
|
||||
transition: 'color 0.12s ease, border-color 0.12s ease',
|
||||
flexShrink: '0', whiteSpace: 'nowrap',
|
||||
});
|
||||
count.textContent = '\u00D7' + selectedCount;
|
||||
count.title = 'Variants: click to change';
|
||||
count.addEventListener('mouseenter', () => { count.style.color = C.ink; count.style.borderColor = C.ink; });
|
||||
count.addEventListener('mouseleave', () => { count.style.color = C.ash; count.style.borderColor = C.mist; });
|
||||
count.addEventListener('mouseenter', () => { count.style.color = BP.text; count.style.borderColor = BP.text; });
|
||||
count.addEventListener('mouseleave', () => { count.style.color = BP.textDim; count.style.borderColor = BP.hairline; });
|
||||
count.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1;
|
||||
@@ -921,15 +958,15 @@
|
||||
// Go button
|
||||
const go = el('button', {
|
||||
padding: '5px 12px', borderRadius: '6px',
|
||||
border: 'none', background: C.brand, color: C.white,
|
||||
border: 'none', background: BP.accent, color: BP.mark,
|
||||
fontFamily: FONT, fontSize: '12px', fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.12s ease, transform 0.1s ease',
|
||||
transition: 'filter 0.12s ease, transform 0.1s ease',
|
||||
flexShrink: '0', whiteSpace: 'nowrap',
|
||||
});
|
||||
go.textContent = 'Go \u2192';
|
||||
go.addEventListener('mouseenter', () => go.style.background = C.brandHov);
|
||||
go.addEventListener('mouseleave', () => go.style.background = C.brand);
|
||||
go.addEventListener('mouseenter', () => go.style.filter = 'brightness(1.1)');
|
||||
go.addEventListener('mouseleave', () => go.style.filter = 'none');
|
||||
go.addEventListener('mousedown', () => go.style.transform = 'scale(0.97)');
|
||||
go.addEventListener('mouseup', () => go.style.transform = 'scale(1)');
|
||||
go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); });
|
||||
@@ -950,7 +987,7 @@
|
||||
|
||||
// Action label
|
||||
const label = el('span', {
|
||||
fontWeight: '600', fontSize: '12px', color: C.ink,
|
||||
fontWeight: '600', fontSize: '12px', color: BP.text,
|
||||
flexShrink: '0', whiteSpace: 'nowrap',
|
||||
});
|
||||
label.textContent = actionLabel();
|
||||
@@ -961,7 +998,7 @@
|
||||
|
||||
// Status
|
||||
const status = el('span', {
|
||||
fontSize: '11px', color: C.ash, whiteSpace: 'nowrap',
|
||||
fontSize: '11px', color: BP.textDim, whiteSpace: 'nowrap',
|
||||
marginLeft: 'auto',
|
||||
});
|
||||
// Variants currently arrive atomically in a single file edit, so a
|
||||
@@ -976,6 +1013,8 @@
|
||||
|
||||
// --- Cycling row ---
|
||||
|
||||
const TUNE_ICON_SVG = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" style="flex-shrink:0"><line x1="4" y1="8" x2="20" y2="8"/><circle cx="14" cy="8" r="2.4" fill="currentColor" stroke="none"/><line x1="4" y1="16" x2="20" y2="16"/><circle cx="10" cy="16" r="2.4" fill="currentColor" stroke="none"/></svg>';
|
||||
|
||||
function buildCyclingRow() {
|
||||
const row = el('div', {
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
@@ -994,7 +1033,7 @@
|
||||
// Counter
|
||||
const counter = el('span', {
|
||||
fontFamily: MONO, fontSize: '11px', fontWeight: '500',
|
||||
color: C.ash, minWidth: '24px', textAlign: 'center',
|
||||
color: BP.textDim, minWidth: '24px', textAlign: 'center',
|
||||
});
|
||||
counter.textContent = visibleVariant + '/' + arrivedVariants;
|
||||
row.appendChild(counter);
|
||||
@@ -1005,20 +1044,67 @@
|
||||
if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
|
||||
row.appendChild(next);
|
||||
|
||||
// Tune chip — only when the visible variant exposes params
|
||||
const visParams = parseVariantParams(getVisibleVariantEl());
|
||||
const hasParams = visParams.length > 0;
|
||||
if (hasParams) {
|
||||
const tune = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '6px',
|
||||
padding: '4px 10px', borderRadius: '5px',
|
||||
border: '1px solid transparent',
|
||||
background: tuneOpen ? BP.accentSoft : 'transparent',
|
||||
color: tuneOpen ? BP.accent : BP.text,
|
||||
fontFamily: FONT, fontSize: '11px', fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'color 0.12s ease, background 0.12s ease',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
tune.innerHTML = TUNE_ICON_SVG;
|
||||
const tuneLabel = document.createElement('span');
|
||||
tuneLabel.textContent = 'Tune';
|
||||
tune.appendChild(tuneLabel);
|
||||
const tuneBadge = document.createElement('span');
|
||||
Object.assign(tuneBadge.style, {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
minWidth: '16px', height: '16px', padding: '0 4px',
|
||||
borderRadius: '999px',
|
||||
background: tuneOpen ? C.brand : BP.hairline,
|
||||
color: tuneOpen ? 'oklch(98% 0 0)' : 'inherit',
|
||||
fontFamily: MONO, fontSize: '9.5px', fontWeight: '600',
|
||||
lineHeight: '1',
|
||||
boxSizing: 'border-box',
|
||||
});
|
||||
tuneBadge.textContent = String(visParams.length);
|
||||
tune.appendChild(tuneBadge);
|
||||
tune.title = 'Tune this variant (' + visParams.length + ' knob' + (visParams.length === 1 ? '' : 's') + ')';
|
||||
tune.addEventListener('mouseenter', () => {
|
||||
if (!tuneOpen) tune.style.background = BP.accentSoft;
|
||||
});
|
||||
tune.addEventListener('mouseleave', () => {
|
||||
if (!tuneOpen) tune.style.background = 'transparent';
|
||||
});
|
||||
tune.addEventListener('click', (e) => { e.stopPropagation(); toggleTunePopover(); });
|
||||
tune.dataset.iceqTune = '1';
|
||||
row.appendChild(tune);
|
||||
}
|
||||
|
||||
// Spacer
|
||||
row.appendChild(el('div', { flex: '1' }));
|
||||
|
||||
// Accept
|
||||
// Accept — primary action, uses the site's saturated brand magenta
|
||||
// with paper-white text, not the theme-muted BP.accent.
|
||||
const accept = el('button', {
|
||||
padding: '4px 10px', borderRadius: '5px',
|
||||
border: 'none', background: C.ink, color: C.white,
|
||||
padding: '5px 14px', borderRadius: '5px',
|
||||
border: 'none', background: C.brand, color: 'oklch(98% 0 0)',
|
||||
fontFamily: FONT, fontSize: '11px', fontWeight: '600',
|
||||
cursor: 'pointer', transition: 'background 0.12s ease',
|
||||
cursor: 'pointer', transition: 'filter 0.12s ease, transform 0.1s ease',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
accept.textContent = '\u2713 Accept';
|
||||
accept.addEventListener('mouseenter', () => accept.style.background = C.brand);
|
||||
accept.addEventListener('mouseleave', () => accept.style.background = C.ink);
|
||||
accept.addEventListener('mouseenter', () => accept.style.filter = 'brightness(1.08)');
|
||||
accept.addEventListener('mouseleave', () => accept.style.filter = 'none');
|
||||
accept.addEventListener('mousedown', () => accept.style.transform = 'scale(0.97)');
|
||||
accept.addEventListener('mouseup', () => accept.style.transform = 'scale(1)');
|
||||
accept.addEventListener('click', (e) => { e.stopPropagation(); handleAccept(); });
|
||||
if (arrivedVariants === 0) { accept.style.opacity = '0.3'; accept.style.pointerEvents = 'none'; }
|
||||
row.appendChild(accept);
|
||||
@@ -1026,14 +1112,14 @@
|
||||
// Discard
|
||||
const discard = el('button', {
|
||||
padding: '4px 6px', borderRadius: '5px',
|
||||
border: '1px solid ' + C.mist, background: 'transparent',
|
||||
fontFamily: FONT, fontSize: '11px', color: C.ash,
|
||||
border: '1px solid ' + BP.hairline, background: 'transparent',
|
||||
fontFamily: FONT, fontSize: '11px', color: BP.textDim,
|
||||
cursor: 'pointer', transition: 'color 0.12s ease, border-color 0.12s ease',
|
||||
});
|
||||
discard.textContent = '\u2715';
|
||||
discard.title = 'Discard all variants';
|
||||
discard.addEventListener('mouseenter', () => { discard.style.color = C.ink; discard.style.borderColor = C.ink; });
|
||||
discard.addEventListener('mouseleave', () => { discard.style.color = C.ash; discard.style.borderColor = C.mist; });
|
||||
discard.addEventListener('mouseenter', () => { discard.style.color = BP.text; discard.style.borderColor = BP.text; });
|
||||
discard.addEventListener('mouseleave', () => { discard.style.color = BP.textDim; discard.style.borderColor = BP.hairline; });
|
||||
discard.addEventListener('click', (e) => { e.stopPropagation(); handleDiscard(); });
|
||||
row.appendChild(discard);
|
||||
|
||||
@@ -1051,14 +1137,14 @@
|
||||
});
|
||||
const spinner = el('div', {
|
||||
width: '14px', height: '14px', borderRadius: '50%',
|
||||
border: '2px solid ' + C.mist,
|
||||
borderTopColor: C.brand,
|
||||
border: '2px solid ' + BP.hairline,
|
||||
borderTopColor: BP.accent,
|
||||
animation: 'impeccable-spin 0.6s linear infinite',
|
||||
flexShrink: '0',
|
||||
});
|
||||
row.appendChild(spinner);
|
||||
const label = el('span', {
|
||||
fontSize: '12px', color: C.ash, fontWeight: '500',
|
||||
fontSize: '12px', color: BP.textDim, fontWeight: '500',
|
||||
});
|
||||
label.textContent = 'Applying variant...';
|
||||
row.appendChild(label);
|
||||
@@ -1103,14 +1189,26 @@
|
||||
for (let i = 1; i <= expectedVariants; i++) {
|
||||
const arrived = i <= arrivedVariants;
|
||||
const active = i === visibleVariant;
|
||||
// active: solid site-brand magenta dot. arrived+inactive: muted neutral.
|
||||
// pending (not yet arrived): faint outline ring. No borders on arrived
|
||||
// dots — the previous "accent ring + ash fill" combo read as noisy
|
||||
// magenta chips, especially when all variants had arrived and every
|
||||
// dot wore an accent ring.
|
||||
const dotBg = active ? C.brand
|
||||
: arrived ? BP.textDim
|
||||
: 'transparent';
|
||||
const dotBorder = arrived ? 'none' : '1.5px solid ' + BP.hairline;
|
||||
const dot = el('div', {
|
||||
width: '7px', height: '7px', borderRadius: '50%',
|
||||
background: active ? C.brand : (arrived ? C.ash : 'transparent'),
|
||||
border: '1.5px solid ' + (arrived ? C.brand : C.mist),
|
||||
width: active ? '8px' : '6px',
|
||||
height: active ? '8px' : '6px',
|
||||
borderRadius: '50%',
|
||||
background: dotBg,
|
||||
border: dotBorder,
|
||||
boxSizing: 'border-box',
|
||||
transition: 'all 0.2s ' + EASE,
|
||||
cursor: (clickable && arrived) ? 'pointer' : 'default',
|
||||
transform: arrived ? 'scale(1)' : 'scale(0.6)',
|
||||
opacity: arrived ? '1' : '0.4',
|
||||
transform: arrived ? 'scale(1)' : 'scale(0.85)',
|
||||
opacity: arrived ? (active ? '1' : '0.6') : '0.4',
|
||||
});
|
||||
if (clickable && arrived) {
|
||||
const idx = i;
|
||||
@@ -1130,15 +1228,15 @@
|
||||
function navBtn(text) {
|
||||
const b = el('button', {
|
||||
width: '26px', height: '26px', borderRadius: '5px',
|
||||
border: '1px solid ' + C.mist, background: 'transparent',
|
||||
color: C.ink, fontFamily: FONT, fontSize: '13px',
|
||||
border: '1px solid ' + BP.hairline, background: 'transparent',
|
||||
color: BP.text, fontFamily: FONT, fontSize: '13px',
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'border-color 0.12s ease, background 0.12s ease',
|
||||
padding: '0', lineHeight: '1',
|
||||
});
|
||||
b.textContent = text;
|
||||
b.addEventListener('mouseenter', () => { b.style.borderColor = C.ink; });
|
||||
b.addEventListener('mouseleave', () => { b.style.borderColor = C.mist; });
|
||||
b.addEventListener('mouseenter', () => { b.style.borderColor = BP.text; });
|
||||
b.addEventListener('mouseleave', () => { b.style.borderColor = BP.hairline; });
|
||||
return b;
|
||||
}
|
||||
|
||||
@@ -1158,6 +1256,7 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initActionPicker() {
|
||||
const P = barPaletteForTheme(detectPageTheme());
|
||||
pickerEl = document.createElement('div');
|
||||
pickerEl.id = PREFIX + '-picker';
|
||||
Object.assign(pickerEl.style, {
|
||||
@@ -1166,12 +1265,14 @@
|
||||
transform: 'scale(0.96) translateY(4px)',
|
||||
transformOrigin: 'bottom left',
|
||||
transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE,
|
||||
background: C.paperSolid,
|
||||
border: '1px solid ' + C.mist,
|
||||
background: P.surface,
|
||||
border: '1px solid ' + P.hairline,
|
||||
borderRadius: '10px',
|
||||
boxShadow: '0 8px 30px oklch(0% 0 0 / 0.10), 0 2px 6px oklch(0% 0 0 / 0.06)',
|
||||
padding: '6px',
|
||||
fontFamily: FONT,
|
||||
backdropFilter: 'blur(10px)',
|
||||
WebkitBackdropFilter: 'blur(10px)',
|
||||
});
|
||||
|
||||
// Build the chip grid
|
||||
@@ -1181,22 +1282,32 @@
|
||||
|
||||
ACTIONS.forEach(action => {
|
||||
const chip = el('button', {
|
||||
padding: '6px 8px', borderRadius: '6px',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '8px 6px', borderRadius: '6px',
|
||||
border: 'none',
|
||||
background: action.value === selectedAction ? C.brandSoft : 'transparent',
|
||||
color: action.value === selectedAction ? C.brand : C.ink,
|
||||
background: action.value === selectedAction ? P.accentSoft : 'transparent',
|
||||
color: action.value === selectedAction ? P.accent : P.text,
|
||||
fontFamily: FONT, fontSize: '11px', fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.1s ease, color 0.1s ease',
|
||||
textAlign: 'center', whiteSpace: 'nowrap',
|
||||
});
|
||||
chip.textContent = action.label;
|
||||
const iconWrap = el('span', {
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
height: '20px', opacity: '0.9',
|
||||
});
|
||||
iconWrap.innerHTML = ICONS[action.value] || '';
|
||||
const labelEl = el('span', { lineHeight: '1' });
|
||||
labelEl.textContent = action.label;
|
||||
chip.appendChild(iconWrap);
|
||||
chip.appendChild(labelEl);
|
||||
chip.dataset.action = action.value;
|
||||
chip.addEventListener('mouseenter', () => {
|
||||
if (action.value !== selectedAction) chip.style.background = C.brandSoft;
|
||||
if (action.value !== selectedAction) chip.style.background = P.accentSoft;
|
||||
});
|
||||
chip.addEventListener('mouseleave', () => {
|
||||
chip.style.background = action.value === selectedAction ? C.brandSoft : 'transparent';
|
||||
chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent';
|
||||
});
|
||||
chip.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -1209,19 +1320,24 @@
|
||||
|
||||
pickerEl.appendChild(grid);
|
||||
document.body.appendChild(pickerEl);
|
||||
|
||||
// Cache the palette on the picker so toggleActionPicker's state refresh
|
||||
// uses the same theme-aware colors when it repaints chips.
|
||||
pickerEl.__iceq_palette = P;
|
||||
}
|
||||
|
||||
function toggleActionPicker() {
|
||||
if (pickerEl.style.display !== 'none') { hideActionPicker(); return; }
|
||||
// Rebuild chips to reflect current selection
|
||||
const P = pickerEl.__iceq_palette || barPaletteForTheme(detectPageTheme());
|
||||
pickerEl.querySelectorAll('button').forEach(chip => {
|
||||
const isActive = chip.dataset.action === selectedAction;
|
||||
chip.style.background = isActive ? C.brandSoft : 'transparent';
|
||||
chip.style.color = isActive ? C.brand : C.ink;
|
||||
chip.style.background = isActive ? P.accentSoft : 'transparent';
|
||||
chip.style.color = isActive ? P.accent : P.text;
|
||||
});
|
||||
// Position above the bar
|
||||
const barRect = barEl.getBoundingClientRect();
|
||||
const pickerH = 90; // approximate
|
||||
const pickerH = 170; // approximate; grows with icon + label rows
|
||||
let top = barRect.top - pickerH - 6;
|
||||
if (top < 8) top = barRect.bottom + 6;
|
||||
Object.assign(pickerEl.style, {
|
||||
@@ -1241,6 +1357,399 @@
|
||||
setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Params panel (per-variant coarse controls)
|
||||
//
|
||||
// Variants may declare a parameter manifest via a JSON attribute on the
|
||||
// variant wrapper:
|
||||
//
|
||||
// <div data-impeccable-variant="1"
|
||||
// data-impeccable-params='[{"id":"density","kind":"steps",...}]'>
|
||||
//
|
||||
// The panel docks to the right edge of the outline during CYCLING and
|
||||
// exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped
|
||||
// CSS can respond instantly without regeneration:
|
||||
//
|
||||
// range / numeric toggle → CSS var (`--p-<id>`) used via var(--p-foo, N)
|
||||
// steps / boolean toggle → data-p-<id> attribute used via :scope[data-p-foo="..."]
|
||||
//
|
||||
// On variant switch, values reset to that variant's declared defaults.
|
||||
// On accept, current values are sent in the event payload so the agent
|
||||
// can bake them into the source-file write.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide)
|
||||
let paramsPanelInner = null; // translating content (carries bg, padding, knobs)
|
||||
let paramsPanelBody = null; // grid holding the knob cells
|
||||
let paramsCurrentValues = {}; // {paramId: value} — mirror of the visible variant's live values
|
||||
let tuneOpen = false; // whether the Tune popover is open right now
|
||||
|
||||
// Theme-aware Tune popover. Appears as a drawer that slides out from the
|
||||
// contextual bar's bar-facing edge (below if the bar sits below the
|
||||
// element, above otherwise). Same width as the bar. Auto-wraps to extra
|
||||
// rows when the knobs exceed one row. The bar's border-radius on the
|
||||
// popover side goes flat while open so the two shapes read as one.
|
||||
let paramsPanelPalette = null;
|
||||
|
||||
function initParamsPanel() {
|
||||
paramsPanelPalette = barPaletteForTheme(detectPageTheme());
|
||||
const P = paramsPanelPalette;
|
||||
|
||||
// Single element, always in the DOM. The slide animation is a CSS mask
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis — no
|
||||
// display toggle, no opacity toggle, no transform trickery. The mask
|
||||
// hides everything initially; as it grows, content is revealed from
|
||||
// the bar edge outward.
|
||||
paramsPanelEl = document.createElement('div');
|
||||
paramsPanelEl.id = PREFIX + '-params-panel';
|
||||
Object.assign(paramsPanelEl.style, {
|
||||
position: 'fixed', zIndex: String(Z.bar - 1),
|
||||
background: P.surfaceDeep,
|
||||
color: P.text,
|
||||
fontFamily: FONT,
|
||||
padding: '14px 18px',
|
||||
boxSizing: 'border-box',
|
||||
borderRadius: '0 0 10px 10px',
|
||||
pointerEvents: 'none',
|
||||
backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
|
||||
|
||||
// clip-path is the same conceptual reveal as mask but with rock-solid
|
||||
// transition support across engines. Closed state clips from the far
|
||||
// edge; open = inset(0) shows everything.
|
||||
clipPath: 'inset(0 0 100% 0)',
|
||||
transition: 'clip-path 0.44s ' + EASE,
|
||||
|
||||
// Park off-screen until positionParamsPanel places it. These are NOT
|
||||
// in the transition list, so they snap instantly — no fly-in from the
|
||||
// top-left when first shown.
|
||||
top: '-9999px', left: '-9999px', width: '0',
|
||||
});
|
||||
|
||||
paramsPanelBody = el('div', {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
|
||||
gap: '12px 16px',
|
||||
});
|
||||
|
||||
paramsPanelEl.appendChild(paramsPanelBody);
|
||||
document.body.appendChild(paramsPanelEl);
|
||||
paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
|
||||
}
|
||||
|
||||
function getVisibleVariantEl() {
|
||||
if (!currentSessionId) return null;
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
|
||||
function parseVariantParams(variantEl) {
|
||||
if (!variantEl) return [];
|
||||
const raw = variantEl.getAttribute('data-impeccable-params');
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] Invalid data-impeccable-params JSON:', err.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function applyParamValue(variantEl, param, value) {
|
||||
if (!variantEl) return;
|
||||
const attr = 'data-p-' + param.id;
|
||||
if (param.kind === 'range') {
|
||||
variantEl.style.setProperty('--p-' + param.id, String(value));
|
||||
} else if (param.kind === 'toggle') {
|
||||
const on = !!value;
|
||||
variantEl.style.setProperty('--p-' + param.id, on ? '1' : '0');
|
||||
if (on) variantEl.setAttribute(attr, 'on');
|
||||
else variantEl.removeAttribute(attr);
|
||||
} else if (param.kind === 'steps') {
|
||||
variantEl.setAttribute(attr, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
function applyParamDefaults(variantEl, params) {
|
||||
paramsCurrentValues = {};
|
||||
for (const p of params) {
|
||||
paramsCurrentValues[p.id] = p.default;
|
||||
applyParamValue(variantEl, p, p.default);
|
||||
}
|
||||
}
|
||||
|
||||
function formatRangeValue(input) {
|
||||
const max = parseFloat(input.max), min = parseFloat(input.min);
|
||||
const v = parseFloat(input.value);
|
||||
if (!isFinite(v)) return input.value;
|
||||
return (max - min) <= 2 ? v.toFixed(2) : String(Math.round(v));
|
||||
}
|
||||
|
||||
function buildParamsPanel(variantEl, params) {
|
||||
const P = paramsPanelPalette || barPaletteForTheme(detectPageTheme());
|
||||
paramsPanelBody.innerHTML = '';
|
||||
for (const p of params) {
|
||||
const row = el('div', { display: 'flex', flexDirection: 'column', gap: '6px' });
|
||||
const labelRow = el('div', {
|
||||
display: 'flex', justifyContent: 'space-between',
|
||||
alignItems: 'baseline', gap: '8px',
|
||||
});
|
||||
const lbl = el('span', {
|
||||
fontSize: '10.5px', fontWeight: '600', color: P.text,
|
||||
letterSpacing: '0.03em',
|
||||
});
|
||||
lbl.textContent = p.label || p.id;
|
||||
labelRow.appendChild(lbl);
|
||||
const readout = el('span', {
|
||||
fontSize: '10.5px', color: P.textDim,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
});
|
||||
labelRow.appendChild(readout);
|
||||
row.appendChild(labelRow);
|
||||
|
||||
if (p.kind === 'range') {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'range';
|
||||
input.min = String(p.min != null ? p.min : 0);
|
||||
input.max = String(p.max != null ? p.max : 1);
|
||||
input.step = String(p.step != null ? p.step : 0.05);
|
||||
input.value = String(p.default);
|
||||
Object.assign(input.style, {
|
||||
width: '100%', accentColor: C.brand, cursor: 'pointer',
|
||||
});
|
||||
readout.textContent = formatRangeValue(input);
|
||||
input.addEventListener('input', (e) => {
|
||||
e.stopPropagation();
|
||||
const v = parseFloat(input.value);
|
||||
paramsCurrentValues[p.id] = v;
|
||||
readout.textContent = formatRangeValue(input);
|
||||
applyParamValue(variantEl, p, v);
|
||||
});
|
||||
row.appendChild(input);
|
||||
} else if (p.kind === 'toggle') {
|
||||
const initial = !!p.default;
|
||||
readout.textContent = initial ? 'On' : 'Off';
|
||||
const track = el('button', {
|
||||
position: 'relative', width: '36px', height: '20px',
|
||||
borderRadius: '10px', border: 'none', padding: '0',
|
||||
cursor: 'pointer',
|
||||
background: initial ? C.brand : P.hairline,
|
||||
transition: 'background 0.15s ease',
|
||||
alignSelf: 'flex-start',
|
||||
});
|
||||
const knob = el('span', {
|
||||
position: 'absolute', top: '2px',
|
||||
left: initial ? '18px' : '2px',
|
||||
width: '16px', height: '16px', borderRadius: '50%',
|
||||
background: 'oklch(98% 0 0)',
|
||||
transition: 'left 0.18s ' + EASE,
|
||||
boxShadow: '0 1px 2px oklch(0% 0 0 / 0.2)',
|
||||
});
|
||||
track.appendChild(knob);
|
||||
track.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const next = !paramsCurrentValues[p.id];
|
||||
paramsCurrentValues[p.id] = next;
|
||||
track.style.background = next ? C.brand : P.hairline;
|
||||
knob.style.left = next ? '18px' : '2px';
|
||||
readout.textContent = next ? 'On' : 'Off';
|
||||
applyParamValue(variantEl, p, next);
|
||||
});
|
||||
row.appendChild(track);
|
||||
} else if (p.kind === 'steps') {
|
||||
const opts = (p.options || []).map(o =>
|
||||
typeof o === 'string' ? { value: o, label: o } : o
|
||||
);
|
||||
const activeOpt = opts.find(o => o.value === p.default) || opts[0];
|
||||
readout.textContent = activeOpt ? activeOpt.label : String(p.default);
|
||||
const segRow = el('div', {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(' + opts.length + ', 1fr)',
|
||||
gap: '1px', padding: '2px',
|
||||
background: P.hairline, borderRadius: '5px',
|
||||
});
|
||||
const segBtns = [];
|
||||
opts.forEach(o => {
|
||||
const active = o.value === p.default;
|
||||
const b = el('button', {
|
||||
padding: '5px 4px', border: 'none', borderRadius: '3px',
|
||||
background: active ? C.brand : 'transparent',
|
||||
color: active ? 'oklch(98% 0 0)' : P.text,
|
||||
fontFamily: FONT, fontSize: '10.5px', fontWeight: '500',
|
||||
cursor: 'pointer', whiteSpace: 'nowrap',
|
||||
transition: 'background 0.1s ease, color 0.1s ease',
|
||||
});
|
||||
b.textContent = o.label;
|
||||
b.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
paramsCurrentValues[p.id] = o.value;
|
||||
readout.textContent = o.label;
|
||||
segBtns.forEach(({ btn, val }) => {
|
||||
const on = val === o.value;
|
||||
btn.style.background = on ? C.brand : 'transparent';
|
||||
btn.style.color = on ? 'oklch(98% 0 0)' : P.text;
|
||||
});
|
||||
applyParamValue(variantEl, p, o.value);
|
||||
});
|
||||
segRow.appendChild(b);
|
||||
segBtns.push({ btn: b, val: o.value });
|
||||
});
|
||||
row.appendChild(segRow);
|
||||
}
|
||||
|
||||
paramsPanelBody.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
// Decide which way the popover opens: away from the picked element. If the
|
||||
// bar landed below the element, popover slides DOWN from the bar's bottom.
|
||||
// If the bar landed above, popover slides UP from the bar's top.
|
||||
function popoverDirection() {
|
||||
if (!barEl || !selectedElement) return 'below';
|
||||
const br = barEl.getBoundingClientRect();
|
||||
const er = selectedElement.getBoundingClientRect();
|
||||
return br.top >= er.bottom - 4 ? 'below' : 'above';
|
||||
}
|
||||
|
||||
// The popover overlaps the bar by OVERLAP px on the bar-facing side. With
|
||||
// popover z-index below bar, that overlap sits behind bar (invisible) and
|
||||
// reinforces the "tucked behind" feel. Padding compensates so the real
|
||||
// content starts flush with bar's outer edge.
|
||||
const TUNE_OVERLAP = 6;
|
||||
|
||||
// Closed clip-path depends on direction: for 'below' clip from the far
|
||||
// (bottom) edge so the reveal grows downward from the bar; for 'above'
|
||||
// clip from the top edge so the reveal grows upward from the bar.
|
||||
function closedClipPath(direction) {
|
||||
return direction === 'below' ? 'inset(0 0 100% 0)' : 'inset(100% 0 0 0)';
|
||||
}
|
||||
|
||||
function setClipPath(value, withTransition) {
|
||||
const saved = paramsPanelEl.style.transition;
|
||||
if (!withTransition) paramsPanelEl.style.transition = 'none';
|
||||
paramsPanelEl.style.clipPath = value;
|
||||
if (!withTransition) {
|
||||
void paramsPanelEl.offsetHeight;
|
||||
paramsPanelEl.style.transition = saved;
|
||||
}
|
||||
}
|
||||
|
||||
function positionParamsPanel() {
|
||||
if (!paramsPanelEl || !barEl || barEl.style.display === 'none') return;
|
||||
const br = barEl.getBoundingClientRect();
|
||||
const direction = popoverDirection();
|
||||
const prevDirection = paramsPanelEl.dataset.tuneDirection;
|
||||
|
||||
// top/left/width are NOT in the transition list, so they snap instantly.
|
||||
paramsPanelEl.style.left = br.left + 'px';
|
||||
paramsPanelEl.style.width = br.width + 'px';
|
||||
|
||||
if (direction === 'below') {
|
||||
paramsPanelEl.style.top = (br.bottom - TUNE_OVERLAP) + 'px';
|
||||
paramsPanelEl.style.borderRadius = '0 0 10px 10px';
|
||||
paramsPanelEl.style.paddingTop = (14 + TUNE_OVERLAP) + 'px';
|
||||
paramsPanelEl.style.paddingBottom = '14px';
|
||||
} else {
|
||||
const ih = paramsPanelEl.offsetHeight || 80;
|
||||
paramsPanelEl.style.top = (br.top - ih + TUNE_OVERLAP) + 'px';
|
||||
paramsPanelEl.style.borderRadius = '10px 10px 0 0';
|
||||
paramsPanelEl.style.paddingTop = '14px';
|
||||
paramsPanelEl.style.paddingBottom = (14 + TUNE_OVERLAP) + 'px';
|
||||
}
|
||||
paramsPanelEl.dataset.tuneDirection = direction;
|
||||
|
||||
// If currently closed and direction flipped (or first-time setup),
|
||||
// snap the clip-path to the new direction's closed pose without
|
||||
// transitioning (so the clip doesn't slide across the element).
|
||||
if (!tuneOpen && (!prevDirection || prevDirection !== direction)) {
|
||||
setClipPath(closedClipPath(direction), false);
|
||||
}
|
||||
}
|
||||
|
||||
function showParamsPanel() {
|
||||
if (!paramsPanelEl) return;
|
||||
positionParamsPanel();
|
||||
paramsPanelEl.style.pointerEvents = 'auto';
|
||||
// rAF so the positioning paint commits before the transition fires.
|
||||
requestAnimationFrame(() => {
|
||||
setClipPath('inset(0 0 0 0)', true);
|
||||
});
|
||||
}
|
||||
|
||||
function hideParamsPanel() {
|
||||
if (!paramsPanelEl) return;
|
||||
paramsPanelEl.style.pointerEvents = 'none';
|
||||
const direction = paramsPanelEl.dataset.tuneDirection || 'below';
|
||||
setClipPath(closedClipPath(direction), true);
|
||||
}
|
||||
|
||||
// Build/rebuild the panel's contents for the current variant AND apply
|
||||
// its defaults to the variant wrapper (so scoped CSS responds even before
|
||||
// the user opens the popover). Visibility is governed by tuneOpen.
|
||||
function refreshParamsPanel() {
|
||||
if (state !== 'CYCLING') {
|
||||
paramsCurrentValues = {};
|
||||
tuneOpen = false;
|
||||
hideParamsPanel();
|
||||
return;
|
||||
}
|
||||
const variantEl = getVisibleVariantEl();
|
||||
const params = parseVariantParams(variantEl);
|
||||
if (!variantEl || params.length === 0) {
|
||||
paramsCurrentValues = {};
|
||||
tuneOpen = false;
|
||||
hideParamsPanel();
|
||||
return;
|
||||
}
|
||||
applyParamDefaults(variantEl, params);
|
||||
buildParamsPanel(variantEl, params);
|
||||
if (tuneOpen) {
|
||||
// If already visible (variant cycled while open), refresh in place
|
||||
// instead of re-running the clip-path animation.
|
||||
const alreadyVisible = paramsPanelEl.style.display === 'block'
|
||||
&& paramsPanelEl.style.opacity === '1';
|
||||
if (alreadyVisible) positionParamsPanel();
|
||||
else showParamsPanel();
|
||||
} else {
|
||||
hideParamsPanel();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTunePopover() {
|
||||
if (tuneOpen) { closeTunePopover(); return; }
|
||||
openTunePopover();
|
||||
}
|
||||
|
||||
function openTunePopover() {
|
||||
if (state !== 'CYCLING') return;
|
||||
const variantEl = getVisibleVariantEl();
|
||||
const params = parseVariantParams(variantEl);
|
||||
if (!variantEl || params.length === 0) return;
|
||||
// Build fresh to ensure the current variant's controls are shown.
|
||||
applyParamDefaults(variantEl, params);
|
||||
buildParamsPanel(variantEl, params);
|
||||
tuneOpen = true;
|
||||
showParamsPanel();
|
||||
// Kill the bar's shadow on the popover-facing side so the dark popover
|
||||
// doesn't pick up a bright glow line.
|
||||
if (barEl) {
|
||||
const direction = paramsPanelEl?.dataset.tuneDirection || 'below';
|
||||
barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN;
|
||||
}
|
||||
// Re-render the bar so the Tune chip picks up the active styling.
|
||||
updateBarContent('cycling');
|
||||
}
|
||||
|
||||
function closeTunePopover() {
|
||||
tuneOpen = false;
|
||||
hideParamsPanel();
|
||||
if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT;
|
||||
if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') {
|
||||
updateBarContent('cycling');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variant cycling in DOM
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1253,6 +1762,10 @@
|
||||
if (!v) continue;
|
||||
child.style.display = (v === String(num)) ? '' : 'none';
|
||||
}
|
||||
// Unconditional refresh — covers first-reveal (no-op if state isn't
|
||||
// CYCLING yet, the subsequent CYCLING transition triggers its own
|
||||
// refresh) and every cycle step.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1316,6 +1829,7 @@
|
||||
state = 'CYCLING';
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
refreshParamsPanel();
|
||||
saveSession();
|
||||
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
|
||||
})
|
||||
@@ -1329,7 +1843,7 @@
|
||||
const next = visibleVariant + dir;
|
||||
if (next < 1 || next > arrivedVariants) return;
|
||||
visibleVariant = next;
|
||||
showVariantInDOM(currentSessionId, next);
|
||||
showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself
|
||||
updateSelectedElement();
|
||||
updateBarContent('cycling');
|
||||
saveSession();
|
||||
@@ -1540,6 +2054,7 @@
|
||||
state = 'CYCLING';
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
refreshParamsPanel();
|
||||
} else if (state === 'GENERATING') {
|
||||
updateBarContent('generating');
|
||||
}
|
||||
@@ -1560,6 +2075,7 @@
|
||||
if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') {
|
||||
positionBar();
|
||||
showHighlight(selectedElement);
|
||||
if (tuneOpen) positionParamsPanel();
|
||||
}
|
||||
if (annotActive) positionAnnotOverlay(selectedElement);
|
||||
// Shader overlay (via debug P toggle or generation) is repositioned
|
||||
@@ -1606,6 +2122,7 @@
|
||||
if (state === 'GENERATING') {
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
refreshParamsPanel();
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -1691,6 +2208,10 @@
|
||||
if (pickerEl?.style.display !== 'none' && !own(e.target)) {
|
||||
hideActionPicker();
|
||||
}
|
||||
// Close Tune popover on outside click (anything outside panel + bar)
|
||||
if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) {
|
||||
closeTunePopover();
|
||||
}
|
||||
// In CONFIGURING: click outside the bar and selected element returns to PICKING
|
||||
if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) {
|
||||
hideBar();
|
||||
@@ -2258,7 +2779,11 @@ void main() {
|
||||
|
||||
function handleAccept() {
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
sendEvent({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) });
|
||||
const acceptPayload = { type: 'accept', id: currentSessionId, variantId: String(visibleVariant) };
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
sendEvent(acceptPayload);
|
||||
markSessionHandled();
|
||||
|
||||
// The accepted variant is already the only visible child of the wrapper
|
||||
@@ -2477,6 +3002,11 @@ void main() {
|
||||
state = arrivedVariants >= expectedVariants ? 'CYCLING' : 'GENERATING';
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -2522,6 +3052,11 @@ void main() {
|
||||
// dark pages. This keeps the bar from fighting with the host design.
|
||||
function detectPageTheme() {
|
||||
try {
|
||||
// Dev override: set localStorage 'impeccable-dev-theme' to 'light' or
|
||||
// 'dark' to preview the opposite palette without actually changing the
|
||||
// page bg. Used for screenshots and theme QA.
|
||||
const override = localStorage.getItem('impeccable-dev-theme');
|
||||
if (override === 'light' || override === 'dark') return override;
|
||||
const bg = getComputedStyle(document.body).backgroundColor
|
||||
|| getComputedStyle(document.documentElement).backgroundColor;
|
||||
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
||||
@@ -2538,6 +3073,7 @@ void main() {
|
||||
// Light bar on dark page
|
||||
return {
|
||||
surface: 'oklch(98% 0 0 / 0.92)',
|
||||
surfaceDeep: 'oklch(92% 0.005 60 / 0.96)', // slightly deeper, faint warm
|
||||
hairline: 'oklch(70% 0 0 / 0.35)',
|
||||
text: 'oklch(15% 0 0)',
|
||||
textDim: 'oklch(45% 0 0)',
|
||||
@@ -2552,6 +3088,7 @@ void main() {
|
||||
// deeper so the rounded-right shape reads as a clear sculpted mark.
|
||||
return {
|
||||
surface: 'oklch(26% 0 0 / 0.94)',
|
||||
surfaceDeep: 'oklch(18% 0 0 / 0.96)', // darker sand for Tune popover
|
||||
hairline: 'oklch(42% 0 0 / 0.5)',
|
||||
text: 'oklch(96% 0 0)',
|
||||
textDim: 'oklch(72% 0 0)',
|
||||
@@ -2883,6 +3420,7 @@ void main() {
|
||||
if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; }
|
||||
if (barEl) { barEl.remove(); barEl = null; }
|
||||
if (pickerEl) { pickerEl.remove(); pickerEl = null; }
|
||||
if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; }
|
||||
if (evtSource) { evtSource.close(); evtSource = null; }
|
||||
document.removeEventListener('mousemove', handleMouseMove, true);
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
@@ -3956,6 +4494,7 @@ void main() {
|
||||
initAnnotOverlay();
|
||||
initBar();
|
||||
initActionPicker();
|
||||
initParamsPanel();
|
||||
initGlobalBar();
|
||||
initDesignPanel();
|
||||
document.addEventListener('mousemove', handleMouseMove, true);
|
||||
|
||||
@@ -145,6 +145,11 @@ Options:
|
||||
const scriptArgs = event.type === 'discard'
|
||||
? ['--id', event.id, '--discard']
|
||||
: ['--id', event.id, '--variant', event.variantId];
|
||||
if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
|
||||
// Pass through a JSON blob; the shell-safe wrap uses single quotes because
|
||||
// values are finite {id, number|string|boolean} pairs from a validated payload.
|
||||
scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
|
||||
}
|
||||
try {
|
||||
const out = execSync(
|
||||
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
|
||||
|
||||
@@ -147,6 +147,11 @@ function validateEvent(msg) {
|
||||
case 'accept':
|
||||
if (!msg.id) return 'accept: missing id';
|
||||
if (!msg.variantId) return 'accept: missing variantId';
|
||||
if (msg.paramValues !== undefined) {
|
||||
if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
|
||||
return 'accept: paramValues must be an object';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
case 'discard':
|
||||
return msg.id ? null : 'discard: missing id';
|
||||
|
||||
@@ -142,3 +142,13 @@ Test that colorization improves the experience:
|
||||
- **Not overwhelming**: Is color balanced and purposeful?
|
||||
|
||||
Remember: Color is emotional and powerful. Use it to create warmth, guide attention, communicate meaning, and express personality. But restraint and strategy matter more than saturation and variety. Be colorful, but be intentional.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
When invoked from live mode, each variant MUST declare a `color-amount` param so the user can dial between a restrained accent and a drenched surface without regeneration. Author the variant's CSS against `var(--p-color-amount, 0.5)` — typically as the alpha multiplier on backgrounds, or as a scaling factor on the chroma axis in an OKLCH expression. 0 = neutral/monochrome, 1 = full saturation / dominant coverage.
|
||||
|
||||
```json
|
||||
{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"}
|
||||
```
|
||||
|
||||
Layer 1-2 variant-specific params on top: palette selection (`steps` with named options), temperature warmth, or tint vs. true color. See `reference/live.md` for the full params contract.
|
||||
|
||||
@@ -119,3 +119,23 @@ Create a systematic plan:
|
||||
- **Responsiveness**: Does the layout adapt gracefully across screen sizes?
|
||||
|
||||
Remember: Space is the most underused design tool. A layout with the right rhythm and hierarchy can make even simple content feel polished and intentional.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
Each variant MUST declare a `density` param. Drive all spacing tokens in the variant's scoped CSS through `calc(var(--p-density, 1) * <base>)` — paddings, gaps, column widths. Users slide from airy to packed and see layout re-breathe with no regeneration.
|
||||
|
||||
```json
|
||||
{"id":"density","kind":"range","min":0.6,"max":1.4,"step":0.05,"default":1,"label":"Density"}
|
||||
```
|
||||
|
||||
For variants whose topology genuinely changes (stacked vs. side-by-side, grid vs. bento), use a `steps` param whose scoped CSS branches via `:scope[data-p-structure="X"]`. One structure param + one density param is a powerful combo; resist adding a third.
|
||||
|
||||
```json
|
||||
{"id":"structure","kind":"steps","default":"grid","label":"Structure","options":[
|
||||
{"value":"stacked","label":"Stacked"},
|
||||
{"value":"grid","label":"Grid"},
|
||||
{"value":"bento","label":"Bento"}
|
||||
]}
|
||||
```
|
||||
|
||||
See `reference/live.md` for the full params contract.
|
||||
|
||||
@@ -179,7 +179,56 @@ The first variant has no `display: none` (visible by default). All others do. If
|
||||
|
||||
One edit, all variants — the browser's MutationObserver picks everything up in one pass.
|
||||
|
||||
### 7. Signal done
|
||||
### 7. Parameters (optional, 2-5 per variant)
|
||||
|
||||
Each variant can expose coarse knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against.
|
||||
|
||||
**When to use.** Any time the variant has a meaningful axis the user might want to dial in: color amount, density, motion intensity, scale ratio. Not micro-level margin tweaks; those defeat the point.
|
||||
|
||||
**Budget scales with the element's visual weight, not the user's curiosity.** Knobs need real estate to produce noticeably different output; slapping three on a small element just crowds the panel without making anything feel tunable.
|
||||
|
||||
- **Leaf / tiny** — a single button, icon, input, bare heading, solitary paragraph: **0 params.** A slider can't meaningfully reshape one element; just use variants.
|
||||
- **Small composition** — labeled input, simple card, short callout (≤ ~5 visual children): **0-1 params.** Only add one if it's a clear dominant axis (e.g. density on a card with visible internal rhythm).
|
||||
- **Medium composition** — section component, nav cluster, dense card, short feature block (6-15 visual children): **2 params.**
|
||||
- **Large composition** — hero section, full page region, spread layout, anything with strong internal structure (16+ visual children or multiple sub-sections): **3-4 params.**
|
||||
|
||||
When in doubt, fewer. The user can always ask for more variants to explore an axis you didn't expose as a knob. Count by visual children, not by DOM-node depth; a deeply-nested-but-visually-simple card still counts as small.
|
||||
|
||||
**How to declare.** Put a JSON manifest on the variant wrapper:
|
||||
|
||||
```html
|
||||
<div data-impeccable-variant="1" data-impeccable-params='[
|
||||
{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"},
|
||||
{"id":"density","kind":"steps","default":"snug","label":"Density","options":[
|
||||
{"value":"airy","label":"Airy"},
|
||||
{"value":"snug","label":"Snug"},
|
||||
{"value":"packed","label":"Packed"}
|
||||
]},
|
||||
{"id":"serif","kind":"toggle","default":false,"label":"Serif display"}
|
||||
]'>
|
||||
...variant content...
|
||||
</div>
|
||||
```
|
||||
|
||||
**Three kinds:**
|
||||
|
||||
- `range` — smooth slider. Drives a CSS custom property `--p-<id>` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`.
|
||||
- `steps` — segmented radio. Drives a data attribute `data-p-<id>` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`.
|
||||
- `toggle` — on/off switch. Drives BOTH a CSS var (`--p-<id>: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`.
|
||||
|
||||
**Signature params per action.** Each action has one or two signature params that MUST be exposed when the variant can meaningfully express them. Check the action's reference file for the list. Layer 1-2 variant-specific params on top.
|
||||
|
||||
**Reset on variant switch.** User dials density on v1, flips to v2, v2 starts at v2's declared defaults. Known limitation; preservation across variants may land later.
|
||||
|
||||
**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment:
|
||||
|
||||
```html
|
||||
<!-- impeccable-param-values SESSION_ID: {"color-amount":0.7,"density":"packed"} -->
|
||||
```
|
||||
|
||||
The carbonize cleanup step (see below) reads that comment and bakes the chosen values into the final CSS. For `steps`/`toggle` attribute selectors: keep only the branch matching the chosen value, drop the others, collapse `:scope[data-p-density="packed"] .grid` to a semantic class rule. For `range` vars: either substitute the literal or keep the var with the chosen value as its new default.
|
||||
|
||||
### 8. Signal done
|
||||
|
||||
```bash
|
||||
node .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 `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element.
|
||||
1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values — read it first; it drives steps 3 and 4 below.
|
||||
2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `public/css/workflow.css` for this repo, or the component's co-located CSS file for a Vite/Next project — pick whichever already owns styling for the surrounding element).
|
||||
3. **Rewrite `@scope ([data-impeccable-variant="N"])` selectors** to target real, semantic classes on the accepted HTML. Example: `@scope ([data-impeccable-variant="2"]) { .v2-label { … } }` becomes `.why-visual--v2 .v2-label { … }` if the accepted element already carries `.why-visual--v2`, or pick/add a suitable class if it doesn't.
|
||||
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it.
|
||||
5. **Delete the inline `<style>` block and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one — those are dead code now.
|
||||
3. **Bake in parameter values while rewriting selectors.** For `@scope ([data-impeccable-variant="N"])` wrappers: retarget to real, semantic classes on the accepted HTML (`.why-visual--v2 .v2-label { … }`). For `:scope[data-p-<id>="VALUE"]` selectors: keep only the branch matching the chosen value from the param-values comment; drop the others (they're dead after accept). For `var(--p-<id>, DEFAULT)` in the CSS: either substitute the literal value, or if the param is still useful as a knob going forward, leave the var and update its initial declaration to the chosen value.
|
||||
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it. Drop `data-impeccable-params` and any `data-p-*` attributes from it — those are live-mode plumbing, not source.
|
||||
5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one — those are dead code now.
|
||||
|
||||
Then poll again.
|
||||
|
||||
|
||||
@@ -110,3 +110,15 @@ Build a clear type scale:
|
||||
- **Accessibility**: Does text meet WCAG contrast ratios? Is it zoomable to 200%?
|
||||
|
||||
Remember: Typography is the foundation of interface design — it carries the majority of information. Getting it right is the highest-leverage improvement you can make.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
Each variant MUST declare a `scale` param controlling the hierarchy ratio. Express all font sizes in the variant's scoped CSS through `calc(var(--p-scale, 1) * <base>)` or, better, scale the type ramp via `clamp(min, calc(var(--p-scale, 1) * Npx), max)`. Users slide from subdued to commanding.
|
||||
|
||||
```json
|
||||
{"id":"scale","kind":"range","min":0.85,"max":1.3,"step":0.05,"default":1,"label":"Scale"}
|
||||
```
|
||||
|
||||
Where the variant riffs on a specific pairing, expose the pairing choice as a `steps` param (e.g. "serif display + sans body" vs. "mono display + sans body" vs. "all-sans"). Each branch routes through `:scope[data-p-pairing="X"]` selectors in scoped CSS.
|
||||
|
||||
See `reference/live.md` for the full params contract.
|
||||
|
||||
@@ -45,11 +45,18 @@ Output (JSON):
|
||||
|
||||
const id = argVal(args, '--id');
|
||||
const variantNum = argVal(args, '--variant');
|
||||
const paramValuesRaw = argVal(args, '--param-values');
|
||||
const isDiscard = args.includes('--discard');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); }
|
||||
|
||||
let paramValues = null;
|
||||
if (paramValuesRaw) {
|
||||
try { paramValues = JSON.parse(paramValuesRaw); }
|
||||
catch { paramValues = null; } // malformed blob: skip the comment rather than failing the accept
|
||||
}
|
||||
|
||||
// Find the file containing this session's markers
|
||||
const found = findSessionFile(id, process.cwd());
|
||||
if (!found) {
|
||||
@@ -78,7 +85,7 @@ Output (JSON):
|
||||
const result = handleDiscard(id, lines, targetFile);
|
||||
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
|
||||
} else {
|
||||
const result = handleAccept(id, variantNum, lines, targetFile);
|
||||
const result = handleAccept(id, variantNum, lines, targetFile, paramValues);
|
||||
// Single-line attention-grabber when cleanup is required. The full
|
||||
// five-step checklist lives in reference/live.md (loaded once per
|
||||
// session); repeating it per-event would waste tokens.
|
||||
@@ -116,7 +123,7 @@ function handleDiscard(id, lines, targetFile) {
|
||||
// Accept
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function handleAccept(id, variantNum, lines, targetFile) {
|
||||
function handleAccept(id, variantNum, lines, targetFile, paramValues) {
|
||||
const block = findMarkerBlock(id, lines);
|
||||
if (!block) return { handled: false, error: 'Markers not found' };
|
||||
|
||||
@@ -149,6 +156,11 @@ function handleAccept(id, variantNum, lines, targetFile) {
|
||||
replacement.push(indent + cssLine.trimStart());
|
||||
}
|
||||
replacement.push(indent + '</style>');
|
||||
if (paramValues && Object.keys(paramValues).length > 0) {
|
||||
// Preserve the user's knob positions for the carbonize-cleanup agent
|
||||
// to bake into the final CSS when it collapses scoped rules.
|
||||
replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
|
||||
}
|
||||
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,10 +30,13 @@
|
||||
// Design tokens
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Brand magenta is pinned to the site token (--color-accent in main.css)
|
||||
// so Accept / knobs / cycle-dots match the site's accent, not a washed
|
||||
// theme-adjusted one.
|
||||
const C = {
|
||||
brand: 'oklch(55% 0.25 350)',
|
||||
brandHov: 'oklch(48% 0.25 350)',
|
||||
brandSoft: 'oklch(55% 0.25 350 / 0.12)',
|
||||
brand: 'oklch(60% 0.25 350)',
|
||||
brandHov: 'oklch(52% 0.25 350)',
|
||||
brandSoft: 'oklch(60% 0.25 350 / 0.15)',
|
||||
ink: 'oklch(15% 0.01 350)',
|
||||
ash: 'oklch(55% 0 0)',
|
||||
paper: 'oklch(98% 0.005 350 / 0.92)',
|
||||
@@ -60,6 +63,25 @@
|
||||
'html', 'head', 'body', 'script', 'style', 'link', 'meta', 'noscript', 'br', 'wbr',
|
||||
]);
|
||||
|
||||
// SVG icons stack above each chip label. All strokes use currentColor so the
|
||||
// icon recolors to C.brand when its chip is selected. 20x20 render, 24-viewBox,
|
||||
// 1.5 stroke — visually consistent with the Foundation grid on the homepage.
|
||||
const ICON_ATTRS = 'width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="display:block"';
|
||||
const ICONS = {
|
||||
impeccable: `<svg ${ICON_ATTRS}><path d="M4 20l4-1L18 9l-3-3L5 16z"/><path d="M14 7l3 3"/></svg>`,
|
||||
bolder: `<svg ${ICON_ATTRS}><rect x="6" y="12" width="4" height="7" rx="0.5"/><rect x="14" y="5" width="4" height="14" rx="0.5"/></svg>`,
|
||||
quieter: `<svg ${ICON_ATTRS}><rect x="6" y="5" width="4" height="14" rx="0.5"/><rect x="14" y="12" width="4" height="7" rx="0.5"/></svg>`,
|
||||
distill: `<svg ${ICON_ATTRS}><path d="M4 5h16l-6 8v7l-4-2v-5z"/></svg>`,
|
||||
polish: `<svg ${ICON_ATTRS}><path d="M15 3l1 3 3 1-3 1-1 3-1-3-3-1 3-1z"/><path d="M7 13l0.6 1.8 1.8 0.6-1.8 0.6-0.6 1.8-0.6-1.8-1.8-0.6 1.8-0.6z"/></svg>`,
|
||||
typeset: `<svg ${ICON_ATTRS}><path d="M5 6h14" stroke-width="2.6"/><path d="M5 12h9" stroke-width="1.9"/><path d="M5 18h5" stroke-width="1.3"/></svg>`,
|
||||
colorize: `<svg ${ICON_ATTRS}><circle cx="9" cy="10" r="5"/><circle cx="15" cy="10" r="5"/><circle cx="12" cy="15" r="5"/></svg>`,
|
||||
layout: `<svg ${ICON_ATTRS}><rect x="3" y="4" width="8" height="16" rx="0.5"/><rect x="13" y="4" width="8" height="7" rx="0.5"/><rect x="13" y="13" width="8" height="7" rx="0.5"/></svg>`,
|
||||
adapt: `<svg ${ICON_ATTRS}><rect x="2.5" y="5" width="12" height="11" rx="1"/><line x1="2.5" y1="19" x2="14.5" y2="19"/><rect x="16.5" y="8" width="5" height="11" rx="1"/></svg>`,
|
||||
animate: `<svg ${ICON_ATTRS}><path d="M3 18c4-4 6-10 10-10"/><path d="M13 8c3 0 5 5 8 10"/><circle cx="13" cy="8" r="1.6" fill="currentColor" stroke="none"/></svg>`,
|
||||
delight: `<svg ${ICON_ATTRS}><path d="M12 3l2 6 6 2-6 2-2 6-2-6-6-2 6-2z"/></svg>`,
|
||||
overdrive: `<svg ${ICON_ATTRS}><path d="M13 3L5 13h5l-1 8 9-12h-6z"/></svg>`,
|
||||
};
|
||||
|
||||
const ACTIONS = [
|
||||
{ value: 'impeccable', label: 'Freeform' },
|
||||
{ value: 'bolder', label: 'Bolder' },
|
||||
@@ -756,7 +778,21 @@
|
||||
// The Bar — one floating element, three modes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Contextual-bar palette. Cached at init so every build*Row reads a
|
||||
// consistent set of colors; detectPageTheme runs once rather than on every
|
||||
// phase transition.
|
||||
let BP = null;
|
||||
|
||||
// Bar shadow variants. The default projects down + subtle around. When
|
||||
// the Tune popover opens below the bar, a downward shadow lands on the
|
||||
// dark popover and reads as a bright ghost line. We swap to UP-only while
|
||||
// tune is open below so the popover's top edge is clean.
|
||||
const BAR_SHADOW_DEFAULT = '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)';
|
||||
const BAR_SHADOW_UP = '0 -4px 20px oklch(0% 0 0 / 0.08), 0 -1px 3px oklch(0% 0 0 / 0.06)';
|
||||
const BAR_SHADOW_DOWN = BAR_SHADOW_DEFAULT;
|
||||
|
||||
function initBar() {
|
||||
BP = barPaletteForTheme(detectPageTheme());
|
||||
barEl = document.createElement('div');
|
||||
barEl.id = PREFIX + '-bar';
|
||||
Object.assign(barEl.style, {
|
||||
@@ -764,15 +800,15 @@
|
||||
display: 'none', opacity: '0',
|
||||
transform: 'translateY(6px)',
|
||||
transition: 'opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
|
||||
// Visual
|
||||
background: C.paper,
|
||||
background: BP.surface,
|
||||
backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
|
||||
border: '1px solid ' + C.mist,
|
||||
border: '1px solid ' + BP.hairline,
|
||||
borderRadius: '10px',
|
||||
boxShadow: '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)',
|
||||
fontFamily: FONT, fontSize: '13px', color: C.ink,
|
||||
boxShadow: BAR_SHADOW_DEFAULT,
|
||||
transition: 'box-shadow 0.2s ease, opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
|
||||
fontFamily: FONT, fontSize: '13px', color: BP.text,
|
||||
padding: '6px',
|
||||
maxWidth: '460px', minWidth: '320px',
|
||||
maxWidth: '520px', minWidth: '320px',
|
||||
});
|
||||
document.body.appendChild(barEl);
|
||||
}
|
||||
@@ -824,14 +860,15 @@
|
||||
barEl.style.transform = 'translateY(6px)';
|
||||
setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250);
|
||||
hideActionPicker();
|
||||
closeTunePopover();
|
||||
}
|
||||
|
||||
function updateBarContent(mode) {
|
||||
if (!barEl || barEl.style.display === 'none') return;
|
||||
barEl.innerHTML = '';
|
||||
// Reset bar styling
|
||||
barEl.style.background = C.paper;
|
||||
barEl.style.border = '1px solid ' + C.mist;
|
||||
// Reset bar styling to the theme-aware palette
|
||||
barEl.style.background = BP.surface;
|
||||
barEl.style.border = '1px solid ' + BP.hairline;
|
||||
if (mode === 'configure') barEl.appendChild(buildConfigureRow());
|
||||
else if (mode === 'generating') barEl.appendChild(buildGeneratingRow());
|
||||
else if (mode === 'cycling') barEl.appendChild(buildCyclingRow());
|
||||
@@ -854,15 +891,15 @@
|
||||
const pill = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
||||
padding: '5px 10px', borderRadius: '6px',
|
||||
background: C.ink, color: C.white,
|
||||
background: BP.mark, color: BP.markText,
|
||||
fontFamily: FONT, fontSize: '12px', fontWeight: '500',
|
||||
border: 'none', cursor: 'pointer',
|
||||
transition: 'background 0.12s ease, transform 0.1s ease',
|
||||
whiteSpace: 'nowrap', flexShrink: '0',
|
||||
});
|
||||
pill.textContent = actionLabel() + ' \u25BE';
|
||||
pill.addEventListener('mouseenter', () => pill.style.background = C.brandHov);
|
||||
pill.addEventListener('mouseleave', () => pill.style.background = C.ink);
|
||||
pill.addEventListener('mouseenter', () => pill.style.background = BP.accent);
|
||||
pill.addEventListener('mouseleave', () => pill.style.background = BP.mark);
|
||||
pill.addEventListener('mousedown', () => pill.style.transform = 'scale(0.97)');
|
||||
pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)');
|
||||
pill.addEventListener('click', (e) => { e.stopPropagation(); toggleActionPicker(); });
|
||||
@@ -877,13 +914,13 @@
|
||||
flex: '1', minWidth: '0',
|
||||
padding: '5px 8px', borderRadius: '6px',
|
||||
border: '1px solid transparent', background: 'transparent',
|
||||
fontFamily: FONT, fontSize: '12px', color: C.ink,
|
||||
fontFamily: FONT, fontSize: '12px', color: BP.text,
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.15s ease, background 0.15s ease',
|
||||
});
|
||||
input.addEventListener('focus', () => {
|
||||
input.style.borderColor = C.mist;
|
||||
input.style.background = C.white;
|
||||
input.style.borderColor = BP.hairline;
|
||||
input.style.background = BP.accentSoft;
|
||||
});
|
||||
input.addEventListener('blur', () => {
|
||||
input.style.borderColor = 'transparent';
|
||||
@@ -901,16 +938,16 @@
|
||||
// Variant count toggle
|
||||
const count = el('button', {
|
||||
padding: '4px 6px', borderRadius: '5px',
|
||||
border: '1px solid ' + C.mist, background: 'transparent',
|
||||
border: '1px solid ' + BP.hairline, background: 'transparent',
|
||||
fontFamily: MONO, fontSize: '11px', fontWeight: '600',
|
||||
color: C.ash, cursor: 'pointer',
|
||||
color: BP.textDim, cursor: 'pointer',
|
||||
transition: 'color 0.12s ease, border-color 0.12s ease',
|
||||
flexShrink: '0', whiteSpace: 'nowrap',
|
||||
});
|
||||
count.textContent = '\u00D7' + selectedCount;
|
||||
count.title = 'Variants: click to change';
|
||||
count.addEventListener('mouseenter', () => { count.style.color = C.ink; count.style.borderColor = C.ink; });
|
||||
count.addEventListener('mouseleave', () => { count.style.color = C.ash; count.style.borderColor = C.mist; });
|
||||
count.addEventListener('mouseenter', () => { count.style.color = BP.text; count.style.borderColor = BP.text; });
|
||||
count.addEventListener('mouseleave', () => { count.style.color = BP.textDim; count.style.borderColor = BP.hairline; });
|
||||
count.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1;
|
||||
@@ -921,15 +958,15 @@
|
||||
// Go button
|
||||
const go = el('button', {
|
||||
padding: '5px 12px', borderRadius: '6px',
|
||||
border: 'none', background: C.brand, color: C.white,
|
||||
border: 'none', background: BP.accent, color: BP.mark,
|
||||
fontFamily: FONT, fontSize: '12px', fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.12s ease, transform 0.1s ease',
|
||||
transition: 'filter 0.12s ease, transform 0.1s ease',
|
||||
flexShrink: '0', whiteSpace: 'nowrap',
|
||||
});
|
||||
go.textContent = 'Go \u2192';
|
||||
go.addEventListener('mouseenter', () => go.style.background = C.brandHov);
|
||||
go.addEventListener('mouseleave', () => go.style.background = C.brand);
|
||||
go.addEventListener('mouseenter', () => go.style.filter = 'brightness(1.1)');
|
||||
go.addEventListener('mouseleave', () => go.style.filter = 'none');
|
||||
go.addEventListener('mousedown', () => go.style.transform = 'scale(0.97)');
|
||||
go.addEventListener('mouseup', () => go.style.transform = 'scale(1)');
|
||||
go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); });
|
||||
@@ -950,7 +987,7 @@
|
||||
|
||||
// Action label
|
||||
const label = el('span', {
|
||||
fontWeight: '600', fontSize: '12px', color: C.ink,
|
||||
fontWeight: '600', fontSize: '12px', color: BP.text,
|
||||
flexShrink: '0', whiteSpace: 'nowrap',
|
||||
});
|
||||
label.textContent = actionLabel();
|
||||
@@ -961,7 +998,7 @@
|
||||
|
||||
// Status
|
||||
const status = el('span', {
|
||||
fontSize: '11px', color: C.ash, whiteSpace: 'nowrap',
|
||||
fontSize: '11px', color: BP.textDim, whiteSpace: 'nowrap',
|
||||
marginLeft: 'auto',
|
||||
});
|
||||
// Variants currently arrive atomically in a single file edit, so a
|
||||
@@ -976,6 +1013,8 @@
|
||||
|
||||
// --- Cycling row ---
|
||||
|
||||
const TUNE_ICON_SVG = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" style="flex-shrink:0"><line x1="4" y1="8" x2="20" y2="8"/><circle cx="14" cy="8" r="2.4" fill="currentColor" stroke="none"/><line x1="4" y1="16" x2="20" y2="16"/><circle cx="10" cy="16" r="2.4" fill="currentColor" stroke="none"/></svg>';
|
||||
|
||||
function buildCyclingRow() {
|
||||
const row = el('div', {
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
@@ -994,7 +1033,7 @@
|
||||
// Counter
|
||||
const counter = el('span', {
|
||||
fontFamily: MONO, fontSize: '11px', fontWeight: '500',
|
||||
color: C.ash, minWidth: '24px', textAlign: 'center',
|
||||
color: BP.textDim, minWidth: '24px', textAlign: 'center',
|
||||
});
|
||||
counter.textContent = visibleVariant + '/' + arrivedVariants;
|
||||
row.appendChild(counter);
|
||||
@@ -1005,20 +1044,67 @@
|
||||
if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
|
||||
row.appendChild(next);
|
||||
|
||||
// Tune chip — only when the visible variant exposes params
|
||||
const visParams = parseVariantParams(getVisibleVariantEl());
|
||||
const hasParams = visParams.length > 0;
|
||||
if (hasParams) {
|
||||
const tune = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '6px',
|
||||
padding: '4px 10px', borderRadius: '5px',
|
||||
border: '1px solid transparent',
|
||||
background: tuneOpen ? BP.accentSoft : 'transparent',
|
||||
color: tuneOpen ? BP.accent : BP.text,
|
||||
fontFamily: FONT, fontSize: '11px', fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'color 0.12s ease, background 0.12s ease',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
tune.innerHTML = TUNE_ICON_SVG;
|
||||
const tuneLabel = document.createElement('span');
|
||||
tuneLabel.textContent = 'Tune';
|
||||
tune.appendChild(tuneLabel);
|
||||
const tuneBadge = document.createElement('span');
|
||||
Object.assign(tuneBadge.style, {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
minWidth: '16px', height: '16px', padding: '0 4px',
|
||||
borderRadius: '999px',
|
||||
background: tuneOpen ? C.brand : BP.hairline,
|
||||
color: tuneOpen ? 'oklch(98% 0 0)' : 'inherit',
|
||||
fontFamily: MONO, fontSize: '9.5px', fontWeight: '600',
|
||||
lineHeight: '1',
|
||||
boxSizing: 'border-box',
|
||||
});
|
||||
tuneBadge.textContent = String(visParams.length);
|
||||
tune.appendChild(tuneBadge);
|
||||
tune.title = 'Tune this variant (' + visParams.length + ' knob' + (visParams.length === 1 ? '' : 's') + ')';
|
||||
tune.addEventListener('mouseenter', () => {
|
||||
if (!tuneOpen) tune.style.background = BP.accentSoft;
|
||||
});
|
||||
tune.addEventListener('mouseleave', () => {
|
||||
if (!tuneOpen) tune.style.background = 'transparent';
|
||||
});
|
||||
tune.addEventListener('click', (e) => { e.stopPropagation(); toggleTunePopover(); });
|
||||
tune.dataset.iceqTune = '1';
|
||||
row.appendChild(tune);
|
||||
}
|
||||
|
||||
// Spacer
|
||||
row.appendChild(el('div', { flex: '1' }));
|
||||
|
||||
// Accept
|
||||
// Accept — primary action, uses the site's saturated brand magenta
|
||||
// with paper-white text, not the theme-muted BP.accent.
|
||||
const accept = el('button', {
|
||||
padding: '4px 10px', borderRadius: '5px',
|
||||
border: 'none', background: C.ink, color: C.white,
|
||||
padding: '5px 14px', borderRadius: '5px',
|
||||
border: 'none', background: C.brand, color: 'oklch(98% 0 0)',
|
||||
fontFamily: FONT, fontSize: '11px', fontWeight: '600',
|
||||
cursor: 'pointer', transition: 'background 0.12s ease',
|
||||
cursor: 'pointer', transition: 'filter 0.12s ease, transform 0.1s ease',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
accept.textContent = '\u2713 Accept';
|
||||
accept.addEventListener('mouseenter', () => accept.style.background = C.brand);
|
||||
accept.addEventListener('mouseleave', () => accept.style.background = C.ink);
|
||||
accept.addEventListener('mouseenter', () => accept.style.filter = 'brightness(1.08)');
|
||||
accept.addEventListener('mouseleave', () => accept.style.filter = 'none');
|
||||
accept.addEventListener('mousedown', () => accept.style.transform = 'scale(0.97)');
|
||||
accept.addEventListener('mouseup', () => accept.style.transform = 'scale(1)');
|
||||
accept.addEventListener('click', (e) => { e.stopPropagation(); handleAccept(); });
|
||||
if (arrivedVariants === 0) { accept.style.opacity = '0.3'; accept.style.pointerEvents = 'none'; }
|
||||
row.appendChild(accept);
|
||||
@@ -1026,14 +1112,14 @@
|
||||
// Discard
|
||||
const discard = el('button', {
|
||||
padding: '4px 6px', borderRadius: '5px',
|
||||
border: '1px solid ' + C.mist, background: 'transparent',
|
||||
fontFamily: FONT, fontSize: '11px', color: C.ash,
|
||||
border: '1px solid ' + BP.hairline, background: 'transparent',
|
||||
fontFamily: FONT, fontSize: '11px', color: BP.textDim,
|
||||
cursor: 'pointer', transition: 'color 0.12s ease, border-color 0.12s ease',
|
||||
});
|
||||
discard.textContent = '\u2715';
|
||||
discard.title = 'Discard all variants';
|
||||
discard.addEventListener('mouseenter', () => { discard.style.color = C.ink; discard.style.borderColor = C.ink; });
|
||||
discard.addEventListener('mouseleave', () => { discard.style.color = C.ash; discard.style.borderColor = C.mist; });
|
||||
discard.addEventListener('mouseenter', () => { discard.style.color = BP.text; discard.style.borderColor = BP.text; });
|
||||
discard.addEventListener('mouseleave', () => { discard.style.color = BP.textDim; discard.style.borderColor = BP.hairline; });
|
||||
discard.addEventListener('click', (e) => { e.stopPropagation(); handleDiscard(); });
|
||||
row.appendChild(discard);
|
||||
|
||||
@@ -1051,14 +1137,14 @@
|
||||
});
|
||||
const spinner = el('div', {
|
||||
width: '14px', height: '14px', borderRadius: '50%',
|
||||
border: '2px solid ' + C.mist,
|
||||
borderTopColor: C.brand,
|
||||
border: '2px solid ' + BP.hairline,
|
||||
borderTopColor: BP.accent,
|
||||
animation: 'impeccable-spin 0.6s linear infinite',
|
||||
flexShrink: '0',
|
||||
});
|
||||
row.appendChild(spinner);
|
||||
const label = el('span', {
|
||||
fontSize: '12px', color: C.ash, fontWeight: '500',
|
||||
fontSize: '12px', color: BP.textDim, fontWeight: '500',
|
||||
});
|
||||
label.textContent = 'Applying variant...';
|
||||
row.appendChild(label);
|
||||
@@ -1103,14 +1189,26 @@
|
||||
for (let i = 1; i <= expectedVariants; i++) {
|
||||
const arrived = i <= arrivedVariants;
|
||||
const active = i === visibleVariant;
|
||||
// active: solid site-brand magenta dot. arrived+inactive: muted neutral.
|
||||
// pending (not yet arrived): faint outline ring. No borders on arrived
|
||||
// dots — the previous "accent ring + ash fill" combo read as noisy
|
||||
// magenta chips, especially when all variants had arrived and every
|
||||
// dot wore an accent ring.
|
||||
const dotBg = active ? C.brand
|
||||
: arrived ? BP.textDim
|
||||
: 'transparent';
|
||||
const dotBorder = arrived ? 'none' : '1.5px solid ' + BP.hairline;
|
||||
const dot = el('div', {
|
||||
width: '7px', height: '7px', borderRadius: '50%',
|
||||
background: active ? C.brand : (arrived ? C.ash : 'transparent'),
|
||||
border: '1.5px solid ' + (arrived ? C.brand : C.mist),
|
||||
width: active ? '8px' : '6px',
|
||||
height: active ? '8px' : '6px',
|
||||
borderRadius: '50%',
|
||||
background: dotBg,
|
||||
border: dotBorder,
|
||||
boxSizing: 'border-box',
|
||||
transition: 'all 0.2s ' + EASE,
|
||||
cursor: (clickable && arrived) ? 'pointer' : 'default',
|
||||
transform: arrived ? 'scale(1)' : 'scale(0.6)',
|
||||
opacity: arrived ? '1' : '0.4',
|
||||
transform: arrived ? 'scale(1)' : 'scale(0.85)',
|
||||
opacity: arrived ? (active ? '1' : '0.6') : '0.4',
|
||||
});
|
||||
if (clickable && arrived) {
|
||||
const idx = i;
|
||||
@@ -1130,15 +1228,15 @@
|
||||
function navBtn(text) {
|
||||
const b = el('button', {
|
||||
width: '26px', height: '26px', borderRadius: '5px',
|
||||
border: '1px solid ' + C.mist, background: 'transparent',
|
||||
color: C.ink, fontFamily: FONT, fontSize: '13px',
|
||||
border: '1px solid ' + BP.hairline, background: 'transparent',
|
||||
color: BP.text, fontFamily: FONT, fontSize: '13px',
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'border-color 0.12s ease, background 0.12s ease',
|
||||
padding: '0', lineHeight: '1',
|
||||
});
|
||||
b.textContent = text;
|
||||
b.addEventListener('mouseenter', () => { b.style.borderColor = C.ink; });
|
||||
b.addEventListener('mouseleave', () => { b.style.borderColor = C.mist; });
|
||||
b.addEventListener('mouseenter', () => { b.style.borderColor = BP.text; });
|
||||
b.addEventListener('mouseleave', () => { b.style.borderColor = BP.hairline; });
|
||||
return b;
|
||||
}
|
||||
|
||||
@@ -1158,6 +1256,7 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initActionPicker() {
|
||||
const P = barPaletteForTheme(detectPageTheme());
|
||||
pickerEl = document.createElement('div');
|
||||
pickerEl.id = PREFIX + '-picker';
|
||||
Object.assign(pickerEl.style, {
|
||||
@@ -1166,12 +1265,14 @@
|
||||
transform: 'scale(0.96) translateY(4px)',
|
||||
transformOrigin: 'bottom left',
|
||||
transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE,
|
||||
background: C.paperSolid,
|
||||
border: '1px solid ' + C.mist,
|
||||
background: P.surface,
|
||||
border: '1px solid ' + P.hairline,
|
||||
borderRadius: '10px',
|
||||
boxShadow: '0 8px 30px oklch(0% 0 0 / 0.10), 0 2px 6px oklch(0% 0 0 / 0.06)',
|
||||
padding: '6px',
|
||||
fontFamily: FONT,
|
||||
backdropFilter: 'blur(10px)',
|
||||
WebkitBackdropFilter: 'blur(10px)',
|
||||
});
|
||||
|
||||
// Build the chip grid
|
||||
@@ -1181,22 +1282,32 @@
|
||||
|
||||
ACTIONS.forEach(action => {
|
||||
const chip = el('button', {
|
||||
padding: '6px 8px', borderRadius: '6px',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '8px 6px', borderRadius: '6px',
|
||||
border: 'none',
|
||||
background: action.value === selectedAction ? C.brandSoft : 'transparent',
|
||||
color: action.value === selectedAction ? C.brand : C.ink,
|
||||
background: action.value === selectedAction ? P.accentSoft : 'transparent',
|
||||
color: action.value === selectedAction ? P.accent : P.text,
|
||||
fontFamily: FONT, fontSize: '11px', fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.1s ease, color 0.1s ease',
|
||||
textAlign: 'center', whiteSpace: 'nowrap',
|
||||
});
|
||||
chip.textContent = action.label;
|
||||
const iconWrap = el('span', {
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
height: '20px', opacity: '0.9',
|
||||
});
|
||||
iconWrap.innerHTML = ICONS[action.value] || '';
|
||||
const labelEl = el('span', { lineHeight: '1' });
|
||||
labelEl.textContent = action.label;
|
||||
chip.appendChild(iconWrap);
|
||||
chip.appendChild(labelEl);
|
||||
chip.dataset.action = action.value;
|
||||
chip.addEventListener('mouseenter', () => {
|
||||
if (action.value !== selectedAction) chip.style.background = C.brandSoft;
|
||||
if (action.value !== selectedAction) chip.style.background = P.accentSoft;
|
||||
});
|
||||
chip.addEventListener('mouseleave', () => {
|
||||
chip.style.background = action.value === selectedAction ? C.brandSoft : 'transparent';
|
||||
chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent';
|
||||
});
|
||||
chip.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -1209,19 +1320,24 @@
|
||||
|
||||
pickerEl.appendChild(grid);
|
||||
document.body.appendChild(pickerEl);
|
||||
|
||||
// Cache the palette on the picker so toggleActionPicker's state refresh
|
||||
// uses the same theme-aware colors when it repaints chips.
|
||||
pickerEl.__iceq_palette = P;
|
||||
}
|
||||
|
||||
function toggleActionPicker() {
|
||||
if (pickerEl.style.display !== 'none') { hideActionPicker(); return; }
|
||||
// Rebuild chips to reflect current selection
|
||||
const P = pickerEl.__iceq_palette || barPaletteForTheme(detectPageTheme());
|
||||
pickerEl.querySelectorAll('button').forEach(chip => {
|
||||
const isActive = chip.dataset.action === selectedAction;
|
||||
chip.style.background = isActive ? C.brandSoft : 'transparent';
|
||||
chip.style.color = isActive ? C.brand : C.ink;
|
||||
chip.style.background = isActive ? P.accentSoft : 'transparent';
|
||||
chip.style.color = isActive ? P.accent : P.text;
|
||||
});
|
||||
// Position above the bar
|
||||
const barRect = barEl.getBoundingClientRect();
|
||||
const pickerH = 90; // approximate
|
||||
const pickerH = 170; // approximate; grows with icon + label rows
|
||||
let top = barRect.top - pickerH - 6;
|
||||
if (top < 8) top = barRect.bottom + 6;
|
||||
Object.assign(pickerEl.style, {
|
||||
@@ -1241,6 +1357,399 @@
|
||||
setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Params panel (per-variant coarse controls)
|
||||
//
|
||||
// Variants may declare a parameter manifest via a JSON attribute on the
|
||||
// variant wrapper:
|
||||
//
|
||||
// <div data-impeccable-variant="1"
|
||||
// data-impeccable-params='[{"id":"density","kind":"steps",...}]'>
|
||||
//
|
||||
// The panel docks to the right edge of the outline during CYCLING and
|
||||
// exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped
|
||||
// CSS can respond instantly without regeneration:
|
||||
//
|
||||
// range / numeric toggle → CSS var (`--p-<id>`) used via var(--p-foo, N)
|
||||
// steps / boolean toggle → data-p-<id> attribute used via :scope[data-p-foo="..."]
|
||||
//
|
||||
// On variant switch, values reset to that variant's declared defaults.
|
||||
// On accept, current values are sent in the event payload so the agent
|
||||
// can bake them into the source-file write.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide)
|
||||
let paramsPanelInner = null; // translating content (carries bg, padding, knobs)
|
||||
let paramsPanelBody = null; // grid holding the knob cells
|
||||
let paramsCurrentValues = {}; // {paramId: value} — mirror of the visible variant's live values
|
||||
let tuneOpen = false; // whether the Tune popover is open right now
|
||||
|
||||
// Theme-aware Tune popover. Appears as a drawer that slides out from the
|
||||
// contextual bar's bar-facing edge (below if the bar sits below the
|
||||
// element, above otherwise). Same width as the bar. Auto-wraps to extra
|
||||
// rows when the knobs exceed one row. The bar's border-radius on the
|
||||
// popover side goes flat while open so the two shapes read as one.
|
||||
let paramsPanelPalette = null;
|
||||
|
||||
function initParamsPanel() {
|
||||
paramsPanelPalette = barPaletteForTheme(detectPageTheme());
|
||||
const P = paramsPanelPalette;
|
||||
|
||||
// Single element, always in the DOM. The slide animation is a CSS mask
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis — no
|
||||
// display toggle, no opacity toggle, no transform trickery. The mask
|
||||
// hides everything initially; as it grows, content is revealed from
|
||||
// the bar edge outward.
|
||||
paramsPanelEl = document.createElement('div');
|
||||
paramsPanelEl.id = PREFIX + '-params-panel';
|
||||
Object.assign(paramsPanelEl.style, {
|
||||
position: 'fixed', zIndex: String(Z.bar - 1),
|
||||
background: P.surfaceDeep,
|
||||
color: P.text,
|
||||
fontFamily: FONT,
|
||||
padding: '14px 18px',
|
||||
boxSizing: 'border-box',
|
||||
borderRadius: '0 0 10px 10px',
|
||||
pointerEvents: 'none',
|
||||
backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
|
||||
|
||||
// clip-path is the same conceptual reveal as mask but with rock-solid
|
||||
// transition support across engines. Closed state clips from the far
|
||||
// edge; open = inset(0) shows everything.
|
||||
clipPath: 'inset(0 0 100% 0)',
|
||||
transition: 'clip-path 0.44s ' + EASE,
|
||||
|
||||
// Park off-screen until positionParamsPanel places it. These are NOT
|
||||
// in the transition list, so they snap instantly — no fly-in from the
|
||||
// top-left when first shown.
|
||||
top: '-9999px', left: '-9999px', width: '0',
|
||||
});
|
||||
|
||||
paramsPanelBody = el('div', {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
|
||||
gap: '12px 16px',
|
||||
});
|
||||
|
||||
paramsPanelEl.appendChild(paramsPanelBody);
|
||||
document.body.appendChild(paramsPanelEl);
|
||||
paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
|
||||
}
|
||||
|
||||
function getVisibleVariantEl() {
|
||||
if (!currentSessionId) return null;
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
|
||||
function parseVariantParams(variantEl) {
|
||||
if (!variantEl) return [];
|
||||
const raw = variantEl.getAttribute('data-impeccable-params');
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] Invalid data-impeccable-params JSON:', err.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function applyParamValue(variantEl, param, value) {
|
||||
if (!variantEl) return;
|
||||
const attr = 'data-p-' + param.id;
|
||||
if (param.kind === 'range') {
|
||||
variantEl.style.setProperty('--p-' + param.id, String(value));
|
||||
} else if (param.kind === 'toggle') {
|
||||
const on = !!value;
|
||||
variantEl.style.setProperty('--p-' + param.id, on ? '1' : '0');
|
||||
if (on) variantEl.setAttribute(attr, 'on');
|
||||
else variantEl.removeAttribute(attr);
|
||||
} else if (param.kind === 'steps') {
|
||||
variantEl.setAttribute(attr, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
function applyParamDefaults(variantEl, params) {
|
||||
paramsCurrentValues = {};
|
||||
for (const p of params) {
|
||||
paramsCurrentValues[p.id] = p.default;
|
||||
applyParamValue(variantEl, p, p.default);
|
||||
}
|
||||
}
|
||||
|
||||
function formatRangeValue(input) {
|
||||
const max = parseFloat(input.max), min = parseFloat(input.min);
|
||||
const v = parseFloat(input.value);
|
||||
if (!isFinite(v)) return input.value;
|
||||
return (max - min) <= 2 ? v.toFixed(2) : String(Math.round(v));
|
||||
}
|
||||
|
||||
function buildParamsPanel(variantEl, params) {
|
||||
const P = paramsPanelPalette || barPaletteForTheme(detectPageTheme());
|
||||
paramsPanelBody.innerHTML = '';
|
||||
for (const p of params) {
|
||||
const row = el('div', { display: 'flex', flexDirection: 'column', gap: '6px' });
|
||||
const labelRow = el('div', {
|
||||
display: 'flex', justifyContent: 'space-between',
|
||||
alignItems: 'baseline', gap: '8px',
|
||||
});
|
||||
const lbl = el('span', {
|
||||
fontSize: '10.5px', fontWeight: '600', color: P.text,
|
||||
letterSpacing: '0.03em',
|
||||
});
|
||||
lbl.textContent = p.label || p.id;
|
||||
labelRow.appendChild(lbl);
|
||||
const readout = el('span', {
|
||||
fontSize: '10.5px', color: P.textDim,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
});
|
||||
labelRow.appendChild(readout);
|
||||
row.appendChild(labelRow);
|
||||
|
||||
if (p.kind === 'range') {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'range';
|
||||
input.min = String(p.min != null ? p.min : 0);
|
||||
input.max = String(p.max != null ? p.max : 1);
|
||||
input.step = String(p.step != null ? p.step : 0.05);
|
||||
input.value = String(p.default);
|
||||
Object.assign(input.style, {
|
||||
width: '100%', accentColor: C.brand, cursor: 'pointer',
|
||||
});
|
||||
readout.textContent = formatRangeValue(input);
|
||||
input.addEventListener('input', (e) => {
|
||||
e.stopPropagation();
|
||||
const v = parseFloat(input.value);
|
||||
paramsCurrentValues[p.id] = v;
|
||||
readout.textContent = formatRangeValue(input);
|
||||
applyParamValue(variantEl, p, v);
|
||||
});
|
||||
row.appendChild(input);
|
||||
} else if (p.kind === 'toggle') {
|
||||
const initial = !!p.default;
|
||||
readout.textContent = initial ? 'On' : 'Off';
|
||||
const track = el('button', {
|
||||
position: 'relative', width: '36px', height: '20px',
|
||||
borderRadius: '10px', border: 'none', padding: '0',
|
||||
cursor: 'pointer',
|
||||
background: initial ? C.brand : P.hairline,
|
||||
transition: 'background 0.15s ease',
|
||||
alignSelf: 'flex-start',
|
||||
});
|
||||
const knob = el('span', {
|
||||
position: 'absolute', top: '2px',
|
||||
left: initial ? '18px' : '2px',
|
||||
width: '16px', height: '16px', borderRadius: '50%',
|
||||
background: 'oklch(98% 0 0)',
|
||||
transition: 'left 0.18s ' + EASE,
|
||||
boxShadow: '0 1px 2px oklch(0% 0 0 / 0.2)',
|
||||
});
|
||||
track.appendChild(knob);
|
||||
track.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const next = !paramsCurrentValues[p.id];
|
||||
paramsCurrentValues[p.id] = next;
|
||||
track.style.background = next ? C.brand : P.hairline;
|
||||
knob.style.left = next ? '18px' : '2px';
|
||||
readout.textContent = next ? 'On' : 'Off';
|
||||
applyParamValue(variantEl, p, next);
|
||||
});
|
||||
row.appendChild(track);
|
||||
} else if (p.kind === 'steps') {
|
||||
const opts = (p.options || []).map(o =>
|
||||
typeof o === 'string' ? { value: o, label: o } : o
|
||||
);
|
||||
const activeOpt = opts.find(o => o.value === p.default) || opts[0];
|
||||
readout.textContent = activeOpt ? activeOpt.label : String(p.default);
|
||||
const segRow = el('div', {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(' + opts.length + ', 1fr)',
|
||||
gap: '1px', padding: '2px',
|
||||
background: P.hairline, borderRadius: '5px',
|
||||
});
|
||||
const segBtns = [];
|
||||
opts.forEach(o => {
|
||||
const active = o.value === p.default;
|
||||
const b = el('button', {
|
||||
padding: '5px 4px', border: 'none', borderRadius: '3px',
|
||||
background: active ? C.brand : 'transparent',
|
||||
color: active ? 'oklch(98% 0 0)' : P.text,
|
||||
fontFamily: FONT, fontSize: '10.5px', fontWeight: '500',
|
||||
cursor: 'pointer', whiteSpace: 'nowrap',
|
||||
transition: 'background 0.1s ease, color 0.1s ease',
|
||||
});
|
||||
b.textContent = o.label;
|
||||
b.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
paramsCurrentValues[p.id] = o.value;
|
||||
readout.textContent = o.label;
|
||||
segBtns.forEach(({ btn, val }) => {
|
||||
const on = val === o.value;
|
||||
btn.style.background = on ? C.brand : 'transparent';
|
||||
btn.style.color = on ? 'oklch(98% 0 0)' : P.text;
|
||||
});
|
||||
applyParamValue(variantEl, p, o.value);
|
||||
});
|
||||
segRow.appendChild(b);
|
||||
segBtns.push({ btn: b, val: o.value });
|
||||
});
|
||||
row.appendChild(segRow);
|
||||
}
|
||||
|
||||
paramsPanelBody.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
// Decide which way the popover opens: away from the picked element. If the
|
||||
// bar landed below the element, popover slides DOWN from the bar's bottom.
|
||||
// If the bar landed above, popover slides UP from the bar's top.
|
||||
function popoverDirection() {
|
||||
if (!barEl || !selectedElement) return 'below';
|
||||
const br = barEl.getBoundingClientRect();
|
||||
const er = selectedElement.getBoundingClientRect();
|
||||
return br.top >= er.bottom - 4 ? 'below' : 'above';
|
||||
}
|
||||
|
||||
// The popover overlaps the bar by OVERLAP px on the bar-facing side. With
|
||||
// popover z-index below bar, that overlap sits behind bar (invisible) and
|
||||
// reinforces the "tucked behind" feel. Padding compensates so the real
|
||||
// content starts flush with bar's outer edge.
|
||||
const TUNE_OVERLAP = 6;
|
||||
|
||||
// Closed clip-path depends on direction: for 'below' clip from the far
|
||||
// (bottom) edge so the reveal grows downward from the bar; for 'above'
|
||||
// clip from the top edge so the reveal grows upward from the bar.
|
||||
function closedClipPath(direction) {
|
||||
return direction === 'below' ? 'inset(0 0 100% 0)' : 'inset(100% 0 0 0)';
|
||||
}
|
||||
|
||||
function setClipPath(value, withTransition) {
|
||||
const saved = paramsPanelEl.style.transition;
|
||||
if (!withTransition) paramsPanelEl.style.transition = 'none';
|
||||
paramsPanelEl.style.clipPath = value;
|
||||
if (!withTransition) {
|
||||
void paramsPanelEl.offsetHeight;
|
||||
paramsPanelEl.style.transition = saved;
|
||||
}
|
||||
}
|
||||
|
||||
function positionParamsPanel() {
|
||||
if (!paramsPanelEl || !barEl || barEl.style.display === 'none') return;
|
||||
const br = barEl.getBoundingClientRect();
|
||||
const direction = popoverDirection();
|
||||
const prevDirection = paramsPanelEl.dataset.tuneDirection;
|
||||
|
||||
// top/left/width are NOT in the transition list, so they snap instantly.
|
||||
paramsPanelEl.style.left = br.left + 'px';
|
||||
paramsPanelEl.style.width = br.width + 'px';
|
||||
|
||||
if (direction === 'below') {
|
||||
paramsPanelEl.style.top = (br.bottom - TUNE_OVERLAP) + 'px';
|
||||
paramsPanelEl.style.borderRadius = '0 0 10px 10px';
|
||||
paramsPanelEl.style.paddingTop = (14 + TUNE_OVERLAP) + 'px';
|
||||
paramsPanelEl.style.paddingBottom = '14px';
|
||||
} else {
|
||||
const ih = paramsPanelEl.offsetHeight || 80;
|
||||
paramsPanelEl.style.top = (br.top - ih + TUNE_OVERLAP) + 'px';
|
||||
paramsPanelEl.style.borderRadius = '10px 10px 0 0';
|
||||
paramsPanelEl.style.paddingTop = '14px';
|
||||
paramsPanelEl.style.paddingBottom = (14 + TUNE_OVERLAP) + 'px';
|
||||
}
|
||||
paramsPanelEl.dataset.tuneDirection = direction;
|
||||
|
||||
// If currently closed and direction flipped (or first-time setup),
|
||||
// snap the clip-path to the new direction's closed pose without
|
||||
// transitioning (so the clip doesn't slide across the element).
|
||||
if (!tuneOpen && (!prevDirection || prevDirection !== direction)) {
|
||||
setClipPath(closedClipPath(direction), false);
|
||||
}
|
||||
}
|
||||
|
||||
function showParamsPanel() {
|
||||
if (!paramsPanelEl) return;
|
||||
positionParamsPanel();
|
||||
paramsPanelEl.style.pointerEvents = 'auto';
|
||||
// rAF so the positioning paint commits before the transition fires.
|
||||
requestAnimationFrame(() => {
|
||||
setClipPath('inset(0 0 0 0)', true);
|
||||
});
|
||||
}
|
||||
|
||||
function hideParamsPanel() {
|
||||
if (!paramsPanelEl) return;
|
||||
paramsPanelEl.style.pointerEvents = 'none';
|
||||
const direction = paramsPanelEl.dataset.tuneDirection || 'below';
|
||||
setClipPath(closedClipPath(direction), true);
|
||||
}
|
||||
|
||||
// Build/rebuild the panel's contents for the current variant AND apply
|
||||
// its defaults to the variant wrapper (so scoped CSS responds even before
|
||||
// the user opens the popover). Visibility is governed by tuneOpen.
|
||||
function refreshParamsPanel() {
|
||||
if (state !== 'CYCLING') {
|
||||
paramsCurrentValues = {};
|
||||
tuneOpen = false;
|
||||
hideParamsPanel();
|
||||
return;
|
||||
}
|
||||
const variantEl = getVisibleVariantEl();
|
||||
const params = parseVariantParams(variantEl);
|
||||
if (!variantEl || params.length === 0) {
|
||||
paramsCurrentValues = {};
|
||||
tuneOpen = false;
|
||||
hideParamsPanel();
|
||||
return;
|
||||
}
|
||||
applyParamDefaults(variantEl, params);
|
||||
buildParamsPanel(variantEl, params);
|
||||
if (tuneOpen) {
|
||||
// If already visible (variant cycled while open), refresh in place
|
||||
// instead of re-running the clip-path animation.
|
||||
const alreadyVisible = paramsPanelEl.style.display === 'block'
|
||||
&& paramsPanelEl.style.opacity === '1';
|
||||
if (alreadyVisible) positionParamsPanel();
|
||||
else showParamsPanel();
|
||||
} else {
|
||||
hideParamsPanel();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTunePopover() {
|
||||
if (tuneOpen) { closeTunePopover(); return; }
|
||||
openTunePopover();
|
||||
}
|
||||
|
||||
function openTunePopover() {
|
||||
if (state !== 'CYCLING') return;
|
||||
const variantEl = getVisibleVariantEl();
|
||||
const params = parseVariantParams(variantEl);
|
||||
if (!variantEl || params.length === 0) return;
|
||||
// Build fresh to ensure the current variant's controls are shown.
|
||||
applyParamDefaults(variantEl, params);
|
||||
buildParamsPanel(variantEl, params);
|
||||
tuneOpen = true;
|
||||
showParamsPanel();
|
||||
// Kill the bar's shadow on the popover-facing side so the dark popover
|
||||
// doesn't pick up a bright glow line.
|
||||
if (barEl) {
|
||||
const direction = paramsPanelEl?.dataset.tuneDirection || 'below';
|
||||
barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN;
|
||||
}
|
||||
// Re-render the bar so the Tune chip picks up the active styling.
|
||||
updateBarContent('cycling');
|
||||
}
|
||||
|
||||
function closeTunePopover() {
|
||||
tuneOpen = false;
|
||||
hideParamsPanel();
|
||||
if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT;
|
||||
if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') {
|
||||
updateBarContent('cycling');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variant cycling in DOM
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1253,6 +1762,10 @@
|
||||
if (!v) continue;
|
||||
child.style.display = (v === String(num)) ? '' : 'none';
|
||||
}
|
||||
// Unconditional refresh — covers first-reveal (no-op if state isn't
|
||||
// CYCLING yet, the subsequent CYCLING transition triggers its own
|
||||
// refresh) and every cycle step.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1316,6 +1829,7 @@
|
||||
state = 'CYCLING';
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
refreshParamsPanel();
|
||||
saveSession();
|
||||
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
|
||||
})
|
||||
@@ -1329,7 +1843,7 @@
|
||||
const next = visibleVariant + dir;
|
||||
if (next < 1 || next > arrivedVariants) return;
|
||||
visibleVariant = next;
|
||||
showVariantInDOM(currentSessionId, next);
|
||||
showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself
|
||||
updateSelectedElement();
|
||||
updateBarContent('cycling');
|
||||
saveSession();
|
||||
@@ -1540,6 +2054,7 @@
|
||||
state = 'CYCLING';
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
refreshParamsPanel();
|
||||
} else if (state === 'GENERATING') {
|
||||
updateBarContent('generating');
|
||||
}
|
||||
@@ -1560,6 +2075,7 @@
|
||||
if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') {
|
||||
positionBar();
|
||||
showHighlight(selectedElement);
|
||||
if (tuneOpen) positionParamsPanel();
|
||||
}
|
||||
if (annotActive) positionAnnotOverlay(selectedElement);
|
||||
// Shader overlay (via debug P toggle or generation) is repositioned
|
||||
@@ -1606,6 +2122,7 @@
|
||||
if (state === 'GENERATING') {
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
refreshParamsPanel();
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -1691,6 +2208,10 @@
|
||||
if (pickerEl?.style.display !== 'none' && !own(e.target)) {
|
||||
hideActionPicker();
|
||||
}
|
||||
// Close Tune popover on outside click (anything outside panel + bar)
|
||||
if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) {
|
||||
closeTunePopover();
|
||||
}
|
||||
// In CONFIGURING: click outside the bar and selected element returns to PICKING
|
||||
if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) {
|
||||
hideBar();
|
||||
@@ -2258,7 +2779,11 @@ void main() {
|
||||
|
||||
function handleAccept() {
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
sendEvent({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) });
|
||||
const acceptPayload = { type: 'accept', id: currentSessionId, variantId: String(visibleVariant) };
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
sendEvent(acceptPayload);
|
||||
markSessionHandled();
|
||||
|
||||
// The accepted variant is already the only visible child of the wrapper
|
||||
@@ -2477,6 +3002,11 @@ void main() {
|
||||
state = arrivedVariants >= expectedVariants ? 'CYCLING' : 'GENERATING';
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -2522,6 +3052,11 @@ void main() {
|
||||
// dark pages. This keeps the bar from fighting with the host design.
|
||||
function detectPageTheme() {
|
||||
try {
|
||||
// Dev override: set localStorage 'impeccable-dev-theme' to 'light' or
|
||||
// 'dark' to preview the opposite palette without actually changing the
|
||||
// page bg. Used for screenshots and theme QA.
|
||||
const override = localStorage.getItem('impeccable-dev-theme');
|
||||
if (override === 'light' || override === 'dark') return override;
|
||||
const bg = getComputedStyle(document.body).backgroundColor
|
||||
|| getComputedStyle(document.documentElement).backgroundColor;
|
||||
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
||||
@@ -2538,6 +3073,7 @@ void main() {
|
||||
// Light bar on dark page
|
||||
return {
|
||||
surface: 'oklch(98% 0 0 / 0.92)',
|
||||
surfaceDeep: 'oklch(92% 0.005 60 / 0.96)', // slightly deeper, faint warm
|
||||
hairline: 'oklch(70% 0 0 / 0.35)',
|
||||
text: 'oklch(15% 0 0)',
|
||||
textDim: 'oklch(45% 0 0)',
|
||||
@@ -2552,6 +3088,7 @@ void main() {
|
||||
// deeper so the rounded-right shape reads as a clear sculpted mark.
|
||||
return {
|
||||
surface: 'oklch(26% 0 0 / 0.94)',
|
||||
surfaceDeep: 'oklch(18% 0 0 / 0.96)', // darker sand for Tune popover
|
||||
hairline: 'oklch(42% 0 0 / 0.5)',
|
||||
text: 'oklch(96% 0 0)',
|
||||
textDim: 'oklch(72% 0 0)',
|
||||
@@ -2883,6 +3420,7 @@ void main() {
|
||||
if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; }
|
||||
if (barEl) { barEl.remove(); barEl = null; }
|
||||
if (pickerEl) { pickerEl.remove(); pickerEl = null; }
|
||||
if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; }
|
||||
if (evtSource) { evtSource.close(); evtSource = null; }
|
||||
document.removeEventListener('mousemove', handleMouseMove, true);
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
@@ -3956,6 +4494,7 @@ void main() {
|
||||
initAnnotOverlay();
|
||||
initBar();
|
||||
initActionPicker();
|
||||
initParamsPanel();
|
||||
initGlobalBar();
|
||||
initDesignPanel();
|
||||
document.addEventListener('mousemove', handleMouseMove, true);
|
||||
|
||||
@@ -145,6 +145,11 @@ Options:
|
||||
const scriptArgs = event.type === 'discard'
|
||||
? ['--id', event.id, '--discard']
|
||||
: ['--id', event.id, '--variant', event.variantId];
|
||||
if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
|
||||
// Pass through a JSON blob; the shell-safe wrap uses single quotes because
|
||||
// values are finite {id, number|string|boolean} pairs from a validated payload.
|
||||
scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
|
||||
}
|
||||
try {
|
||||
const out = execSync(
|
||||
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
|
||||
|
||||
@@ -147,6 +147,11 @@ function validateEvent(msg) {
|
||||
case 'accept':
|
||||
if (!msg.id) return 'accept: missing id';
|
||||
if (!msg.variantId) return 'accept: missing variantId';
|
||||
if (msg.paramValues !== undefined) {
|
||||
if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
|
||||
return 'accept: paramValues must be an object';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
case 'discard':
|
||||
return msg.id ? null : 'discard: missing id';
|
||||
|
||||
@@ -142,3 +142,13 @@ Test that colorization improves the experience:
|
||||
- **Not overwhelming**: Is color balanced and purposeful?
|
||||
|
||||
Remember: Color is emotional and powerful. Use it to create warmth, guide attention, communicate meaning, and express personality. But restraint and strategy matter more than saturation and variety. Be colorful, but be intentional.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
When invoked from live mode, each variant MUST declare a `color-amount` param so the user can dial between a restrained accent and a drenched surface without regeneration. Author the variant's CSS against `var(--p-color-amount, 0.5)` — typically as the alpha multiplier on backgrounds, or as a scaling factor on the chroma axis in an OKLCH expression. 0 = neutral/monochrome, 1 = full saturation / dominant coverage.
|
||||
|
||||
```json
|
||||
{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"}
|
||||
```
|
||||
|
||||
Layer 1-2 variant-specific params on top: palette selection (`steps` with named options), temperature warmth, or tint vs. true color. See `reference/live.md` for the full params contract.
|
||||
|
||||
@@ -119,3 +119,23 @@ Create a systematic plan:
|
||||
- **Responsiveness**: Does the layout adapt gracefully across screen sizes?
|
||||
|
||||
Remember: Space is the most underused design tool. A layout with the right rhythm and hierarchy can make even simple content feel polished and intentional.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
Each variant MUST declare a `density` param. Drive all spacing tokens in the variant's scoped CSS through `calc(var(--p-density, 1) * <base>)` — paddings, gaps, column widths. Users slide from airy to packed and see layout re-breathe with no regeneration.
|
||||
|
||||
```json
|
||||
{"id":"density","kind":"range","min":0.6,"max":1.4,"step":0.05,"default":1,"label":"Density"}
|
||||
```
|
||||
|
||||
For variants whose topology genuinely changes (stacked vs. side-by-side, grid vs. bento), use a `steps` param whose scoped CSS branches via `:scope[data-p-structure="X"]`. One structure param + one density param is a powerful combo; resist adding a third.
|
||||
|
||||
```json
|
||||
{"id":"structure","kind":"steps","default":"grid","label":"Structure","options":[
|
||||
{"value":"stacked","label":"Stacked"},
|
||||
{"value":"grid","label":"Grid"},
|
||||
{"value":"bento","label":"Bento"}
|
||||
]}
|
||||
```
|
||||
|
||||
See `reference/live.md` for the full params contract.
|
||||
|
||||
@@ -179,7 +179,56 @@ The first variant has no `display: none` (visible by default). All others do. If
|
||||
|
||||
One edit, all variants — the browser's MutationObserver picks everything up in one pass.
|
||||
|
||||
### 7. Signal done
|
||||
### 7. Parameters (optional, 2-5 per variant)
|
||||
|
||||
Each variant can expose coarse knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against.
|
||||
|
||||
**When to use.** Any time the variant has a meaningful axis the user might want to dial in: color amount, density, motion intensity, scale ratio. Not micro-level margin tweaks; those defeat the point.
|
||||
|
||||
**Budget scales with the element's visual weight, not the user's curiosity.** Knobs need real estate to produce noticeably different output; slapping three on a small element just crowds the panel without making anything feel tunable.
|
||||
|
||||
- **Leaf / tiny** — a single button, icon, input, bare heading, solitary paragraph: **0 params.** A slider can't meaningfully reshape one element; just use variants.
|
||||
- **Small composition** — labeled input, simple card, short callout (≤ ~5 visual children): **0-1 params.** Only add one if it's a clear dominant axis (e.g. density on a card with visible internal rhythm).
|
||||
- **Medium composition** — section component, nav cluster, dense card, short feature block (6-15 visual children): **2 params.**
|
||||
- **Large composition** — hero section, full page region, spread layout, anything with strong internal structure (16+ visual children or multiple sub-sections): **3-4 params.**
|
||||
|
||||
When in doubt, fewer. The user can always ask for more variants to explore an axis you didn't expose as a knob. Count by visual children, not by DOM-node depth; a deeply-nested-but-visually-simple card still counts as small.
|
||||
|
||||
**How to declare.** Put a JSON manifest on the variant wrapper:
|
||||
|
||||
```html
|
||||
<div data-impeccable-variant="1" data-impeccable-params='[
|
||||
{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"},
|
||||
{"id":"density","kind":"steps","default":"snug","label":"Density","options":[
|
||||
{"value":"airy","label":"Airy"},
|
||||
{"value":"snug","label":"Snug"},
|
||||
{"value":"packed","label":"Packed"}
|
||||
]},
|
||||
{"id":"serif","kind":"toggle","default":false,"label":"Serif display"}
|
||||
]'>
|
||||
...variant content...
|
||||
</div>
|
||||
```
|
||||
|
||||
**Three kinds:**
|
||||
|
||||
- `range` — smooth slider. Drives a CSS custom property `--p-<id>` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`.
|
||||
- `steps` — segmented radio. Drives a data attribute `data-p-<id>` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`.
|
||||
- `toggle` — on/off switch. Drives BOTH a CSS var (`--p-<id>: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`.
|
||||
|
||||
**Signature params per action.** Each action has one or two signature params that MUST be exposed when the variant can meaningfully express them. Check the action's reference file for the list. Layer 1-2 variant-specific params on top.
|
||||
|
||||
**Reset on variant switch.** User dials density on v1, flips to v2, v2 starts at v2's declared defaults. Known limitation; preservation across variants may land later.
|
||||
|
||||
**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment:
|
||||
|
||||
```html
|
||||
<!-- impeccable-param-values SESSION_ID: {"color-amount":0.7,"density":"packed"} -->
|
||||
```
|
||||
|
||||
The carbonize cleanup step (see below) reads that comment and bakes the chosen values into the final CSS. For `steps`/`toggle` attribute selectors: keep only the branch matching the chosen value, drop the others, collapse `:scope[data-p-density="packed"] .grid` to a semantic class rule. For `range` vars: either substitute the literal or keep the var with the chosen value as its new default.
|
||||
|
||||
### 8. Signal done
|
||||
|
||||
```bash
|
||||
node .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 `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element.
|
||||
1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values — read it first; it drives steps 3 and 4 below.
|
||||
2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `public/css/workflow.css` for this repo, or the component's co-located CSS file for a Vite/Next project — pick whichever already owns styling for the surrounding element).
|
||||
3. **Rewrite `@scope ([data-impeccable-variant="N"])` selectors** to target real, semantic classes on the accepted HTML. Example: `@scope ([data-impeccable-variant="2"]) { .v2-label { … } }` becomes `.why-visual--v2 .v2-label { … }` if the accepted element already carries `.why-visual--v2`, or pick/add a suitable class if it doesn't.
|
||||
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it.
|
||||
5. **Delete the inline `<style>` block and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one — those are dead code now.
|
||||
3. **Bake in parameter values while rewriting selectors.** For `@scope ([data-impeccable-variant="N"])` wrappers: retarget to real, semantic classes on the accepted HTML (`.why-visual--v2 .v2-label { … }`). For `:scope[data-p-<id>="VALUE"]` selectors: keep only the branch matching the chosen value from the param-values comment; drop the others (they're dead after accept). For `var(--p-<id>, DEFAULT)` in the CSS: either substitute the literal value, or if the param is still useful as a knob going forward, leave the var and update its initial declaration to the chosen value.
|
||||
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it. Drop `data-impeccable-params` and any `data-p-*` attributes from it — those are live-mode plumbing, not source.
|
||||
5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one — those are dead code now.
|
||||
|
||||
Then poll again.
|
||||
|
||||
|
||||
@@ -110,3 +110,15 @@ Build a clear type scale:
|
||||
- **Accessibility**: Does text meet WCAG contrast ratios? Is it zoomable to 200%?
|
||||
|
||||
Remember: Typography is the foundation of interface design — it carries the majority of information. Getting it right is the highest-leverage improvement you can make.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
Each variant MUST declare a `scale` param controlling the hierarchy ratio. Express all font sizes in the variant's scoped CSS through `calc(var(--p-scale, 1) * <base>)` or, better, scale the type ramp via `clamp(min, calc(var(--p-scale, 1) * Npx), max)`. Users slide from subdued to commanding.
|
||||
|
||||
```json
|
||||
{"id":"scale","kind":"range","min":0.85,"max":1.3,"step":0.05,"default":1,"label":"Scale"}
|
||||
```
|
||||
|
||||
Where the variant riffs on a specific pairing, expose the pairing choice as a `steps` param (e.g. "serif display + sans body" vs. "mono display + sans body" vs. "all-sans"). Each branch routes through `:scope[data-p-pairing="X"]` selectors in scoped CSS.
|
||||
|
||||
See `reference/live.md` for the full params contract.
|
||||
|
||||
@@ -45,11 +45,18 @@ Output (JSON):
|
||||
|
||||
const id = argVal(args, '--id');
|
||||
const variantNum = argVal(args, '--variant');
|
||||
const paramValuesRaw = argVal(args, '--param-values');
|
||||
const isDiscard = args.includes('--discard');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); }
|
||||
|
||||
let paramValues = null;
|
||||
if (paramValuesRaw) {
|
||||
try { paramValues = JSON.parse(paramValuesRaw); }
|
||||
catch { paramValues = null; } // malformed blob: skip the comment rather than failing the accept
|
||||
}
|
||||
|
||||
// Find the file containing this session's markers
|
||||
const found = findSessionFile(id, process.cwd());
|
||||
if (!found) {
|
||||
@@ -78,7 +85,7 @@ Output (JSON):
|
||||
const result = handleDiscard(id, lines, targetFile);
|
||||
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
|
||||
} else {
|
||||
const result = handleAccept(id, variantNum, lines, targetFile);
|
||||
const result = handleAccept(id, variantNum, lines, targetFile, paramValues);
|
||||
// Single-line attention-grabber when cleanup is required. The full
|
||||
// five-step checklist lives in reference/live.md (loaded once per
|
||||
// session); repeating it per-event would waste tokens.
|
||||
@@ -116,7 +123,7 @@ function handleDiscard(id, lines, targetFile) {
|
||||
// Accept
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function handleAccept(id, variantNum, lines, targetFile) {
|
||||
function handleAccept(id, variantNum, lines, targetFile, paramValues) {
|
||||
const block = findMarkerBlock(id, lines);
|
||||
if (!block) return { handled: false, error: 'Markers not found' };
|
||||
|
||||
@@ -149,6 +156,11 @@ function handleAccept(id, variantNum, lines, targetFile) {
|
||||
replacement.push(indent + cssLine.trimStart());
|
||||
}
|
||||
replacement.push(indent + '</style>');
|
||||
if (paramValues && Object.keys(paramValues).length > 0) {
|
||||
// Preserve the user's knob positions for the carbonize-cleanup agent
|
||||
// to bake into the final CSS when it collapses scoped rules.
|
||||
replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
|
||||
}
|
||||
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,10 +30,13 @@
|
||||
// Design tokens
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Brand magenta is pinned to the site token (--color-accent in main.css)
|
||||
// so Accept / knobs / cycle-dots match the site's accent, not a washed
|
||||
// theme-adjusted one.
|
||||
const C = {
|
||||
brand: 'oklch(55% 0.25 350)',
|
||||
brandHov: 'oklch(48% 0.25 350)',
|
||||
brandSoft: 'oklch(55% 0.25 350 / 0.12)',
|
||||
brand: 'oklch(60% 0.25 350)',
|
||||
brandHov: 'oklch(52% 0.25 350)',
|
||||
brandSoft: 'oklch(60% 0.25 350 / 0.15)',
|
||||
ink: 'oklch(15% 0.01 350)',
|
||||
ash: 'oklch(55% 0 0)',
|
||||
paper: 'oklch(98% 0.005 350 / 0.92)',
|
||||
@@ -60,6 +63,25 @@
|
||||
'html', 'head', 'body', 'script', 'style', 'link', 'meta', 'noscript', 'br', 'wbr',
|
||||
]);
|
||||
|
||||
// SVG icons stack above each chip label. All strokes use currentColor so the
|
||||
// icon recolors to C.brand when its chip is selected. 20x20 render, 24-viewBox,
|
||||
// 1.5 stroke — visually consistent with the Foundation grid on the homepage.
|
||||
const ICON_ATTRS = 'width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="display:block"';
|
||||
const ICONS = {
|
||||
impeccable: `<svg ${ICON_ATTRS}><path d="M4 20l4-1L18 9l-3-3L5 16z"/><path d="M14 7l3 3"/></svg>`,
|
||||
bolder: `<svg ${ICON_ATTRS}><rect x="6" y="12" width="4" height="7" rx="0.5"/><rect x="14" y="5" width="4" height="14" rx="0.5"/></svg>`,
|
||||
quieter: `<svg ${ICON_ATTRS}><rect x="6" y="5" width="4" height="14" rx="0.5"/><rect x="14" y="12" width="4" height="7" rx="0.5"/></svg>`,
|
||||
distill: `<svg ${ICON_ATTRS}><path d="M4 5h16l-6 8v7l-4-2v-5z"/></svg>`,
|
||||
polish: `<svg ${ICON_ATTRS}><path d="M15 3l1 3 3 1-3 1-1 3-1-3-3-1 3-1z"/><path d="M7 13l0.6 1.8 1.8 0.6-1.8 0.6-0.6 1.8-0.6-1.8-1.8-0.6 1.8-0.6z"/></svg>`,
|
||||
typeset: `<svg ${ICON_ATTRS}><path d="M5 6h14" stroke-width="2.6"/><path d="M5 12h9" stroke-width="1.9"/><path d="M5 18h5" stroke-width="1.3"/></svg>`,
|
||||
colorize: `<svg ${ICON_ATTRS}><circle cx="9" cy="10" r="5"/><circle cx="15" cy="10" r="5"/><circle cx="12" cy="15" r="5"/></svg>`,
|
||||
layout: `<svg ${ICON_ATTRS}><rect x="3" y="4" width="8" height="16" rx="0.5"/><rect x="13" y="4" width="8" height="7" rx="0.5"/><rect x="13" y="13" width="8" height="7" rx="0.5"/></svg>`,
|
||||
adapt: `<svg ${ICON_ATTRS}><rect x="2.5" y="5" width="12" height="11" rx="1"/><line x1="2.5" y1="19" x2="14.5" y2="19"/><rect x="16.5" y="8" width="5" height="11" rx="1"/></svg>`,
|
||||
animate: `<svg ${ICON_ATTRS}><path d="M3 18c4-4 6-10 10-10"/><path d="M13 8c3 0 5 5 8 10"/><circle cx="13" cy="8" r="1.6" fill="currentColor" stroke="none"/></svg>`,
|
||||
delight: `<svg ${ICON_ATTRS}><path d="M12 3l2 6 6 2-6 2-2 6-2-6-6-2 6-2z"/></svg>`,
|
||||
overdrive: `<svg ${ICON_ATTRS}><path d="M13 3L5 13h5l-1 8 9-12h-6z"/></svg>`,
|
||||
};
|
||||
|
||||
const ACTIONS = [
|
||||
{ value: 'impeccable', label: 'Freeform' },
|
||||
{ value: 'bolder', label: 'Bolder' },
|
||||
@@ -756,7 +778,21 @@
|
||||
// The Bar — one floating element, three modes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Contextual-bar palette. Cached at init so every build*Row reads a
|
||||
// consistent set of colors; detectPageTheme runs once rather than on every
|
||||
// phase transition.
|
||||
let BP = null;
|
||||
|
||||
// Bar shadow variants. The default projects down + subtle around. When
|
||||
// the Tune popover opens below the bar, a downward shadow lands on the
|
||||
// dark popover and reads as a bright ghost line. We swap to UP-only while
|
||||
// tune is open below so the popover's top edge is clean.
|
||||
const BAR_SHADOW_DEFAULT = '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)';
|
||||
const BAR_SHADOW_UP = '0 -4px 20px oklch(0% 0 0 / 0.08), 0 -1px 3px oklch(0% 0 0 / 0.06)';
|
||||
const BAR_SHADOW_DOWN = BAR_SHADOW_DEFAULT;
|
||||
|
||||
function initBar() {
|
||||
BP = barPaletteForTheme(detectPageTheme());
|
||||
barEl = document.createElement('div');
|
||||
barEl.id = PREFIX + '-bar';
|
||||
Object.assign(barEl.style, {
|
||||
@@ -764,15 +800,15 @@
|
||||
display: 'none', opacity: '0',
|
||||
transform: 'translateY(6px)',
|
||||
transition: 'opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
|
||||
// Visual
|
||||
background: C.paper,
|
||||
background: BP.surface,
|
||||
backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
|
||||
border: '1px solid ' + C.mist,
|
||||
border: '1px solid ' + BP.hairline,
|
||||
borderRadius: '10px',
|
||||
boxShadow: '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)',
|
||||
fontFamily: FONT, fontSize: '13px', color: C.ink,
|
||||
boxShadow: BAR_SHADOW_DEFAULT,
|
||||
transition: 'box-shadow 0.2s ease, opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
|
||||
fontFamily: FONT, fontSize: '13px', color: BP.text,
|
||||
padding: '6px',
|
||||
maxWidth: '460px', minWidth: '320px',
|
||||
maxWidth: '520px', minWidth: '320px',
|
||||
});
|
||||
document.body.appendChild(barEl);
|
||||
}
|
||||
@@ -824,14 +860,15 @@
|
||||
barEl.style.transform = 'translateY(6px)';
|
||||
setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250);
|
||||
hideActionPicker();
|
||||
closeTunePopover();
|
||||
}
|
||||
|
||||
function updateBarContent(mode) {
|
||||
if (!barEl || barEl.style.display === 'none') return;
|
||||
barEl.innerHTML = '';
|
||||
// Reset bar styling
|
||||
barEl.style.background = C.paper;
|
||||
barEl.style.border = '1px solid ' + C.mist;
|
||||
// Reset bar styling to the theme-aware palette
|
||||
barEl.style.background = BP.surface;
|
||||
barEl.style.border = '1px solid ' + BP.hairline;
|
||||
if (mode === 'configure') barEl.appendChild(buildConfigureRow());
|
||||
else if (mode === 'generating') barEl.appendChild(buildGeneratingRow());
|
||||
else if (mode === 'cycling') barEl.appendChild(buildCyclingRow());
|
||||
@@ -854,15 +891,15 @@
|
||||
const pill = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
||||
padding: '5px 10px', borderRadius: '6px',
|
||||
background: C.ink, color: C.white,
|
||||
background: BP.mark, color: BP.markText,
|
||||
fontFamily: FONT, fontSize: '12px', fontWeight: '500',
|
||||
border: 'none', cursor: 'pointer',
|
||||
transition: 'background 0.12s ease, transform 0.1s ease',
|
||||
whiteSpace: 'nowrap', flexShrink: '0',
|
||||
});
|
||||
pill.textContent = actionLabel() + ' \u25BE';
|
||||
pill.addEventListener('mouseenter', () => pill.style.background = C.brandHov);
|
||||
pill.addEventListener('mouseleave', () => pill.style.background = C.ink);
|
||||
pill.addEventListener('mouseenter', () => pill.style.background = BP.accent);
|
||||
pill.addEventListener('mouseleave', () => pill.style.background = BP.mark);
|
||||
pill.addEventListener('mousedown', () => pill.style.transform = 'scale(0.97)');
|
||||
pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)');
|
||||
pill.addEventListener('click', (e) => { e.stopPropagation(); toggleActionPicker(); });
|
||||
@@ -877,13 +914,13 @@
|
||||
flex: '1', minWidth: '0',
|
||||
padding: '5px 8px', borderRadius: '6px',
|
||||
border: '1px solid transparent', background: 'transparent',
|
||||
fontFamily: FONT, fontSize: '12px', color: C.ink,
|
||||
fontFamily: FONT, fontSize: '12px', color: BP.text,
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.15s ease, background 0.15s ease',
|
||||
});
|
||||
input.addEventListener('focus', () => {
|
||||
input.style.borderColor = C.mist;
|
||||
input.style.background = C.white;
|
||||
input.style.borderColor = BP.hairline;
|
||||
input.style.background = BP.accentSoft;
|
||||
});
|
||||
input.addEventListener('blur', () => {
|
||||
input.style.borderColor = 'transparent';
|
||||
@@ -901,16 +938,16 @@
|
||||
// Variant count toggle
|
||||
const count = el('button', {
|
||||
padding: '4px 6px', borderRadius: '5px',
|
||||
border: '1px solid ' + C.mist, background: 'transparent',
|
||||
border: '1px solid ' + BP.hairline, background: 'transparent',
|
||||
fontFamily: MONO, fontSize: '11px', fontWeight: '600',
|
||||
color: C.ash, cursor: 'pointer',
|
||||
color: BP.textDim, cursor: 'pointer',
|
||||
transition: 'color 0.12s ease, border-color 0.12s ease',
|
||||
flexShrink: '0', whiteSpace: 'nowrap',
|
||||
});
|
||||
count.textContent = '\u00D7' + selectedCount;
|
||||
count.title = 'Variants: click to change';
|
||||
count.addEventListener('mouseenter', () => { count.style.color = C.ink; count.style.borderColor = C.ink; });
|
||||
count.addEventListener('mouseleave', () => { count.style.color = C.ash; count.style.borderColor = C.mist; });
|
||||
count.addEventListener('mouseenter', () => { count.style.color = BP.text; count.style.borderColor = BP.text; });
|
||||
count.addEventListener('mouseleave', () => { count.style.color = BP.textDim; count.style.borderColor = BP.hairline; });
|
||||
count.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1;
|
||||
@@ -921,15 +958,15 @@
|
||||
// Go button
|
||||
const go = el('button', {
|
||||
padding: '5px 12px', borderRadius: '6px',
|
||||
border: 'none', background: C.brand, color: C.white,
|
||||
border: 'none', background: BP.accent, color: BP.mark,
|
||||
fontFamily: FONT, fontSize: '12px', fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.12s ease, transform 0.1s ease',
|
||||
transition: 'filter 0.12s ease, transform 0.1s ease',
|
||||
flexShrink: '0', whiteSpace: 'nowrap',
|
||||
});
|
||||
go.textContent = 'Go \u2192';
|
||||
go.addEventListener('mouseenter', () => go.style.background = C.brandHov);
|
||||
go.addEventListener('mouseleave', () => go.style.background = C.brand);
|
||||
go.addEventListener('mouseenter', () => go.style.filter = 'brightness(1.1)');
|
||||
go.addEventListener('mouseleave', () => go.style.filter = 'none');
|
||||
go.addEventListener('mousedown', () => go.style.transform = 'scale(0.97)');
|
||||
go.addEventListener('mouseup', () => go.style.transform = 'scale(1)');
|
||||
go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); });
|
||||
@@ -950,7 +987,7 @@
|
||||
|
||||
// Action label
|
||||
const label = el('span', {
|
||||
fontWeight: '600', fontSize: '12px', color: C.ink,
|
||||
fontWeight: '600', fontSize: '12px', color: BP.text,
|
||||
flexShrink: '0', whiteSpace: 'nowrap',
|
||||
});
|
||||
label.textContent = actionLabel();
|
||||
@@ -961,7 +998,7 @@
|
||||
|
||||
// Status
|
||||
const status = el('span', {
|
||||
fontSize: '11px', color: C.ash, whiteSpace: 'nowrap',
|
||||
fontSize: '11px', color: BP.textDim, whiteSpace: 'nowrap',
|
||||
marginLeft: 'auto',
|
||||
});
|
||||
// Variants currently arrive atomically in a single file edit, so a
|
||||
@@ -976,6 +1013,8 @@
|
||||
|
||||
// --- Cycling row ---
|
||||
|
||||
const TUNE_ICON_SVG = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" style="flex-shrink:0"><line x1="4" y1="8" x2="20" y2="8"/><circle cx="14" cy="8" r="2.4" fill="currentColor" stroke="none"/><line x1="4" y1="16" x2="20" y2="16"/><circle cx="10" cy="16" r="2.4" fill="currentColor" stroke="none"/></svg>';
|
||||
|
||||
function buildCyclingRow() {
|
||||
const row = el('div', {
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
@@ -994,7 +1033,7 @@
|
||||
// Counter
|
||||
const counter = el('span', {
|
||||
fontFamily: MONO, fontSize: '11px', fontWeight: '500',
|
||||
color: C.ash, minWidth: '24px', textAlign: 'center',
|
||||
color: BP.textDim, minWidth: '24px', textAlign: 'center',
|
||||
});
|
||||
counter.textContent = visibleVariant + '/' + arrivedVariants;
|
||||
row.appendChild(counter);
|
||||
@@ -1005,20 +1044,67 @@
|
||||
if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
|
||||
row.appendChild(next);
|
||||
|
||||
// Tune chip — only when the visible variant exposes params
|
||||
const visParams = parseVariantParams(getVisibleVariantEl());
|
||||
const hasParams = visParams.length > 0;
|
||||
if (hasParams) {
|
||||
const tune = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '6px',
|
||||
padding: '4px 10px', borderRadius: '5px',
|
||||
border: '1px solid transparent',
|
||||
background: tuneOpen ? BP.accentSoft : 'transparent',
|
||||
color: tuneOpen ? BP.accent : BP.text,
|
||||
fontFamily: FONT, fontSize: '11px', fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'color 0.12s ease, background 0.12s ease',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
tune.innerHTML = TUNE_ICON_SVG;
|
||||
const tuneLabel = document.createElement('span');
|
||||
tuneLabel.textContent = 'Tune';
|
||||
tune.appendChild(tuneLabel);
|
||||
const tuneBadge = document.createElement('span');
|
||||
Object.assign(tuneBadge.style, {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
minWidth: '16px', height: '16px', padding: '0 4px',
|
||||
borderRadius: '999px',
|
||||
background: tuneOpen ? C.brand : BP.hairline,
|
||||
color: tuneOpen ? 'oklch(98% 0 0)' : 'inherit',
|
||||
fontFamily: MONO, fontSize: '9.5px', fontWeight: '600',
|
||||
lineHeight: '1',
|
||||
boxSizing: 'border-box',
|
||||
});
|
||||
tuneBadge.textContent = String(visParams.length);
|
||||
tune.appendChild(tuneBadge);
|
||||
tune.title = 'Tune this variant (' + visParams.length + ' knob' + (visParams.length === 1 ? '' : 's') + ')';
|
||||
tune.addEventListener('mouseenter', () => {
|
||||
if (!tuneOpen) tune.style.background = BP.accentSoft;
|
||||
});
|
||||
tune.addEventListener('mouseleave', () => {
|
||||
if (!tuneOpen) tune.style.background = 'transparent';
|
||||
});
|
||||
tune.addEventListener('click', (e) => { e.stopPropagation(); toggleTunePopover(); });
|
||||
tune.dataset.iceqTune = '1';
|
||||
row.appendChild(tune);
|
||||
}
|
||||
|
||||
// Spacer
|
||||
row.appendChild(el('div', { flex: '1' }));
|
||||
|
||||
// Accept
|
||||
// Accept — primary action, uses the site's saturated brand magenta
|
||||
// with paper-white text, not the theme-muted BP.accent.
|
||||
const accept = el('button', {
|
||||
padding: '4px 10px', borderRadius: '5px',
|
||||
border: 'none', background: C.ink, color: C.white,
|
||||
padding: '5px 14px', borderRadius: '5px',
|
||||
border: 'none', background: C.brand, color: 'oklch(98% 0 0)',
|
||||
fontFamily: FONT, fontSize: '11px', fontWeight: '600',
|
||||
cursor: 'pointer', transition: 'background 0.12s ease',
|
||||
cursor: 'pointer', transition: 'filter 0.12s ease, transform 0.1s ease',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
accept.textContent = '\u2713 Accept';
|
||||
accept.addEventListener('mouseenter', () => accept.style.background = C.brand);
|
||||
accept.addEventListener('mouseleave', () => accept.style.background = C.ink);
|
||||
accept.addEventListener('mouseenter', () => accept.style.filter = 'brightness(1.08)');
|
||||
accept.addEventListener('mouseleave', () => accept.style.filter = 'none');
|
||||
accept.addEventListener('mousedown', () => accept.style.transform = 'scale(0.97)');
|
||||
accept.addEventListener('mouseup', () => accept.style.transform = 'scale(1)');
|
||||
accept.addEventListener('click', (e) => { e.stopPropagation(); handleAccept(); });
|
||||
if (arrivedVariants === 0) { accept.style.opacity = '0.3'; accept.style.pointerEvents = 'none'; }
|
||||
row.appendChild(accept);
|
||||
@@ -1026,14 +1112,14 @@
|
||||
// Discard
|
||||
const discard = el('button', {
|
||||
padding: '4px 6px', borderRadius: '5px',
|
||||
border: '1px solid ' + C.mist, background: 'transparent',
|
||||
fontFamily: FONT, fontSize: '11px', color: C.ash,
|
||||
border: '1px solid ' + BP.hairline, background: 'transparent',
|
||||
fontFamily: FONT, fontSize: '11px', color: BP.textDim,
|
||||
cursor: 'pointer', transition: 'color 0.12s ease, border-color 0.12s ease',
|
||||
});
|
||||
discard.textContent = '\u2715';
|
||||
discard.title = 'Discard all variants';
|
||||
discard.addEventListener('mouseenter', () => { discard.style.color = C.ink; discard.style.borderColor = C.ink; });
|
||||
discard.addEventListener('mouseleave', () => { discard.style.color = C.ash; discard.style.borderColor = C.mist; });
|
||||
discard.addEventListener('mouseenter', () => { discard.style.color = BP.text; discard.style.borderColor = BP.text; });
|
||||
discard.addEventListener('mouseleave', () => { discard.style.color = BP.textDim; discard.style.borderColor = BP.hairline; });
|
||||
discard.addEventListener('click', (e) => { e.stopPropagation(); handleDiscard(); });
|
||||
row.appendChild(discard);
|
||||
|
||||
@@ -1051,14 +1137,14 @@
|
||||
});
|
||||
const spinner = el('div', {
|
||||
width: '14px', height: '14px', borderRadius: '50%',
|
||||
border: '2px solid ' + C.mist,
|
||||
borderTopColor: C.brand,
|
||||
border: '2px solid ' + BP.hairline,
|
||||
borderTopColor: BP.accent,
|
||||
animation: 'impeccable-spin 0.6s linear infinite',
|
||||
flexShrink: '0',
|
||||
});
|
||||
row.appendChild(spinner);
|
||||
const label = el('span', {
|
||||
fontSize: '12px', color: C.ash, fontWeight: '500',
|
||||
fontSize: '12px', color: BP.textDim, fontWeight: '500',
|
||||
});
|
||||
label.textContent = 'Applying variant...';
|
||||
row.appendChild(label);
|
||||
@@ -1103,14 +1189,26 @@
|
||||
for (let i = 1; i <= expectedVariants; i++) {
|
||||
const arrived = i <= arrivedVariants;
|
||||
const active = i === visibleVariant;
|
||||
// active: solid site-brand magenta dot. arrived+inactive: muted neutral.
|
||||
// pending (not yet arrived): faint outline ring. No borders on arrived
|
||||
// dots — the previous "accent ring + ash fill" combo read as noisy
|
||||
// magenta chips, especially when all variants had arrived and every
|
||||
// dot wore an accent ring.
|
||||
const dotBg = active ? C.brand
|
||||
: arrived ? BP.textDim
|
||||
: 'transparent';
|
||||
const dotBorder = arrived ? 'none' : '1.5px solid ' + BP.hairline;
|
||||
const dot = el('div', {
|
||||
width: '7px', height: '7px', borderRadius: '50%',
|
||||
background: active ? C.brand : (arrived ? C.ash : 'transparent'),
|
||||
border: '1.5px solid ' + (arrived ? C.brand : C.mist),
|
||||
width: active ? '8px' : '6px',
|
||||
height: active ? '8px' : '6px',
|
||||
borderRadius: '50%',
|
||||
background: dotBg,
|
||||
border: dotBorder,
|
||||
boxSizing: 'border-box',
|
||||
transition: 'all 0.2s ' + EASE,
|
||||
cursor: (clickable && arrived) ? 'pointer' : 'default',
|
||||
transform: arrived ? 'scale(1)' : 'scale(0.6)',
|
||||
opacity: arrived ? '1' : '0.4',
|
||||
transform: arrived ? 'scale(1)' : 'scale(0.85)',
|
||||
opacity: arrived ? (active ? '1' : '0.6') : '0.4',
|
||||
});
|
||||
if (clickable && arrived) {
|
||||
const idx = i;
|
||||
@@ -1130,15 +1228,15 @@
|
||||
function navBtn(text) {
|
||||
const b = el('button', {
|
||||
width: '26px', height: '26px', borderRadius: '5px',
|
||||
border: '1px solid ' + C.mist, background: 'transparent',
|
||||
color: C.ink, fontFamily: FONT, fontSize: '13px',
|
||||
border: '1px solid ' + BP.hairline, background: 'transparent',
|
||||
color: BP.text, fontFamily: FONT, fontSize: '13px',
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'border-color 0.12s ease, background 0.12s ease',
|
||||
padding: '0', lineHeight: '1',
|
||||
});
|
||||
b.textContent = text;
|
||||
b.addEventListener('mouseenter', () => { b.style.borderColor = C.ink; });
|
||||
b.addEventListener('mouseleave', () => { b.style.borderColor = C.mist; });
|
||||
b.addEventListener('mouseenter', () => { b.style.borderColor = BP.text; });
|
||||
b.addEventListener('mouseleave', () => { b.style.borderColor = BP.hairline; });
|
||||
return b;
|
||||
}
|
||||
|
||||
@@ -1158,6 +1256,7 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initActionPicker() {
|
||||
const P = barPaletteForTheme(detectPageTheme());
|
||||
pickerEl = document.createElement('div');
|
||||
pickerEl.id = PREFIX + '-picker';
|
||||
Object.assign(pickerEl.style, {
|
||||
@@ -1166,12 +1265,14 @@
|
||||
transform: 'scale(0.96) translateY(4px)',
|
||||
transformOrigin: 'bottom left',
|
||||
transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE,
|
||||
background: C.paperSolid,
|
||||
border: '1px solid ' + C.mist,
|
||||
background: P.surface,
|
||||
border: '1px solid ' + P.hairline,
|
||||
borderRadius: '10px',
|
||||
boxShadow: '0 8px 30px oklch(0% 0 0 / 0.10), 0 2px 6px oklch(0% 0 0 / 0.06)',
|
||||
padding: '6px',
|
||||
fontFamily: FONT,
|
||||
backdropFilter: 'blur(10px)',
|
||||
WebkitBackdropFilter: 'blur(10px)',
|
||||
});
|
||||
|
||||
// Build the chip grid
|
||||
@@ -1181,22 +1282,32 @@
|
||||
|
||||
ACTIONS.forEach(action => {
|
||||
const chip = el('button', {
|
||||
padding: '6px 8px', borderRadius: '6px',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '8px 6px', borderRadius: '6px',
|
||||
border: 'none',
|
||||
background: action.value === selectedAction ? C.brandSoft : 'transparent',
|
||||
color: action.value === selectedAction ? C.brand : C.ink,
|
||||
background: action.value === selectedAction ? P.accentSoft : 'transparent',
|
||||
color: action.value === selectedAction ? P.accent : P.text,
|
||||
fontFamily: FONT, fontSize: '11px', fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.1s ease, color 0.1s ease',
|
||||
textAlign: 'center', whiteSpace: 'nowrap',
|
||||
});
|
||||
chip.textContent = action.label;
|
||||
const iconWrap = el('span', {
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
height: '20px', opacity: '0.9',
|
||||
});
|
||||
iconWrap.innerHTML = ICONS[action.value] || '';
|
||||
const labelEl = el('span', { lineHeight: '1' });
|
||||
labelEl.textContent = action.label;
|
||||
chip.appendChild(iconWrap);
|
||||
chip.appendChild(labelEl);
|
||||
chip.dataset.action = action.value;
|
||||
chip.addEventListener('mouseenter', () => {
|
||||
if (action.value !== selectedAction) chip.style.background = C.brandSoft;
|
||||
if (action.value !== selectedAction) chip.style.background = P.accentSoft;
|
||||
});
|
||||
chip.addEventListener('mouseleave', () => {
|
||||
chip.style.background = action.value === selectedAction ? C.brandSoft : 'transparent';
|
||||
chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent';
|
||||
});
|
||||
chip.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -1209,19 +1320,24 @@
|
||||
|
||||
pickerEl.appendChild(grid);
|
||||
document.body.appendChild(pickerEl);
|
||||
|
||||
// Cache the palette on the picker so toggleActionPicker's state refresh
|
||||
// uses the same theme-aware colors when it repaints chips.
|
||||
pickerEl.__iceq_palette = P;
|
||||
}
|
||||
|
||||
function toggleActionPicker() {
|
||||
if (pickerEl.style.display !== 'none') { hideActionPicker(); return; }
|
||||
// Rebuild chips to reflect current selection
|
||||
const P = pickerEl.__iceq_palette || barPaletteForTheme(detectPageTheme());
|
||||
pickerEl.querySelectorAll('button').forEach(chip => {
|
||||
const isActive = chip.dataset.action === selectedAction;
|
||||
chip.style.background = isActive ? C.brandSoft : 'transparent';
|
||||
chip.style.color = isActive ? C.brand : C.ink;
|
||||
chip.style.background = isActive ? P.accentSoft : 'transparent';
|
||||
chip.style.color = isActive ? P.accent : P.text;
|
||||
});
|
||||
// Position above the bar
|
||||
const barRect = barEl.getBoundingClientRect();
|
||||
const pickerH = 90; // approximate
|
||||
const pickerH = 170; // approximate; grows with icon + label rows
|
||||
let top = barRect.top - pickerH - 6;
|
||||
if (top < 8) top = barRect.bottom + 6;
|
||||
Object.assign(pickerEl.style, {
|
||||
@@ -1241,6 +1357,399 @@
|
||||
setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Params panel (per-variant coarse controls)
|
||||
//
|
||||
// Variants may declare a parameter manifest via a JSON attribute on the
|
||||
// variant wrapper:
|
||||
//
|
||||
// <div data-impeccable-variant="1"
|
||||
// data-impeccable-params='[{"id":"density","kind":"steps",...}]'>
|
||||
//
|
||||
// The panel docks to the right edge of the outline during CYCLING and
|
||||
// exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped
|
||||
// CSS can respond instantly without regeneration:
|
||||
//
|
||||
// range / numeric toggle → CSS var (`--p-<id>`) used via var(--p-foo, N)
|
||||
// steps / boolean toggle → data-p-<id> attribute used via :scope[data-p-foo="..."]
|
||||
//
|
||||
// On variant switch, values reset to that variant's declared defaults.
|
||||
// On accept, current values are sent in the event payload so the agent
|
||||
// can bake them into the source-file write.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide)
|
||||
let paramsPanelInner = null; // translating content (carries bg, padding, knobs)
|
||||
let paramsPanelBody = null; // grid holding the knob cells
|
||||
let paramsCurrentValues = {}; // {paramId: value} — mirror of the visible variant's live values
|
||||
let tuneOpen = false; // whether the Tune popover is open right now
|
||||
|
||||
// Theme-aware Tune popover. Appears as a drawer that slides out from the
|
||||
// contextual bar's bar-facing edge (below if the bar sits below the
|
||||
// element, above otherwise). Same width as the bar. Auto-wraps to extra
|
||||
// rows when the knobs exceed one row. The bar's border-radius on the
|
||||
// popover side goes flat while open so the two shapes read as one.
|
||||
let paramsPanelPalette = null;
|
||||
|
||||
function initParamsPanel() {
|
||||
paramsPanelPalette = barPaletteForTheme(detectPageTheme());
|
||||
const P = paramsPanelPalette;
|
||||
|
||||
// Single element, always in the DOM. The slide animation is a CSS mask
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis — no
|
||||
// display toggle, no opacity toggle, no transform trickery. The mask
|
||||
// hides everything initially; as it grows, content is revealed from
|
||||
// the bar edge outward.
|
||||
paramsPanelEl = document.createElement('div');
|
||||
paramsPanelEl.id = PREFIX + '-params-panel';
|
||||
Object.assign(paramsPanelEl.style, {
|
||||
position: 'fixed', zIndex: String(Z.bar - 1),
|
||||
background: P.surfaceDeep,
|
||||
color: P.text,
|
||||
fontFamily: FONT,
|
||||
padding: '14px 18px',
|
||||
boxSizing: 'border-box',
|
||||
borderRadius: '0 0 10px 10px',
|
||||
pointerEvents: 'none',
|
||||
backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
|
||||
|
||||
// clip-path is the same conceptual reveal as mask but with rock-solid
|
||||
// transition support across engines. Closed state clips from the far
|
||||
// edge; open = inset(0) shows everything.
|
||||
clipPath: 'inset(0 0 100% 0)',
|
||||
transition: 'clip-path 0.44s ' + EASE,
|
||||
|
||||
// Park off-screen until positionParamsPanel places it. These are NOT
|
||||
// in the transition list, so they snap instantly — no fly-in from the
|
||||
// top-left when first shown.
|
||||
top: '-9999px', left: '-9999px', width: '0',
|
||||
});
|
||||
|
||||
paramsPanelBody = el('div', {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
|
||||
gap: '12px 16px',
|
||||
});
|
||||
|
||||
paramsPanelEl.appendChild(paramsPanelBody);
|
||||
document.body.appendChild(paramsPanelEl);
|
||||
paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
|
||||
}
|
||||
|
||||
function getVisibleVariantEl() {
|
||||
if (!currentSessionId) return null;
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
|
||||
function parseVariantParams(variantEl) {
|
||||
if (!variantEl) return [];
|
||||
const raw = variantEl.getAttribute('data-impeccable-params');
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] Invalid data-impeccable-params JSON:', err.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function applyParamValue(variantEl, param, value) {
|
||||
if (!variantEl) return;
|
||||
const attr = 'data-p-' + param.id;
|
||||
if (param.kind === 'range') {
|
||||
variantEl.style.setProperty('--p-' + param.id, String(value));
|
||||
} else if (param.kind === 'toggle') {
|
||||
const on = !!value;
|
||||
variantEl.style.setProperty('--p-' + param.id, on ? '1' : '0');
|
||||
if (on) variantEl.setAttribute(attr, 'on');
|
||||
else variantEl.removeAttribute(attr);
|
||||
} else if (param.kind === 'steps') {
|
||||
variantEl.setAttribute(attr, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
function applyParamDefaults(variantEl, params) {
|
||||
paramsCurrentValues = {};
|
||||
for (const p of params) {
|
||||
paramsCurrentValues[p.id] = p.default;
|
||||
applyParamValue(variantEl, p, p.default);
|
||||
}
|
||||
}
|
||||
|
||||
function formatRangeValue(input) {
|
||||
const max = parseFloat(input.max), min = parseFloat(input.min);
|
||||
const v = parseFloat(input.value);
|
||||
if (!isFinite(v)) return input.value;
|
||||
return (max - min) <= 2 ? v.toFixed(2) : String(Math.round(v));
|
||||
}
|
||||
|
||||
function buildParamsPanel(variantEl, params) {
|
||||
const P = paramsPanelPalette || barPaletteForTheme(detectPageTheme());
|
||||
paramsPanelBody.innerHTML = '';
|
||||
for (const p of params) {
|
||||
const row = el('div', { display: 'flex', flexDirection: 'column', gap: '6px' });
|
||||
const labelRow = el('div', {
|
||||
display: 'flex', justifyContent: 'space-between',
|
||||
alignItems: 'baseline', gap: '8px',
|
||||
});
|
||||
const lbl = el('span', {
|
||||
fontSize: '10.5px', fontWeight: '600', color: P.text,
|
||||
letterSpacing: '0.03em',
|
||||
});
|
||||
lbl.textContent = p.label || p.id;
|
||||
labelRow.appendChild(lbl);
|
||||
const readout = el('span', {
|
||||
fontSize: '10.5px', color: P.textDim,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
});
|
||||
labelRow.appendChild(readout);
|
||||
row.appendChild(labelRow);
|
||||
|
||||
if (p.kind === 'range') {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'range';
|
||||
input.min = String(p.min != null ? p.min : 0);
|
||||
input.max = String(p.max != null ? p.max : 1);
|
||||
input.step = String(p.step != null ? p.step : 0.05);
|
||||
input.value = String(p.default);
|
||||
Object.assign(input.style, {
|
||||
width: '100%', accentColor: C.brand, cursor: 'pointer',
|
||||
});
|
||||
readout.textContent = formatRangeValue(input);
|
||||
input.addEventListener('input', (e) => {
|
||||
e.stopPropagation();
|
||||
const v = parseFloat(input.value);
|
||||
paramsCurrentValues[p.id] = v;
|
||||
readout.textContent = formatRangeValue(input);
|
||||
applyParamValue(variantEl, p, v);
|
||||
});
|
||||
row.appendChild(input);
|
||||
} else if (p.kind === 'toggle') {
|
||||
const initial = !!p.default;
|
||||
readout.textContent = initial ? 'On' : 'Off';
|
||||
const track = el('button', {
|
||||
position: 'relative', width: '36px', height: '20px',
|
||||
borderRadius: '10px', border: 'none', padding: '0',
|
||||
cursor: 'pointer',
|
||||
background: initial ? C.brand : P.hairline,
|
||||
transition: 'background 0.15s ease',
|
||||
alignSelf: 'flex-start',
|
||||
});
|
||||
const knob = el('span', {
|
||||
position: 'absolute', top: '2px',
|
||||
left: initial ? '18px' : '2px',
|
||||
width: '16px', height: '16px', borderRadius: '50%',
|
||||
background: 'oklch(98% 0 0)',
|
||||
transition: 'left 0.18s ' + EASE,
|
||||
boxShadow: '0 1px 2px oklch(0% 0 0 / 0.2)',
|
||||
});
|
||||
track.appendChild(knob);
|
||||
track.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const next = !paramsCurrentValues[p.id];
|
||||
paramsCurrentValues[p.id] = next;
|
||||
track.style.background = next ? C.brand : P.hairline;
|
||||
knob.style.left = next ? '18px' : '2px';
|
||||
readout.textContent = next ? 'On' : 'Off';
|
||||
applyParamValue(variantEl, p, next);
|
||||
});
|
||||
row.appendChild(track);
|
||||
} else if (p.kind === 'steps') {
|
||||
const opts = (p.options || []).map(o =>
|
||||
typeof o === 'string' ? { value: o, label: o } : o
|
||||
);
|
||||
const activeOpt = opts.find(o => o.value === p.default) || opts[0];
|
||||
readout.textContent = activeOpt ? activeOpt.label : String(p.default);
|
||||
const segRow = el('div', {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(' + opts.length + ', 1fr)',
|
||||
gap: '1px', padding: '2px',
|
||||
background: P.hairline, borderRadius: '5px',
|
||||
});
|
||||
const segBtns = [];
|
||||
opts.forEach(o => {
|
||||
const active = o.value === p.default;
|
||||
const b = el('button', {
|
||||
padding: '5px 4px', border: 'none', borderRadius: '3px',
|
||||
background: active ? C.brand : 'transparent',
|
||||
color: active ? 'oklch(98% 0 0)' : P.text,
|
||||
fontFamily: FONT, fontSize: '10.5px', fontWeight: '500',
|
||||
cursor: 'pointer', whiteSpace: 'nowrap',
|
||||
transition: 'background 0.1s ease, color 0.1s ease',
|
||||
});
|
||||
b.textContent = o.label;
|
||||
b.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
paramsCurrentValues[p.id] = o.value;
|
||||
readout.textContent = o.label;
|
||||
segBtns.forEach(({ btn, val }) => {
|
||||
const on = val === o.value;
|
||||
btn.style.background = on ? C.brand : 'transparent';
|
||||
btn.style.color = on ? 'oklch(98% 0 0)' : P.text;
|
||||
});
|
||||
applyParamValue(variantEl, p, o.value);
|
||||
});
|
||||
segRow.appendChild(b);
|
||||
segBtns.push({ btn: b, val: o.value });
|
||||
});
|
||||
row.appendChild(segRow);
|
||||
}
|
||||
|
||||
paramsPanelBody.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
// Decide which way the popover opens: away from the picked element. If the
|
||||
// bar landed below the element, popover slides DOWN from the bar's bottom.
|
||||
// If the bar landed above, popover slides UP from the bar's top.
|
||||
function popoverDirection() {
|
||||
if (!barEl || !selectedElement) return 'below';
|
||||
const br = barEl.getBoundingClientRect();
|
||||
const er = selectedElement.getBoundingClientRect();
|
||||
return br.top >= er.bottom - 4 ? 'below' : 'above';
|
||||
}
|
||||
|
||||
// The popover overlaps the bar by OVERLAP px on the bar-facing side. With
|
||||
// popover z-index below bar, that overlap sits behind bar (invisible) and
|
||||
// reinforces the "tucked behind" feel. Padding compensates so the real
|
||||
// content starts flush with bar's outer edge.
|
||||
const TUNE_OVERLAP = 6;
|
||||
|
||||
// Closed clip-path depends on direction: for 'below' clip from the far
|
||||
// (bottom) edge so the reveal grows downward from the bar; for 'above'
|
||||
// clip from the top edge so the reveal grows upward from the bar.
|
||||
function closedClipPath(direction) {
|
||||
return direction === 'below' ? 'inset(0 0 100% 0)' : 'inset(100% 0 0 0)';
|
||||
}
|
||||
|
||||
function setClipPath(value, withTransition) {
|
||||
const saved = paramsPanelEl.style.transition;
|
||||
if (!withTransition) paramsPanelEl.style.transition = 'none';
|
||||
paramsPanelEl.style.clipPath = value;
|
||||
if (!withTransition) {
|
||||
void paramsPanelEl.offsetHeight;
|
||||
paramsPanelEl.style.transition = saved;
|
||||
}
|
||||
}
|
||||
|
||||
function positionParamsPanel() {
|
||||
if (!paramsPanelEl || !barEl || barEl.style.display === 'none') return;
|
||||
const br = barEl.getBoundingClientRect();
|
||||
const direction = popoverDirection();
|
||||
const prevDirection = paramsPanelEl.dataset.tuneDirection;
|
||||
|
||||
// top/left/width are NOT in the transition list, so they snap instantly.
|
||||
paramsPanelEl.style.left = br.left + 'px';
|
||||
paramsPanelEl.style.width = br.width + 'px';
|
||||
|
||||
if (direction === 'below') {
|
||||
paramsPanelEl.style.top = (br.bottom - TUNE_OVERLAP) + 'px';
|
||||
paramsPanelEl.style.borderRadius = '0 0 10px 10px';
|
||||
paramsPanelEl.style.paddingTop = (14 + TUNE_OVERLAP) + 'px';
|
||||
paramsPanelEl.style.paddingBottom = '14px';
|
||||
} else {
|
||||
const ih = paramsPanelEl.offsetHeight || 80;
|
||||
paramsPanelEl.style.top = (br.top - ih + TUNE_OVERLAP) + 'px';
|
||||
paramsPanelEl.style.borderRadius = '10px 10px 0 0';
|
||||
paramsPanelEl.style.paddingTop = '14px';
|
||||
paramsPanelEl.style.paddingBottom = (14 + TUNE_OVERLAP) + 'px';
|
||||
}
|
||||
paramsPanelEl.dataset.tuneDirection = direction;
|
||||
|
||||
// If currently closed and direction flipped (or first-time setup),
|
||||
// snap the clip-path to the new direction's closed pose without
|
||||
// transitioning (so the clip doesn't slide across the element).
|
||||
if (!tuneOpen && (!prevDirection || prevDirection !== direction)) {
|
||||
setClipPath(closedClipPath(direction), false);
|
||||
}
|
||||
}
|
||||
|
||||
function showParamsPanel() {
|
||||
if (!paramsPanelEl) return;
|
||||
positionParamsPanel();
|
||||
paramsPanelEl.style.pointerEvents = 'auto';
|
||||
// rAF so the positioning paint commits before the transition fires.
|
||||
requestAnimationFrame(() => {
|
||||
setClipPath('inset(0 0 0 0)', true);
|
||||
});
|
||||
}
|
||||
|
||||
function hideParamsPanel() {
|
||||
if (!paramsPanelEl) return;
|
||||
paramsPanelEl.style.pointerEvents = 'none';
|
||||
const direction = paramsPanelEl.dataset.tuneDirection || 'below';
|
||||
setClipPath(closedClipPath(direction), true);
|
||||
}
|
||||
|
||||
// Build/rebuild the panel's contents for the current variant AND apply
|
||||
// its defaults to the variant wrapper (so scoped CSS responds even before
|
||||
// the user opens the popover). Visibility is governed by tuneOpen.
|
||||
function refreshParamsPanel() {
|
||||
if (state !== 'CYCLING') {
|
||||
paramsCurrentValues = {};
|
||||
tuneOpen = false;
|
||||
hideParamsPanel();
|
||||
return;
|
||||
}
|
||||
const variantEl = getVisibleVariantEl();
|
||||
const params = parseVariantParams(variantEl);
|
||||
if (!variantEl || params.length === 0) {
|
||||
paramsCurrentValues = {};
|
||||
tuneOpen = false;
|
||||
hideParamsPanel();
|
||||
return;
|
||||
}
|
||||
applyParamDefaults(variantEl, params);
|
||||
buildParamsPanel(variantEl, params);
|
||||
if (tuneOpen) {
|
||||
// If already visible (variant cycled while open), refresh in place
|
||||
// instead of re-running the clip-path animation.
|
||||
const alreadyVisible = paramsPanelEl.style.display === 'block'
|
||||
&& paramsPanelEl.style.opacity === '1';
|
||||
if (alreadyVisible) positionParamsPanel();
|
||||
else showParamsPanel();
|
||||
} else {
|
||||
hideParamsPanel();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTunePopover() {
|
||||
if (tuneOpen) { closeTunePopover(); return; }
|
||||
openTunePopover();
|
||||
}
|
||||
|
||||
function openTunePopover() {
|
||||
if (state !== 'CYCLING') return;
|
||||
const variantEl = getVisibleVariantEl();
|
||||
const params = parseVariantParams(variantEl);
|
||||
if (!variantEl || params.length === 0) return;
|
||||
// Build fresh to ensure the current variant's controls are shown.
|
||||
applyParamDefaults(variantEl, params);
|
||||
buildParamsPanel(variantEl, params);
|
||||
tuneOpen = true;
|
||||
showParamsPanel();
|
||||
// Kill the bar's shadow on the popover-facing side so the dark popover
|
||||
// doesn't pick up a bright glow line.
|
||||
if (barEl) {
|
||||
const direction = paramsPanelEl?.dataset.tuneDirection || 'below';
|
||||
barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN;
|
||||
}
|
||||
// Re-render the bar so the Tune chip picks up the active styling.
|
||||
updateBarContent('cycling');
|
||||
}
|
||||
|
||||
function closeTunePopover() {
|
||||
tuneOpen = false;
|
||||
hideParamsPanel();
|
||||
if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT;
|
||||
if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') {
|
||||
updateBarContent('cycling');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variant cycling in DOM
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1253,6 +1762,10 @@
|
||||
if (!v) continue;
|
||||
child.style.display = (v === String(num)) ? '' : 'none';
|
||||
}
|
||||
// Unconditional refresh — covers first-reveal (no-op if state isn't
|
||||
// CYCLING yet, the subsequent CYCLING transition triggers its own
|
||||
// refresh) and every cycle step.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1316,6 +1829,7 @@
|
||||
state = 'CYCLING';
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
refreshParamsPanel();
|
||||
saveSession();
|
||||
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
|
||||
})
|
||||
@@ -1329,7 +1843,7 @@
|
||||
const next = visibleVariant + dir;
|
||||
if (next < 1 || next > arrivedVariants) return;
|
||||
visibleVariant = next;
|
||||
showVariantInDOM(currentSessionId, next);
|
||||
showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself
|
||||
updateSelectedElement();
|
||||
updateBarContent('cycling');
|
||||
saveSession();
|
||||
@@ -1540,6 +2054,7 @@
|
||||
state = 'CYCLING';
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
refreshParamsPanel();
|
||||
} else if (state === 'GENERATING') {
|
||||
updateBarContent('generating');
|
||||
}
|
||||
@@ -1560,6 +2075,7 @@
|
||||
if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') {
|
||||
positionBar();
|
||||
showHighlight(selectedElement);
|
||||
if (tuneOpen) positionParamsPanel();
|
||||
}
|
||||
if (annotActive) positionAnnotOverlay(selectedElement);
|
||||
// Shader overlay (via debug P toggle or generation) is repositioned
|
||||
@@ -1606,6 +2122,7 @@
|
||||
if (state === 'GENERATING') {
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
refreshParamsPanel();
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -1691,6 +2208,10 @@
|
||||
if (pickerEl?.style.display !== 'none' && !own(e.target)) {
|
||||
hideActionPicker();
|
||||
}
|
||||
// Close Tune popover on outside click (anything outside panel + bar)
|
||||
if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) {
|
||||
closeTunePopover();
|
||||
}
|
||||
// In CONFIGURING: click outside the bar and selected element returns to PICKING
|
||||
if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) {
|
||||
hideBar();
|
||||
@@ -2258,7 +2779,11 @@ void main() {
|
||||
|
||||
function handleAccept() {
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
sendEvent({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) });
|
||||
const acceptPayload = { type: 'accept', id: currentSessionId, variantId: String(visibleVariant) };
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
sendEvent(acceptPayload);
|
||||
markSessionHandled();
|
||||
|
||||
// The accepted variant is already the only visible child of the wrapper
|
||||
@@ -2477,6 +3002,11 @@ void main() {
|
||||
state = arrivedVariants >= expectedVariants ? 'CYCLING' : 'GENERATING';
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -2522,6 +3052,11 @@ void main() {
|
||||
// dark pages. This keeps the bar from fighting with the host design.
|
||||
function detectPageTheme() {
|
||||
try {
|
||||
// Dev override: set localStorage 'impeccable-dev-theme' to 'light' or
|
||||
// 'dark' to preview the opposite palette without actually changing the
|
||||
// page bg. Used for screenshots and theme QA.
|
||||
const override = localStorage.getItem('impeccable-dev-theme');
|
||||
if (override === 'light' || override === 'dark') return override;
|
||||
const bg = getComputedStyle(document.body).backgroundColor
|
||||
|| getComputedStyle(document.documentElement).backgroundColor;
|
||||
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
||||
@@ -2538,6 +3073,7 @@ void main() {
|
||||
// Light bar on dark page
|
||||
return {
|
||||
surface: 'oklch(98% 0 0 / 0.92)',
|
||||
surfaceDeep: 'oklch(92% 0.005 60 / 0.96)', // slightly deeper, faint warm
|
||||
hairline: 'oklch(70% 0 0 / 0.35)',
|
||||
text: 'oklch(15% 0 0)',
|
||||
textDim: 'oklch(45% 0 0)',
|
||||
@@ -2552,6 +3088,7 @@ void main() {
|
||||
// deeper so the rounded-right shape reads as a clear sculpted mark.
|
||||
return {
|
||||
surface: 'oklch(26% 0 0 / 0.94)',
|
||||
surfaceDeep: 'oklch(18% 0 0 / 0.96)', // darker sand for Tune popover
|
||||
hairline: 'oklch(42% 0 0 / 0.5)',
|
||||
text: 'oklch(96% 0 0)',
|
||||
textDim: 'oklch(72% 0 0)',
|
||||
@@ -2883,6 +3420,7 @@ void main() {
|
||||
if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; }
|
||||
if (barEl) { barEl.remove(); barEl = null; }
|
||||
if (pickerEl) { pickerEl.remove(); pickerEl = null; }
|
||||
if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; }
|
||||
if (evtSource) { evtSource.close(); evtSource = null; }
|
||||
document.removeEventListener('mousemove', handleMouseMove, true);
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
@@ -3956,6 +4494,7 @@ void main() {
|
||||
initAnnotOverlay();
|
||||
initBar();
|
||||
initActionPicker();
|
||||
initParamsPanel();
|
||||
initGlobalBar();
|
||||
initDesignPanel();
|
||||
document.addEventListener('mousemove', handleMouseMove, true);
|
||||
|
||||
@@ -145,6 +145,11 @@ Options:
|
||||
const scriptArgs = event.type === 'discard'
|
||||
? ['--id', event.id, '--discard']
|
||||
: ['--id', event.id, '--variant', event.variantId];
|
||||
if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
|
||||
// Pass through a JSON blob; the shell-safe wrap uses single quotes because
|
||||
// values are finite {id, number|string|boolean} pairs from a validated payload.
|
||||
scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
|
||||
}
|
||||
try {
|
||||
const out = execSync(
|
||||
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
|
||||
|
||||
@@ -147,6 +147,11 @@ function validateEvent(msg) {
|
||||
case 'accept':
|
||||
if (!msg.id) return 'accept: missing id';
|
||||
if (!msg.variantId) return 'accept: missing variantId';
|
||||
if (msg.paramValues !== undefined) {
|
||||
if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
|
||||
return 'accept: paramValues must be an object';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
case 'discard':
|
||||
return msg.id ? null : 'discard: missing id';
|
||||
|
||||
@@ -142,3 +142,13 @@ Test that colorization improves the experience:
|
||||
- **Not overwhelming**: Is color balanced and purposeful?
|
||||
|
||||
Remember: Color is emotional and powerful. Use it to create warmth, guide attention, communicate meaning, and express personality. But restraint and strategy matter more than saturation and variety. Be colorful, but be intentional.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
When invoked from live mode, each variant MUST declare a `color-amount` param so the user can dial between a restrained accent and a drenched surface without regeneration. Author the variant's CSS against `var(--p-color-amount, 0.5)` — typically as the alpha multiplier on backgrounds, or as a scaling factor on the chroma axis in an OKLCH expression. 0 = neutral/monochrome, 1 = full saturation / dominant coverage.
|
||||
|
||||
```json
|
||||
{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"}
|
||||
```
|
||||
|
||||
Layer 1-2 variant-specific params on top: palette selection (`steps` with named options), temperature warmth, or tint vs. true color. See `reference/live.md` for the full params contract.
|
||||
|
||||
@@ -119,3 +119,23 @@ Create a systematic plan:
|
||||
- **Responsiveness**: Does the layout adapt gracefully across screen sizes?
|
||||
|
||||
Remember: Space is the most underused design tool. A layout with the right rhythm and hierarchy can make even simple content feel polished and intentional.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
Each variant MUST declare a `density` param. Drive all spacing tokens in the variant's scoped CSS through `calc(var(--p-density, 1) * <base>)` — paddings, gaps, column widths. Users slide from airy to packed and see layout re-breathe with no regeneration.
|
||||
|
||||
```json
|
||||
{"id":"density","kind":"range","min":0.6,"max":1.4,"step":0.05,"default":1,"label":"Density"}
|
||||
```
|
||||
|
||||
For variants whose topology genuinely changes (stacked vs. side-by-side, grid vs. bento), use a `steps` param whose scoped CSS branches via `:scope[data-p-structure="X"]`. One structure param + one density param is a powerful combo; resist adding a third.
|
||||
|
||||
```json
|
||||
{"id":"structure","kind":"steps","default":"grid","label":"Structure","options":[
|
||||
{"value":"stacked","label":"Stacked"},
|
||||
{"value":"grid","label":"Grid"},
|
||||
{"value":"bento","label":"Bento"}
|
||||
]}
|
||||
```
|
||||
|
||||
See `reference/live.md` for the full params contract.
|
||||
|
||||
@@ -179,7 +179,56 @@ The first variant has no `display: none` (visible by default). All others do. If
|
||||
|
||||
One edit, all variants — the browser's MutationObserver picks everything up in one pass.
|
||||
|
||||
### 7. Signal done
|
||||
### 7. Parameters (optional, 2-5 per variant)
|
||||
|
||||
Each variant can expose coarse knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against.
|
||||
|
||||
**When to use.** Any time the variant has a meaningful axis the user might want to dial in: color amount, density, motion intensity, scale ratio. Not micro-level margin tweaks; those defeat the point.
|
||||
|
||||
**Budget scales with the element's visual weight, not the user's curiosity.** Knobs need real estate to produce noticeably different output; slapping three on a small element just crowds the panel without making anything feel tunable.
|
||||
|
||||
- **Leaf / tiny** — a single button, icon, input, bare heading, solitary paragraph: **0 params.** A slider can't meaningfully reshape one element; just use variants.
|
||||
- **Small composition** — labeled input, simple card, short callout (≤ ~5 visual children): **0-1 params.** Only add one if it's a clear dominant axis (e.g. density on a card with visible internal rhythm).
|
||||
- **Medium composition** — section component, nav cluster, dense card, short feature block (6-15 visual children): **2 params.**
|
||||
- **Large composition** — hero section, full page region, spread layout, anything with strong internal structure (16+ visual children or multiple sub-sections): **3-4 params.**
|
||||
|
||||
When in doubt, fewer. The user can always ask for more variants to explore an axis you didn't expose as a knob. Count by visual children, not by DOM-node depth; a deeply-nested-but-visually-simple card still counts as small.
|
||||
|
||||
**How to declare.** Put a JSON manifest on the variant wrapper:
|
||||
|
||||
```html
|
||||
<div data-impeccable-variant="1" data-impeccable-params='[
|
||||
{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"},
|
||||
{"id":"density","kind":"steps","default":"snug","label":"Density","options":[
|
||||
{"value":"airy","label":"Airy"},
|
||||
{"value":"snug","label":"Snug"},
|
||||
{"value":"packed","label":"Packed"}
|
||||
]},
|
||||
{"id":"serif","kind":"toggle","default":false,"label":"Serif display"}
|
||||
]'>
|
||||
...variant content...
|
||||
</div>
|
||||
```
|
||||
|
||||
**Three kinds:**
|
||||
|
||||
- `range` — smooth slider. Drives a CSS custom property `--p-<id>` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`.
|
||||
- `steps` — segmented radio. Drives a data attribute `data-p-<id>` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`.
|
||||
- `toggle` — on/off switch. Drives BOTH a CSS var (`--p-<id>: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`.
|
||||
|
||||
**Signature params per action.** Each action has one or two signature params that MUST be exposed when the variant can meaningfully express them. Check the action's reference file for the list. Layer 1-2 variant-specific params on top.
|
||||
|
||||
**Reset on variant switch.** User dials density on v1, flips to v2, v2 starts at v2's declared defaults. Known limitation; preservation across variants may land later.
|
||||
|
||||
**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment:
|
||||
|
||||
```html
|
||||
<!-- impeccable-param-values SESSION_ID: {"color-amount":0.7,"density":"packed"} -->
|
||||
```
|
||||
|
||||
The carbonize cleanup step (see below) reads that comment and bakes the chosen values into the final CSS. For `steps`/`toggle` attribute selectors: keep only the branch matching the chosen value, drop the others, collapse `:scope[data-p-density="packed"] .grid` to a semantic class rule. For `range` vars: either substitute the literal or keep the var with the chosen value as its new default.
|
||||
|
||||
### 8. Signal done
|
||||
|
||||
```bash
|
||||
node .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 `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element.
|
||||
1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values — read it first; it drives steps 3 and 4 below.
|
||||
2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `public/css/workflow.css` for this repo, or the component's co-located CSS file for a Vite/Next project — pick whichever already owns styling for the surrounding element).
|
||||
3. **Rewrite `@scope ([data-impeccable-variant="N"])` selectors** to target real, semantic classes on the accepted HTML. Example: `@scope ([data-impeccable-variant="2"]) { .v2-label { … } }` becomes `.why-visual--v2 .v2-label { … }` if the accepted element already carries `.why-visual--v2`, or pick/add a suitable class if it doesn't.
|
||||
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it.
|
||||
5. **Delete the inline `<style>` block and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one — those are dead code now.
|
||||
3. **Bake in parameter values while rewriting selectors.** For `@scope ([data-impeccable-variant="N"])` wrappers: retarget to real, semantic classes on the accepted HTML (`.why-visual--v2 .v2-label { … }`). For `:scope[data-p-<id>="VALUE"]` selectors: keep only the branch matching the chosen value from the param-values comment; drop the others (they're dead after accept). For `var(--p-<id>, DEFAULT)` in the CSS: either substitute the literal value, or if the param is still useful as a knob going forward, leave the var and update its initial declaration to the chosen value.
|
||||
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it. Drop `data-impeccable-params` and any `data-p-*` attributes from it — those are live-mode plumbing, not source.
|
||||
5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one — those are dead code now.
|
||||
|
||||
Then poll again.
|
||||
|
||||
|
||||
@@ -110,3 +110,15 @@ Build a clear type scale:
|
||||
- **Accessibility**: Does text meet WCAG contrast ratios? Is it zoomable to 200%?
|
||||
|
||||
Remember: Typography is the foundation of interface design — it carries the majority of information. Getting it right is the highest-leverage improvement you can make.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
Each variant MUST declare a `scale` param controlling the hierarchy ratio. Express all font sizes in the variant's scoped CSS through `calc(var(--p-scale, 1) * <base>)` or, better, scale the type ramp via `clamp(min, calc(var(--p-scale, 1) * Npx), max)`. Users slide from subdued to commanding.
|
||||
|
||||
```json
|
||||
{"id":"scale","kind":"range","min":0.85,"max":1.3,"step":0.05,"default":1,"label":"Scale"}
|
||||
```
|
||||
|
||||
Where the variant riffs on a specific pairing, expose the pairing choice as a `steps` param (e.g. "serif display + sans body" vs. "mono display + sans body" vs. "all-sans"). Each branch routes through `:scope[data-p-pairing="X"]` selectors in scoped CSS.
|
||||
|
||||
See `reference/live.md` for the full params contract.
|
||||
|
||||
@@ -45,11 +45,18 @@ Output (JSON):
|
||||
|
||||
const id = argVal(args, '--id');
|
||||
const variantNum = argVal(args, '--variant');
|
||||
const paramValuesRaw = argVal(args, '--param-values');
|
||||
const isDiscard = args.includes('--discard');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); }
|
||||
|
||||
let paramValues = null;
|
||||
if (paramValuesRaw) {
|
||||
try { paramValues = JSON.parse(paramValuesRaw); }
|
||||
catch { paramValues = null; } // malformed blob: skip the comment rather than failing the accept
|
||||
}
|
||||
|
||||
// Find the file containing this session's markers
|
||||
const found = findSessionFile(id, process.cwd());
|
||||
if (!found) {
|
||||
@@ -78,7 +85,7 @@ Output (JSON):
|
||||
const result = handleDiscard(id, lines, targetFile);
|
||||
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
|
||||
} else {
|
||||
const result = handleAccept(id, variantNum, lines, targetFile);
|
||||
const result = handleAccept(id, variantNum, lines, targetFile, paramValues);
|
||||
// Single-line attention-grabber when cleanup is required. The full
|
||||
// five-step checklist lives in reference/live.md (loaded once per
|
||||
// session); repeating it per-event would waste tokens.
|
||||
@@ -116,7 +123,7 @@ function handleDiscard(id, lines, targetFile) {
|
||||
// Accept
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function handleAccept(id, variantNum, lines, targetFile) {
|
||||
function handleAccept(id, variantNum, lines, targetFile, paramValues) {
|
||||
const block = findMarkerBlock(id, lines);
|
||||
if (!block) return { handled: false, error: 'Markers not found' };
|
||||
|
||||
@@ -149,6 +156,11 @@ function handleAccept(id, variantNum, lines, targetFile) {
|
||||
replacement.push(indent + cssLine.trimStart());
|
||||
}
|
||||
replacement.push(indent + '</style>');
|
||||
if (paramValues && Object.keys(paramValues).length > 0) {
|
||||
// Preserve the user's knob positions for the carbonize-cleanup agent
|
||||
// to bake into the final CSS when it collapses scoped rules.
|
||||
replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
|
||||
}
|
||||
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,10 +30,13 @@
|
||||
// Design tokens
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Brand magenta is pinned to the site token (--color-accent in main.css)
|
||||
// so Accept / knobs / cycle-dots match the site's accent, not a washed
|
||||
// theme-adjusted one.
|
||||
const C = {
|
||||
brand: 'oklch(55% 0.25 350)',
|
||||
brandHov: 'oklch(48% 0.25 350)',
|
||||
brandSoft: 'oklch(55% 0.25 350 / 0.12)',
|
||||
brand: 'oklch(60% 0.25 350)',
|
||||
brandHov: 'oklch(52% 0.25 350)',
|
||||
brandSoft: 'oklch(60% 0.25 350 / 0.15)',
|
||||
ink: 'oklch(15% 0.01 350)',
|
||||
ash: 'oklch(55% 0 0)',
|
||||
paper: 'oklch(98% 0.005 350 / 0.92)',
|
||||
@@ -60,6 +63,25 @@
|
||||
'html', 'head', 'body', 'script', 'style', 'link', 'meta', 'noscript', 'br', 'wbr',
|
||||
]);
|
||||
|
||||
// SVG icons stack above each chip label. All strokes use currentColor so the
|
||||
// icon recolors to C.brand when its chip is selected. 20x20 render, 24-viewBox,
|
||||
// 1.5 stroke — visually consistent with the Foundation grid on the homepage.
|
||||
const ICON_ATTRS = 'width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="display:block"';
|
||||
const ICONS = {
|
||||
impeccable: `<svg ${ICON_ATTRS}><path d="M4 20l4-1L18 9l-3-3L5 16z"/><path d="M14 7l3 3"/></svg>`,
|
||||
bolder: `<svg ${ICON_ATTRS}><rect x="6" y="12" width="4" height="7" rx="0.5"/><rect x="14" y="5" width="4" height="14" rx="0.5"/></svg>`,
|
||||
quieter: `<svg ${ICON_ATTRS}><rect x="6" y="5" width="4" height="14" rx="0.5"/><rect x="14" y="12" width="4" height="7" rx="0.5"/></svg>`,
|
||||
distill: `<svg ${ICON_ATTRS}><path d="M4 5h16l-6 8v7l-4-2v-5z"/></svg>`,
|
||||
polish: `<svg ${ICON_ATTRS}><path d="M15 3l1 3 3 1-3 1-1 3-1-3-3-1 3-1z"/><path d="M7 13l0.6 1.8 1.8 0.6-1.8 0.6-0.6 1.8-0.6-1.8-1.8-0.6 1.8-0.6z"/></svg>`,
|
||||
typeset: `<svg ${ICON_ATTRS}><path d="M5 6h14" stroke-width="2.6"/><path d="M5 12h9" stroke-width="1.9"/><path d="M5 18h5" stroke-width="1.3"/></svg>`,
|
||||
colorize: `<svg ${ICON_ATTRS}><circle cx="9" cy="10" r="5"/><circle cx="15" cy="10" r="5"/><circle cx="12" cy="15" r="5"/></svg>`,
|
||||
layout: `<svg ${ICON_ATTRS}><rect x="3" y="4" width="8" height="16" rx="0.5"/><rect x="13" y="4" width="8" height="7" rx="0.5"/><rect x="13" y="13" width="8" height="7" rx="0.5"/></svg>`,
|
||||
adapt: `<svg ${ICON_ATTRS}><rect x="2.5" y="5" width="12" height="11" rx="1"/><line x1="2.5" y1="19" x2="14.5" y2="19"/><rect x="16.5" y="8" width="5" height="11" rx="1"/></svg>`,
|
||||
animate: `<svg ${ICON_ATTRS}><path d="M3 18c4-4 6-10 10-10"/><path d="M13 8c3 0 5 5 8 10"/><circle cx="13" cy="8" r="1.6" fill="currentColor" stroke="none"/></svg>`,
|
||||
delight: `<svg ${ICON_ATTRS}><path d="M12 3l2 6 6 2-6 2-2 6-2-6-6-2 6-2z"/></svg>`,
|
||||
overdrive: `<svg ${ICON_ATTRS}><path d="M13 3L5 13h5l-1 8 9-12h-6z"/></svg>`,
|
||||
};
|
||||
|
||||
const ACTIONS = [
|
||||
{ value: 'impeccable', label: 'Freeform' },
|
||||
{ value: 'bolder', label: 'Bolder' },
|
||||
@@ -756,7 +778,21 @@
|
||||
// The Bar — one floating element, three modes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Contextual-bar palette. Cached at init so every build*Row reads a
|
||||
// consistent set of colors; detectPageTheme runs once rather than on every
|
||||
// phase transition.
|
||||
let BP = null;
|
||||
|
||||
// Bar shadow variants. The default projects down + subtle around. When
|
||||
// the Tune popover opens below the bar, a downward shadow lands on the
|
||||
// dark popover and reads as a bright ghost line. We swap to UP-only while
|
||||
// tune is open below so the popover's top edge is clean.
|
||||
const BAR_SHADOW_DEFAULT = '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)';
|
||||
const BAR_SHADOW_UP = '0 -4px 20px oklch(0% 0 0 / 0.08), 0 -1px 3px oklch(0% 0 0 / 0.06)';
|
||||
const BAR_SHADOW_DOWN = BAR_SHADOW_DEFAULT;
|
||||
|
||||
function initBar() {
|
||||
BP = barPaletteForTheme(detectPageTheme());
|
||||
barEl = document.createElement('div');
|
||||
barEl.id = PREFIX + '-bar';
|
||||
Object.assign(barEl.style, {
|
||||
@@ -764,15 +800,15 @@
|
||||
display: 'none', opacity: '0',
|
||||
transform: 'translateY(6px)',
|
||||
transition: 'opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
|
||||
// Visual
|
||||
background: C.paper,
|
||||
background: BP.surface,
|
||||
backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
|
||||
border: '1px solid ' + C.mist,
|
||||
border: '1px solid ' + BP.hairline,
|
||||
borderRadius: '10px',
|
||||
boxShadow: '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)',
|
||||
fontFamily: FONT, fontSize: '13px', color: C.ink,
|
||||
boxShadow: BAR_SHADOW_DEFAULT,
|
||||
transition: 'box-shadow 0.2s ease, opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
|
||||
fontFamily: FONT, fontSize: '13px', color: BP.text,
|
||||
padding: '6px',
|
||||
maxWidth: '460px', minWidth: '320px',
|
||||
maxWidth: '520px', minWidth: '320px',
|
||||
});
|
||||
document.body.appendChild(barEl);
|
||||
}
|
||||
@@ -824,14 +860,15 @@
|
||||
barEl.style.transform = 'translateY(6px)';
|
||||
setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250);
|
||||
hideActionPicker();
|
||||
closeTunePopover();
|
||||
}
|
||||
|
||||
function updateBarContent(mode) {
|
||||
if (!barEl || barEl.style.display === 'none') return;
|
||||
barEl.innerHTML = '';
|
||||
// Reset bar styling
|
||||
barEl.style.background = C.paper;
|
||||
barEl.style.border = '1px solid ' + C.mist;
|
||||
// Reset bar styling to the theme-aware palette
|
||||
barEl.style.background = BP.surface;
|
||||
barEl.style.border = '1px solid ' + BP.hairline;
|
||||
if (mode === 'configure') barEl.appendChild(buildConfigureRow());
|
||||
else if (mode === 'generating') barEl.appendChild(buildGeneratingRow());
|
||||
else if (mode === 'cycling') barEl.appendChild(buildCyclingRow());
|
||||
@@ -854,15 +891,15 @@
|
||||
const pill = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
||||
padding: '5px 10px', borderRadius: '6px',
|
||||
background: C.ink, color: C.white,
|
||||
background: BP.mark, color: BP.markText,
|
||||
fontFamily: FONT, fontSize: '12px', fontWeight: '500',
|
||||
border: 'none', cursor: 'pointer',
|
||||
transition: 'background 0.12s ease, transform 0.1s ease',
|
||||
whiteSpace: 'nowrap', flexShrink: '0',
|
||||
});
|
||||
pill.textContent = actionLabel() + ' \u25BE';
|
||||
pill.addEventListener('mouseenter', () => pill.style.background = C.brandHov);
|
||||
pill.addEventListener('mouseleave', () => pill.style.background = C.ink);
|
||||
pill.addEventListener('mouseenter', () => pill.style.background = BP.accent);
|
||||
pill.addEventListener('mouseleave', () => pill.style.background = BP.mark);
|
||||
pill.addEventListener('mousedown', () => pill.style.transform = 'scale(0.97)');
|
||||
pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)');
|
||||
pill.addEventListener('click', (e) => { e.stopPropagation(); toggleActionPicker(); });
|
||||
@@ -877,13 +914,13 @@
|
||||
flex: '1', minWidth: '0',
|
||||
padding: '5px 8px', borderRadius: '6px',
|
||||
border: '1px solid transparent', background: 'transparent',
|
||||
fontFamily: FONT, fontSize: '12px', color: C.ink,
|
||||
fontFamily: FONT, fontSize: '12px', color: BP.text,
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.15s ease, background 0.15s ease',
|
||||
});
|
||||
input.addEventListener('focus', () => {
|
||||
input.style.borderColor = C.mist;
|
||||
input.style.background = C.white;
|
||||
input.style.borderColor = BP.hairline;
|
||||
input.style.background = BP.accentSoft;
|
||||
});
|
||||
input.addEventListener('blur', () => {
|
||||
input.style.borderColor = 'transparent';
|
||||
@@ -901,16 +938,16 @@
|
||||
// Variant count toggle
|
||||
const count = el('button', {
|
||||
padding: '4px 6px', borderRadius: '5px',
|
||||
border: '1px solid ' + C.mist, background: 'transparent',
|
||||
border: '1px solid ' + BP.hairline, background: 'transparent',
|
||||
fontFamily: MONO, fontSize: '11px', fontWeight: '600',
|
||||
color: C.ash, cursor: 'pointer',
|
||||
color: BP.textDim, cursor: 'pointer',
|
||||
transition: 'color 0.12s ease, border-color 0.12s ease',
|
||||
flexShrink: '0', whiteSpace: 'nowrap',
|
||||
});
|
||||
count.textContent = '\u00D7' + selectedCount;
|
||||
count.title = 'Variants: click to change';
|
||||
count.addEventListener('mouseenter', () => { count.style.color = C.ink; count.style.borderColor = C.ink; });
|
||||
count.addEventListener('mouseleave', () => { count.style.color = C.ash; count.style.borderColor = C.mist; });
|
||||
count.addEventListener('mouseenter', () => { count.style.color = BP.text; count.style.borderColor = BP.text; });
|
||||
count.addEventListener('mouseleave', () => { count.style.color = BP.textDim; count.style.borderColor = BP.hairline; });
|
||||
count.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1;
|
||||
@@ -921,15 +958,15 @@
|
||||
// Go button
|
||||
const go = el('button', {
|
||||
padding: '5px 12px', borderRadius: '6px',
|
||||
border: 'none', background: C.brand, color: C.white,
|
||||
border: 'none', background: BP.accent, color: BP.mark,
|
||||
fontFamily: FONT, fontSize: '12px', fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.12s ease, transform 0.1s ease',
|
||||
transition: 'filter 0.12s ease, transform 0.1s ease',
|
||||
flexShrink: '0', whiteSpace: 'nowrap',
|
||||
});
|
||||
go.textContent = 'Go \u2192';
|
||||
go.addEventListener('mouseenter', () => go.style.background = C.brandHov);
|
||||
go.addEventListener('mouseleave', () => go.style.background = C.brand);
|
||||
go.addEventListener('mouseenter', () => go.style.filter = 'brightness(1.1)');
|
||||
go.addEventListener('mouseleave', () => go.style.filter = 'none');
|
||||
go.addEventListener('mousedown', () => go.style.transform = 'scale(0.97)');
|
||||
go.addEventListener('mouseup', () => go.style.transform = 'scale(1)');
|
||||
go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); });
|
||||
@@ -950,7 +987,7 @@
|
||||
|
||||
// Action label
|
||||
const label = el('span', {
|
||||
fontWeight: '600', fontSize: '12px', color: C.ink,
|
||||
fontWeight: '600', fontSize: '12px', color: BP.text,
|
||||
flexShrink: '0', whiteSpace: 'nowrap',
|
||||
});
|
||||
label.textContent = actionLabel();
|
||||
@@ -961,7 +998,7 @@
|
||||
|
||||
// Status
|
||||
const status = el('span', {
|
||||
fontSize: '11px', color: C.ash, whiteSpace: 'nowrap',
|
||||
fontSize: '11px', color: BP.textDim, whiteSpace: 'nowrap',
|
||||
marginLeft: 'auto',
|
||||
});
|
||||
// Variants currently arrive atomically in a single file edit, so a
|
||||
@@ -976,6 +1013,8 @@
|
||||
|
||||
// --- Cycling row ---
|
||||
|
||||
const TUNE_ICON_SVG = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" style="flex-shrink:0"><line x1="4" y1="8" x2="20" y2="8"/><circle cx="14" cy="8" r="2.4" fill="currentColor" stroke="none"/><line x1="4" y1="16" x2="20" y2="16"/><circle cx="10" cy="16" r="2.4" fill="currentColor" stroke="none"/></svg>';
|
||||
|
||||
function buildCyclingRow() {
|
||||
const row = el('div', {
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
@@ -994,7 +1033,7 @@
|
||||
// Counter
|
||||
const counter = el('span', {
|
||||
fontFamily: MONO, fontSize: '11px', fontWeight: '500',
|
||||
color: C.ash, minWidth: '24px', textAlign: 'center',
|
||||
color: BP.textDim, minWidth: '24px', textAlign: 'center',
|
||||
});
|
||||
counter.textContent = visibleVariant + '/' + arrivedVariants;
|
||||
row.appendChild(counter);
|
||||
@@ -1005,20 +1044,67 @@
|
||||
if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
|
||||
row.appendChild(next);
|
||||
|
||||
// Tune chip — only when the visible variant exposes params
|
||||
const visParams = parseVariantParams(getVisibleVariantEl());
|
||||
const hasParams = visParams.length > 0;
|
||||
if (hasParams) {
|
||||
const tune = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '6px',
|
||||
padding: '4px 10px', borderRadius: '5px',
|
||||
border: '1px solid transparent',
|
||||
background: tuneOpen ? BP.accentSoft : 'transparent',
|
||||
color: tuneOpen ? BP.accent : BP.text,
|
||||
fontFamily: FONT, fontSize: '11px', fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'color 0.12s ease, background 0.12s ease',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
tune.innerHTML = TUNE_ICON_SVG;
|
||||
const tuneLabel = document.createElement('span');
|
||||
tuneLabel.textContent = 'Tune';
|
||||
tune.appendChild(tuneLabel);
|
||||
const tuneBadge = document.createElement('span');
|
||||
Object.assign(tuneBadge.style, {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
minWidth: '16px', height: '16px', padding: '0 4px',
|
||||
borderRadius: '999px',
|
||||
background: tuneOpen ? C.brand : BP.hairline,
|
||||
color: tuneOpen ? 'oklch(98% 0 0)' : 'inherit',
|
||||
fontFamily: MONO, fontSize: '9.5px', fontWeight: '600',
|
||||
lineHeight: '1',
|
||||
boxSizing: 'border-box',
|
||||
});
|
||||
tuneBadge.textContent = String(visParams.length);
|
||||
tune.appendChild(tuneBadge);
|
||||
tune.title = 'Tune this variant (' + visParams.length + ' knob' + (visParams.length === 1 ? '' : 's') + ')';
|
||||
tune.addEventListener('mouseenter', () => {
|
||||
if (!tuneOpen) tune.style.background = BP.accentSoft;
|
||||
});
|
||||
tune.addEventListener('mouseleave', () => {
|
||||
if (!tuneOpen) tune.style.background = 'transparent';
|
||||
});
|
||||
tune.addEventListener('click', (e) => { e.stopPropagation(); toggleTunePopover(); });
|
||||
tune.dataset.iceqTune = '1';
|
||||
row.appendChild(tune);
|
||||
}
|
||||
|
||||
// Spacer
|
||||
row.appendChild(el('div', { flex: '1' }));
|
||||
|
||||
// Accept
|
||||
// Accept — primary action, uses the site's saturated brand magenta
|
||||
// with paper-white text, not the theme-muted BP.accent.
|
||||
const accept = el('button', {
|
||||
padding: '4px 10px', borderRadius: '5px',
|
||||
border: 'none', background: C.ink, color: C.white,
|
||||
padding: '5px 14px', borderRadius: '5px',
|
||||
border: 'none', background: C.brand, color: 'oklch(98% 0 0)',
|
||||
fontFamily: FONT, fontSize: '11px', fontWeight: '600',
|
||||
cursor: 'pointer', transition: 'background 0.12s ease',
|
||||
cursor: 'pointer', transition: 'filter 0.12s ease, transform 0.1s ease',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
accept.textContent = '\u2713 Accept';
|
||||
accept.addEventListener('mouseenter', () => accept.style.background = C.brand);
|
||||
accept.addEventListener('mouseleave', () => accept.style.background = C.ink);
|
||||
accept.addEventListener('mouseenter', () => accept.style.filter = 'brightness(1.08)');
|
||||
accept.addEventListener('mouseleave', () => accept.style.filter = 'none');
|
||||
accept.addEventListener('mousedown', () => accept.style.transform = 'scale(0.97)');
|
||||
accept.addEventListener('mouseup', () => accept.style.transform = 'scale(1)');
|
||||
accept.addEventListener('click', (e) => { e.stopPropagation(); handleAccept(); });
|
||||
if (arrivedVariants === 0) { accept.style.opacity = '0.3'; accept.style.pointerEvents = 'none'; }
|
||||
row.appendChild(accept);
|
||||
@@ -1026,14 +1112,14 @@
|
||||
// Discard
|
||||
const discard = el('button', {
|
||||
padding: '4px 6px', borderRadius: '5px',
|
||||
border: '1px solid ' + C.mist, background: 'transparent',
|
||||
fontFamily: FONT, fontSize: '11px', color: C.ash,
|
||||
border: '1px solid ' + BP.hairline, background: 'transparent',
|
||||
fontFamily: FONT, fontSize: '11px', color: BP.textDim,
|
||||
cursor: 'pointer', transition: 'color 0.12s ease, border-color 0.12s ease',
|
||||
});
|
||||
discard.textContent = '\u2715';
|
||||
discard.title = 'Discard all variants';
|
||||
discard.addEventListener('mouseenter', () => { discard.style.color = C.ink; discard.style.borderColor = C.ink; });
|
||||
discard.addEventListener('mouseleave', () => { discard.style.color = C.ash; discard.style.borderColor = C.mist; });
|
||||
discard.addEventListener('mouseenter', () => { discard.style.color = BP.text; discard.style.borderColor = BP.text; });
|
||||
discard.addEventListener('mouseleave', () => { discard.style.color = BP.textDim; discard.style.borderColor = BP.hairline; });
|
||||
discard.addEventListener('click', (e) => { e.stopPropagation(); handleDiscard(); });
|
||||
row.appendChild(discard);
|
||||
|
||||
@@ -1051,14 +1137,14 @@
|
||||
});
|
||||
const spinner = el('div', {
|
||||
width: '14px', height: '14px', borderRadius: '50%',
|
||||
border: '2px solid ' + C.mist,
|
||||
borderTopColor: C.brand,
|
||||
border: '2px solid ' + BP.hairline,
|
||||
borderTopColor: BP.accent,
|
||||
animation: 'impeccable-spin 0.6s linear infinite',
|
||||
flexShrink: '0',
|
||||
});
|
||||
row.appendChild(spinner);
|
||||
const label = el('span', {
|
||||
fontSize: '12px', color: C.ash, fontWeight: '500',
|
||||
fontSize: '12px', color: BP.textDim, fontWeight: '500',
|
||||
});
|
||||
label.textContent = 'Applying variant...';
|
||||
row.appendChild(label);
|
||||
@@ -1103,14 +1189,26 @@
|
||||
for (let i = 1; i <= expectedVariants; i++) {
|
||||
const arrived = i <= arrivedVariants;
|
||||
const active = i === visibleVariant;
|
||||
// active: solid site-brand magenta dot. arrived+inactive: muted neutral.
|
||||
// pending (not yet arrived): faint outline ring. No borders on arrived
|
||||
// dots — the previous "accent ring + ash fill" combo read as noisy
|
||||
// magenta chips, especially when all variants had arrived and every
|
||||
// dot wore an accent ring.
|
||||
const dotBg = active ? C.brand
|
||||
: arrived ? BP.textDim
|
||||
: 'transparent';
|
||||
const dotBorder = arrived ? 'none' : '1.5px solid ' + BP.hairline;
|
||||
const dot = el('div', {
|
||||
width: '7px', height: '7px', borderRadius: '50%',
|
||||
background: active ? C.brand : (arrived ? C.ash : 'transparent'),
|
||||
border: '1.5px solid ' + (arrived ? C.brand : C.mist),
|
||||
width: active ? '8px' : '6px',
|
||||
height: active ? '8px' : '6px',
|
||||
borderRadius: '50%',
|
||||
background: dotBg,
|
||||
border: dotBorder,
|
||||
boxSizing: 'border-box',
|
||||
transition: 'all 0.2s ' + EASE,
|
||||
cursor: (clickable && arrived) ? 'pointer' : 'default',
|
||||
transform: arrived ? 'scale(1)' : 'scale(0.6)',
|
||||
opacity: arrived ? '1' : '0.4',
|
||||
transform: arrived ? 'scale(1)' : 'scale(0.85)',
|
||||
opacity: arrived ? (active ? '1' : '0.6') : '0.4',
|
||||
});
|
||||
if (clickable && arrived) {
|
||||
const idx = i;
|
||||
@@ -1130,15 +1228,15 @@
|
||||
function navBtn(text) {
|
||||
const b = el('button', {
|
||||
width: '26px', height: '26px', borderRadius: '5px',
|
||||
border: '1px solid ' + C.mist, background: 'transparent',
|
||||
color: C.ink, fontFamily: FONT, fontSize: '13px',
|
||||
border: '1px solid ' + BP.hairline, background: 'transparent',
|
||||
color: BP.text, fontFamily: FONT, fontSize: '13px',
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'border-color 0.12s ease, background 0.12s ease',
|
||||
padding: '0', lineHeight: '1',
|
||||
});
|
||||
b.textContent = text;
|
||||
b.addEventListener('mouseenter', () => { b.style.borderColor = C.ink; });
|
||||
b.addEventListener('mouseleave', () => { b.style.borderColor = C.mist; });
|
||||
b.addEventListener('mouseenter', () => { b.style.borderColor = BP.text; });
|
||||
b.addEventListener('mouseleave', () => { b.style.borderColor = BP.hairline; });
|
||||
return b;
|
||||
}
|
||||
|
||||
@@ -1158,6 +1256,7 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initActionPicker() {
|
||||
const P = barPaletteForTheme(detectPageTheme());
|
||||
pickerEl = document.createElement('div');
|
||||
pickerEl.id = PREFIX + '-picker';
|
||||
Object.assign(pickerEl.style, {
|
||||
@@ -1166,12 +1265,14 @@
|
||||
transform: 'scale(0.96) translateY(4px)',
|
||||
transformOrigin: 'bottom left',
|
||||
transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE,
|
||||
background: C.paperSolid,
|
||||
border: '1px solid ' + C.mist,
|
||||
background: P.surface,
|
||||
border: '1px solid ' + P.hairline,
|
||||
borderRadius: '10px',
|
||||
boxShadow: '0 8px 30px oklch(0% 0 0 / 0.10), 0 2px 6px oklch(0% 0 0 / 0.06)',
|
||||
padding: '6px',
|
||||
fontFamily: FONT,
|
||||
backdropFilter: 'blur(10px)',
|
||||
WebkitBackdropFilter: 'blur(10px)',
|
||||
});
|
||||
|
||||
// Build the chip grid
|
||||
@@ -1181,22 +1282,32 @@
|
||||
|
||||
ACTIONS.forEach(action => {
|
||||
const chip = el('button', {
|
||||
padding: '6px 8px', borderRadius: '6px',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '8px 6px', borderRadius: '6px',
|
||||
border: 'none',
|
||||
background: action.value === selectedAction ? C.brandSoft : 'transparent',
|
||||
color: action.value === selectedAction ? C.brand : C.ink,
|
||||
background: action.value === selectedAction ? P.accentSoft : 'transparent',
|
||||
color: action.value === selectedAction ? P.accent : P.text,
|
||||
fontFamily: FONT, fontSize: '11px', fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.1s ease, color 0.1s ease',
|
||||
textAlign: 'center', whiteSpace: 'nowrap',
|
||||
});
|
||||
chip.textContent = action.label;
|
||||
const iconWrap = el('span', {
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
height: '20px', opacity: '0.9',
|
||||
});
|
||||
iconWrap.innerHTML = ICONS[action.value] || '';
|
||||
const labelEl = el('span', { lineHeight: '1' });
|
||||
labelEl.textContent = action.label;
|
||||
chip.appendChild(iconWrap);
|
||||
chip.appendChild(labelEl);
|
||||
chip.dataset.action = action.value;
|
||||
chip.addEventListener('mouseenter', () => {
|
||||
if (action.value !== selectedAction) chip.style.background = C.brandSoft;
|
||||
if (action.value !== selectedAction) chip.style.background = P.accentSoft;
|
||||
});
|
||||
chip.addEventListener('mouseleave', () => {
|
||||
chip.style.background = action.value === selectedAction ? C.brandSoft : 'transparent';
|
||||
chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent';
|
||||
});
|
||||
chip.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -1209,19 +1320,24 @@
|
||||
|
||||
pickerEl.appendChild(grid);
|
||||
document.body.appendChild(pickerEl);
|
||||
|
||||
// Cache the palette on the picker so toggleActionPicker's state refresh
|
||||
// uses the same theme-aware colors when it repaints chips.
|
||||
pickerEl.__iceq_palette = P;
|
||||
}
|
||||
|
||||
function toggleActionPicker() {
|
||||
if (pickerEl.style.display !== 'none') { hideActionPicker(); return; }
|
||||
// Rebuild chips to reflect current selection
|
||||
const P = pickerEl.__iceq_palette || barPaletteForTheme(detectPageTheme());
|
||||
pickerEl.querySelectorAll('button').forEach(chip => {
|
||||
const isActive = chip.dataset.action === selectedAction;
|
||||
chip.style.background = isActive ? C.brandSoft : 'transparent';
|
||||
chip.style.color = isActive ? C.brand : C.ink;
|
||||
chip.style.background = isActive ? P.accentSoft : 'transparent';
|
||||
chip.style.color = isActive ? P.accent : P.text;
|
||||
});
|
||||
// Position above the bar
|
||||
const barRect = barEl.getBoundingClientRect();
|
||||
const pickerH = 90; // approximate
|
||||
const pickerH = 170; // approximate; grows with icon + label rows
|
||||
let top = barRect.top - pickerH - 6;
|
||||
if (top < 8) top = barRect.bottom + 6;
|
||||
Object.assign(pickerEl.style, {
|
||||
@@ -1241,6 +1357,399 @@
|
||||
setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Params panel (per-variant coarse controls)
|
||||
//
|
||||
// Variants may declare a parameter manifest via a JSON attribute on the
|
||||
// variant wrapper:
|
||||
//
|
||||
// <div data-impeccable-variant="1"
|
||||
// data-impeccable-params='[{"id":"density","kind":"steps",...}]'>
|
||||
//
|
||||
// The panel docks to the right edge of the outline during CYCLING and
|
||||
// exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped
|
||||
// CSS can respond instantly without regeneration:
|
||||
//
|
||||
// range / numeric toggle → CSS var (`--p-<id>`) used via var(--p-foo, N)
|
||||
// steps / boolean toggle → data-p-<id> attribute used via :scope[data-p-foo="..."]
|
||||
//
|
||||
// On variant switch, values reset to that variant's declared defaults.
|
||||
// On accept, current values are sent in the event payload so the agent
|
||||
// can bake them into the source-file write.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide)
|
||||
let paramsPanelInner = null; // translating content (carries bg, padding, knobs)
|
||||
let paramsPanelBody = null; // grid holding the knob cells
|
||||
let paramsCurrentValues = {}; // {paramId: value} — mirror of the visible variant's live values
|
||||
let tuneOpen = false; // whether the Tune popover is open right now
|
||||
|
||||
// Theme-aware Tune popover. Appears as a drawer that slides out from the
|
||||
// contextual bar's bar-facing edge (below if the bar sits below the
|
||||
// element, above otherwise). Same width as the bar. Auto-wraps to extra
|
||||
// rows when the knobs exceed one row. The bar's border-radius on the
|
||||
// popover side goes flat while open so the two shapes read as one.
|
||||
let paramsPanelPalette = null;
|
||||
|
||||
function initParamsPanel() {
|
||||
paramsPanelPalette = barPaletteForTheme(detectPageTheme());
|
||||
const P = paramsPanelPalette;
|
||||
|
||||
// Single element, always in the DOM. The slide animation is a CSS mask
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis — no
|
||||
// display toggle, no opacity toggle, no transform trickery. The mask
|
||||
// hides everything initially; as it grows, content is revealed from
|
||||
// the bar edge outward.
|
||||
paramsPanelEl = document.createElement('div');
|
||||
paramsPanelEl.id = PREFIX + '-params-panel';
|
||||
Object.assign(paramsPanelEl.style, {
|
||||
position: 'fixed', zIndex: String(Z.bar - 1),
|
||||
background: P.surfaceDeep,
|
||||
color: P.text,
|
||||
fontFamily: FONT,
|
||||
padding: '14px 18px',
|
||||
boxSizing: 'border-box',
|
||||
borderRadius: '0 0 10px 10px',
|
||||
pointerEvents: 'none',
|
||||
backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
|
||||
|
||||
// clip-path is the same conceptual reveal as mask but with rock-solid
|
||||
// transition support across engines. Closed state clips from the far
|
||||
// edge; open = inset(0) shows everything.
|
||||
clipPath: 'inset(0 0 100% 0)',
|
||||
transition: 'clip-path 0.44s ' + EASE,
|
||||
|
||||
// Park off-screen until positionParamsPanel places it. These are NOT
|
||||
// in the transition list, so they snap instantly — no fly-in from the
|
||||
// top-left when first shown.
|
||||
top: '-9999px', left: '-9999px', width: '0',
|
||||
});
|
||||
|
||||
paramsPanelBody = el('div', {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
|
||||
gap: '12px 16px',
|
||||
});
|
||||
|
||||
paramsPanelEl.appendChild(paramsPanelBody);
|
||||
document.body.appendChild(paramsPanelEl);
|
||||
paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
|
||||
}
|
||||
|
||||
function getVisibleVariantEl() {
|
||||
if (!currentSessionId) return null;
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
|
||||
function parseVariantParams(variantEl) {
|
||||
if (!variantEl) return [];
|
||||
const raw = variantEl.getAttribute('data-impeccable-params');
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] Invalid data-impeccable-params JSON:', err.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function applyParamValue(variantEl, param, value) {
|
||||
if (!variantEl) return;
|
||||
const attr = 'data-p-' + param.id;
|
||||
if (param.kind === 'range') {
|
||||
variantEl.style.setProperty('--p-' + param.id, String(value));
|
||||
} else if (param.kind === 'toggle') {
|
||||
const on = !!value;
|
||||
variantEl.style.setProperty('--p-' + param.id, on ? '1' : '0');
|
||||
if (on) variantEl.setAttribute(attr, 'on');
|
||||
else variantEl.removeAttribute(attr);
|
||||
} else if (param.kind === 'steps') {
|
||||
variantEl.setAttribute(attr, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
function applyParamDefaults(variantEl, params) {
|
||||
paramsCurrentValues = {};
|
||||
for (const p of params) {
|
||||
paramsCurrentValues[p.id] = p.default;
|
||||
applyParamValue(variantEl, p, p.default);
|
||||
}
|
||||
}
|
||||
|
||||
function formatRangeValue(input) {
|
||||
const max = parseFloat(input.max), min = parseFloat(input.min);
|
||||
const v = parseFloat(input.value);
|
||||
if (!isFinite(v)) return input.value;
|
||||
return (max - min) <= 2 ? v.toFixed(2) : String(Math.round(v));
|
||||
}
|
||||
|
||||
function buildParamsPanel(variantEl, params) {
|
||||
const P = paramsPanelPalette || barPaletteForTheme(detectPageTheme());
|
||||
paramsPanelBody.innerHTML = '';
|
||||
for (const p of params) {
|
||||
const row = el('div', { display: 'flex', flexDirection: 'column', gap: '6px' });
|
||||
const labelRow = el('div', {
|
||||
display: 'flex', justifyContent: 'space-between',
|
||||
alignItems: 'baseline', gap: '8px',
|
||||
});
|
||||
const lbl = el('span', {
|
||||
fontSize: '10.5px', fontWeight: '600', color: P.text,
|
||||
letterSpacing: '0.03em',
|
||||
});
|
||||
lbl.textContent = p.label || p.id;
|
||||
labelRow.appendChild(lbl);
|
||||
const readout = el('span', {
|
||||
fontSize: '10.5px', color: P.textDim,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
});
|
||||
labelRow.appendChild(readout);
|
||||
row.appendChild(labelRow);
|
||||
|
||||
if (p.kind === 'range') {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'range';
|
||||
input.min = String(p.min != null ? p.min : 0);
|
||||
input.max = String(p.max != null ? p.max : 1);
|
||||
input.step = String(p.step != null ? p.step : 0.05);
|
||||
input.value = String(p.default);
|
||||
Object.assign(input.style, {
|
||||
width: '100%', accentColor: C.brand, cursor: 'pointer',
|
||||
});
|
||||
readout.textContent = formatRangeValue(input);
|
||||
input.addEventListener('input', (e) => {
|
||||
e.stopPropagation();
|
||||
const v = parseFloat(input.value);
|
||||
paramsCurrentValues[p.id] = v;
|
||||
readout.textContent = formatRangeValue(input);
|
||||
applyParamValue(variantEl, p, v);
|
||||
});
|
||||
row.appendChild(input);
|
||||
} else if (p.kind === 'toggle') {
|
||||
const initial = !!p.default;
|
||||
readout.textContent = initial ? 'On' : 'Off';
|
||||
const track = el('button', {
|
||||
position: 'relative', width: '36px', height: '20px',
|
||||
borderRadius: '10px', border: 'none', padding: '0',
|
||||
cursor: 'pointer',
|
||||
background: initial ? C.brand : P.hairline,
|
||||
transition: 'background 0.15s ease',
|
||||
alignSelf: 'flex-start',
|
||||
});
|
||||
const knob = el('span', {
|
||||
position: 'absolute', top: '2px',
|
||||
left: initial ? '18px' : '2px',
|
||||
width: '16px', height: '16px', borderRadius: '50%',
|
||||
background: 'oklch(98% 0 0)',
|
||||
transition: 'left 0.18s ' + EASE,
|
||||
boxShadow: '0 1px 2px oklch(0% 0 0 / 0.2)',
|
||||
});
|
||||
track.appendChild(knob);
|
||||
track.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const next = !paramsCurrentValues[p.id];
|
||||
paramsCurrentValues[p.id] = next;
|
||||
track.style.background = next ? C.brand : P.hairline;
|
||||
knob.style.left = next ? '18px' : '2px';
|
||||
readout.textContent = next ? 'On' : 'Off';
|
||||
applyParamValue(variantEl, p, next);
|
||||
});
|
||||
row.appendChild(track);
|
||||
} else if (p.kind === 'steps') {
|
||||
const opts = (p.options || []).map(o =>
|
||||
typeof o === 'string' ? { value: o, label: o } : o
|
||||
);
|
||||
const activeOpt = opts.find(o => o.value === p.default) || opts[0];
|
||||
readout.textContent = activeOpt ? activeOpt.label : String(p.default);
|
||||
const segRow = el('div', {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(' + opts.length + ', 1fr)',
|
||||
gap: '1px', padding: '2px',
|
||||
background: P.hairline, borderRadius: '5px',
|
||||
});
|
||||
const segBtns = [];
|
||||
opts.forEach(o => {
|
||||
const active = o.value === p.default;
|
||||
const b = el('button', {
|
||||
padding: '5px 4px', border: 'none', borderRadius: '3px',
|
||||
background: active ? C.brand : 'transparent',
|
||||
color: active ? 'oklch(98% 0 0)' : P.text,
|
||||
fontFamily: FONT, fontSize: '10.5px', fontWeight: '500',
|
||||
cursor: 'pointer', whiteSpace: 'nowrap',
|
||||
transition: 'background 0.1s ease, color 0.1s ease',
|
||||
});
|
||||
b.textContent = o.label;
|
||||
b.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
paramsCurrentValues[p.id] = o.value;
|
||||
readout.textContent = o.label;
|
||||
segBtns.forEach(({ btn, val }) => {
|
||||
const on = val === o.value;
|
||||
btn.style.background = on ? C.brand : 'transparent';
|
||||
btn.style.color = on ? 'oklch(98% 0 0)' : P.text;
|
||||
});
|
||||
applyParamValue(variantEl, p, o.value);
|
||||
});
|
||||
segRow.appendChild(b);
|
||||
segBtns.push({ btn: b, val: o.value });
|
||||
});
|
||||
row.appendChild(segRow);
|
||||
}
|
||||
|
||||
paramsPanelBody.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
// Decide which way the popover opens: away from the picked element. If the
|
||||
// bar landed below the element, popover slides DOWN from the bar's bottom.
|
||||
// If the bar landed above, popover slides UP from the bar's top.
|
||||
function popoverDirection() {
|
||||
if (!barEl || !selectedElement) return 'below';
|
||||
const br = barEl.getBoundingClientRect();
|
||||
const er = selectedElement.getBoundingClientRect();
|
||||
return br.top >= er.bottom - 4 ? 'below' : 'above';
|
||||
}
|
||||
|
||||
// The popover overlaps the bar by OVERLAP px on the bar-facing side. With
|
||||
// popover z-index below bar, that overlap sits behind bar (invisible) and
|
||||
// reinforces the "tucked behind" feel. Padding compensates so the real
|
||||
// content starts flush with bar's outer edge.
|
||||
const TUNE_OVERLAP = 6;
|
||||
|
||||
// Closed clip-path depends on direction: for 'below' clip from the far
|
||||
// (bottom) edge so the reveal grows downward from the bar; for 'above'
|
||||
// clip from the top edge so the reveal grows upward from the bar.
|
||||
function closedClipPath(direction) {
|
||||
return direction === 'below' ? 'inset(0 0 100% 0)' : 'inset(100% 0 0 0)';
|
||||
}
|
||||
|
||||
function setClipPath(value, withTransition) {
|
||||
const saved = paramsPanelEl.style.transition;
|
||||
if (!withTransition) paramsPanelEl.style.transition = 'none';
|
||||
paramsPanelEl.style.clipPath = value;
|
||||
if (!withTransition) {
|
||||
void paramsPanelEl.offsetHeight;
|
||||
paramsPanelEl.style.transition = saved;
|
||||
}
|
||||
}
|
||||
|
||||
function positionParamsPanel() {
|
||||
if (!paramsPanelEl || !barEl || barEl.style.display === 'none') return;
|
||||
const br = barEl.getBoundingClientRect();
|
||||
const direction = popoverDirection();
|
||||
const prevDirection = paramsPanelEl.dataset.tuneDirection;
|
||||
|
||||
// top/left/width are NOT in the transition list, so they snap instantly.
|
||||
paramsPanelEl.style.left = br.left + 'px';
|
||||
paramsPanelEl.style.width = br.width + 'px';
|
||||
|
||||
if (direction === 'below') {
|
||||
paramsPanelEl.style.top = (br.bottom - TUNE_OVERLAP) + 'px';
|
||||
paramsPanelEl.style.borderRadius = '0 0 10px 10px';
|
||||
paramsPanelEl.style.paddingTop = (14 + TUNE_OVERLAP) + 'px';
|
||||
paramsPanelEl.style.paddingBottom = '14px';
|
||||
} else {
|
||||
const ih = paramsPanelEl.offsetHeight || 80;
|
||||
paramsPanelEl.style.top = (br.top - ih + TUNE_OVERLAP) + 'px';
|
||||
paramsPanelEl.style.borderRadius = '10px 10px 0 0';
|
||||
paramsPanelEl.style.paddingTop = '14px';
|
||||
paramsPanelEl.style.paddingBottom = (14 + TUNE_OVERLAP) + 'px';
|
||||
}
|
||||
paramsPanelEl.dataset.tuneDirection = direction;
|
||||
|
||||
// If currently closed and direction flipped (or first-time setup),
|
||||
// snap the clip-path to the new direction's closed pose without
|
||||
// transitioning (so the clip doesn't slide across the element).
|
||||
if (!tuneOpen && (!prevDirection || prevDirection !== direction)) {
|
||||
setClipPath(closedClipPath(direction), false);
|
||||
}
|
||||
}
|
||||
|
||||
function showParamsPanel() {
|
||||
if (!paramsPanelEl) return;
|
||||
positionParamsPanel();
|
||||
paramsPanelEl.style.pointerEvents = 'auto';
|
||||
// rAF so the positioning paint commits before the transition fires.
|
||||
requestAnimationFrame(() => {
|
||||
setClipPath('inset(0 0 0 0)', true);
|
||||
});
|
||||
}
|
||||
|
||||
function hideParamsPanel() {
|
||||
if (!paramsPanelEl) return;
|
||||
paramsPanelEl.style.pointerEvents = 'none';
|
||||
const direction = paramsPanelEl.dataset.tuneDirection || 'below';
|
||||
setClipPath(closedClipPath(direction), true);
|
||||
}
|
||||
|
||||
// Build/rebuild the panel's contents for the current variant AND apply
|
||||
// its defaults to the variant wrapper (so scoped CSS responds even before
|
||||
// the user opens the popover). Visibility is governed by tuneOpen.
|
||||
function refreshParamsPanel() {
|
||||
if (state !== 'CYCLING') {
|
||||
paramsCurrentValues = {};
|
||||
tuneOpen = false;
|
||||
hideParamsPanel();
|
||||
return;
|
||||
}
|
||||
const variantEl = getVisibleVariantEl();
|
||||
const params = parseVariantParams(variantEl);
|
||||
if (!variantEl || params.length === 0) {
|
||||
paramsCurrentValues = {};
|
||||
tuneOpen = false;
|
||||
hideParamsPanel();
|
||||
return;
|
||||
}
|
||||
applyParamDefaults(variantEl, params);
|
||||
buildParamsPanel(variantEl, params);
|
||||
if (tuneOpen) {
|
||||
// If already visible (variant cycled while open), refresh in place
|
||||
// instead of re-running the clip-path animation.
|
||||
const alreadyVisible = paramsPanelEl.style.display === 'block'
|
||||
&& paramsPanelEl.style.opacity === '1';
|
||||
if (alreadyVisible) positionParamsPanel();
|
||||
else showParamsPanel();
|
||||
} else {
|
||||
hideParamsPanel();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTunePopover() {
|
||||
if (tuneOpen) { closeTunePopover(); return; }
|
||||
openTunePopover();
|
||||
}
|
||||
|
||||
function openTunePopover() {
|
||||
if (state !== 'CYCLING') return;
|
||||
const variantEl = getVisibleVariantEl();
|
||||
const params = parseVariantParams(variantEl);
|
||||
if (!variantEl || params.length === 0) return;
|
||||
// Build fresh to ensure the current variant's controls are shown.
|
||||
applyParamDefaults(variantEl, params);
|
||||
buildParamsPanel(variantEl, params);
|
||||
tuneOpen = true;
|
||||
showParamsPanel();
|
||||
// Kill the bar's shadow on the popover-facing side so the dark popover
|
||||
// doesn't pick up a bright glow line.
|
||||
if (barEl) {
|
||||
const direction = paramsPanelEl?.dataset.tuneDirection || 'below';
|
||||
barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN;
|
||||
}
|
||||
// Re-render the bar so the Tune chip picks up the active styling.
|
||||
updateBarContent('cycling');
|
||||
}
|
||||
|
||||
function closeTunePopover() {
|
||||
tuneOpen = false;
|
||||
hideParamsPanel();
|
||||
if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT;
|
||||
if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') {
|
||||
updateBarContent('cycling');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variant cycling in DOM
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1253,6 +1762,10 @@
|
||||
if (!v) continue;
|
||||
child.style.display = (v === String(num)) ? '' : 'none';
|
||||
}
|
||||
// Unconditional refresh — covers first-reveal (no-op if state isn't
|
||||
// CYCLING yet, the subsequent CYCLING transition triggers its own
|
||||
// refresh) and every cycle step.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1316,6 +1829,7 @@
|
||||
state = 'CYCLING';
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
refreshParamsPanel();
|
||||
saveSession();
|
||||
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
|
||||
})
|
||||
@@ -1329,7 +1843,7 @@
|
||||
const next = visibleVariant + dir;
|
||||
if (next < 1 || next > arrivedVariants) return;
|
||||
visibleVariant = next;
|
||||
showVariantInDOM(currentSessionId, next);
|
||||
showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself
|
||||
updateSelectedElement();
|
||||
updateBarContent('cycling');
|
||||
saveSession();
|
||||
@@ -1540,6 +2054,7 @@
|
||||
state = 'CYCLING';
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
refreshParamsPanel();
|
||||
} else if (state === 'GENERATING') {
|
||||
updateBarContent('generating');
|
||||
}
|
||||
@@ -1560,6 +2075,7 @@
|
||||
if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') {
|
||||
positionBar();
|
||||
showHighlight(selectedElement);
|
||||
if (tuneOpen) positionParamsPanel();
|
||||
}
|
||||
if (annotActive) positionAnnotOverlay(selectedElement);
|
||||
// Shader overlay (via debug P toggle or generation) is repositioned
|
||||
@@ -1606,6 +2122,7 @@
|
||||
if (state === 'GENERATING') {
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
refreshParamsPanel();
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -1691,6 +2208,10 @@
|
||||
if (pickerEl?.style.display !== 'none' && !own(e.target)) {
|
||||
hideActionPicker();
|
||||
}
|
||||
// Close Tune popover on outside click (anything outside panel + bar)
|
||||
if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) {
|
||||
closeTunePopover();
|
||||
}
|
||||
// In CONFIGURING: click outside the bar and selected element returns to PICKING
|
||||
if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) {
|
||||
hideBar();
|
||||
@@ -2258,7 +2779,11 @@ void main() {
|
||||
|
||||
function handleAccept() {
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
sendEvent({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) });
|
||||
const acceptPayload = { type: 'accept', id: currentSessionId, variantId: String(visibleVariant) };
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
sendEvent(acceptPayload);
|
||||
markSessionHandled();
|
||||
|
||||
// The accepted variant is already the only visible child of the wrapper
|
||||
@@ -2477,6 +3002,11 @@ void main() {
|
||||
state = arrivedVariants >= expectedVariants ? 'CYCLING' : 'GENERATING';
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -2522,6 +3052,11 @@ void main() {
|
||||
// dark pages. This keeps the bar from fighting with the host design.
|
||||
function detectPageTheme() {
|
||||
try {
|
||||
// Dev override: set localStorage 'impeccable-dev-theme' to 'light' or
|
||||
// 'dark' to preview the opposite palette without actually changing the
|
||||
// page bg. Used for screenshots and theme QA.
|
||||
const override = localStorage.getItem('impeccable-dev-theme');
|
||||
if (override === 'light' || override === 'dark') return override;
|
||||
const bg = getComputedStyle(document.body).backgroundColor
|
||||
|| getComputedStyle(document.documentElement).backgroundColor;
|
||||
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
||||
@@ -2538,6 +3073,7 @@ void main() {
|
||||
// Light bar on dark page
|
||||
return {
|
||||
surface: 'oklch(98% 0 0 / 0.92)',
|
||||
surfaceDeep: 'oklch(92% 0.005 60 / 0.96)', // slightly deeper, faint warm
|
||||
hairline: 'oklch(70% 0 0 / 0.35)',
|
||||
text: 'oklch(15% 0 0)',
|
||||
textDim: 'oklch(45% 0 0)',
|
||||
@@ -2552,6 +3088,7 @@ void main() {
|
||||
// deeper so the rounded-right shape reads as a clear sculpted mark.
|
||||
return {
|
||||
surface: 'oklch(26% 0 0 / 0.94)',
|
||||
surfaceDeep: 'oklch(18% 0 0 / 0.96)', // darker sand for Tune popover
|
||||
hairline: 'oklch(42% 0 0 / 0.5)',
|
||||
text: 'oklch(96% 0 0)',
|
||||
textDim: 'oklch(72% 0 0)',
|
||||
@@ -2883,6 +3420,7 @@ void main() {
|
||||
if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; }
|
||||
if (barEl) { barEl.remove(); barEl = null; }
|
||||
if (pickerEl) { pickerEl.remove(); pickerEl = null; }
|
||||
if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; }
|
||||
if (evtSource) { evtSource.close(); evtSource = null; }
|
||||
document.removeEventListener('mousemove', handleMouseMove, true);
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
@@ -3956,6 +4494,7 @@ void main() {
|
||||
initAnnotOverlay();
|
||||
initBar();
|
||||
initActionPicker();
|
||||
initParamsPanel();
|
||||
initGlobalBar();
|
||||
initDesignPanel();
|
||||
document.addEventListener('mousemove', handleMouseMove, true);
|
||||
|
||||
@@ -145,6 +145,11 @@ Options:
|
||||
const scriptArgs = event.type === 'discard'
|
||||
? ['--id', event.id, '--discard']
|
||||
: ['--id', event.id, '--variant', event.variantId];
|
||||
if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
|
||||
// Pass through a JSON blob; the shell-safe wrap uses single quotes because
|
||||
// values are finite {id, number|string|boolean} pairs from a validated payload.
|
||||
scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
|
||||
}
|
||||
try {
|
||||
const out = execSync(
|
||||
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
|
||||
|
||||
@@ -147,6 +147,11 @@ function validateEvent(msg) {
|
||||
case 'accept':
|
||||
if (!msg.id) return 'accept: missing id';
|
||||
if (!msg.variantId) return 'accept: missing variantId';
|
||||
if (msg.paramValues !== undefined) {
|
||||
if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
|
||||
return 'accept: paramValues must be an object';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
case 'discard':
|
||||
return msg.id ? null : 'discard: missing id';
|
||||
|
||||
@@ -142,3 +142,13 @@ Test that colorization improves the experience:
|
||||
- **Not overwhelming**: Is color balanced and purposeful?
|
||||
|
||||
Remember: Color is emotional and powerful. Use it to create warmth, guide attention, communicate meaning, and express personality. But restraint and strategy matter more than saturation and variety. Be colorful, but be intentional.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
When invoked from live mode, each variant MUST declare a `color-amount` param so the user can dial between a restrained accent and a drenched surface without regeneration. Author the variant's CSS against `var(--p-color-amount, 0.5)` — typically as the alpha multiplier on backgrounds, or as a scaling factor on the chroma axis in an OKLCH expression. 0 = neutral/monochrome, 1 = full saturation / dominant coverage.
|
||||
|
||||
```json
|
||||
{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"}
|
||||
```
|
||||
|
||||
Layer 1-2 variant-specific params on top: palette selection (`steps` with named options), temperature warmth, or tint vs. true color. See `reference/live.md` for the full params contract.
|
||||
|
||||
@@ -119,3 +119,23 @@ Create a systematic plan:
|
||||
- **Responsiveness**: Does the layout adapt gracefully across screen sizes?
|
||||
|
||||
Remember: Space is the most underused design tool. A layout with the right rhythm and hierarchy can make even simple content feel polished and intentional.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
Each variant MUST declare a `density` param. Drive all spacing tokens in the variant's scoped CSS through `calc(var(--p-density, 1) * <base>)` — paddings, gaps, column widths. Users slide from airy to packed and see layout re-breathe with no regeneration.
|
||||
|
||||
```json
|
||||
{"id":"density","kind":"range","min":0.6,"max":1.4,"step":0.05,"default":1,"label":"Density"}
|
||||
```
|
||||
|
||||
For variants whose topology genuinely changes (stacked vs. side-by-side, grid vs. bento), use a `steps` param whose scoped CSS branches via `:scope[data-p-structure="X"]`. One structure param + one density param is a powerful combo; resist adding a third.
|
||||
|
||||
```json
|
||||
{"id":"structure","kind":"steps","default":"grid","label":"Structure","options":[
|
||||
{"value":"stacked","label":"Stacked"},
|
||||
{"value":"grid","label":"Grid"},
|
||||
{"value":"bento","label":"Bento"}
|
||||
]}
|
||||
```
|
||||
|
||||
See `reference/live.md` for the full params contract.
|
||||
|
||||
@@ -179,7 +179,56 @@ The first variant has no `display: none` (visible by default). All others do. If
|
||||
|
||||
One edit, all variants — the browser's MutationObserver picks everything up in one pass.
|
||||
|
||||
### 7. Signal done
|
||||
### 7. Parameters (optional, 2-5 per variant)
|
||||
|
||||
Each variant can expose coarse knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against.
|
||||
|
||||
**When to use.** Any time the variant has a meaningful axis the user might want to dial in: color amount, density, motion intensity, scale ratio. Not micro-level margin tweaks; those defeat the point.
|
||||
|
||||
**Budget scales with the element's visual weight, not the user's curiosity.** Knobs need real estate to produce noticeably different output; slapping three on a small element just crowds the panel without making anything feel tunable.
|
||||
|
||||
- **Leaf / tiny** — a single button, icon, input, bare heading, solitary paragraph: **0 params.** A slider can't meaningfully reshape one element; just use variants.
|
||||
- **Small composition** — labeled input, simple card, short callout (≤ ~5 visual children): **0-1 params.** Only add one if it's a clear dominant axis (e.g. density on a card with visible internal rhythm).
|
||||
- **Medium composition** — section component, nav cluster, dense card, short feature block (6-15 visual children): **2 params.**
|
||||
- **Large composition** — hero section, full page region, spread layout, anything with strong internal structure (16+ visual children or multiple sub-sections): **3-4 params.**
|
||||
|
||||
When in doubt, fewer. The user can always ask for more variants to explore an axis you didn't expose as a knob. Count by visual children, not by DOM-node depth; a deeply-nested-but-visually-simple card still counts as small.
|
||||
|
||||
**How to declare.** Put a JSON manifest on the variant wrapper:
|
||||
|
||||
```html
|
||||
<div data-impeccable-variant="1" data-impeccable-params='[
|
||||
{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"},
|
||||
{"id":"density","kind":"steps","default":"snug","label":"Density","options":[
|
||||
{"value":"airy","label":"Airy"},
|
||||
{"value":"snug","label":"Snug"},
|
||||
{"value":"packed","label":"Packed"}
|
||||
]},
|
||||
{"id":"serif","kind":"toggle","default":false,"label":"Serif display"}
|
||||
]'>
|
||||
...variant content...
|
||||
</div>
|
||||
```
|
||||
|
||||
**Three kinds:**
|
||||
|
||||
- `range` — smooth slider. Drives a CSS custom property `--p-<id>` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`.
|
||||
- `steps` — segmented radio. Drives a data attribute `data-p-<id>` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`.
|
||||
- `toggle` — on/off switch. Drives BOTH a CSS var (`--p-<id>: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`.
|
||||
|
||||
**Signature params per action.** Each action has one or two signature params that MUST be exposed when the variant can meaningfully express them. Check the action's reference file for the list. Layer 1-2 variant-specific params on top.
|
||||
|
||||
**Reset on variant switch.** User dials density on v1, flips to v2, v2 starts at v2's declared defaults. Known limitation; preservation across variants may land later.
|
||||
|
||||
**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment:
|
||||
|
||||
```html
|
||||
<!-- impeccable-param-values SESSION_ID: {"color-amount":0.7,"density":"packed"} -->
|
||||
```
|
||||
|
||||
The carbonize cleanup step (see below) reads that comment and bakes the chosen values into the final CSS. For `steps`/`toggle` attribute selectors: keep only the branch matching the chosen value, drop the others, collapse `:scope[data-p-density="packed"] .grid` to a semantic class rule. For `range` vars: either substitute the literal or keep the var with the chosen value as its new default.
|
||||
|
||||
### 8. Signal done
|
||||
|
||||
```bash
|
||||
node .opencode/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
|
||||
@@ -244,11 +293,11 @@ When `_acceptResult.carbonize === true`, the accepted variant was stitched into
|
||||
|
||||
Do these five steps in the current thread, synchronously, before the next poll. Do not poll again until the file is clean.
|
||||
|
||||
1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element.
|
||||
1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values — read it first; it drives steps 3 and 4 below.
|
||||
2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `public/css/workflow.css` for this repo, or the component's co-located CSS file for a Vite/Next project — pick whichever already owns styling for the surrounding element).
|
||||
3. **Rewrite `@scope ([data-impeccable-variant="N"])` selectors** to target real, semantic classes on the accepted HTML. Example: `@scope ([data-impeccable-variant="2"]) { .v2-label { … } }` becomes `.why-visual--v2 .v2-label { … }` if the accepted element already carries `.why-visual--v2`, or pick/add a suitable class if it doesn't.
|
||||
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it.
|
||||
5. **Delete the inline `<style>` block and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one — those are dead code now.
|
||||
3. **Bake in parameter values while rewriting selectors.** For `@scope ([data-impeccable-variant="N"])` wrappers: retarget to real, semantic classes on the accepted HTML (`.why-visual--v2 .v2-label { … }`). For `:scope[data-p-<id>="VALUE"]` selectors: keep only the branch matching the chosen value from the param-values comment; drop the others (they're dead after accept). For `var(--p-<id>, DEFAULT)` in the CSS: either substitute the literal value, or if the param is still useful as a knob going forward, leave the var and update its initial declaration to the chosen value.
|
||||
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it. Drop `data-impeccable-params` and any `data-p-*` attributes from it — those are live-mode plumbing, not source.
|
||||
5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one — those are dead code now.
|
||||
|
||||
Then poll again.
|
||||
|
||||
|
||||
@@ -110,3 +110,15 @@ Build a clear type scale:
|
||||
- **Accessibility**: Does text meet WCAG contrast ratios? Is it zoomable to 200%?
|
||||
|
||||
Remember: Typography is the foundation of interface design — it carries the majority of information. Getting it right is the highest-leverage improvement you can make.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
Each variant MUST declare a `scale` param controlling the hierarchy ratio. Express all font sizes in the variant's scoped CSS through `calc(var(--p-scale, 1) * <base>)` or, better, scale the type ramp via `clamp(min, calc(var(--p-scale, 1) * Npx), max)`. Users slide from subdued to commanding.
|
||||
|
||||
```json
|
||||
{"id":"scale","kind":"range","min":0.85,"max":1.3,"step":0.05,"default":1,"label":"Scale"}
|
||||
```
|
||||
|
||||
Where the variant riffs on a specific pairing, expose the pairing choice as a `steps` param (e.g. "serif display + sans body" vs. "mono display + sans body" vs. "all-sans"). Each branch routes through `:scope[data-p-pairing="X"]` selectors in scoped CSS.
|
||||
|
||||
See `reference/live.md` for the full params contract.
|
||||
|
||||
@@ -45,11 +45,18 @@ Output (JSON):
|
||||
|
||||
const id = argVal(args, '--id');
|
||||
const variantNum = argVal(args, '--variant');
|
||||
const paramValuesRaw = argVal(args, '--param-values');
|
||||
const isDiscard = args.includes('--discard');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); }
|
||||
|
||||
let paramValues = null;
|
||||
if (paramValuesRaw) {
|
||||
try { paramValues = JSON.parse(paramValuesRaw); }
|
||||
catch { paramValues = null; } // malformed blob: skip the comment rather than failing the accept
|
||||
}
|
||||
|
||||
// Find the file containing this session's markers
|
||||
const found = findSessionFile(id, process.cwd());
|
||||
if (!found) {
|
||||
@@ -78,7 +85,7 @@ Output (JSON):
|
||||
const result = handleDiscard(id, lines, targetFile);
|
||||
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
|
||||
} else {
|
||||
const result = handleAccept(id, variantNum, lines, targetFile);
|
||||
const result = handleAccept(id, variantNum, lines, targetFile, paramValues);
|
||||
// Single-line attention-grabber when cleanup is required. The full
|
||||
// five-step checklist lives in reference/live.md (loaded once per
|
||||
// session); repeating it per-event would waste tokens.
|
||||
@@ -116,7 +123,7 @@ function handleDiscard(id, lines, targetFile) {
|
||||
// Accept
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function handleAccept(id, variantNum, lines, targetFile) {
|
||||
function handleAccept(id, variantNum, lines, targetFile, paramValues) {
|
||||
const block = findMarkerBlock(id, lines);
|
||||
if (!block) return { handled: false, error: 'Markers not found' };
|
||||
|
||||
@@ -149,6 +156,11 @@ function handleAccept(id, variantNum, lines, targetFile) {
|
||||
replacement.push(indent + cssLine.trimStart());
|
||||
}
|
||||
replacement.push(indent + '</style>');
|
||||
if (paramValues && Object.keys(paramValues).length > 0) {
|
||||
// Preserve the user's knob positions for the carbonize-cleanup agent
|
||||
// to bake into the final CSS when it collapses scoped rules.
|
||||
replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
|
||||
}
|
||||
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,10 +30,13 @@
|
||||
// Design tokens
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Brand magenta is pinned to the site token (--color-accent in main.css)
|
||||
// so Accept / knobs / cycle-dots match the site's accent, not a washed
|
||||
// theme-adjusted one.
|
||||
const C = {
|
||||
brand: 'oklch(55% 0.25 350)',
|
||||
brandHov: 'oklch(48% 0.25 350)',
|
||||
brandSoft: 'oklch(55% 0.25 350 / 0.12)',
|
||||
brand: 'oklch(60% 0.25 350)',
|
||||
brandHov: 'oklch(52% 0.25 350)',
|
||||
brandSoft: 'oklch(60% 0.25 350 / 0.15)',
|
||||
ink: 'oklch(15% 0.01 350)',
|
||||
ash: 'oklch(55% 0 0)',
|
||||
paper: 'oklch(98% 0.005 350 / 0.92)',
|
||||
@@ -60,6 +63,25 @@
|
||||
'html', 'head', 'body', 'script', 'style', 'link', 'meta', 'noscript', 'br', 'wbr',
|
||||
]);
|
||||
|
||||
// SVG icons stack above each chip label. All strokes use currentColor so the
|
||||
// icon recolors to C.brand when its chip is selected. 20x20 render, 24-viewBox,
|
||||
// 1.5 stroke — visually consistent with the Foundation grid on the homepage.
|
||||
const ICON_ATTRS = 'width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="display:block"';
|
||||
const ICONS = {
|
||||
impeccable: `<svg ${ICON_ATTRS}><path d="M4 20l4-1L18 9l-3-3L5 16z"/><path d="M14 7l3 3"/></svg>`,
|
||||
bolder: `<svg ${ICON_ATTRS}><rect x="6" y="12" width="4" height="7" rx="0.5"/><rect x="14" y="5" width="4" height="14" rx="0.5"/></svg>`,
|
||||
quieter: `<svg ${ICON_ATTRS}><rect x="6" y="5" width="4" height="14" rx="0.5"/><rect x="14" y="12" width="4" height="7" rx="0.5"/></svg>`,
|
||||
distill: `<svg ${ICON_ATTRS}><path d="M4 5h16l-6 8v7l-4-2v-5z"/></svg>`,
|
||||
polish: `<svg ${ICON_ATTRS}><path d="M15 3l1 3 3 1-3 1-1 3-1-3-3-1 3-1z"/><path d="M7 13l0.6 1.8 1.8 0.6-1.8 0.6-0.6 1.8-0.6-1.8-1.8-0.6 1.8-0.6z"/></svg>`,
|
||||
typeset: `<svg ${ICON_ATTRS}><path d="M5 6h14" stroke-width="2.6"/><path d="M5 12h9" stroke-width="1.9"/><path d="M5 18h5" stroke-width="1.3"/></svg>`,
|
||||
colorize: `<svg ${ICON_ATTRS}><circle cx="9" cy="10" r="5"/><circle cx="15" cy="10" r="5"/><circle cx="12" cy="15" r="5"/></svg>`,
|
||||
layout: `<svg ${ICON_ATTRS}><rect x="3" y="4" width="8" height="16" rx="0.5"/><rect x="13" y="4" width="8" height="7" rx="0.5"/><rect x="13" y="13" width="8" height="7" rx="0.5"/></svg>`,
|
||||
adapt: `<svg ${ICON_ATTRS}><rect x="2.5" y="5" width="12" height="11" rx="1"/><line x1="2.5" y1="19" x2="14.5" y2="19"/><rect x="16.5" y="8" width="5" height="11" rx="1"/></svg>`,
|
||||
animate: `<svg ${ICON_ATTRS}><path d="M3 18c4-4 6-10 10-10"/><path d="M13 8c3 0 5 5 8 10"/><circle cx="13" cy="8" r="1.6" fill="currentColor" stroke="none"/></svg>`,
|
||||
delight: `<svg ${ICON_ATTRS}><path d="M12 3l2 6 6 2-6 2-2 6-2-6-6-2 6-2z"/></svg>`,
|
||||
overdrive: `<svg ${ICON_ATTRS}><path d="M13 3L5 13h5l-1 8 9-12h-6z"/></svg>`,
|
||||
};
|
||||
|
||||
const ACTIONS = [
|
||||
{ value: 'impeccable', label: 'Freeform' },
|
||||
{ value: 'bolder', label: 'Bolder' },
|
||||
@@ -756,7 +778,21 @@
|
||||
// The Bar — one floating element, three modes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Contextual-bar palette. Cached at init so every build*Row reads a
|
||||
// consistent set of colors; detectPageTheme runs once rather than on every
|
||||
// phase transition.
|
||||
let BP = null;
|
||||
|
||||
// Bar shadow variants. The default projects down + subtle around. When
|
||||
// the Tune popover opens below the bar, a downward shadow lands on the
|
||||
// dark popover and reads as a bright ghost line. We swap to UP-only while
|
||||
// tune is open below so the popover's top edge is clean.
|
||||
const BAR_SHADOW_DEFAULT = '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)';
|
||||
const BAR_SHADOW_UP = '0 -4px 20px oklch(0% 0 0 / 0.08), 0 -1px 3px oklch(0% 0 0 / 0.06)';
|
||||
const BAR_SHADOW_DOWN = BAR_SHADOW_DEFAULT;
|
||||
|
||||
function initBar() {
|
||||
BP = barPaletteForTheme(detectPageTheme());
|
||||
barEl = document.createElement('div');
|
||||
barEl.id = PREFIX + '-bar';
|
||||
Object.assign(barEl.style, {
|
||||
@@ -764,15 +800,15 @@
|
||||
display: 'none', opacity: '0',
|
||||
transform: 'translateY(6px)',
|
||||
transition: 'opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
|
||||
// Visual
|
||||
background: C.paper,
|
||||
background: BP.surface,
|
||||
backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
|
||||
border: '1px solid ' + C.mist,
|
||||
border: '1px solid ' + BP.hairline,
|
||||
borderRadius: '10px',
|
||||
boxShadow: '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)',
|
||||
fontFamily: FONT, fontSize: '13px', color: C.ink,
|
||||
boxShadow: BAR_SHADOW_DEFAULT,
|
||||
transition: 'box-shadow 0.2s ease, opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
|
||||
fontFamily: FONT, fontSize: '13px', color: BP.text,
|
||||
padding: '6px',
|
||||
maxWidth: '460px', minWidth: '320px',
|
||||
maxWidth: '520px', minWidth: '320px',
|
||||
});
|
||||
document.body.appendChild(barEl);
|
||||
}
|
||||
@@ -824,14 +860,15 @@
|
||||
barEl.style.transform = 'translateY(6px)';
|
||||
setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250);
|
||||
hideActionPicker();
|
||||
closeTunePopover();
|
||||
}
|
||||
|
||||
function updateBarContent(mode) {
|
||||
if (!barEl || barEl.style.display === 'none') return;
|
||||
barEl.innerHTML = '';
|
||||
// Reset bar styling
|
||||
barEl.style.background = C.paper;
|
||||
barEl.style.border = '1px solid ' + C.mist;
|
||||
// Reset bar styling to the theme-aware palette
|
||||
barEl.style.background = BP.surface;
|
||||
barEl.style.border = '1px solid ' + BP.hairline;
|
||||
if (mode === 'configure') barEl.appendChild(buildConfigureRow());
|
||||
else if (mode === 'generating') barEl.appendChild(buildGeneratingRow());
|
||||
else if (mode === 'cycling') barEl.appendChild(buildCyclingRow());
|
||||
@@ -854,15 +891,15 @@
|
||||
const pill = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
||||
padding: '5px 10px', borderRadius: '6px',
|
||||
background: C.ink, color: C.white,
|
||||
background: BP.mark, color: BP.markText,
|
||||
fontFamily: FONT, fontSize: '12px', fontWeight: '500',
|
||||
border: 'none', cursor: 'pointer',
|
||||
transition: 'background 0.12s ease, transform 0.1s ease',
|
||||
whiteSpace: 'nowrap', flexShrink: '0',
|
||||
});
|
||||
pill.textContent = actionLabel() + ' \u25BE';
|
||||
pill.addEventListener('mouseenter', () => pill.style.background = C.brandHov);
|
||||
pill.addEventListener('mouseleave', () => pill.style.background = C.ink);
|
||||
pill.addEventListener('mouseenter', () => pill.style.background = BP.accent);
|
||||
pill.addEventListener('mouseleave', () => pill.style.background = BP.mark);
|
||||
pill.addEventListener('mousedown', () => pill.style.transform = 'scale(0.97)');
|
||||
pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)');
|
||||
pill.addEventListener('click', (e) => { e.stopPropagation(); toggleActionPicker(); });
|
||||
@@ -877,13 +914,13 @@
|
||||
flex: '1', minWidth: '0',
|
||||
padding: '5px 8px', borderRadius: '6px',
|
||||
border: '1px solid transparent', background: 'transparent',
|
||||
fontFamily: FONT, fontSize: '12px', color: C.ink,
|
||||
fontFamily: FONT, fontSize: '12px', color: BP.text,
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.15s ease, background 0.15s ease',
|
||||
});
|
||||
input.addEventListener('focus', () => {
|
||||
input.style.borderColor = C.mist;
|
||||
input.style.background = C.white;
|
||||
input.style.borderColor = BP.hairline;
|
||||
input.style.background = BP.accentSoft;
|
||||
});
|
||||
input.addEventListener('blur', () => {
|
||||
input.style.borderColor = 'transparent';
|
||||
@@ -901,16 +938,16 @@
|
||||
// Variant count toggle
|
||||
const count = el('button', {
|
||||
padding: '4px 6px', borderRadius: '5px',
|
||||
border: '1px solid ' + C.mist, background: 'transparent',
|
||||
border: '1px solid ' + BP.hairline, background: 'transparent',
|
||||
fontFamily: MONO, fontSize: '11px', fontWeight: '600',
|
||||
color: C.ash, cursor: 'pointer',
|
||||
color: BP.textDim, cursor: 'pointer',
|
||||
transition: 'color 0.12s ease, border-color 0.12s ease',
|
||||
flexShrink: '0', whiteSpace: 'nowrap',
|
||||
});
|
||||
count.textContent = '\u00D7' + selectedCount;
|
||||
count.title = 'Variants: click to change';
|
||||
count.addEventListener('mouseenter', () => { count.style.color = C.ink; count.style.borderColor = C.ink; });
|
||||
count.addEventListener('mouseleave', () => { count.style.color = C.ash; count.style.borderColor = C.mist; });
|
||||
count.addEventListener('mouseenter', () => { count.style.color = BP.text; count.style.borderColor = BP.text; });
|
||||
count.addEventListener('mouseleave', () => { count.style.color = BP.textDim; count.style.borderColor = BP.hairline; });
|
||||
count.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1;
|
||||
@@ -921,15 +958,15 @@
|
||||
// Go button
|
||||
const go = el('button', {
|
||||
padding: '5px 12px', borderRadius: '6px',
|
||||
border: 'none', background: C.brand, color: C.white,
|
||||
border: 'none', background: BP.accent, color: BP.mark,
|
||||
fontFamily: FONT, fontSize: '12px', fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.12s ease, transform 0.1s ease',
|
||||
transition: 'filter 0.12s ease, transform 0.1s ease',
|
||||
flexShrink: '0', whiteSpace: 'nowrap',
|
||||
});
|
||||
go.textContent = 'Go \u2192';
|
||||
go.addEventListener('mouseenter', () => go.style.background = C.brandHov);
|
||||
go.addEventListener('mouseleave', () => go.style.background = C.brand);
|
||||
go.addEventListener('mouseenter', () => go.style.filter = 'brightness(1.1)');
|
||||
go.addEventListener('mouseleave', () => go.style.filter = 'none');
|
||||
go.addEventListener('mousedown', () => go.style.transform = 'scale(0.97)');
|
||||
go.addEventListener('mouseup', () => go.style.transform = 'scale(1)');
|
||||
go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); });
|
||||
@@ -950,7 +987,7 @@
|
||||
|
||||
// Action label
|
||||
const label = el('span', {
|
||||
fontWeight: '600', fontSize: '12px', color: C.ink,
|
||||
fontWeight: '600', fontSize: '12px', color: BP.text,
|
||||
flexShrink: '0', whiteSpace: 'nowrap',
|
||||
});
|
||||
label.textContent = actionLabel();
|
||||
@@ -961,7 +998,7 @@
|
||||
|
||||
// Status
|
||||
const status = el('span', {
|
||||
fontSize: '11px', color: C.ash, whiteSpace: 'nowrap',
|
||||
fontSize: '11px', color: BP.textDim, whiteSpace: 'nowrap',
|
||||
marginLeft: 'auto',
|
||||
});
|
||||
// Variants currently arrive atomically in a single file edit, so a
|
||||
@@ -976,6 +1013,8 @@
|
||||
|
||||
// --- Cycling row ---
|
||||
|
||||
const TUNE_ICON_SVG = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" style="flex-shrink:0"><line x1="4" y1="8" x2="20" y2="8"/><circle cx="14" cy="8" r="2.4" fill="currentColor" stroke="none"/><line x1="4" y1="16" x2="20" y2="16"/><circle cx="10" cy="16" r="2.4" fill="currentColor" stroke="none"/></svg>';
|
||||
|
||||
function buildCyclingRow() {
|
||||
const row = el('div', {
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
@@ -994,7 +1033,7 @@
|
||||
// Counter
|
||||
const counter = el('span', {
|
||||
fontFamily: MONO, fontSize: '11px', fontWeight: '500',
|
||||
color: C.ash, minWidth: '24px', textAlign: 'center',
|
||||
color: BP.textDim, minWidth: '24px', textAlign: 'center',
|
||||
});
|
||||
counter.textContent = visibleVariant + '/' + arrivedVariants;
|
||||
row.appendChild(counter);
|
||||
@@ -1005,20 +1044,67 @@
|
||||
if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
|
||||
row.appendChild(next);
|
||||
|
||||
// Tune chip — only when the visible variant exposes params
|
||||
const visParams = parseVariantParams(getVisibleVariantEl());
|
||||
const hasParams = visParams.length > 0;
|
||||
if (hasParams) {
|
||||
const tune = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '6px',
|
||||
padding: '4px 10px', borderRadius: '5px',
|
||||
border: '1px solid transparent',
|
||||
background: tuneOpen ? BP.accentSoft : 'transparent',
|
||||
color: tuneOpen ? BP.accent : BP.text,
|
||||
fontFamily: FONT, fontSize: '11px', fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'color 0.12s ease, background 0.12s ease',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
tune.innerHTML = TUNE_ICON_SVG;
|
||||
const tuneLabel = document.createElement('span');
|
||||
tuneLabel.textContent = 'Tune';
|
||||
tune.appendChild(tuneLabel);
|
||||
const tuneBadge = document.createElement('span');
|
||||
Object.assign(tuneBadge.style, {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
minWidth: '16px', height: '16px', padding: '0 4px',
|
||||
borderRadius: '999px',
|
||||
background: tuneOpen ? C.brand : BP.hairline,
|
||||
color: tuneOpen ? 'oklch(98% 0 0)' : 'inherit',
|
||||
fontFamily: MONO, fontSize: '9.5px', fontWeight: '600',
|
||||
lineHeight: '1',
|
||||
boxSizing: 'border-box',
|
||||
});
|
||||
tuneBadge.textContent = String(visParams.length);
|
||||
tune.appendChild(tuneBadge);
|
||||
tune.title = 'Tune this variant (' + visParams.length + ' knob' + (visParams.length === 1 ? '' : 's') + ')';
|
||||
tune.addEventListener('mouseenter', () => {
|
||||
if (!tuneOpen) tune.style.background = BP.accentSoft;
|
||||
});
|
||||
tune.addEventListener('mouseleave', () => {
|
||||
if (!tuneOpen) tune.style.background = 'transparent';
|
||||
});
|
||||
tune.addEventListener('click', (e) => { e.stopPropagation(); toggleTunePopover(); });
|
||||
tune.dataset.iceqTune = '1';
|
||||
row.appendChild(tune);
|
||||
}
|
||||
|
||||
// Spacer
|
||||
row.appendChild(el('div', { flex: '1' }));
|
||||
|
||||
// Accept
|
||||
// Accept — primary action, uses the site's saturated brand magenta
|
||||
// with paper-white text, not the theme-muted BP.accent.
|
||||
const accept = el('button', {
|
||||
padding: '4px 10px', borderRadius: '5px',
|
||||
border: 'none', background: C.ink, color: C.white,
|
||||
padding: '5px 14px', borderRadius: '5px',
|
||||
border: 'none', background: C.brand, color: 'oklch(98% 0 0)',
|
||||
fontFamily: FONT, fontSize: '11px', fontWeight: '600',
|
||||
cursor: 'pointer', transition: 'background 0.12s ease',
|
||||
cursor: 'pointer', transition: 'filter 0.12s ease, transform 0.1s ease',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
accept.textContent = '\u2713 Accept';
|
||||
accept.addEventListener('mouseenter', () => accept.style.background = C.brand);
|
||||
accept.addEventListener('mouseleave', () => accept.style.background = C.ink);
|
||||
accept.addEventListener('mouseenter', () => accept.style.filter = 'brightness(1.08)');
|
||||
accept.addEventListener('mouseleave', () => accept.style.filter = 'none');
|
||||
accept.addEventListener('mousedown', () => accept.style.transform = 'scale(0.97)');
|
||||
accept.addEventListener('mouseup', () => accept.style.transform = 'scale(1)');
|
||||
accept.addEventListener('click', (e) => { e.stopPropagation(); handleAccept(); });
|
||||
if (arrivedVariants === 0) { accept.style.opacity = '0.3'; accept.style.pointerEvents = 'none'; }
|
||||
row.appendChild(accept);
|
||||
@@ -1026,14 +1112,14 @@
|
||||
// Discard
|
||||
const discard = el('button', {
|
||||
padding: '4px 6px', borderRadius: '5px',
|
||||
border: '1px solid ' + C.mist, background: 'transparent',
|
||||
fontFamily: FONT, fontSize: '11px', color: C.ash,
|
||||
border: '1px solid ' + BP.hairline, background: 'transparent',
|
||||
fontFamily: FONT, fontSize: '11px', color: BP.textDim,
|
||||
cursor: 'pointer', transition: 'color 0.12s ease, border-color 0.12s ease',
|
||||
});
|
||||
discard.textContent = '\u2715';
|
||||
discard.title = 'Discard all variants';
|
||||
discard.addEventListener('mouseenter', () => { discard.style.color = C.ink; discard.style.borderColor = C.ink; });
|
||||
discard.addEventListener('mouseleave', () => { discard.style.color = C.ash; discard.style.borderColor = C.mist; });
|
||||
discard.addEventListener('mouseenter', () => { discard.style.color = BP.text; discard.style.borderColor = BP.text; });
|
||||
discard.addEventListener('mouseleave', () => { discard.style.color = BP.textDim; discard.style.borderColor = BP.hairline; });
|
||||
discard.addEventListener('click', (e) => { e.stopPropagation(); handleDiscard(); });
|
||||
row.appendChild(discard);
|
||||
|
||||
@@ -1051,14 +1137,14 @@
|
||||
});
|
||||
const spinner = el('div', {
|
||||
width: '14px', height: '14px', borderRadius: '50%',
|
||||
border: '2px solid ' + C.mist,
|
||||
borderTopColor: C.brand,
|
||||
border: '2px solid ' + BP.hairline,
|
||||
borderTopColor: BP.accent,
|
||||
animation: 'impeccable-spin 0.6s linear infinite',
|
||||
flexShrink: '0',
|
||||
});
|
||||
row.appendChild(spinner);
|
||||
const label = el('span', {
|
||||
fontSize: '12px', color: C.ash, fontWeight: '500',
|
||||
fontSize: '12px', color: BP.textDim, fontWeight: '500',
|
||||
});
|
||||
label.textContent = 'Applying variant...';
|
||||
row.appendChild(label);
|
||||
@@ -1103,14 +1189,26 @@
|
||||
for (let i = 1; i <= expectedVariants; i++) {
|
||||
const arrived = i <= arrivedVariants;
|
||||
const active = i === visibleVariant;
|
||||
// active: solid site-brand magenta dot. arrived+inactive: muted neutral.
|
||||
// pending (not yet arrived): faint outline ring. No borders on arrived
|
||||
// dots — the previous "accent ring + ash fill" combo read as noisy
|
||||
// magenta chips, especially when all variants had arrived and every
|
||||
// dot wore an accent ring.
|
||||
const dotBg = active ? C.brand
|
||||
: arrived ? BP.textDim
|
||||
: 'transparent';
|
||||
const dotBorder = arrived ? 'none' : '1.5px solid ' + BP.hairline;
|
||||
const dot = el('div', {
|
||||
width: '7px', height: '7px', borderRadius: '50%',
|
||||
background: active ? C.brand : (arrived ? C.ash : 'transparent'),
|
||||
border: '1.5px solid ' + (arrived ? C.brand : C.mist),
|
||||
width: active ? '8px' : '6px',
|
||||
height: active ? '8px' : '6px',
|
||||
borderRadius: '50%',
|
||||
background: dotBg,
|
||||
border: dotBorder,
|
||||
boxSizing: 'border-box',
|
||||
transition: 'all 0.2s ' + EASE,
|
||||
cursor: (clickable && arrived) ? 'pointer' : 'default',
|
||||
transform: arrived ? 'scale(1)' : 'scale(0.6)',
|
||||
opacity: arrived ? '1' : '0.4',
|
||||
transform: arrived ? 'scale(1)' : 'scale(0.85)',
|
||||
opacity: arrived ? (active ? '1' : '0.6') : '0.4',
|
||||
});
|
||||
if (clickable && arrived) {
|
||||
const idx = i;
|
||||
@@ -1130,15 +1228,15 @@
|
||||
function navBtn(text) {
|
||||
const b = el('button', {
|
||||
width: '26px', height: '26px', borderRadius: '5px',
|
||||
border: '1px solid ' + C.mist, background: 'transparent',
|
||||
color: C.ink, fontFamily: FONT, fontSize: '13px',
|
||||
border: '1px solid ' + BP.hairline, background: 'transparent',
|
||||
color: BP.text, fontFamily: FONT, fontSize: '13px',
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'border-color 0.12s ease, background 0.12s ease',
|
||||
padding: '0', lineHeight: '1',
|
||||
});
|
||||
b.textContent = text;
|
||||
b.addEventListener('mouseenter', () => { b.style.borderColor = C.ink; });
|
||||
b.addEventListener('mouseleave', () => { b.style.borderColor = C.mist; });
|
||||
b.addEventListener('mouseenter', () => { b.style.borderColor = BP.text; });
|
||||
b.addEventListener('mouseleave', () => { b.style.borderColor = BP.hairline; });
|
||||
return b;
|
||||
}
|
||||
|
||||
@@ -1158,6 +1256,7 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initActionPicker() {
|
||||
const P = barPaletteForTheme(detectPageTheme());
|
||||
pickerEl = document.createElement('div');
|
||||
pickerEl.id = PREFIX + '-picker';
|
||||
Object.assign(pickerEl.style, {
|
||||
@@ -1166,12 +1265,14 @@
|
||||
transform: 'scale(0.96) translateY(4px)',
|
||||
transformOrigin: 'bottom left',
|
||||
transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE,
|
||||
background: C.paperSolid,
|
||||
border: '1px solid ' + C.mist,
|
||||
background: P.surface,
|
||||
border: '1px solid ' + P.hairline,
|
||||
borderRadius: '10px',
|
||||
boxShadow: '0 8px 30px oklch(0% 0 0 / 0.10), 0 2px 6px oklch(0% 0 0 / 0.06)',
|
||||
padding: '6px',
|
||||
fontFamily: FONT,
|
||||
backdropFilter: 'blur(10px)',
|
||||
WebkitBackdropFilter: 'blur(10px)',
|
||||
});
|
||||
|
||||
// Build the chip grid
|
||||
@@ -1181,22 +1282,32 @@
|
||||
|
||||
ACTIONS.forEach(action => {
|
||||
const chip = el('button', {
|
||||
padding: '6px 8px', borderRadius: '6px',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '8px 6px', borderRadius: '6px',
|
||||
border: 'none',
|
||||
background: action.value === selectedAction ? C.brandSoft : 'transparent',
|
||||
color: action.value === selectedAction ? C.brand : C.ink,
|
||||
background: action.value === selectedAction ? P.accentSoft : 'transparent',
|
||||
color: action.value === selectedAction ? P.accent : P.text,
|
||||
fontFamily: FONT, fontSize: '11px', fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.1s ease, color 0.1s ease',
|
||||
textAlign: 'center', whiteSpace: 'nowrap',
|
||||
});
|
||||
chip.textContent = action.label;
|
||||
const iconWrap = el('span', {
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
height: '20px', opacity: '0.9',
|
||||
});
|
||||
iconWrap.innerHTML = ICONS[action.value] || '';
|
||||
const labelEl = el('span', { lineHeight: '1' });
|
||||
labelEl.textContent = action.label;
|
||||
chip.appendChild(iconWrap);
|
||||
chip.appendChild(labelEl);
|
||||
chip.dataset.action = action.value;
|
||||
chip.addEventListener('mouseenter', () => {
|
||||
if (action.value !== selectedAction) chip.style.background = C.brandSoft;
|
||||
if (action.value !== selectedAction) chip.style.background = P.accentSoft;
|
||||
});
|
||||
chip.addEventListener('mouseleave', () => {
|
||||
chip.style.background = action.value === selectedAction ? C.brandSoft : 'transparent';
|
||||
chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent';
|
||||
});
|
||||
chip.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -1209,19 +1320,24 @@
|
||||
|
||||
pickerEl.appendChild(grid);
|
||||
document.body.appendChild(pickerEl);
|
||||
|
||||
// Cache the palette on the picker so toggleActionPicker's state refresh
|
||||
// uses the same theme-aware colors when it repaints chips.
|
||||
pickerEl.__iceq_palette = P;
|
||||
}
|
||||
|
||||
function toggleActionPicker() {
|
||||
if (pickerEl.style.display !== 'none') { hideActionPicker(); return; }
|
||||
// Rebuild chips to reflect current selection
|
||||
const P = pickerEl.__iceq_palette || barPaletteForTheme(detectPageTheme());
|
||||
pickerEl.querySelectorAll('button').forEach(chip => {
|
||||
const isActive = chip.dataset.action === selectedAction;
|
||||
chip.style.background = isActive ? C.brandSoft : 'transparent';
|
||||
chip.style.color = isActive ? C.brand : C.ink;
|
||||
chip.style.background = isActive ? P.accentSoft : 'transparent';
|
||||
chip.style.color = isActive ? P.accent : P.text;
|
||||
});
|
||||
// Position above the bar
|
||||
const barRect = barEl.getBoundingClientRect();
|
||||
const pickerH = 90; // approximate
|
||||
const pickerH = 170; // approximate; grows with icon + label rows
|
||||
let top = barRect.top - pickerH - 6;
|
||||
if (top < 8) top = barRect.bottom + 6;
|
||||
Object.assign(pickerEl.style, {
|
||||
@@ -1241,6 +1357,399 @@
|
||||
setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Params panel (per-variant coarse controls)
|
||||
//
|
||||
// Variants may declare a parameter manifest via a JSON attribute on the
|
||||
// variant wrapper:
|
||||
//
|
||||
// <div data-impeccable-variant="1"
|
||||
// data-impeccable-params='[{"id":"density","kind":"steps",...}]'>
|
||||
//
|
||||
// The panel docks to the right edge of the outline during CYCLING and
|
||||
// exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped
|
||||
// CSS can respond instantly without regeneration:
|
||||
//
|
||||
// range / numeric toggle → CSS var (`--p-<id>`) used via var(--p-foo, N)
|
||||
// steps / boolean toggle → data-p-<id> attribute used via :scope[data-p-foo="..."]
|
||||
//
|
||||
// On variant switch, values reset to that variant's declared defaults.
|
||||
// On accept, current values are sent in the event payload so the agent
|
||||
// can bake them into the source-file write.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide)
|
||||
let paramsPanelInner = null; // translating content (carries bg, padding, knobs)
|
||||
let paramsPanelBody = null; // grid holding the knob cells
|
||||
let paramsCurrentValues = {}; // {paramId: value} — mirror of the visible variant's live values
|
||||
let tuneOpen = false; // whether the Tune popover is open right now
|
||||
|
||||
// Theme-aware Tune popover. Appears as a drawer that slides out from the
|
||||
// contextual bar's bar-facing edge (below if the bar sits below the
|
||||
// element, above otherwise). Same width as the bar. Auto-wraps to extra
|
||||
// rows when the knobs exceed one row. The bar's border-radius on the
|
||||
// popover side goes flat while open so the two shapes read as one.
|
||||
let paramsPanelPalette = null;
|
||||
|
||||
function initParamsPanel() {
|
||||
paramsPanelPalette = barPaletteForTheme(detectPageTheme());
|
||||
const P = paramsPanelPalette;
|
||||
|
||||
// Single element, always in the DOM. The slide animation is a CSS mask
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis — no
|
||||
// display toggle, no opacity toggle, no transform trickery. The mask
|
||||
// hides everything initially; as it grows, content is revealed from
|
||||
// the bar edge outward.
|
||||
paramsPanelEl = document.createElement('div');
|
||||
paramsPanelEl.id = PREFIX + '-params-panel';
|
||||
Object.assign(paramsPanelEl.style, {
|
||||
position: 'fixed', zIndex: String(Z.bar - 1),
|
||||
background: P.surfaceDeep,
|
||||
color: P.text,
|
||||
fontFamily: FONT,
|
||||
padding: '14px 18px',
|
||||
boxSizing: 'border-box',
|
||||
borderRadius: '0 0 10px 10px',
|
||||
pointerEvents: 'none',
|
||||
backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
|
||||
|
||||
// clip-path is the same conceptual reveal as mask but with rock-solid
|
||||
// transition support across engines. Closed state clips from the far
|
||||
// edge; open = inset(0) shows everything.
|
||||
clipPath: 'inset(0 0 100% 0)',
|
||||
transition: 'clip-path 0.44s ' + EASE,
|
||||
|
||||
// Park off-screen until positionParamsPanel places it. These are NOT
|
||||
// in the transition list, so they snap instantly — no fly-in from the
|
||||
// top-left when first shown.
|
||||
top: '-9999px', left: '-9999px', width: '0',
|
||||
});
|
||||
|
||||
paramsPanelBody = el('div', {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
|
||||
gap: '12px 16px',
|
||||
});
|
||||
|
||||
paramsPanelEl.appendChild(paramsPanelBody);
|
||||
document.body.appendChild(paramsPanelEl);
|
||||
paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
|
||||
}
|
||||
|
||||
function getVisibleVariantEl() {
|
||||
if (!currentSessionId) return null;
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
|
||||
function parseVariantParams(variantEl) {
|
||||
if (!variantEl) return [];
|
||||
const raw = variantEl.getAttribute('data-impeccable-params');
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] Invalid data-impeccable-params JSON:', err.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function applyParamValue(variantEl, param, value) {
|
||||
if (!variantEl) return;
|
||||
const attr = 'data-p-' + param.id;
|
||||
if (param.kind === 'range') {
|
||||
variantEl.style.setProperty('--p-' + param.id, String(value));
|
||||
} else if (param.kind === 'toggle') {
|
||||
const on = !!value;
|
||||
variantEl.style.setProperty('--p-' + param.id, on ? '1' : '0');
|
||||
if (on) variantEl.setAttribute(attr, 'on');
|
||||
else variantEl.removeAttribute(attr);
|
||||
} else if (param.kind === 'steps') {
|
||||
variantEl.setAttribute(attr, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
function applyParamDefaults(variantEl, params) {
|
||||
paramsCurrentValues = {};
|
||||
for (const p of params) {
|
||||
paramsCurrentValues[p.id] = p.default;
|
||||
applyParamValue(variantEl, p, p.default);
|
||||
}
|
||||
}
|
||||
|
||||
function formatRangeValue(input) {
|
||||
const max = parseFloat(input.max), min = parseFloat(input.min);
|
||||
const v = parseFloat(input.value);
|
||||
if (!isFinite(v)) return input.value;
|
||||
return (max - min) <= 2 ? v.toFixed(2) : String(Math.round(v));
|
||||
}
|
||||
|
||||
function buildParamsPanel(variantEl, params) {
|
||||
const P = paramsPanelPalette || barPaletteForTheme(detectPageTheme());
|
||||
paramsPanelBody.innerHTML = '';
|
||||
for (const p of params) {
|
||||
const row = el('div', { display: 'flex', flexDirection: 'column', gap: '6px' });
|
||||
const labelRow = el('div', {
|
||||
display: 'flex', justifyContent: 'space-between',
|
||||
alignItems: 'baseline', gap: '8px',
|
||||
});
|
||||
const lbl = el('span', {
|
||||
fontSize: '10.5px', fontWeight: '600', color: P.text,
|
||||
letterSpacing: '0.03em',
|
||||
});
|
||||
lbl.textContent = p.label || p.id;
|
||||
labelRow.appendChild(lbl);
|
||||
const readout = el('span', {
|
||||
fontSize: '10.5px', color: P.textDim,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
});
|
||||
labelRow.appendChild(readout);
|
||||
row.appendChild(labelRow);
|
||||
|
||||
if (p.kind === 'range') {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'range';
|
||||
input.min = String(p.min != null ? p.min : 0);
|
||||
input.max = String(p.max != null ? p.max : 1);
|
||||
input.step = String(p.step != null ? p.step : 0.05);
|
||||
input.value = String(p.default);
|
||||
Object.assign(input.style, {
|
||||
width: '100%', accentColor: C.brand, cursor: 'pointer',
|
||||
});
|
||||
readout.textContent = formatRangeValue(input);
|
||||
input.addEventListener('input', (e) => {
|
||||
e.stopPropagation();
|
||||
const v = parseFloat(input.value);
|
||||
paramsCurrentValues[p.id] = v;
|
||||
readout.textContent = formatRangeValue(input);
|
||||
applyParamValue(variantEl, p, v);
|
||||
});
|
||||
row.appendChild(input);
|
||||
} else if (p.kind === 'toggle') {
|
||||
const initial = !!p.default;
|
||||
readout.textContent = initial ? 'On' : 'Off';
|
||||
const track = el('button', {
|
||||
position: 'relative', width: '36px', height: '20px',
|
||||
borderRadius: '10px', border: 'none', padding: '0',
|
||||
cursor: 'pointer',
|
||||
background: initial ? C.brand : P.hairline,
|
||||
transition: 'background 0.15s ease',
|
||||
alignSelf: 'flex-start',
|
||||
});
|
||||
const knob = el('span', {
|
||||
position: 'absolute', top: '2px',
|
||||
left: initial ? '18px' : '2px',
|
||||
width: '16px', height: '16px', borderRadius: '50%',
|
||||
background: 'oklch(98% 0 0)',
|
||||
transition: 'left 0.18s ' + EASE,
|
||||
boxShadow: '0 1px 2px oklch(0% 0 0 / 0.2)',
|
||||
});
|
||||
track.appendChild(knob);
|
||||
track.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const next = !paramsCurrentValues[p.id];
|
||||
paramsCurrentValues[p.id] = next;
|
||||
track.style.background = next ? C.brand : P.hairline;
|
||||
knob.style.left = next ? '18px' : '2px';
|
||||
readout.textContent = next ? 'On' : 'Off';
|
||||
applyParamValue(variantEl, p, next);
|
||||
});
|
||||
row.appendChild(track);
|
||||
} else if (p.kind === 'steps') {
|
||||
const opts = (p.options || []).map(o =>
|
||||
typeof o === 'string' ? { value: o, label: o } : o
|
||||
);
|
||||
const activeOpt = opts.find(o => o.value === p.default) || opts[0];
|
||||
readout.textContent = activeOpt ? activeOpt.label : String(p.default);
|
||||
const segRow = el('div', {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(' + opts.length + ', 1fr)',
|
||||
gap: '1px', padding: '2px',
|
||||
background: P.hairline, borderRadius: '5px',
|
||||
});
|
||||
const segBtns = [];
|
||||
opts.forEach(o => {
|
||||
const active = o.value === p.default;
|
||||
const b = el('button', {
|
||||
padding: '5px 4px', border: 'none', borderRadius: '3px',
|
||||
background: active ? C.brand : 'transparent',
|
||||
color: active ? 'oklch(98% 0 0)' : P.text,
|
||||
fontFamily: FONT, fontSize: '10.5px', fontWeight: '500',
|
||||
cursor: 'pointer', whiteSpace: 'nowrap',
|
||||
transition: 'background 0.1s ease, color 0.1s ease',
|
||||
});
|
||||
b.textContent = o.label;
|
||||
b.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
paramsCurrentValues[p.id] = o.value;
|
||||
readout.textContent = o.label;
|
||||
segBtns.forEach(({ btn, val }) => {
|
||||
const on = val === o.value;
|
||||
btn.style.background = on ? C.brand : 'transparent';
|
||||
btn.style.color = on ? 'oklch(98% 0 0)' : P.text;
|
||||
});
|
||||
applyParamValue(variantEl, p, o.value);
|
||||
});
|
||||
segRow.appendChild(b);
|
||||
segBtns.push({ btn: b, val: o.value });
|
||||
});
|
||||
row.appendChild(segRow);
|
||||
}
|
||||
|
||||
paramsPanelBody.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
// Decide which way the popover opens: away from the picked element. If the
|
||||
// bar landed below the element, popover slides DOWN from the bar's bottom.
|
||||
// If the bar landed above, popover slides UP from the bar's top.
|
||||
function popoverDirection() {
|
||||
if (!barEl || !selectedElement) return 'below';
|
||||
const br = barEl.getBoundingClientRect();
|
||||
const er = selectedElement.getBoundingClientRect();
|
||||
return br.top >= er.bottom - 4 ? 'below' : 'above';
|
||||
}
|
||||
|
||||
// The popover overlaps the bar by OVERLAP px on the bar-facing side. With
|
||||
// popover z-index below bar, that overlap sits behind bar (invisible) and
|
||||
// reinforces the "tucked behind" feel. Padding compensates so the real
|
||||
// content starts flush with bar's outer edge.
|
||||
const TUNE_OVERLAP = 6;
|
||||
|
||||
// Closed clip-path depends on direction: for 'below' clip from the far
|
||||
// (bottom) edge so the reveal grows downward from the bar; for 'above'
|
||||
// clip from the top edge so the reveal grows upward from the bar.
|
||||
function closedClipPath(direction) {
|
||||
return direction === 'below' ? 'inset(0 0 100% 0)' : 'inset(100% 0 0 0)';
|
||||
}
|
||||
|
||||
function setClipPath(value, withTransition) {
|
||||
const saved = paramsPanelEl.style.transition;
|
||||
if (!withTransition) paramsPanelEl.style.transition = 'none';
|
||||
paramsPanelEl.style.clipPath = value;
|
||||
if (!withTransition) {
|
||||
void paramsPanelEl.offsetHeight;
|
||||
paramsPanelEl.style.transition = saved;
|
||||
}
|
||||
}
|
||||
|
||||
function positionParamsPanel() {
|
||||
if (!paramsPanelEl || !barEl || barEl.style.display === 'none') return;
|
||||
const br = barEl.getBoundingClientRect();
|
||||
const direction = popoverDirection();
|
||||
const prevDirection = paramsPanelEl.dataset.tuneDirection;
|
||||
|
||||
// top/left/width are NOT in the transition list, so they snap instantly.
|
||||
paramsPanelEl.style.left = br.left + 'px';
|
||||
paramsPanelEl.style.width = br.width + 'px';
|
||||
|
||||
if (direction === 'below') {
|
||||
paramsPanelEl.style.top = (br.bottom - TUNE_OVERLAP) + 'px';
|
||||
paramsPanelEl.style.borderRadius = '0 0 10px 10px';
|
||||
paramsPanelEl.style.paddingTop = (14 + TUNE_OVERLAP) + 'px';
|
||||
paramsPanelEl.style.paddingBottom = '14px';
|
||||
} else {
|
||||
const ih = paramsPanelEl.offsetHeight || 80;
|
||||
paramsPanelEl.style.top = (br.top - ih + TUNE_OVERLAP) + 'px';
|
||||
paramsPanelEl.style.borderRadius = '10px 10px 0 0';
|
||||
paramsPanelEl.style.paddingTop = '14px';
|
||||
paramsPanelEl.style.paddingBottom = (14 + TUNE_OVERLAP) + 'px';
|
||||
}
|
||||
paramsPanelEl.dataset.tuneDirection = direction;
|
||||
|
||||
// If currently closed and direction flipped (or first-time setup),
|
||||
// snap the clip-path to the new direction's closed pose without
|
||||
// transitioning (so the clip doesn't slide across the element).
|
||||
if (!tuneOpen && (!prevDirection || prevDirection !== direction)) {
|
||||
setClipPath(closedClipPath(direction), false);
|
||||
}
|
||||
}
|
||||
|
||||
function showParamsPanel() {
|
||||
if (!paramsPanelEl) return;
|
||||
positionParamsPanel();
|
||||
paramsPanelEl.style.pointerEvents = 'auto';
|
||||
// rAF so the positioning paint commits before the transition fires.
|
||||
requestAnimationFrame(() => {
|
||||
setClipPath('inset(0 0 0 0)', true);
|
||||
});
|
||||
}
|
||||
|
||||
function hideParamsPanel() {
|
||||
if (!paramsPanelEl) return;
|
||||
paramsPanelEl.style.pointerEvents = 'none';
|
||||
const direction = paramsPanelEl.dataset.tuneDirection || 'below';
|
||||
setClipPath(closedClipPath(direction), true);
|
||||
}
|
||||
|
||||
// Build/rebuild the panel's contents for the current variant AND apply
|
||||
// its defaults to the variant wrapper (so scoped CSS responds even before
|
||||
// the user opens the popover). Visibility is governed by tuneOpen.
|
||||
function refreshParamsPanel() {
|
||||
if (state !== 'CYCLING') {
|
||||
paramsCurrentValues = {};
|
||||
tuneOpen = false;
|
||||
hideParamsPanel();
|
||||
return;
|
||||
}
|
||||
const variantEl = getVisibleVariantEl();
|
||||
const params = parseVariantParams(variantEl);
|
||||
if (!variantEl || params.length === 0) {
|
||||
paramsCurrentValues = {};
|
||||
tuneOpen = false;
|
||||
hideParamsPanel();
|
||||
return;
|
||||
}
|
||||
applyParamDefaults(variantEl, params);
|
||||
buildParamsPanel(variantEl, params);
|
||||
if (tuneOpen) {
|
||||
// If already visible (variant cycled while open), refresh in place
|
||||
// instead of re-running the clip-path animation.
|
||||
const alreadyVisible = paramsPanelEl.style.display === 'block'
|
||||
&& paramsPanelEl.style.opacity === '1';
|
||||
if (alreadyVisible) positionParamsPanel();
|
||||
else showParamsPanel();
|
||||
} else {
|
||||
hideParamsPanel();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTunePopover() {
|
||||
if (tuneOpen) { closeTunePopover(); return; }
|
||||
openTunePopover();
|
||||
}
|
||||
|
||||
function openTunePopover() {
|
||||
if (state !== 'CYCLING') return;
|
||||
const variantEl = getVisibleVariantEl();
|
||||
const params = parseVariantParams(variantEl);
|
||||
if (!variantEl || params.length === 0) return;
|
||||
// Build fresh to ensure the current variant's controls are shown.
|
||||
applyParamDefaults(variantEl, params);
|
||||
buildParamsPanel(variantEl, params);
|
||||
tuneOpen = true;
|
||||
showParamsPanel();
|
||||
// Kill the bar's shadow on the popover-facing side so the dark popover
|
||||
// doesn't pick up a bright glow line.
|
||||
if (barEl) {
|
||||
const direction = paramsPanelEl?.dataset.tuneDirection || 'below';
|
||||
barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN;
|
||||
}
|
||||
// Re-render the bar so the Tune chip picks up the active styling.
|
||||
updateBarContent('cycling');
|
||||
}
|
||||
|
||||
function closeTunePopover() {
|
||||
tuneOpen = false;
|
||||
hideParamsPanel();
|
||||
if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT;
|
||||
if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') {
|
||||
updateBarContent('cycling');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variant cycling in DOM
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1253,6 +1762,10 @@
|
||||
if (!v) continue;
|
||||
child.style.display = (v === String(num)) ? '' : 'none';
|
||||
}
|
||||
// Unconditional refresh — covers first-reveal (no-op if state isn't
|
||||
// CYCLING yet, the subsequent CYCLING transition triggers its own
|
||||
// refresh) and every cycle step.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1316,6 +1829,7 @@
|
||||
state = 'CYCLING';
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
refreshParamsPanel();
|
||||
saveSession();
|
||||
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
|
||||
})
|
||||
@@ -1329,7 +1843,7 @@
|
||||
const next = visibleVariant + dir;
|
||||
if (next < 1 || next > arrivedVariants) return;
|
||||
visibleVariant = next;
|
||||
showVariantInDOM(currentSessionId, next);
|
||||
showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself
|
||||
updateSelectedElement();
|
||||
updateBarContent('cycling');
|
||||
saveSession();
|
||||
@@ -1540,6 +2054,7 @@
|
||||
state = 'CYCLING';
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
refreshParamsPanel();
|
||||
} else if (state === 'GENERATING') {
|
||||
updateBarContent('generating');
|
||||
}
|
||||
@@ -1560,6 +2075,7 @@
|
||||
if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') {
|
||||
positionBar();
|
||||
showHighlight(selectedElement);
|
||||
if (tuneOpen) positionParamsPanel();
|
||||
}
|
||||
if (annotActive) positionAnnotOverlay(selectedElement);
|
||||
// Shader overlay (via debug P toggle or generation) is repositioned
|
||||
@@ -1606,6 +2122,7 @@
|
||||
if (state === 'GENERATING') {
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
refreshParamsPanel();
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -1691,6 +2208,10 @@
|
||||
if (pickerEl?.style.display !== 'none' && !own(e.target)) {
|
||||
hideActionPicker();
|
||||
}
|
||||
// Close Tune popover on outside click (anything outside panel + bar)
|
||||
if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) {
|
||||
closeTunePopover();
|
||||
}
|
||||
// In CONFIGURING: click outside the bar and selected element returns to PICKING
|
||||
if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) {
|
||||
hideBar();
|
||||
@@ -2258,7 +2779,11 @@ void main() {
|
||||
|
||||
function handleAccept() {
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
sendEvent({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) });
|
||||
const acceptPayload = { type: 'accept', id: currentSessionId, variantId: String(visibleVariant) };
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
sendEvent(acceptPayload);
|
||||
markSessionHandled();
|
||||
|
||||
// The accepted variant is already the only visible child of the wrapper
|
||||
@@ -2477,6 +3002,11 @@ void main() {
|
||||
state = arrivedVariants >= expectedVariants ? 'CYCLING' : 'GENERATING';
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -2522,6 +3052,11 @@ void main() {
|
||||
// dark pages. This keeps the bar from fighting with the host design.
|
||||
function detectPageTheme() {
|
||||
try {
|
||||
// Dev override: set localStorage 'impeccable-dev-theme' to 'light' or
|
||||
// 'dark' to preview the opposite palette without actually changing the
|
||||
// page bg. Used for screenshots and theme QA.
|
||||
const override = localStorage.getItem('impeccable-dev-theme');
|
||||
if (override === 'light' || override === 'dark') return override;
|
||||
const bg = getComputedStyle(document.body).backgroundColor
|
||||
|| getComputedStyle(document.documentElement).backgroundColor;
|
||||
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
||||
@@ -2538,6 +3073,7 @@ void main() {
|
||||
// Light bar on dark page
|
||||
return {
|
||||
surface: 'oklch(98% 0 0 / 0.92)',
|
||||
surfaceDeep: 'oklch(92% 0.005 60 / 0.96)', // slightly deeper, faint warm
|
||||
hairline: 'oklch(70% 0 0 / 0.35)',
|
||||
text: 'oklch(15% 0 0)',
|
||||
textDim: 'oklch(45% 0 0)',
|
||||
@@ -2552,6 +3088,7 @@ void main() {
|
||||
// deeper so the rounded-right shape reads as a clear sculpted mark.
|
||||
return {
|
||||
surface: 'oklch(26% 0 0 / 0.94)',
|
||||
surfaceDeep: 'oklch(18% 0 0 / 0.96)', // darker sand for Tune popover
|
||||
hairline: 'oklch(42% 0 0 / 0.5)',
|
||||
text: 'oklch(96% 0 0)',
|
||||
textDim: 'oklch(72% 0 0)',
|
||||
@@ -2883,6 +3420,7 @@ void main() {
|
||||
if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; }
|
||||
if (barEl) { barEl.remove(); barEl = null; }
|
||||
if (pickerEl) { pickerEl.remove(); pickerEl = null; }
|
||||
if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; }
|
||||
if (evtSource) { evtSource.close(); evtSource = null; }
|
||||
document.removeEventListener('mousemove', handleMouseMove, true);
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
@@ -3956,6 +4494,7 @@ void main() {
|
||||
initAnnotOverlay();
|
||||
initBar();
|
||||
initActionPicker();
|
||||
initParamsPanel();
|
||||
initGlobalBar();
|
||||
initDesignPanel();
|
||||
document.addEventListener('mousemove', handleMouseMove, true);
|
||||
|
||||
@@ -145,6 +145,11 @@ Options:
|
||||
const scriptArgs = event.type === 'discard'
|
||||
? ['--id', event.id, '--discard']
|
||||
: ['--id', event.id, '--variant', event.variantId];
|
||||
if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
|
||||
// Pass through a JSON blob; the shell-safe wrap uses single quotes because
|
||||
// values are finite {id, number|string|boolean} pairs from a validated payload.
|
||||
scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
|
||||
}
|
||||
try {
|
||||
const out = execSync(
|
||||
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
|
||||
|
||||
@@ -147,6 +147,11 @@ function validateEvent(msg) {
|
||||
case 'accept':
|
||||
if (!msg.id) return 'accept: missing id';
|
||||
if (!msg.variantId) return 'accept: missing variantId';
|
||||
if (msg.paramValues !== undefined) {
|
||||
if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
|
||||
return 'accept: paramValues must be an object';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
case 'discard':
|
||||
return msg.id ? null : 'discard: missing id';
|
||||
|
||||
@@ -142,3 +142,13 @@ Test that colorization improves the experience:
|
||||
- **Not overwhelming**: Is color balanced and purposeful?
|
||||
|
||||
Remember: Color is emotional and powerful. Use it to create warmth, guide attention, communicate meaning, and express personality. But restraint and strategy matter more than saturation and variety. Be colorful, but be intentional.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
When invoked from live mode, each variant MUST declare a `color-amount` param so the user can dial between a restrained accent and a drenched surface without regeneration. Author the variant's CSS against `var(--p-color-amount, 0.5)` — typically as the alpha multiplier on backgrounds, or as a scaling factor on the chroma axis in an OKLCH expression. 0 = neutral/monochrome, 1 = full saturation / dominant coverage.
|
||||
|
||||
```json
|
||||
{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"}
|
||||
```
|
||||
|
||||
Layer 1-2 variant-specific params on top: palette selection (`steps` with named options), temperature warmth, or tint vs. true color. See `reference/live.md` for the full params contract.
|
||||
|
||||
@@ -119,3 +119,23 @@ Create a systematic plan:
|
||||
- **Responsiveness**: Does the layout adapt gracefully across screen sizes?
|
||||
|
||||
Remember: Space is the most underused design tool. A layout with the right rhythm and hierarchy can make even simple content feel polished and intentional.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
Each variant MUST declare a `density` param. Drive all spacing tokens in the variant's scoped CSS through `calc(var(--p-density, 1) * <base>)` — paddings, gaps, column widths. Users slide from airy to packed and see layout re-breathe with no regeneration.
|
||||
|
||||
```json
|
||||
{"id":"density","kind":"range","min":0.6,"max":1.4,"step":0.05,"default":1,"label":"Density"}
|
||||
```
|
||||
|
||||
For variants whose topology genuinely changes (stacked vs. side-by-side, grid vs. bento), use a `steps` param whose scoped CSS branches via `:scope[data-p-structure="X"]`. One structure param + one density param is a powerful combo; resist adding a third.
|
||||
|
||||
```json
|
||||
{"id":"structure","kind":"steps","default":"grid","label":"Structure","options":[
|
||||
{"value":"stacked","label":"Stacked"},
|
||||
{"value":"grid","label":"Grid"},
|
||||
{"value":"bento","label":"Bento"}
|
||||
]}
|
||||
```
|
||||
|
||||
See `reference/live.md` for the full params contract.
|
||||
|
||||
@@ -179,7 +179,56 @@ The first variant has no `display: none` (visible by default). All others do. If
|
||||
|
||||
One edit, all variants — the browser's MutationObserver picks everything up in one pass.
|
||||
|
||||
### 7. Signal done
|
||||
### 7. Parameters (optional, 2-5 per variant)
|
||||
|
||||
Each variant can expose coarse knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against.
|
||||
|
||||
**When to use.** Any time the variant has a meaningful axis the user might want to dial in: color amount, density, motion intensity, scale ratio. Not micro-level margin tweaks; those defeat the point.
|
||||
|
||||
**Budget scales with the element's visual weight, not the user's curiosity.** Knobs need real estate to produce noticeably different output; slapping three on a small element just crowds the panel without making anything feel tunable.
|
||||
|
||||
- **Leaf / tiny** — a single button, icon, input, bare heading, solitary paragraph: **0 params.** A slider can't meaningfully reshape one element; just use variants.
|
||||
- **Small composition** — labeled input, simple card, short callout (≤ ~5 visual children): **0-1 params.** Only add one if it's a clear dominant axis (e.g. density on a card with visible internal rhythm).
|
||||
- **Medium composition** — section component, nav cluster, dense card, short feature block (6-15 visual children): **2 params.**
|
||||
- **Large composition** — hero section, full page region, spread layout, anything with strong internal structure (16+ visual children or multiple sub-sections): **3-4 params.**
|
||||
|
||||
When in doubt, fewer. The user can always ask for more variants to explore an axis you didn't expose as a knob. Count by visual children, not by DOM-node depth; a deeply-nested-but-visually-simple card still counts as small.
|
||||
|
||||
**How to declare.** Put a JSON manifest on the variant wrapper:
|
||||
|
||||
```html
|
||||
<div data-impeccable-variant="1" data-impeccable-params='[
|
||||
{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"},
|
||||
{"id":"density","kind":"steps","default":"snug","label":"Density","options":[
|
||||
{"value":"airy","label":"Airy"},
|
||||
{"value":"snug","label":"Snug"},
|
||||
{"value":"packed","label":"Packed"}
|
||||
]},
|
||||
{"id":"serif","kind":"toggle","default":false,"label":"Serif display"}
|
||||
]'>
|
||||
...variant content...
|
||||
</div>
|
||||
```
|
||||
|
||||
**Three kinds:**
|
||||
|
||||
- `range` — smooth slider. Drives a CSS custom property `--p-<id>` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`.
|
||||
- `steps` — segmented radio. Drives a data attribute `data-p-<id>` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`.
|
||||
- `toggle` — on/off switch. Drives BOTH a CSS var (`--p-<id>: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`.
|
||||
|
||||
**Signature params per action.** Each action has one or two signature params that MUST be exposed when the variant can meaningfully express them. Check the action's reference file for the list. Layer 1-2 variant-specific params on top.
|
||||
|
||||
**Reset on variant switch.** User dials density on v1, flips to v2, v2 starts at v2's declared defaults. Known limitation; preservation across variants may land later.
|
||||
|
||||
**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment:
|
||||
|
||||
```html
|
||||
<!-- impeccable-param-values SESSION_ID: {"color-amount":0.7,"density":"packed"} -->
|
||||
```
|
||||
|
||||
The carbonize cleanup step (see below) reads that comment and bakes the chosen values into the final CSS. For `steps`/`toggle` attribute selectors: keep only the branch matching the chosen value, drop the others, collapse `:scope[data-p-density="packed"] .grid` to a semantic class rule. For `range` vars: either substitute the literal or keep the var with the chosen value as its new default.
|
||||
|
||||
### 8. Signal done
|
||||
|
||||
```bash
|
||||
node .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 `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element.
|
||||
1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values — read it first; it drives steps 3 and 4 below.
|
||||
2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `public/css/workflow.css` for this repo, or the component's co-located CSS file for a Vite/Next project — pick whichever already owns styling for the surrounding element).
|
||||
3. **Rewrite `@scope ([data-impeccable-variant="N"])` selectors** to target real, semantic classes on the accepted HTML. Example: `@scope ([data-impeccable-variant="2"]) { .v2-label { … } }` becomes `.why-visual--v2 .v2-label { … }` if the accepted element already carries `.why-visual--v2`, or pick/add a suitable class if it doesn't.
|
||||
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it.
|
||||
5. **Delete the inline `<style>` block and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one — those are dead code now.
|
||||
3. **Bake in parameter values while rewriting selectors.** For `@scope ([data-impeccable-variant="N"])` wrappers: retarget to real, semantic classes on the accepted HTML (`.why-visual--v2 .v2-label { … }`). For `:scope[data-p-<id>="VALUE"]` selectors: keep only the branch matching the chosen value from the param-values comment; drop the others (they're dead after accept). For `var(--p-<id>, DEFAULT)` in the CSS: either substitute the literal value, or if the param is still useful as a knob going forward, leave the var and update its initial declaration to the chosen value.
|
||||
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it. Drop `data-impeccable-params` and any `data-p-*` attributes from it — those are live-mode plumbing, not source.
|
||||
5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one — those are dead code now.
|
||||
|
||||
Then poll again.
|
||||
|
||||
|
||||
@@ -110,3 +110,15 @@ Build a clear type scale:
|
||||
- **Accessibility**: Does text meet WCAG contrast ratios? Is it zoomable to 200%?
|
||||
|
||||
Remember: Typography is the foundation of interface design — it carries the majority of information. Getting it right is the highest-leverage improvement you can make.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
Each variant MUST declare a `scale` param controlling the hierarchy ratio. Express all font sizes in the variant's scoped CSS through `calc(var(--p-scale, 1) * <base>)` or, better, scale the type ramp via `clamp(min, calc(var(--p-scale, 1) * Npx), max)`. Users slide from subdued to commanding.
|
||||
|
||||
```json
|
||||
{"id":"scale","kind":"range","min":0.85,"max":1.3,"step":0.05,"default":1,"label":"Scale"}
|
||||
```
|
||||
|
||||
Where the variant riffs on a specific pairing, expose the pairing choice as a `steps` param (e.g. "serif display + sans body" vs. "mono display + sans body" vs. "all-sans"). Each branch routes through `:scope[data-p-pairing="X"]` selectors in scoped CSS.
|
||||
|
||||
See `reference/live.md` for the full params contract.
|
||||
|
||||
@@ -45,11 +45,18 @@ Output (JSON):
|
||||
|
||||
const id = argVal(args, '--id');
|
||||
const variantNum = argVal(args, '--variant');
|
||||
const paramValuesRaw = argVal(args, '--param-values');
|
||||
const isDiscard = args.includes('--discard');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); }
|
||||
|
||||
let paramValues = null;
|
||||
if (paramValuesRaw) {
|
||||
try { paramValues = JSON.parse(paramValuesRaw); }
|
||||
catch { paramValues = null; } // malformed blob: skip the comment rather than failing the accept
|
||||
}
|
||||
|
||||
// Find the file containing this session's markers
|
||||
const found = findSessionFile(id, process.cwd());
|
||||
if (!found) {
|
||||
@@ -78,7 +85,7 @@ Output (JSON):
|
||||
const result = handleDiscard(id, lines, targetFile);
|
||||
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
|
||||
} else {
|
||||
const result = handleAccept(id, variantNum, lines, targetFile);
|
||||
const result = handleAccept(id, variantNum, lines, targetFile, paramValues);
|
||||
// Single-line attention-grabber when cleanup is required. The full
|
||||
// five-step checklist lives in reference/live.md (loaded once per
|
||||
// session); repeating it per-event would waste tokens.
|
||||
@@ -116,7 +123,7 @@ function handleDiscard(id, lines, targetFile) {
|
||||
// Accept
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function handleAccept(id, variantNum, lines, targetFile) {
|
||||
function handleAccept(id, variantNum, lines, targetFile, paramValues) {
|
||||
const block = findMarkerBlock(id, lines);
|
||||
if (!block) return { handled: false, error: 'Markers not found' };
|
||||
|
||||
@@ -149,6 +156,11 @@ function handleAccept(id, variantNum, lines, targetFile) {
|
||||
replacement.push(indent + cssLine.trimStart());
|
||||
}
|
||||
replacement.push(indent + '</style>');
|
||||
if (paramValues && Object.keys(paramValues).length > 0) {
|
||||
// Preserve the user's knob positions for the carbonize-cleanup agent
|
||||
// to bake into the final CSS when it collapses scoped rules.
|
||||
replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
|
||||
}
|
||||
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,10 +30,13 @@
|
||||
// Design tokens
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Brand magenta is pinned to the site token (--color-accent in main.css)
|
||||
// so Accept / knobs / cycle-dots match the site's accent, not a washed
|
||||
// theme-adjusted one.
|
||||
const C = {
|
||||
brand: 'oklch(55% 0.25 350)',
|
||||
brandHov: 'oklch(48% 0.25 350)',
|
||||
brandSoft: 'oklch(55% 0.25 350 / 0.12)',
|
||||
brand: 'oklch(60% 0.25 350)',
|
||||
brandHov: 'oklch(52% 0.25 350)',
|
||||
brandSoft: 'oklch(60% 0.25 350 / 0.15)',
|
||||
ink: 'oklch(15% 0.01 350)',
|
||||
ash: 'oklch(55% 0 0)',
|
||||
paper: 'oklch(98% 0.005 350 / 0.92)',
|
||||
@@ -60,6 +63,25 @@
|
||||
'html', 'head', 'body', 'script', 'style', 'link', 'meta', 'noscript', 'br', 'wbr',
|
||||
]);
|
||||
|
||||
// SVG icons stack above each chip label. All strokes use currentColor so the
|
||||
// icon recolors to C.brand when its chip is selected. 20x20 render, 24-viewBox,
|
||||
// 1.5 stroke — visually consistent with the Foundation grid on the homepage.
|
||||
const ICON_ATTRS = 'width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="display:block"';
|
||||
const ICONS = {
|
||||
impeccable: `<svg ${ICON_ATTRS}><path d="M4 20l4-1L18 9l-3-3L5 16z"/><path d="M14 7l3 3"/></svg>`,
|
||||
bolder: `<svg ${ICON_ATTRS}><rect x="6" y="12" width="4" height="7" rx="0.5"/><rect x="14" y="5" width="4" height="14" rx="0.5"/></svg>`,
|
||||
quieter: `<svg ${ICON_ATTRS}><rect x="6" y="5" width="4" height="14" rx="0.5"/><rect x="14" y="12" width="4" height="7" rx="0.5"/></svg>`,
|
||||
distill: `<svg ${ICON_ATTRS}><path d="M4 5h16l-6 8v7l-4-2v-5z"/></svg>`,
|
||||
polish: `<svg ${ICON_ATTRS}><path d="M15 3l1 3 3 1-3 1-1 3-1-3-3-1 3-1z"/><path d="M7 13l0.6 1.8 1.8 0.6-1.8 0.6-0.6 1.8-0.6-1.8-1.8-0.6 1.8-0.6z"/></svg>`,
|
||||
typeset: `<svg ${ICON_ATTRS}><path d="M5 6h14" stroke-width="2.6"/><path d="M5 12h9" stroke-width="1.9"/><path d="M5 18h5" stroke-width="1.3"/></svg>`,
|
||||
colorize: `<svg ${ICON_ATTRS}><circle cx="9" cy="10" r="5"/><circle cx="15" cy="10" r="5"/><circle cx="12" cy="15" r="5"/></svg>`,
|
||||
layout: `<svg ${ICON_ATTRS}><rect x="3" y="4" width="8" height="16" rx="0.5"/><rect x="13" y="4" width="8" height="7" rx="0.5"/><rect x="13" y="13" width="8" height="7" rx="0.5"/></svg>`,
|
||||
adapt: `<svg ${ICON_ATTRS}><rect x="2.5" y="5" width="12" height="11" rx="1"/><line x1="2.5" y1="19" x2="14.5" y2="19"/><rect x="16.5" y="8" width="5" height="11" rx="1"/></svg>`,
|
||||
animate: `<svg ${ICON_ATTRS}><path d="M3 18c4-4 6-10 10-10"/><path d="M13 8c3 0 5 5 8 10"/><circle cx="13" cy="8" r="1.6" fill="currentColor" stroke="none"/></svg>`,
|
||||
delight: `<svg ${ICON_ATTRS}><path d="M12 3l2 6 6 2-6 2-2 6-2-6-6-2 6-2z"/></svg>`,
|
||||
overdrive: `<svg ${ICON_ATTRS}><path d="M13 3L5 13h5l-1 8 9-12h-6z"/></svg>`,
|
||||
};
|
||||
|
||||
const ACTIONS = [
|
||||
{ value: 'impeccable', label: 'Freeform' },
|
||||
{ value: 'bolder', label: 'Bolder' },
|
||||
@@ -756,7 +778,21 @@
|
||||
// The Bar — one floating element, three modes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Contextual-bar palette. Cached at init so every build*Row reads a
|
||||
// consistent set of colors; detectPageTheme runs once rather than on every
|
||||
// phase transition.
|
||||
let BP = null;
|
||||
|
||||
// Bar shadow variants. The default projects down + subtle around. When
|
||||
// the Tune popover opens below the bar, a downward shadow lands on the
|
||||
// dark popover and reads as a bright ghost line. We swap to UP-only while
|
||||
// tune is open below so the popover's top edge is clean.
|
||||
const BAR_SHADOW_DEFAULT = '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)';
|
||||
const BAR_SHADOW_UP = '0 -4px 20px oklch(0% 0 0 / 0.08), 0 -1px 3px oklch(0% 0 0 / 0.06)';
|
||||
const BAR_SHADOW_DOWN = BAR_SHADOW_DEFAULT;
|
||||
|
||||
function initBar() {
|
||||
BP = barPaletteForTheme(detectPageTheme());
|
||||
barEl = document.createElement('div');
|
||||
barEl.id = PREFIX + '-bar';
|
||||
Object.assign(barEl.style, {
|
||||
@@ -764,15 +800,15 @@
|
||||
display: 'none', opacity: '0',
|
||||
transform: 'translateY(6px)',
|
||||
transition: 'opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
|
||||
// Visual
|
||||
background: C.paper,
|
||||
background: BP.surface,
|
||||
backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
|
||||
border: '1px solid ' + C.mist,
|
||||
border: '1px solid ' + BP.hairline,
|
||||
borderRadius: '10px',
|
||||
boxShadow: '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)',
|
||||
fontFamily: FONT, fontSize: '13px', color: C.ink,
|
||||
boxShadow: BAR_SHADOW_DEFAULT,
|
||||
transition: 'box-shadow 0.2s ease, opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
|
||||
fontFamily: FONT, fontSize: '13px', color: BP.text,
|
||||
padding: '6px',
|
||||
maxWidth: '460px', minWidth: '320px',
|
||||
maxWidth: '520px', minWidth: '320px',
|
||||
});
|
||||
document.body.appendChild(barEl);
|
||||
}
|
||||
@@ -824,14 +860,15 @@
|
||||
barEl.style.transform = 'translateY(6px)';
|
||||
setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250);
|
||||
hideActionPicker();
|
||||
closeTunePopover();
|
||||
}
|
||||
|
||||
function updateBarContent(mode) {
|
||||
if (!barEl || barEl.style.display === 'none') return;
|
||||
barEl.innerHTML = '';
|
||||
// Reset bar styling
|
||||
barEl.style.background = C.paper;
|
||||
barEl.style.border = '1px solid ' + C.mist;
|
||||
// Reset bar styling to the theme-aware palette
|
||||
barEl.style.background = BP.surface;
|
||||
barEl.style.border = '1px solid ' + BP.hairline;
|
||||
if (mode === 'configure') barEl.appendChild(buildConfigureRow());
|
||||
else if (mode === 'generating') barEl.appendChild(buildGeneratingRow());
|
||||
else if (mode === 'cycling') barEl.appendChild(buildCyclingRow());
|
||||
@@ -854,15 +891,15 @@
|
||||
const pill = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
||||
padding: '5px 10px', borderRadius: '6px',
|
||||
background: C.ink, color: C.white,
|
||||
background: BP.mark, color: BP.markText,
|
||||
fontFamily: FONT, fontSize: '12px', fontWeight: '500',
|
||||
border: 'none', cursor: 'pointer',
|
||||
transition: 'background 0.12s ease, transform 0.1s ease',
|
||||
whiteSpace: 'nowrap', flexShrink: '0',
|
||||
});
|
||||
pill.textContent = actionLabel() + ' \u25BE';
|
||||
pill.addEventListener('mouseenter', () => pill.style.background = C.brandHov);
|
||||
pill.addEventListener('mouseleave', () => pill.style.background = C.ink);
|
||||
pill.addEventListener('mouseenter', () => pill.style.background = BP.accent);
|
||||
pill.addEventListener('mouseleave', () => pill.style.background = BP.mark);
|
||||
pill.addEventListener('mousedown', () => pill.style.transform = 'scale(0.97)');
|
||||
pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)');
|
||||
pill.addEventListener('click', (e) => { e.stopPropagation(); toggleActionPicker(); });
|
||||
@@ -877,13 +914,13 @@
|
||||
flex: '1', minWidth: '0',
|
||||
padding: '5px 8px', borderRadius: '6px',
|
||||
border: '1px solid transparent', background: 'transparent',
|
||||
fontFamily: FONT, fontSize: '12px', color: C.ink,
|
||||
fontFamily: FONT, fontSize: '12px', color: BP.text,
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.15s ease, background 0.15s ease',
|
||||
});
|
||||
input.addEventListener('focus', () => {
|
||||
input.style.borderColor = C.mist;
|
||||
input.style.background = C.white;
|
||||
input.style.borderColor = BP.hairline;
|
||||
input.style.background = BP.accentSoft;
|
||||
});
|
||||
input.addEventListener('blur', () => {
|
||||
input.style.borderColor = 'transparent';
|
||||
@@ -901,16 +938,16 @@
|
||||
// Variant count toggle
|
||||
const count = el('button', {
|
||||
padding: '4px 6px', borderRadius: '5px',
|
||||
border: '1px solid ' + C.mist, background: 'transparent',
|
||||
border: '1px solid ' + BP.hairline, background: 'transparent',
|
||||
fontFamily: MONO, fontSize: '11px', fontWeight: '600',
|
||||
color: C.ash, cursor: 'pointer',
|
||||
color: BP.textDim, cursor: 'pointer',
|
||||
transition: 'color 0.12s ease, border-color 0.12s ease',
|
||||
flexShrink: '0', whiteSpace: 'nowrap',
|
||||
});
|
||||
count.textContent = '\u00D7' + selectedCount;
|
||||
count.title = 'Variants: click to change';
|
||||
count.addEventListener('mouseenter', () => { count.style.color = C.ink; count.style.borderColor = C.ink; });
|
||||
count.addEventListener('mouseleave', () => { count.style.color = C.ash; count.style.borderColor = C.mist; });
|
||||
count.addEventListener('mouseenter', () => { count.style.color = BP.text; count.style.borderColor = BP.text; });
|
||||
count.addEventListener('mouseleave', () => { count.style.color = BP.textDim; count.style.borderColor = BP.hairline; });
|
||||
count.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1;
|
||||
@@ -921,15 +958,15 @@
|
||||
// Go button
|
||||
const go = el('button', {
|
||||
padding: '5px 12px', borderRadius: '6px',
|
||||
border: 'none', background: C.brand, color: C.white,
|
||||
border: 'none', background: BP.accent, color: BP.mark,
|
||||
fontFamily: FONT, fontSize: '12px', fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.12s ease, transform 0.1s ease',
|
||||
transition: 'filter 0.12s ease, transform 0.1s ease',
|
||||
flexShrink: '0', whiteSpace: 'nowrap',
|
||||
});
|
||||
go.textContent = 'Go \u2192';
|
||||
go.addEventListener('mouseenter', () => go.style.background = C.brandHov);
|
||||
go.addEventListener('mouseleave', () => go.style.background = C.brand);
|
||||
go.addEventListener('mouseenter', () => go.style.filter = 'brightness(1.1)');
|
||||
go.addEventListener('mouseleave', () => go.style.filter = 'none');
|
||||
go.addEventListener('mousedown', () => go.style.transform = 'scale(0.97)');
|
||||
go.addEventListener('mouseup', () => go.style.transform = 'scale(1)');
|
||||
go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); });
|
||||
@@ -950,7 +987,7 @@
|
||||
|
||||
// Action label
|
||||
const label = el('span', {
|
||||
fontWeight: '600', fontSize: '12px', color: C.ink,
|
||||
fontWeight: '600', fontSize: '12px', color: BP.text,
|
||||
flexShrink: '0', whiteSpace: 'nowrap',
|
||||
});
|
||||
label.textContent = actionLabel();
|
||||
@@ -961,7 +998,7 @@
|
||||
|
||||
// Status
|
||||
const status = el('span', {
|
||||
fontSize: '11px', color: C.ash, whiteSpace: 'nowrap',
|
||||
fontSize: '11px', color: BP.textDim, whiteSpace: 'nowrap',
|
||||
marginLeft: 'auto',
|
||||
});
|
||||
// Variants currently arrive atomically in a single file edit, so a
|
||||
@@ -976,6 +1013,8 @@
|
||||
|
||||
// --- Cycling row ---
|
||||
|
||||
const TUNE_ICON_SVG = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" style="flex-shrink:0"><line x1="4" y1="8" x2="20" y2="8"/><circle cx="14" cy="8" r="2.4" fill="currentColor" stroke="none"/><line x1="4" y1="16" x2="20" y2="16"/><circle cx="10" cy="16" r="2.4" fill="currentColor" stroke="none"/></svg>';
|
||||
|
||||
function buildCyclingRow() {
|
||||
const row = el('div', {
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
@@ -994,7 +1033,7 @@
|
||||
// Counter
|
||||
const counter = el('span', {
|
||||
fontFamily: MONO, fontSize: '11px', fontWeight: '500',
|
||||
color: C.ash, minWidth: '24px', textAlign: 'center',
|
||||
color: BP.textDim, minWidth: '24px', textAlign: 'center',
|
||||
});
|
||||
counter.textContent = visibleVariant + '/' + arrivedVariants;
|
||||
row.appendChild(counter);
|
||||
@@ -1005,20 +1044,67 @@
|
||||
if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
|
||||
row.appendChild(next);
|
||||
|
||||
// Tune chip — only when the visible variant exposes params
|
||||
const visParams = parseVariantParams(getVisibleVariantEl());
|
||||
const hasParams = visParams.length > 0;
|
||||
if (hasParams) {
|
||||
const tune = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '6px',
|
||||
padding: '4px 10px', borderRadius: '5px',
|
||||
border: '1px solid transparent',
|
||||
background: tuneOpen ? BP.accentSoft : 'transparent',
|
||||
color: tuneOpen ? BP.accent : BP.text,
|
||||
fontFamily: FONT, fontSize: '11px', fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'color 0.12s ease, background 0.12s ease',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
tune.innerHTML = TUNE_ICON_SVG;
|
||||
const tuneLabel = document.createElement('span');
|
||||
tuneLabel.textContent = 'Tune';
|
||||
tune.appendChild(tuneLabel);
|
||||
const tuneBadge = document.createElement('span');
|
||||
Object.assign(tuneBadge.style, {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
minWidth: '16px', height: '16px', padding: '0 4px',
|
||||
borderRadius: '999px',
|
||||
background: tuneOpen ? C.brand : BP.hairline,
|
||||
color: tuneOpen ? 'oklch(98% 0 0)' : 'inherit',
|
||||
fontFamily: MONO, fontSize: '9.5px', fontWeight: '600',
|
||||
lineHeight: '1',
|
||||
boxSizing: 'border-box',
|
||||
});
|
||||
tuneBadge.textContent = String(visParams.length);
|
||||
tune.appendChild(tuneBadge);
|
||||
tune.title = 'Tune this variant (' + visParams.length + ' knob' + (visParams.length === 1 ? '' : 's') + ')';
|
||||
tune.addEventListener('mouseenter', () => {
|
||||
if (!tuneOpen) tune.style.background = BP.accentSoft;
|
||||
});
|
||||
tune.addEventListener('mouseleave', () => {
|
||||
if (!tuneOpen) tune.style.background = 'transparent';
|
||||
});
|
||||
tune.addEventListener('click', (e) => { e.stopPropagation(); toggleTunePopover(); });
|
||||
tune.dataset.iceqTune = '1';
|
||||
row.appendChild(tune);
|
||||
}
|
||||
|
||||
// Spacer
|
||||
row.appendChild(el('div', { flex: '1' }));
|
||||
|
||||
// Accept
|
||||
// Accept — primary action, uses the site's saturated brand magenta
|
||||
// with paper-white text, not the theme-muted BP.accent.
|
||||
const accept = el('button', {
|
||||
padding: '4px 10px', borderRadius: '5px',
|
||||
border: 'none', background: C.ink, color: C.white,
|
||||
padding: '5px 14px', borderRadius: '5px',
|
||||
border: 'none', background: C.brand, color: 'oklch(98% 0 0)',
|
||||
fontFamily: FONT, fontSize: '11px', fontWeight: '600',
|
||||
cursor: 'pointer', transition: 'background 0.12s ease',
|
||||
cursor: 'pointer', transition: 'filter 0.12s ease, transform 0.1s ease',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
accept.textContent = '\u2713 Accept';
|
||||
accept.addEventListener('mouseenter', () => accept.style.background = C.brand);
|
||||
accept.addEventListener('mouseleave', () => accept.style.background = C.ink);
|
||||
accept.addEventListener('mouseenter', () => accept.style.filter = 'brightness(1.08)');
|
||||
accept.addEventListener('mouseleave', () => accept.style.filter = 'none');
|
||||
accept.addEventListener('mousedown', () => accept.style.transform = 'scale(0.97)');
|
||||
accept.addEventListener('mouseup', () => accept.style.transform = 'scale(1)');
|
||||
accept.addEventListener('click', (e) => { e.stopPropagation(); handleAccept(); });
|
||||
if (arrivedVariants === 0) { accept.style.opacity = '0.3'; accept.style.pointerEvents = 'none'; }
|
||||
row.appendChild(accept);
|
||||
@@ -1026,14 +1112,14 @@
|
||||
// Discard
|
||||
const discard = el('button', {
|
||||
padding: '4px 6px', borderRadius: '5px',
|
||||
border: '1px solid ' + C.mist, background: 'transparent',
|
||||
fontFamily: FONT, fontSize: '11px', color: C.ash,
|
||||
border: '1px solid ' + BP.hairline, background: 'transparent',
|
||||
fontFamily: FONT, fontSize: '11px', color: BP.textDim,
|
||||
cursor: 'pointer', transition: 'color 0.12s ease, border-color 0.12s ease',
|
||||
});
|
||||
discard.textContent = '\u2715';
|
||||
discard.title = 'Discard all variants';
|
||||
discard.addEventListener('mouseenter', () => { discard.style.color = C.ink; discard.style.borderColor = C.ink; });
|
||||
discard.addEventListener('mouseleave', () => { discard.style.color = C.ash; discard.style.borderColor = C.mist; });
|
||||
discard.addEventListener('mouseenter', () => { discard.style.color = BP.text; discard.style.borderColor = BP.text; });
|
||||
discard.addEventListener('mouseleave', () => { discard.style.color = BP.textDim; discard.style.borderColor = BP.hairline; });
|
||||
discard.addEventListener('click', (e) => { e.stopPropagation(); handleDiscard(); });
|
||||
row.appendChild(discard);
|
||||
|
||||
@@ -1051,14 +1137,14 @@
|
||||
});
|
||||
const spinner = el('div', {
|
||||
width: '14px', height: '14px', borderRadius: '50%',
|
||||
border: '2px solid ' + C.mist,
|
||||
borderTopColor: C.brand,
|
||||
border: '2px solid ' + BP.hairline,
|
||||
borderTopColor: BP.accent,
|
||||
animation: 'impeccable-spin 0.6s linear infinite',
|
||||
flexShrink: '0',
|
||||
});
|
||||
row.appendChild(spinner);
|
||||
const label = el('span', {
|
||||
fontSize: '12px', color: C.ash, fontWeight: '500',
|
||||
fontSize: '12px', color: BP.textDim, fontWeight: '500',
|
||||
});
|
||||
label.textContent = 'Applying variant...';
|
||||
row.appendChild(label);
|
||||
@@ -1103,14 +1189,26 @@
|
||||
for (let i = 1; i <= expectedVariants; i++) {
|
||||
const arrived = i <= arrivedVariants;
|
||||
const active = i === visibleVariant;
|
||||
// active: solid site-brand magenta dot. arrived+inactive: muted neutral.
|
||||
// pending (not yet arrived): faint outline ring. No borders on arrived
|
||||
// dots — the previous "accent ring + ash fill" combo read as noisy
|
||||
// magenta chips, especially when all variants had arrived and every
|
||||
// dot wore an accent ring.
|
||||
const dotBg = active ? C.brand
|
||||
: arrived ? BP.textDim
|
||||
: 'transparent';
|
||||
const dotBorder = arrived ? 'none' : '1.5px solid ' + BP.hairline;
|
||||
const dot = el('div', {
|
||||
width: '7px', height: '7px', borderRadius: '50%',
|
||||
background: active ? C.brand : (arrived ? C.ash : 'transparent'),
|
||||
border: '1.5px solid ' + (arrived ? C.brand : C.mist),
|
||||
width: active ? '8px' : '6px',
|
||||
height: active ? '8px' : '6px',
|
||||
borderRadius: '50%',
|
||||
background: dotBg,
|
||||
border: dotBorder,
|
||||
boxSizing: 'border-box',
|
||||
transition: 'all 0.2s ' + EASE,
|
||||
cursor: (clickable && arrived) ? 'pointer' : 'default',
|
||||
transform: arrived ? 'scale(1)' : 'scale(0.6)',
|
||||
opacity: arrived ? '1' : '0.4',
|
||||
transform: arrived ? 'scale(1)' : 'scale(0.85)',
|
||||
opacity: arrived ? (active ? '1' : '0.6') : '0.4',
|
||||
});
|
||||
if (clickable && arrived) {
|
||||
const idx = i;
|
||||
@@ -1130,15 +1228,15 @@
|
||||
function navBtn(text) {
|
||||
const b = el('button', {
|
||||
width: '26px', height: '26px', borderRadius: '5px',
|
||||
border: '1px solid ' + C.mist, background: 'transparent',
|
||||
color: C.ink, fontFamily: FONT, fontSize: '13px',
|
||||
border: '1px solid ' + BP.hairline, background: 'transparent',
|
||||
color: BP.text, fontFamily: FONT, fontSize: '13px',
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'border-color 0.12s ease, background 0.12s ease',
|
||||
padding: '0', lineHeight: '1',
|
||||
});
|
||||
b.textContent = text;
|
||||
b.addEventListener('mouseenter', () => { b.style.borderColor = C.ink; });
|
||||
b.addEventListener('mouseleave', () => { b.style.borderColor = C.mist; });
|
||||
b.addEventListener('mouseenter', () => { b.style.borderColor = BP.text; });
|
||||
b.addEventListener('mouseleave', () => { b.style.borderColor = BP.hairline; });
|
||||
return b;
|
||||
}
|
||||
|
||||
@@ -1158,6 +1256,7 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initActionPicker() {
|
||||
const P = barPaletteForTheme(detectPageTheme());
|
||||
pickerEl = document.createElement('div');
|
||||
pickerEl.id = PREFIX + '-picker';
|
||||
Object.assign(pickerEl.style, {
|
||||
@@ -1166,12 +1265,14 @@
|
||||
transform: 'scale(0.96) translateY(4px)',
|
||||
transformOrigin: 'bottom left',
|
||||
transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE,
|
||||
background: C.paperSolid,
|
||||
border: '1px solid ' + C.mist,
|
||||
background: P.surface,
|
||||
border: '1px solid ' + P.hairline,
|
||||
borderRadius: '10px',
|
||||
boxShadow: '0 8px 30px oklch(0% 0 0 / 0.10), 0 2px 6px oklch(0% 0 0 / 0.06)',
|
||||
padding: '6px',
|
||||
fontFamily: FONT,
|
||||
backdropFilter: 'blur(10px)',
|
||||
WebkitBackdropFilter: 'blur(10px)',
|
||||
});
|
||||
|
||||
// Build the chip grid
|
||||
@@ -1181,22 +1282,32 @@
|
||||
|
||||
ACTIONS.forEach(action => {
|
||||
const chip = el('button', {
|
||||
padding: '6px 8px', borderRadius: '6px',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '8px 6px', borderRadius: '6px',
|
||||
border: 'none',
|
||||
background: action.value === selectedAction ? C.brandSoft : 'transparent',
|
||||
color: action.value === selectedAction ? C.brand : C.ink,
|
||||
background: action.value === selectedAction ? P.accentSoft : 'transparent',
|
||||
color: action.value === selectedAction ? P.accent : P.text,
|
||||
fontFamily: FONT, fontSize: '11px', fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.1s ease, color 0.1s ease',
|
||||
textAlign: 'center', whiteSpace: 'nowrap',
|
||||
});
|
||||
chip.textContent = action.label;
|
||||
const iconWrap = el('span', {
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
height: '20px', opacity: '0.9',
|
||||
});
|
||||
iconWrap.innerHTML = ICONS[action.value] || '';
|
||||
const labelEl = el('span', { lineHeight: '1' });
|
||||
labelEl.textContent = action.label;
|
||||
chip.appendChild(iconWrap);
|
||||
chip.appendChild(labelEl);
|
||||
chip.dataset.action = action.value;
|
||||
chip.addEventListener('mouseenter', () => {
|
||||
if (action.value !== selectedAction) chip.style.background = C.brandSoft;
|
||||
if (action.value !== selectedAction) chip.style.background = P.accentSoft;
|
||||
});
|
||||
chip.addEventListener('mouseleave', () => {
|
||||
chip.style.background = action.value === selectedAction ? C.brandSoft : 'transparent';
|
||||
chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent';
|
||||
});
|
||||
chip.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -1209,19 +1320,24 @@
|
||||
|
||||
pickerEl.appendChild(grid);
|
||||
document.body.appendChild(pickerEl);
|
||||
|
||||
// Cache the palette on the picker so toggleActionPicker's state refresh
|
||||
// uses the same theme-aware colors when it repaints chips.
|
||||
pickerEl.__iceq_palette = P;
|
||||
}
|
||||
|
||||
function toggleActionPicker() {
|
||||
if (pickerEl.style.display !== 'none') { hideActionPicker(); return; }
|
||||
// Rebuild chips to reflect current selection
|
||||
const P = pickerEl.__iceq_palette || barPaletteForTheme(detectPageTheme());
|
||||
pickerEl.querySelectorAll('button').forEach(chip => {
|
||||
const isActive = chip.dataset.action === selectedAction;
|
||||
chip.style.background = isActive ? C.brandSoft : 'transparent';
|
||||
chip.style.color = isActive ? C.brand : C.ink;
|
||||
chip.style.background = isActive ? P.accentSoft : 'transparent';
|
||||
chip.style.color = isActive ? P.accent : P.text;
|
||||
});
|
||||
// Position above the bar
|
||||
const barRect = barEl.getBoundingClientRect();
|
||||
const pickerH = 90; // approximate
|
||||
const pickerH = 170; // approximate; grows with icon + label rows
|
||||
let top = barRect.top - pickerH - 6;
|
||||
if (top < 8) top = barRect.bottom + 6;
|
||||
Object.assign(pickerEl.style, {
|
||||
@@ -1241,6 +1357,399 @@
|
||||
setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Params panel (per-variant coarse controls)
|
||||
//
|
||||
// Variants may declare a parameter manifest via a JSON attribute on the
|
||||
// variant wrapper:
|
||||
//
|
||||
// <div data-impeccable-variant="1"
|
||||
// data-impeccable-params='[{"id":"density","kind":"steps",...}]'>
|
||||
//
|
||||
// The panel docks to the right edge of the outline during CYCLING and
|
||||
// exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped
|
||||
// CSS can respond instantly without regeneration:
|
||||
//
|
||||
// range / numeric toggle → CSS var (`--p-<id>`) used via var(--p-foo, N)
|
||||
// steps / boolean toggle → data-p-<id> attribute used via :scope[data-p-foo="..."]
|
||||
//
|
||||
// On variant switch, values reset to that variant's declared defaults.
|
||||
// On accept, current values are sent in the event payload so the agent
|
||||
// can bake them into the source-file write.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide)
|
||||
let paramsPanelInner = null; // translating content (carries bg, padding, knobs)
|
||||
let paramsPanelBody = null; // grid holding the knob cells
|
||||
let paramsCurrentValues = {}; // {paramId: value} — mirror of the visible variant's live values
|
||||
let tuneOpen = false; // whether the Tune popover is open right now
|
||||
|
||||
// Theme-aware Tune popover. Appears as a drawer that slides out from the
|
||||
// contextual bar's bar-facing edge (below if the bar sits below the
|
||||
// element, above otherwise). Same width as the bar. Auto-wraps to extra
|
||||
// rows when the knobs exceed one row. The bar's border-radius on the
|
||||
// popover side goes flat while open so the two shapes read as one.
|
||||
let paramsPanelPalette = null;
|
||||
|
||||
function initParamsPanel() {
|
||||
paramsPanelPalette = barPaletteForTheme(detectPageTheme());
|
||||
const P = paramsPanelPalette;
|
||||
|
||||
// Single element, always in the DOM. The slide animation is a CSS mask
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis — no
|
||||
// display toggle, no opacity toggle, no transform trickery. The mask
|
||||
// hides everything initially; as it grows, content is revealed from
|
||||
// the bar edge outward.
|
||||
paramsPanelEl = document.createElement('div');
|
||||
paramsPanelEl.id = PREFIX + '-params-panel';
|
||||
Object.assign(paramsPanelEl.style, {
|
||||
position: 'fixed', zIndex: String(Z.bar - 1),
|
||||
background: P.surfaceDeep,
|
||||
color: P.text,
|
||||
fontFamily: FONT,
|
||||
padding: '14px 18px',
|
||||
boxSizing: 'border-box',
|
||||
borderRadius: '0 0 10px 10px',
|
||||
pointerEvents: 'none',
|
||||
backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
|
||||
|
||||
// clip-path is the same conceptual reveal as mask but with rock-solid
|
||||
// transition support across engines. Closed state clips from the far
|
||||
// edge; open = inset(0) shows everything.
|
||||
clipPath: 'inset(0 0 100% 0)',
|
||||
transition: 'clip-path 0.44s ' + EASE,
|
||||
|
||||
// Park off-screen until positionParamsPanel places it. These are NOT
|
||||
// in the transition list, so they snap instantly — no fly-in from the
|
||||
// top-left when first shown.
|
||||
top: '-9999px', left: '-9999px', width: '0',
|
||||
});
|
||||
|
||||
paramsPanelBody = el('div', {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
|
||||
gap: '12px 16px',
|
||||
});
|
||||
|
||||
paramsPanelEl.appendChild(paramsPanelBody);
|
||||
document.body.appendChild(paramsPanelEl);
|
||||
paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
|
||||
}
|
||||
|
||||
function getVisibleVariantEl() {
|
||||
if (!currentSessionId) return null;
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
|
||||
function parseVariantParams(variantEl) {
|
||||
if (!variantEl) return [];
|
||||
const raw = variantEl.getAttribute('data-impeccable-params');
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] Invalid data-impeccable-params JSON:', err.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function applyParamValue(variantEl, param, value) {
|
||||
if (!variantEl) return;
|
||||
const attr = 'data-p-' + param.id;
|
||||
if (param.kind === 'range') {
|
||||
variantEl.style.setProperty('--p-' + param.id, String(value));
|
||||
} else if (param.kind === 'toggle') {
|
||||
const on = !!value;
|
||||
variantEl.style.setProperty('--p-' + param.id, on ? '1' : '0');
|
||||
if (on) variantEl.setAttribute(attr, 'on');
|
||||
else variantEl.removeAttribute(attr);
|
||||
} else if (param.kind === 'steps') {
|
||||
variantEl.setAttribute(attr, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
function applyParamDefaults(variantEl, params) {
|
||||
paramsCurrentValues = {};
|
||||
for (const p of params) {
|
||||
paramsCurrentValues[p.id] = p.default;
|
||||
applyParamValue(variantEl, p, p.default);
|
||||
}
|
||||
}
|
||||
|
||||
function formatRangeValue(input) {
|
||||
const max = parseFloat(input.max), min = parseFloat(input.min);
|
||||
const v = parseFloat(input.value);
|
||||
if (!isFinite(v)) return input.value;
|
||||
return (max - min) <= 2 ? v.toFixed(2) : String(Math.round(v));
|
||||
}
|
||||
|
||||
function buildParamsPanel(variantEl, params) {
|
||||
const P = paramsPanelPalette || barPaletteForTheme(detectPageTheme());
|
||||
paramsPanelBody.innerHTML = '';
|
||||
for (const p of params) {
|
||||
const row = el('div', { display: 'flex', flexDirection: 'column', gap: '6px' });
|
||||
const labelRow = el('div', {
|
||||
display: 'flex', justifyContent: 'space-between',
|
||||
alignItems: 'baseline', gap: '8px',
|
||||
});
|
||||
const lbl = el('span', {
|
||||
fontSize: '10.5px', fontWeight: '600', color: P.text,
|
||||
letterSpacing: '0.03em',
|
||||
});
|
||||
lbl.textContent = p.label || p.id;
|
||||
labelRow.appendChild(lbl);
|
||||
const readout = el('span', {
|
||||
fontSize: '10.5px', color: P.textDim,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
});
|
||||
labelRow.appendChild(readout);
|
||||
row.appendChild(labelRow);
|
||||
|
||||
if (p.kind === 'range') {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'range';
|
||||
input.min = String(p.min != null ? p.min : 0);
|
||||
input.max = String(p.max != null ? p.max : 1);
|
||||
input.step = String(p.step != null ? p.step : 0.05);
|
||||
input.value = String(p.default);
|
||||
Object.assign(input.style, {
|
||||
width: '100%', accentColor: C.brand, cursor: 'pointer',
|
||||
});
|
||||
readout.textContent = formatRangeValue(input);
|
||||
input.addEventListener('input', (e) => {
|
||||
e.stopPropagation();
|
||||
const v = parseFloat(input.value);
|
||||
paramsCurrentValues[p.id] = v;
|
||||
readout.textContent = formatRangeValue(input);
|
||||
applyParamValue(variantEl, p, v);
|
||||
});
|
||||
row.appendChild(input);
|
||||
} else if (p.kind === 'toggle') {
|
||||
const initial = !!p.default;
|
||||
readout.textContent = initial ? 'On' : 'Off';
|
||||
const track = el('button', {
|
||||
position: 'relative', width: '36px', height: '20px',
|
||||
borderRadius: '10px', border: 'none', padding: '0',
|
||||
cursor: 'pointer',
|
||||
background: initial ? C.brand : P.hairline,
|
||||
transition: 'background 0.15s ease',
|
||||
alignSelf: 'flex-start',
|
||||
});
|
||||
const knob = el('span', {
|
||||
position: 'absolute', top: '2px',
|
||||
left: initial ? '18px' : '2px',
|
||||
width: '16px', height: '16px', borderRadius: '50%',
|
||||
background: 'oklch(98% 0 0)',
|
||||
transition: 'left 0.18s ' + EASE,
|
||||
boxShadow: '0 1px 2px oklch(0% 0 0 / 0.2)',
|
||||
});
|
||||
track.appendChild(knob);
|
||||
track.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const next = !paramsCurrentValues[p.id];
|
||||
paramsCurrentValues[p.id] = next;
|
||||
track.style.background = next ? C.brand : P.hairline;
|
||||
knob.style.left = next ? '18px' : '2px';
|
||||
readout.textContent = next ? 'On' : 'Off';
|
||||
applyParamValue(variantEl, p, next);
|
||||
});
|
||||
row.appendChild(track);
|
||||
} else if (p.kind === 'steps') {
|
||||
const opts = (p.options || []).map(o =>
|
||||
typeof o === 'string' ? { value: o, label: o } : o
|
||||
);
|
||||
const activeOpt = opts.find(o => o.value === p.default) || opts[0];
|
||||
readout.textContent = activeOpt ? activeOpt.label : String(p.default);
|
||||
const segRow = el('div', {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(' + opts.length + ', 1fr)',
|
||||
gap: '1px', padding: '2px',
|
||||
background: P.hairline, borderRadius: '5px',
|
||||
});
|
||||
const segBtns = [];
|
||||
opts.forEach(o => {
|
||||
const active = o.value === p.default;
|
||||
const b = el('button', {
|
||||
padding: '5px 4px', border: 'none', borderRadius: '3px',
|
||||
background: active ? C.brand : 'transparent',
|
||||
color: active ? 'oklch(98% 0 0)' : P.text,
|
||||
fontFamily: FONT, fontSize: '10.5px', fontWeight: '500',
|
||||
cursor: 'pointer', whiteSpace: 'nowrap',
|
||||
transition: 'background 0.1s ease, color 0.1s ease',
|
||||
});
|
||||
b.textContent = o.label;
|
||||
b.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
paramsCurrentValues[p.id] = o.value;
|
||||
readout.textContent = o.label;
|
||||
segBtns.forEach(({ btn, val }) => {
|
||||
const on = val === o.value;
|
||||
btn.style.background = on ? C.brand : 'transparent';
|
||||
btn.style.color = on ? 'oklch(98% 0 0)' : P.text;
|
||||
});
|
||||
applyParamValue(variantEl, p, o.value);
|
||||
});
|
||||
segRow.appendChild(b);
|
||||
segBtns.push({ btn: b, val: o.value });
|
||||
});
|
||||
row.appendChild(segRow);
|
||||
}
|
||||
|
||||
paramsPanelBody.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
// Decide which way the popover opens: away from the picked element. If the
|
||||
// bar landed below the element, popover slides DOWN from the bar's bottom.
|
||||
// If the bar landed above, popover slides UP from the bar's top.
|
||||
function popoverDirection() {
|
||||
if (!barEl || !selectedElement) return 'below';
|
||||
const br = barEl.getBoundingClientRect();
|
||||
const er = selectedElement.getBoundingClientRect();
|
||||
return br.top >= er.bottom - 4 ? 'below' : 'above';
|
||||
}
|
||||
|
||||
// The popover overlaps the bar by OVERLAP px on the bar-facing side. With
|
||||
// popover z-index below bar, that overlap sits behind bar (invisible) and
|
||||
// reinforces the "tucked behind" feel. Padding compensates so the real
|
||||
// content starts flush with bar's outer edge.
|
||||
const TUNE_OVERLAP = 6;
|
||||
|
||||
// Closed clip-path depends on direction: for 'below' clip from the far
|
||||
// (bottom) edge so the reveal grows downward from the bar; for 'above'
|
||||
// clip from the top edge so the reveal grows upward from the bar.
|
||||
function closedClipPath(direction) {
|
||||
return direction === 'below' ? 'inset(0 0 100% 0)' : 'inset(100% 0 0 0)';
|
||||
}
|
||||
|
||||
function setClipPath(value, withTransition) {
|
||||
const saved = paramsPanelEl.style.transition;
|
||||
if (!withTransition) paramsPanelEl.style.transition = 'none';
|
||||
paramsPanelEl.style.clipPath = value;
|
||||
if (!withTransition) {
|
||||
void paramsPanelEl.offsetHeight;
|
||||
paramsPanelEl.style.transition = saved;
|
||||
}
|
||||
}
|
||||
|
||||
function positionParamsPanel() {
|
||||
if (!paramsPanelEl || !barEl || barEl.style.display === 'none') return;
|
||||
const br = barEl.getBoundingClientRect();
|
||||
const direction = popoverDirection();
|
||||
const prevDirection = paramsPanelEl.dataset.tuneDirection;
|
||||
|
||||
// top/left/width are NOT in the transition list, so they snap instantly.
|
||||
paramsPanelEl.style.left = br.left + 'px';
|
||||
paramsPanelEl.style.width = br.width + 'px';
|
||||
|
||||
if (direction === 'below') {
|
||||
paramsPanelEl.style.top = (br.bottom - TUNE_OVERLAP) + 'px';
|
||||
paramsPanelEl.style.borderRadius = '0 0 10px 10px';
|
||||
paramsPanelEl.style.paddingTop = (14 + TUNE_OVERLAP) + 'px';
|
||||
paramsPanelEl.style.paddingBottom = '14px';
|
||||
} else {
|
||||
const ih = paramsPanelEl.offsetHeight || 80;
|
||||
paramsPanelEl.style.top = (br.top - ih + TUNE_OVERLAP) + 'px';
|
||||
paramsPanelEl.style.borderRadius = '10px 10px 0 0';
|
||||
paramsPanelEl.style.paddingTop = '14px';
|
||||
paramsPanelEl.style.paddingBottom = (14 + TUNE_OVERLAP) + 'px';
|
||||
}
|
||||
paramsPanelEl.dataset.tuneDirection = direction;
|
||||
|
||||
// If currently closed and direction flipped (or first-time setup),
|
||||
// snap the clip-path to the new direction's closed pose without
|
||||
// transitioning (so the clip doesn't slide across the element).
|
||||
if (!tuneOpen && (!prevDirection || prevDirection !== direction)) {
|
||||
setClipPath(closedClipPath(direction), false);
|
||||
}
|
||||
}
|
||||
|
||||
function showParamsPanel() {
|
||||
if (!paramsPanelEl) return;
|
||||
positionParamsPanel();
|
||||
paramsPanelEl.style.pointerEvents = 'auto';
|
||||
// rAF so the positioning paint commits before the transition fires.
|
||||
requestAnimationFrame(() => {
|
||||
setClipPath('inset(0 0 0 0)', true);
|
||||
});
|
||||
}
|
||||
|
||||
function hideParamsPanel() {
|
||||
if (!paramsPanelEl) return;
|
||||
paramsPanelEl.style.pointerEvents = 'none';
|
||||
const direction = paramsPanelEl.dataset.tuneDirection || 'below';
|
||||
setClipPath(closedClipPath(direction), true);
|
||||
}
|
||||
|
||||
// Build/rebuild the panel's contents for the current variant AND apply
|
||||
// its defaults to the variant wrapper (so scoped CSS responds even before
|
||||
// the user opens the popover). Visibility is governed by tuneOpen.
|
||||
function refreshParamsPanel() {
|
||||
if (state !== 'CYCLING') {
|
||||
paramsCurrentValues = {};
|
||||
tuneOpen = false;
|
||||
hideParamsPanel();
|
||||
return;
|
||||
}
|
||||
const variantEl = getVisibleVariantEl();
|
||||
const params = parseVariantParams(variantEl);
|
||||
if (!variantEl || params.length === 0) {
|
||||
paramsCurrentValues = {};
|
||||
tuneOpen = false;
|
||||
hideParamsPanel();
|
||||
return;
|
||||
}
|
||||
applyParamDefaults(variantEl, params);
|
||||
buildParamsPanel(variantEl, params);
|
||||
if (tuneOpen) {
|
||||
// If already visible (variant cycled while open), refresh in place
|
||||
// instead of re-running the clip-path animation.
|
||||
const alreadyVisible = paramsPanelEl.style.display === 'block'
|
||||
&& paramsPanelEl.style.opacity === '1';
|
||||
if (alreadyVisible) positionParamsPanel();
|
||||
else showParamsPanel();
|
||||
} else {
|
||||
hideParamsPanel();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTunePopover() {
|
||||
if (tuneOpen) { closeTunePopover(); return; }
|
||||
openTunePopover();
|
||||
}
|
||||
|
||||
function openTunePopover() {
|
||||
if (state !== 'CYCLING') return;
|
||||
const variantEl = getVisibleVariantEl();
|
||||
const params = parseVariantParams(variantEl);
|
||||
if (!variantEl || params.length === 0) return;
|
||||
// Build fresh to ensure the current variant's controls are shown.
|
||||
applyParamDefaults(variantEl, params);
|
||||
buildParamsPanel(variantEl, params);
|
||||
tuneOpen = true;
|
||||
showParamsPanel();
|
||||
// Kill the bar's shadow on the popover-facing side so the dark popover
|
||||
// doesn't pick up a bright glow line.
|
||||
if (barEl) {
|
||||
const direction = paramsPanelEl?.dataset.tuneDirection || 'below';
|
||||
barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN;
|
||||
}
|
||||
// Re-render the bar so the Tune chip picks up the active styling.
|
||||
updateBarContent('cycling');
|
||||
}
|
||||
|
||||
function closeTunePopover() {
|
||||
tuneOpen = false;
|
||||
hideParamsPanel();
|
||||
if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT;
|
||||
if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') {
|
||||
updateBarContent('cycling');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variant cycling in DOM
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1253,6 +1762,10 @@
|
||||
if (!v) continue;
|
||||
child.style.display = (v === String(num)) ? '' : 'none';
|
||||
}
|
||||
// Unconditional refresh — covers first-reveal (no-op if state isn't
|
||||
// CYCLING yet, the subsequent CYCLING transition triggers its own
|
||||
// refresh) and every cycle step.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1316,6 +1829,7 @@
|
||||
state = 'CYCLING';
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
refreshParamsPanel();
|
||||
saveSession();
|
||||
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
|
||||
})
|
||||
@@ -1329,7 +1843,7 @@
|
||||
const next = visibleVariant + dir;
|
||||
if (next < 1 || next > arrivedVariants) return;
|
||||
visibleVariant = next;
|
||||
showVariantInDOM(currentSessionId, next);
|
||||
showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself
|
||||
updateSelectedElement();
|
||||
updateBarContent('cycling');
|
||||
saveSession();
|
||||
@@ -1540,6 +2054,7 @@
|
||||
state = 'CYCLING';
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
refreshParamsPanel();
|
||||
} else if (state === 'GENERATING') {
|
||||
updateBarContent('generating');
|
||||
}
|
||||
@@ -1560,6 +2075,7 @@
|
||||
if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') {
|
||||
positionBar();
|
||||
showHighlight(selectedElement);
|
||||
if (tuneOpen) positionParamsPanel();
|
||||
}
|
||||
if (annotActive) positionAnnotOverlay(selectedElement);
|
||||
// Shader overlay (via debug P toggle or generation) is repositioned
|
||||
@@ -1606,6 +2122,7 @@
|
||||
if (state === 'GENERATING') {
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
refreshParamsPanel();
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -1691,6 +2208,10 @@
|
||||
if (pickerEl?.style.display !== 'none' && !own(e.target)) {
|
||||
hideActionPicker();
|
||||
}
|
||||
// Close Tune popover on outside click (anything outside panel + bar)
|
||||
if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) {
|
||||
closeTunePopover();
|
||||
}
|
||||
// In CONFIGURING: click outside the bar and selected element returns to PICKING
|
||||
if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) {
|
||||
hideBar();
|
||||
@@ -2258,7 +2779,11 @@ void main() {
|
||||
|
||||
function handleAccept() {
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
sendEvent({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) });
|
||||
const acceptPayload = { type: 'accept', id: currentSessionId, variantId: String(visibleVariant) };
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
sendEvent(acceptPayload);
|
||||
markSessionHandled();
|
||||
|
||||
// The accepted variant is already the only visible child of the wrapper
|
||||
@@ -2477,6 +3002,11 @@ void main() {
|
||||
state = arrivedVariants >= expectedVariants ? 'CYCLING' : 'GENERATING';
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -2522,6 +3052,11 @@ void main() {
|
||||
// dark pages. This keeps the bar from fighting with the host design.
|
||||
function detectPageTheme() {
|
||||
try {
|
||||
// Dev override: set localStorage 'impeccable-dev-theme' to 'light' or
|
||||
// 'dark' to preview the opposite palette without actually changing the
|
||||
// page bg. Used for screenshots and theme QA.
|
||||
const override = localStorage.getItem('impeccable-dev-theme');
|
||||
if (override === 'light' || override === 'dark') return override;
|
||||
const bg = getComputedStyle(document.body).backgroundColor
|
||||
|| getComputedStyle(document.documentElement).backgroundColor;
|
||||
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
||||
@@ -2538,6 +3073,7 @@ void main() {
|
||||
// Light bar on dark page
|
||||
return {
|
||||
surface: 'oklch(98% 0 0 / 0.92)',
|
||||
surfaceDeep: 'oklch(92% 0.005 60 / 0.96)', // slightly deeper, faint warm
|
||||
hairline: 'oklch(70% 0 0 / 0.35)',
|
||||
text: 'oklch(15% 0 0)',
|
||||
textDim: 'oklch(45% 0 0)',
|
||||
@@ -2552,6 +3088,7 @@ void main() {
|
||||
// deeper so the rounded-right shape reads as a clear sculpted mark.
|
||||
return {
|
||||
surface: 'oklch(26% 0 0 / 0.94)',
|
||||
surfaceDeep: 'oklch(18% 0 0 / 0.96)', // darker sand for Tune popover
|
||||
hairline: 'oklch(42% 0 0 / 0.5)',
|
||||
text: 'oklch(96% 0 0)',
|
||||
textDim: 'oklch(72% 0 0)',
|
||||
@@ -2883,6 +3420,7 @@ void main() {
|
||||
if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; }
|
||||
if (barEl) { barEl.remove(); barEl = null; }
|
||||
if (pickerEl) { pickerEl.remove(); pickerEl = null; }
|
||||
if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; }
|
||||
if (evtSource) { evtSource.close(); evtSource = null; }
|
||||
document.removeEventListener('mousemove', handleMouseMove, true);
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
@@ -3956,6 +4494,7 @@ void main() {
|
||||
initAnnotOverlay();
|
||||
initBar();
|
||||
initActionPicker();
|
||||
initParamsPanel();
|
||||
initGlobalBar();
|
||||
initDesignPanel();
|
||||
document.addEventListener('mousemove', handleMouseMove, true);
|
||||
|
||||
@@ -145,6 +145,11 @@ Options:
|
||||
const scriptArgs = event.type === 'discard'
|
||||
? ['--id', event.id, '--discard']
|
||||
: ['--id', event.id, '--variant', event.variantId];
|
||||
if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
|
||||
// Pass through a JSON blob; the shell-safe wrap uses single quotes because
|
||||
// values are finite {id, number|string|boolean} pairs from a validated payload.
|
||||
scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
|
||||
}
|
||||
try {
|
||||
const out = execSync(
|
||||
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
|
||||
|
||||
@@ -147,6 +147,11 @@ function validateEvent(msg) {
|
||||
case 'accept':
|
||||
if (!msg.id) return 'accept: missing id';
|
||||
if (!msg.variantId) return 'accept: missing variantId';
|
||||
if (msg.paramValues !== undefined) {
|
||||
if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
|
||||
return 'accept: paramValues must be an object';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
case 'discard':
|
||||
return msg.id ? null : 'discard: missing id';
|
||||
|
||||
@@ -142,3 +142,13 @@ Test that colorization improves the experience:
|
||||
- **Not overwhelming**: Is color balanced and purposeful?
|
||||
|
||||
Remember: Color is emotional and powerful. Use it to create warmth, guide attention, communicate meaning, and express personality. But restraint and strategy matter more than saturation and variety. Be colorful, but be intentional.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
When invoked from live mode, each variant MUST declare a `color-amount` param so the user can dial between a restrained accent and a drenched surface without regeneration. Author the variant's CSS against `var(--p-color-amount, 0.5)` — typically as the alpha multiplier on backgrounds, or as a scaling factor on the chroma axis in an OKLCH expression. 0 = neutral/monochrome, 1 = full saturation / dominant coverage.
|
||||
|
||||
```json
|
||||
{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"}
|
||||
```
|
||||
|
||||
Layer 1-2 variant-specific params on top: palette selection (`steps` with named options), temperature warmth, or tint vs. true color. See `reference/live.md` for the full params contract.
|
||||
|
||||
@@ -119,3 +119,23 @@ Create a systematic plan:
|
||||
- **Responsiveness**: Does the layout adapt gracefully across screen sizes?
|
||||
|
||||
Remember: Space is the most underused design tool. A layout with the right rhythm and hierarchy can make even simple content feel polished and intentional.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
Each variant MUST declare a `density` param. Drive all spacing tokens in the variant's scoped CSS through `calc(var(--p-density, 1) * <base>)` — paddings, gaps, column widths. Users slide from airy to packed and see layout re-breathe with no regeneration.
|
||||
|
||||
```json
|
||||
{"id":"density","kind":"range","min":0.6,"max":1.4,"step":0.05,"default":1,"label":"Density"}
|
||||
```
|
||||
|
||||
For variants whose topology genuinely changes (stacked vs. side-by-side, grid vs. bento), use a `steps` param whose scoped CSS branches via `:scope[data-p-structure="X"]`. One structure param + one density param is a powerful combo; resist adding a third.
|
||||
|
||||
```json
|
||||
{"id":"structure","kind":"steps","default":"grid","label":"Structure","options":[
|
||||
{"value":"stacked","label":"Stacked"},
|
||||
{"value":"grid","label":"Grid"},
|
||||
{"value":"bento","label":"Bento"}
|
||||
]}
|
||||
```
|
||||
|
||||
See `reference/live.md` for the full params contract.
|
||||
|
||||
@@ -179,7 +179,56 @@ The first variant has no `display: none` (visible by default). All others do. If
|
||||
|
||||
One edit, all variants — the browser's MutationObserver picks everything up in one pass.
|
||||
|
||||
### 7. Signal done
|
||||
### 7. Parameters (optional, 2-5 per variant)
|
||||
|
||||
Each variant can expose coarse knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against.
|
||||
|
||||
**When to use.** Any time the variant has a meaningful axis the user might want to dial in: color amount, density, motion intensity, scale ratio. Not micro-level margin tweaks; those defeat the point.
|
||||
|
||||
**Budget scales with the element's visual weight, not the user's curiosity.** Knobs need real estate to produce noticeably different output; slapping three on a small element just crowds the panel without making anything feel tunable.
|
||||
|
||||
- **Leaf / tiny** — a single button, icon, input, bare heading, solitary paragraph: **0 params.** A slider can't meaningfully reshape one element; just use variants.
|
||||
- **Small composition** — labeled input, simple card, short callout (≤ ~5 visual children): **0-1 params.** Only add one if it's a clear dominant axis (e.g. density on a card with visible internal rhythm).
|
||||
- **Medium composition** — section component, nav cluster, dense card, short feature block (6-15 visual children): **2 params.**
|
||||
- **Large composition** — hero section, full page region, spread layout, anything with strong internal structure (16+ visual children or multiple sub-sections): **3-4 params.**
|
||||
|
||||
When in doubt, fewer. The user can always ask for more variants to explore an axis you didn't expose as a knob. Count by visual children, not by DOM-node depth; a deeply-nested-but-visually-simple card still counts as small.
|
||||
|
||||
**How to declare.** Put a JSON manifest on the variant wrapper:
|
||||
|
||||
```html
|
||||
<div data-impeccable-variant="1" data-impeccable-params='[
|
||||
{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"},
|
||||
{"id":"density","kind":"steps","default":"snug","label":"Density","options":[
|
||||
{"value":"airy","label":"Airy"},
|
||||
{"value":"snug","label":"Snug"},
|
||||
{"value":"packed","label":"Packed"}
|
||||
]},
|
||||
{"id":"serif","kind":"toggle","default":false,"label":"Serif display"}
|
||||
]'>
|
||||
...variant content...
|
||||
</div>
|
||||
```
|
||||
|
||||
**Three kinds:**
|
||||
|
||||
- `range` — smooth slider. Drives a CSS custom property `--p-<id>` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`.
|
||||
- `steps` — segmented radio. Drives a data attribute `data-p-<id>` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`.
|
||||
- `toggle` — on/off switch. Drives BOTH a CSS var (`--p-<id>: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`.
|
||||
|
||||
**Signature params per action.** Each action has one or two signature params that MUST be exposed when the variant can meaningfully express them. Check the action's reference file for the list. Layer 1-2 variant-specific params on top.
|
||||
|
||||
**Reset on variant switch.** User dials density on v1, flips to v2, v2 starts at v2's declared defaults. Known limitation; preservation across variants may land later.
|
||||
|
||||
**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment:
|
||||
|
||||
```html
|
||||
<!-- impeccable-param-values SESSION_ID: {"color-amount":0.7,"density":"packed"} -->
|
||||
```
|
||||
|
||||
The carbonize cleanup step (see below) reads that comment and bakes the chosen values into the final CSS. For `steps`/`toggle` attribute selectors: keep only the branch matching the chosen value, drop the others, collapse `:scope[data-p-density="packed"] .grid` to a semantic class rule. For `range` vars: either substitute the literal or keep the var with the chosen value as its new default.
|
||||
|
||||
### 8. Signal done
|
||||
|
||||
```bash
|
||||
node .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 `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element.
|
||||
1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values — read it first; it drives steps 3 and 4 below.
|
||||
2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `public/css/workflow.css` for this repo, or the component's co-located CSS file for a Vite/Next project — pick whichever already owns styling for the surrounding element).
|
||||
3. **Rewrite `@scope ([data-impeccable-variant="N"])` selectors** to target real, semantic classes on the accepted HTML. Example: `@scope ([data-impeccable-variant="2"]) { .v2-label { … } }` becomes `.why-visual--v2 .v2-label { … }` if the accepted element already carries `.why-visual--v2`, or pick/add a suitable class if it doesn't.
|
||||
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it.
|
||||
5. **Delete the inline `<style>` block and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one — those are dead code now.
|
||||
3. **Bake in parameter values while rewriting selectors.** For `@scope ([data-impeccable-variant="N"])` wrappers: retarget to real, semantic classes on the accepted HTML (`.why-visual--v2 .v2-label { … }`). For `:scope[data-p-<id>="VALUE"]` selectors: keep only the branch matching the chosen value from the param-values comment; drop the others (they're dead after accept). For `var(--p-<id>, DEFAULT)` in the CSS: either substitute the literal value, or if the param is still useful as a knob going forward, leave the var and update its initial declaration to the chosen value.
|
||||
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it. Drop `data-impeccable-params` and any `data-p-*` attributes from it — those are live-mode plumbing, not source.
|
||||
5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one — those are dead code now.
|
||||
|
||||
Then poll again.
|
||||
|
||||
|
||||
@@ -110,3 +110,15 @@ Build a clear type scale:
|
||||
- **Accessibility**: Does text meet WCAG contrast ratios? Is it zoomable to 200%?
|
||||
|
||||
Remember: Typography is the foundation of interface design — it carries the majority of information. Getting it right is the highest-leverage improvement you can make.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
Each variant MUST declare a `scale` param controlling the hierarchy ratio. Express all font sizes in the variant's scoped CSS through `calc(var(--p-scale, 1) * <base>)` or, better, scale the type ramp via `clamp(min, calc(var(--p-scale, 1) * Npx), max)`. Users slide from subdued to commanding.
|
||||
|
||||
```json
|
||||
{"id":"scale","kind":"range","min":0.85,"max":1.3,"step":0.05,"default":1,"label":"Scale"}
|
||||
```
|
||||
|
||||
Where the variant riffs on a specific pairing, expose the pairing choice as a `steps` param (e.g. "serif display + sans body" vs. "mono display + sans body" vs. "all-sans"). Each branch routes through `:scope[data-p-pairing="X"]` selectors in scoped CSS.
|
||||
|
||||
See `reference/live.md` for the full params contract.
|
||||
|
||||
@@ -45,11 +45,18 @@ Output (JSON):
|
||||
|
||||
const id = argVal(args, '--id');
|
||||
const variantNum = argVal(args, '--variant');
|
||||
const paramValuesRaw = argVal(args, '--param-values');
|
||||
const isDiscard = args.includes('--discard');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); }
|
||||
|
||||
let paramValues = null;
|
||||
if (paramValuesRaw) {
|
||||
try { paramValues = JSON.parse(paramValuesRaw); }
|
||||
catch { paramValues = null; } // malformed blob: skip the comment rather than failing the accept
|
||||
}
|
||||
|
||||
// Find the file containing this session's markers
|
||||
const found = findSessionFile(id, process.cwd());
|
||||
if (!found) {
|
||||
@@ -78,7 +85,7 @@ Output (JSON):
|
||||
const result = handleDiscard(id, lines, targetFile);
|
||||
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
|
||||
} else {
|
||||
const result = handleAccept(id, variantNum, lines, targetFile);
|
||||
const result = handleAccept(id, variantNum, lines, targetFile, paramValues);
|
||||
// Single-line attention-grabber when cleanup is required. The full
|
||||
// five-step checklist lives in reference/live.md (loaded once per
|
||||
// session); repeating it per-event would waste tokens.
|
||||
@@ -116,7 +123,7 @@ function handleDiscard(id, lines, targetFile) {
|
||||
// Accept
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function handleAccept(id, variantNum, lines, targetFile) {
|
||||
function handleAccept(id, variantNum, lines, targetFile, paramValues) {
|
||||
const block = findMarkerBlock(id, lines);
|
||||
if (!block) return { handled: false, error: 'Markers not found' };
|
||||
|
||||
@@ -149,6 +156,11 @@ function handleAccept(id, variantNum, lines, targetFile) {
|
||||
replacement.push(indent + cssLine.trimStart());
|
||||
}
|
||||
replacement.push(indent + '</style>');
|
||||
if (paramValues && Object.keys(paramValues).length > 0) {
|
||||
// Preserve the user's knob positions for the carbonize-cleanup agent
|
||||
// to bake into the final CSS when it collapses scoped rules.
|
||||
replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
|
||||
}
|
||||
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,10 +30,13 @@
|
||||
// Design tokens
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Brand magenta is pinned to the site token (--color-accent in main.css)
|
||||
// so Accept / knobs / cycle-dots match the site's accent, not a washed
|
||||
// theme-adjusted one.
|
||||
const C = {
|
||||
brand: 'oklch(55% 0.25 350)',
|
||||
brandHov: 'oklch(48% 0.25 350)',
|
||||
brandSoft: 'oklch(55% 0.25 350 / 0.12)',
|
||||
brand: 'oklch(60% 0.25 350)',
|
||||
brandHov: 'oklch(52% 0.25 350)',
|
||||
brandSoft: 'oklch(60% 0.25 350 / 0.15)',
|
||||
ink: 'oklch(15% 0.01 350)',
|
||||
ash: 'oklch(55% 0 0)',
|
||||
paper: 'oklch(98% 0.005 350 / 0.92)',
|
||||
@@ -60,6 +63,25 @@
|
||||
'html', 'head', 'body', 'script', 'style', 'link', 'meta', 'noscript', 'br', 'wbr',
|
||||
]);
|
||||
|
||||
// SVG icons stack above each chip label. All strokes use currentColor so the
|
||||
// icon recolors to C.brand when its chip is selected. 20x20 render, 24-viewBox,
|
||||
// 1.5 stroke — visually consistent with the Foundation grid on the homepage.
|
||||
const ICON_ATTRS = 'width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="display:block"';
|
||||
const ICONS = {
|
||||
impeccable: `<svg ${ICON_ATTRS}><path d="M4 20l4-1L18 9l-3-3L5 16z"/><path d="M14 7l3 3"/></svg>`,
|
||||
bolder: `<svg ${ICON_ATTRS}><rect x="6" y="12" width="4" height="7" rx="0.5"/><rect x="14" y="5" width="4" height="14" rx="0.5"/></svg>`,
|
||||
quieter: `<svg ${ICON_ATTRS}><rect x="6" y="5" width="4" height="14" rx="0.5"/><rect x="14" y="12" width="4" height="7" rx="0.5"/></svg>`,
|
||||
distill: `<svg ${ICON_ATTRS}><path d="M4 5h16l-6 8v7l-4-2v-5z"/></svg>`,
|
||||
polish: `<svg ${ICON_ATTRS}><path d="M15 3l1 3 3 1-3 1-1 3-1-3-3-1 3-1z"/><path d="M7 13l0.6 1.8 1.8 0.6-1.8 0.6-0.6 1.8-0.6-1.8-1.8-0.6 1.8-0.6z"/></svg>`,
|
||||
typeset: `<svg ${ICON_ATTRS}><path d="M5 6h14" stroke-width="2.6"/><path d="M5 12h9" stroke-width="1.9"/><path d="M5 18h5" stroke-width="1.3"/></svg>`,
|
||||
colorize: `<svg ${ICON_ATTRS}><circle cx="9" cy="10" r="5"/><circle cx="15" cy="10" r="5"/><circle cx="12" cy="15" r="5"/></svg>`,
|
||||
layout: `<svg ${ICON_ATTRS}><rect x="3" y="4" width="8" height="16" rx="0.5"/><rect x="13" y="4" width="8" height="7" rx="0.5"/><rect x="13" y="13" width="8" height="7" rx="0.5"/></svg>`,
|
||||
adapt: `<svg ${ICON_ATTRS}><rect x="2.5" y="5" width="12" height="11" rx="1"/><line x1="2.5" y1="19" x2="14.5" y2="19"/><rect x="16.5" y="8" width="5" height="11" rx="1"/></svg>`,
|
||||
animate: `<svg ${ICON_ATTRS}><path d="M3 18c4-4 6-10 10-10"/><path d="M13 8c3 0 5 5 8 10"/><circle cx="13" cy="8" r="1.6" fill="currentColor" stroke="none"/></svg>`,
|
||||
delight: `<svg ${ICON_ATTRS}><path d="M12 3l2 6 6 2-6 2-2 6-2-6-6-2 6-2z"/></svg>`,
|
||||
overdrive: `<svg ${ICON_ATTRS}><path d="M13 3L5 13h5l-1 8 9-12h-6z"/></svg>`,
|
||||
};
|
||||
|
||||
const ACTIONS = [
|
||||
{ value: 'impeccable', label: 'Freeform' },
|
||||
{ value: 'bolder', label: 'Bolder' },
|
||||
@@ -756,7 +778,21 @@
|
||||
// The Bar — one floating element, three modes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Contextual-bar palette. Cached at init so every build*Row reads a
|
||||
// consistent set of colors; detectPageTheme runs once rather than on every
|
||||
// phase transition.
|
||||
let BP = null;
|
||||
|
||||
// Bar shadow variants. The default projects down + subtle around. When
|
||||
// the Tune popover opens below the bar, a downward shadow lands on the
|
||||
// dark popover and reads as a bright ghost line. We swap to UP-only while
|
||||
// tune is open below so the popover's top edge is clean.
|
||||
const BAR_SHADOW_DEFAULT = '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)';
|
||||
const BAR_SHADOW_UP = '0 -4px 20px oklch(0% 0 0 / 0.08), 0 -1px 3px oklch(0% 0 0 / 0.06)';
|
||||
const BAR_SHADOW_DOWN = BAR_SHADOW_DEFAULT;
|
||||
|
||||
function initBar() {
|
||||
BP = barPaletteForTheme(detectPageTheme());
|
||||
barEl = document.createElement('div');
|
||||
barEl.id = PREFIX + '-bar';
|
||||
Object.assign(barEl.style, {
|
||||
@@ -764,15 +800,15 @@
|
||||
display: 'none', opacity: '0',
|
||||
transform: 'translateY(6px)',
|
||||
transition: 'opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
|
||||
// Visual
|
||||
background: C.paper,
|
||||
background: BP.surface,
|
||||
backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
|
||||
border: '1px solid ' + C.mist,
|
||||
border: '1px solid ' + BP.hairline,
|
||||
borderRadius: '10px',
|
||||
boxShadow: '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)',
|
||||
fontFamily: FONT, fontSize: '13px', color: C.ink,
|
||||
boxShadow: BAR_SHADOW_DEFAULT,
|
||||
transition: 'box-shadow 0.2s ease, opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
|
||||
fontFamily: FONT, fontSize: '13px', color: BP.text,
|
||||
padding: '6px',
|
||||
maxWidth: '460px', minWidth: '320px',
|
||||
maxWidth: '520px', minWidth: '320px',
|
||||
});
|
||||
document.body.appendChild(barEl);
|
||||
}
|
||||
@@ -824,14 +860,15 @@
|
||||
barEl.style.transform = 'translateY(6px)';
|
||||
setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250);
|
||||
hideActionPicker();
|
||||
closeTunePopover();
|
||||
}
|
||||
|
||||
function updateBarContent(mode) {
|
||||
if (!barEl || barEl.style.display === 'none') return;
|
||||
barEl.innerHTML = '';
|
||||
// Reset bar styling
|
||||
barEl.style.background = C.paper;
|
||||
barEl.style.border = '1px solid ' + C.mist;
|
||||
// Reset bar styling to the theme-aware palette
|
||||
barEl.style.background = BP.surface;
|
||||
barEl.style.border = '1px solid ' + BP.hairline;
|
||||
if (mode === 'configure') barEl.appendChild(buildConfigureRow());
|
||||
else if (mode === 'generating') barEl.appendChild(buildGeneratingRow());
|
||||
else if (mode === 'cycling') barEl.appendChild(buildCyclingRow());
|
||||
@@ -854,15 +891,15 @@
|
||||
const pill = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
||||
padding: '5px 10px', borderRadius: '6px',
|
||||
background: C.ink, color: C.white,
|
||||
background: BP.mark, color: BP.markText,
|
||||
fontFamily: FONT, fontSize: '12px', fontWeight: '500',
|
||||
border: 'none', cursor: 'pointer',
|
||||
transition: 'background 0.12s ease, transform 0.1s ease',
|
||||
whiteSpace: 'nowrap', flexShrink: '0',
|
||||
});
|
||||
pill.textContent = actionLabel() + ' \u25BE';
|
||||
pill.addEventListener('mouseenter', () => pill.style.background = C.brandHov);
|
||||
pill.addEventListener('mouseleave', () => pill.style.background = C.ink);
|
||||
pill.addEventListener('mouseenter', () => pill.style.background = BP.accent);
|
||||
pill.addEventListener('mouseleave', () => pill.style.background = BP.mark);
|
||||
pill.addEventListener('mousedown', () => pill.style.transform = 'scale(0.97)');
|
||||
pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)');
|
||||
pill.addEventListener('click', (e) => { e.stopPropagation(); toggleActionPicker(); });
|
||||
@@ -877,13 +914,13 @@
|
||||
flex: '1', minWidth: '0',
|
||||
padding: '5px 8px', borderRadius: '6px',
|
||||
border: '1px solid transparent', background: 'transparent',
|
||||
fontFamily: FONT, fontSize: '12px', color: C.ink,
|
||||
fontFamily: FONT, fontSize: '12px', color: BP.text,
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.15s ease, background 0.15s ease',
|
||||
});
|
||||
input.addEventListener('focus', () => {
|
||||
input.style.borderColor = C.mist;
|
||||
input.style.background = C.white;
|
||||
input.style.borderColor = BP.hairline;
|
||||
input.style.background = BP.accentSoft;
|
||||
});
|
||||
input.addEventListener('blur', () => {
|
||||
input.style.borderColor = 'transparent';
|
||||
@@ -901,16 +938,16 @@
|
||||
// Variant count toggle
|
||||
const count = el('button', {
|
||||
padding: '4px 6px', borderRadius: '5px',
|
||||
border: '1px solid ' + C.mist, background: 'transparent',
|
||||
border: '1px solid ' + BP.hairline, background: 'transparent',
|
||||
fontFamily: MONO, fontSize: '11px', fontWeight: '600',
|
||||
color: C.ash, cursor: 'pointer',
|
||||
color: BP.textDim, cursor: 'pointer',
|
||||
transition: 'color 0.12s ease, border-color 0.12s ease',
|
||||
flexShrink: '0', whiteSpace: 'nowrap',
|
||||
});
|
||||
count.textContent = '\u00D7' + selectedCount;
|
||||
count.title = 'Variants: click to change';
|
||||
count.addEventListener('mouseenter', () => { count.style.color = C.ink; count.style.borderColor = C.ink; });
|
||||
count.addEventListener('mouseleave', () => { count.style.color = C.ash; count.style.borderColor = C.mist; });
|
||||
count.addEventListener('mouseenter', () => { count.style.color = BP.text; count.style.borderColor = BP.text; });
|
||||
count.addEventListener('mouseleave', () => { count.style.color = BP.textDim; count.style.borderColor = BP.hairline; });
|
||||
count.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1;
|
||||
@@ -921,15 +958,15 @@
|
||||
// Go button
|
||||
const go = el('button', {
|
||||
padding: '5px 12px', borderRadius: '6px',
|
||||
border: 'none', background: C.brand, color: C.white,
|
||||
border: 'none', background: BP.accent, color: BP.mark,
|
||||
fontFamily: FONT, fontSize: '12px', fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.12s ease, transform 0.1s ease',
|
||||
transition: 'filter 0.12s ease, transform 0.1s ease',
|
||||
flexShrink: '0', whiteSpace: 'nowrap',
|
||||
});
|
||||
go.textContent = 'Go \u2192';
|
||||
go.addEventListener('mouseenter', () => go.style.background = C.brandHov);
|
||||
go.addEventListener('mouseleave', () => go.style.background = C.brand);
|
||||
go.addEventListener('mouseenter', () => go.style.filter = 'brightness(1.1)');
|
||||
go.addEventListener('mouseleave', () => go.style.filter = 'none');
|
||||
go.addEventListener('mousedown', () => go.style.transform = 'scale(0.97)');
|
||||
go.addEventListener('mouseup', () => go.style.transform = 'scale(1)');
|
||||
go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); });
|
||||
@@ -950,7 +987,7 @@
|
||||
|
||||
// Action label
|
||||
const label = el('span', {
|
||||
fontWeight: '600', fontSize: '12px', color: C.ink,
|
||||
fontWeight: '600', fontSize: '12px', color: BP.text,
|
||||
flexShrink: '0', whiteSpace: 'nowrap',
|
||||
});
|
||||
label.textContent = actionLabel();
|
||||
@@ -961,7 +998,7 @@
|
||||
|
||||
// Status
|
||||
const status = el('span', {
|
||||
fontSize: '11px', color: C.ash, whiteSpace: 'nowrap',
|
||||
fontSize: '11px', color: BP.textDim, whiteSpace: 'nowrap',
|
||||
marginLeft: 'auto',
|
||||
});
|
||||
// Variants currently arrive atomically in a single file edit, so a
|
||||
@@ -976,6 +1013,8 @@
|
||||
|
||||
// --- Cycling row ---
|
||||
|
||||
const TUNE_ICON_SVG = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" style="flex-shrink:0"><line x1="4" y1="8" x2="20" y2="8"/><circle cx="14" cy="8" r="2.4" fill="currentColor" stroke="none"/><line x1="4" y1="16" x2="20" y2="16"/><circle cx="10" cy="16" r="2.4" fill="currentColor" stroke="none"/></svg>';
|
||||
|
||||
function buildCyclingRow() {
|
||||
const row = el('div', {
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
@@ -994,7 +1033,7 @@
|
||||
// Counter
|
||||
const counter = el('span', {
|
||||
fontFamily: MONO, fontSize: '11px', fontWeight: '500',
|
||||
color: C.ash, minWidth: '24px', textAlign: 'center',
|
||||
color: BP.textDim, minWidth: '24px', textAlign: 'center',
|
||||
});
|
||||
counter.textContent = visibleVariant + '/' + arrivedVariants;
|
||||
row.appendChild(counter);
|
||||
@@ -1005,20 +1044,67 @@
|
||||
if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
|
||||
row.appendChild(next);
|
||||
|
||||
// Tune chip — only when the visible variant exposes params
|
||||
const visParams = parseVariantParams(getVisibleVariantEl());
|
||||
const hasParams = visParams.length > 0;
|
||||
if (hasParams) {
|
||||
const tune = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '6px',
|
||||
padding: '4px 10px', borderRadius: '5px',
|
||||
border: '1px solid transparent',
|
||||
background: tuneOpen ? BP.accentSoft : 'transparent',
|
||||
color: tuneOpen ? BP.accent : BP.text,
|
||||
fontFamily: FONT, fontSize: '11px', fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'color 0.12s ease, background 0.12s ease',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
tune.innerHTML = TUNE_ICON_SVG;
|
||||
const tuneLabel = document.createElement('span');
|
||||
tuneLabel.textContent = 'Tune';
|
||||
tune.appendChild(tuneLabel);
|
||||
const tuneBadge = document.createElement('span');
|
||||
Object.assign(tuneBadge.style, {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
minWidth: '16px', height: '16px', padding: '0 4px',
|
||||
borderRadius: '999px',
|
||||
background: tuneOpen ? C.brand : BP.hairline,
|
||||
color: tuneOpen ? 'oklch(98% 0 0)' : 'inherit',
|
||||
fontFamily: MONO, fontSize: '9.5px', fontWeight: '600',
|
||||
lineHeight: '1',
|
||||
boxSizing: 'border-box',
|
||||
});
|
||||
tuneBadge.textContent = String(visParams.length);
|
||||
tune.appendChild(tuneBadge);
|
||||
tune.title = 'Tune this variant (' + visParams.length + ' knob' + (visParams.length === 1 ? '' : 's') + ')';
|
||||
tune.addEventListener('mouseenter', () => {
|
||||
if (!tuneOpen) tune.style.background = BP.accentSoft;
|
||||
});
|
||||
tune.addEventListener('mouseleave', () => {
|
||||
if (!tuneOpen) tune.style.background = 'transparent';
|
||||
});
|
||||
tune.addEventListener('click', (e) => { e.stopPropagation(); toggleTunePopover(); });
|
||||
tune.dataset.iceqTune = '1';
|
||||
row.appendChild(tune);
|
||||
}
|
||||
|
||||
// Spacer
|
||||
row.appendChild(el('div', { flex: '1' }));
|
||||
|
||||
// Accept
|
||||
// Accept — primary action, uses the site's saturated brand magenta
|
||||
// with paper-white text, not the theme-muted BP.accent.
|
||||
const accept = el('button', {
|
||||
padding: '4px 10px', borderRadius: '5px',
|
||||
border: 'none', background: C.ink, color: C.white,
|
||||
padding: '5px 14px', borderRadius: '5px',
|
||||
border: 'none', background: C.brand, color: 'oklch(98% 0 0)',
|
||||
fontFamily: FONT, fontSize: '11px', fontWeight: '600',
|
||||
cursor: 'pointer', transition: 'background 0.12s ease',
|
||||
cursor: 'pointer', transition: 'filter 0.12s ease, transform 0.1s ease',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
accept.textContent = '\u2713 Accept';
|
||||
accept.addEventListener('mouseenter', () => accept.style.background = C.brand);
|
||||
accept.addEventListener('mouseleave', () => accept.style.background = C.ink);
|
||||
accept.addEventListener('mouseenter', () => accept.style.filter = 'brightness(1.08)');
|
||||
accept.addEventListener('mouseleave', () => accept.style.filter = 'none');
|
||||
accept.addEventListener('mousedown', () => accept.style.transform = 'scale(0.97)');
|
||||
accept.addEventListener('mouseup', () => accept.style.transform = 'scale(1)');
|
||||
accept.addEventListener('click', (e) => { e.stopPropagation(); handleAccept(); });
|
||||
if (arrivedVariants === 0) { accept.style.opacity = '0.3'; accept.style.pointerEvents = 'none'; }
|
||||
row.appendChild(accept);
|
||||
@@ -1026,14 +1112,14 @@
|
||||
// Discard
|
||||
const discard = el('button', {
|
||||
padding: '4px 6px', borderRadius: '5px',
|
||||
border: '1px solid ' + C.mist, background: 'transparent',
|
||||
fontFamily: FONT, fontSize: '11px', color: C.ash,
|
||||
border: '1px solid ' + BP.hairline, background: 'transparent',
|
||||
fontFamily: FONT, fontSize: '11px', color: BP.textDim,
|
||||
cursor: 'pointer', transition: 'color 0.12s ease, border-color 0.12s ease',
|
||||
});
|
||||
discard.textContent = '\u2715';
|
||||
discard.title = 'Discard all variants';
|
||||
discard.addEventListener('mouseenter', () => { discard.style.color = C.ink; discard.style.borderColor = C.ink; });
|
||||
discard.addEventListener('mouseleave', () => { discard.style.color = C.ash; discard.style.borderColor = C.mist; });
|
||||
discard.addEventListener('mouseenter', () => { discard.style.color = BP.text; discard.style.borderColor = BP.text; });
|
||||
discard.addEventListener('mouseleave', () => { discard.style.color = BP.textDim; discard.style.borderColor = BP.hairline; });
|
||||
discard.addEventListener('click', (e) => { e.stopPropagation(); handleDiscard(); });
|
||||
row.appendChild(discard);
|
||||
|
||||
@@ -1051,14 +1137,14 @@
|
||||
});
|
||||
const spinner = el('div', {
|
||||
width: '14px', height: '14px', borderRadius: '50%',
|
||||
border: '2px solid ' + C.mist,
|
||||
borderTopColor: C.brand,
|
||||
border: '2px solid ' + BP.hairline,
|
||||
borderTopColor: BP.accent,
|
||||
animation: 'impeccable-spin 0.6s linear infinite',
|
||||
flexShrink: '0',
|
||||
});
|
||||
row.appendChild(spinner);
|
||||
const label = el('span', {
|
||||
fontSize: '12px', color: C.ash, fontWeight: '500',
|
||||
fontSize: '12px', color: BP.textDim, fontWeight: '500',
|
||||
});
|
||||
label.textContent = 'Applying variant...';
|
||||
row.appendChild(label);
|
||||
@@ -1103,14 +1189,26 @@
|
||||
for (let i = 1; i <= expectedVariants; i++) {
|
||||
const arrived = i <= arrivedVariants;
|
||||
const active = i === visibleVariant;
|
||||
// active: solid site-brand magenta dot. arrived+inactive: muted neutral.
|
||||
// pending (not yet arrived): faint outline ring. No borders on arrived
|
||||
// dots — the previous "accent ring + ash fill" combo read as noisy
|
||||
// magenta chips, especially when all variants had arrived and every
|
||||
// dot wore an accent ring.
|
||||
const dotBg = active ? C.brand
|
||||
: arrived ? BP.textDim
|
||||
: 'transparent';
|
||||
const dotBorder = arrived ? 'none' : '1.5px solid ' + BP.hairline;
|
||||
const dot = el('div', {
|
||||
width: '7px', height: '7px', borderRadius: '50%',
|
||||
background: active ? C.brand : (arrived ? C.ash : 'transparent'),
|
||||
border: '1.5px solid ' + (arrived ? C.brand : C.mist),
|
||||
width: active ? '8px' : '6px',
|
||||
height: active ? '8px' : '6px',
|
||||
borderRadius: '50%',
|
||||
background: dotBg,
|
||||
border: dotBorder,
|
||||
boxSizing: 'border-box',
|
||||
transition: 'all 0.2s ' + EASE,
|
||||
cursor: (clickable && arrived) ? 'pointer' : 'default',
|
||||
transform: arrived ? 'scale(1)' : 'scale(0.6)',
|
||||
opacity: arrived ? '1' : '0.4',
|
||||
transform: arrived ? 'scale(1)' : 'scale(0.85)',
|
||||
opacity: arrived ? (active ? '1' : '0.6') : '0.4',
|
||||
});
|
||||
if (clickable && arrived) {
|
||||
const idx = i;
|
||||
@@ -1130,15 +1228,15 @@
|
||||
function navBtn(text) {
|
||||
const b = el('button', {
|
||||
width: '26px', height: '26px', borderRadius: '5px',
|
||||
border: '1px solid ' + C.mist, background: 'transparent',
|
||||
color: C.ink, fontFamily: FONT, fontSize: '13px',
|
||||
border: '1px solid ' + BP.hairline, background: 'transparent',
|
||||
color: BP.text, fontFamily: FONT, fontSize: '13px',
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'border-color 0.12s ease, background 0.12s ease',
|
||||
padding: '0', lineHeight: '1',
|
||||
});
|
||||
b.textContent = text;
|
||||
b.addEventListener('mouseenter', () => { b.style.borderColor = C.ink; });
|
||||
b.addEventListener('mouseleave', () => { b.style.borderColor = C.mist; });
|
||||
b.addEventListener('mouseenter', () => { b.style.borderColor = BP.text; });
|
||||
b.addEventListener('mouseleave', () => { b.style.borderColor = BP.hairline; });
|
||||
return b;
|
||||
}
|
||||
|
||||
@@ -1158,6 +1256,7 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initActionPicker() {
|
||||
const P = barPaletteForTheme(detectPageTheme());
|
||||
pickerEl = document.createElement('div');
|
||||
pickerEl.id = PREFIX + '-picker';
|
||||
Object.assign(pickerEl.style, {
|
||||
@@ -1166,12 +1265,14 @@
|
||||
transform: 'scale(0.96) translateY(4px)',
|
||||
transformOrigin: 'bottom left',
|
||||
transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE,
|
||||
background: C.paperSolid,
|
||||
border: '1px solid ' + C.mist,
|
||||
background: P.surface,
|
||||
border: '1px solid ' + P.hairline,
|
||||
borderRadius: '10px',
|
||||
boxShadow: '0 8px 30px oklch(0% 0 0 / 0.10), 0 2px 6px oklch(0% 0 0 / 0.06)',
|
||||
padding: '6px',
|
||||
fontFamily: FONT,
|
||||
backdropFilter: 'blur(10px)',
|
||||
WebkitBackdropFilter: 'blur(10px)',
|
||||
});
|
||||
|
||||
// Build the chip grid
|
||||
@@ -1181,22 +1282,32 @@
|
||||
|
||||
ACTIONS.forEach(action => {
|
||||
const chip = el('button', {
|
||||
padding: '6px 8px', borderRadius: '6px',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '8px 6px', borderRadius: '6px',
|
||||
border: 'none',
|
||||
background: action.value === selectedAction ? C.brandSoft : 'transparent',
|
||||
color: action.value === selectedAction ? C.brand : C.ink,
|
||||
background: action.value === selectedAction ? P.accentSoft : 'transparent',
|
||||
color: action.value === selectedAction ? P.accent : P.text,
|
||||
fontFamily: FONT, fontSize: '11px', fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.1s ease, color 0.1s ease',
|
||||
textAlign: 'center', whiteSpace: 'nowrap',
|
||||
});
|
||||
chip.textContent = action.label;
|
||||
const iconWrap = el('span', {
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
height: '20px', opacity: '0.9',
|
||||
});
|
||||
iconWrap.innerHTML = ICONS[action.value] || '';
|
||||
const labelEl = el('span', { lineHeight: '1' });
|
||||
labelEl.textContent = action.label;
|
||||
chip.appendChild(iconWrap);
|
||||
chip.appendChild(labelEl);
|
||||
chip.dataset.action = action.value;
|
||||
chip.addEventListener('mouseenter', () => {
|
||||
if (action.value !== selectedAction) chip.style.background = C.brandSoft;
|
||||
if (action.value !== selectedAction) chip.style.background = P.accentSoft;
|
||||
});
|
||||
chip.addEventListener('mouseleave', () => {
|
||||
chip.style.background = action.value === selectedAction ? C.brandSoft : 'transparent';
|
||||
chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent';
|
||||
});
|
||||
chip.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -1209,19 +1320,24 @@
|
||||
|
||||
pickerEl.appendChild(grid);
|
||||
document.body.appendChild(pickerEl);
|
||||
|
||||
// Cache the palette on the picker so toggleActionPicker's state refresh
|
||||
// uses the same theme-aware colors when it repaints chips.
|
||||
pickerEl.__iceq_palette = P;
|
||||
}
|
||||
|
||||
function toggleActionPicker() {
|
||||
if (pickerEl.style.display !== 'none') { hideActionPicker(); return; }
|
||||
// Rebuild chips to reflect current selection
|
||||
const P = pickerEl.__iceq_palette || barPaletteForTheme(detectPageTheme());
|
||||
pickerEl.querySelectorAll('button').forEach(chip => {
|
||||
const isActive = chip.dataset.action === selectedAction;
|
||||
chip.style.background = isActive ? C.brandSoft : 'transparent';
|
||||
chip.style.color = isActive ? C.brand : C.ink;
|
||||
chip.style.background = isActive ? P.accentSoft : 'transparent';
|
||||
chip.style.color = isActive ? P.accent : P.text;
|
||||
});
|
||||
// Position above the bar
|
||||
const barRect = barEl.getBoundingClientRect();
|
||||
const pickerH = 90; // approximate
|
||||
const pickerH = 170; // approximate; grows with icon + label rows
|
||||
let top = barRect.top - pickerH - 6;
|
||||
if (top < 8) top = barRect.bottom + 6;
|
||||
Object.assign(pickerEl.style, {
|
||||
@@ -1241,6 +1357,399 @@
|
||||
setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Params panel (per-variant coarse controls)
|
||||
//
|
||||
// Variants may declare a parameter manifest via a JSON attribute on the
|
||||
// variant wrapper:
|
||||
//
|
||||
// <div data-impeccable-variant="1"
|
||||
// data-impeccable-params='[{"id":"density","kind":"steps",...}]'>
|
||||
//
|
||||
// The panel docks to the right edge of the outline during CYCLING and
|
||||
// exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped
|
||||
// CSS can respond instantly without regeneration:
|
||||
//
|
||||
// range / numeric toggle → CSS var (`--p-<id>`) used via var(--p-foo, N)
|
||||
// steps / boolean toggle → data-p-<id> attribute used via :scope[data-p-foo="..."]
|
||||
//
|
||||
// On variant switch, values reset to that variant's declared defaults.
|
||||
// On accept, current values are sent in the event payload so the agent
|
||||
// can bake them into the source-file write.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide)
|
||||
let paramsPanelInner = null; // translating content (carries bg, padding, knobs)
|
||||
let paramsPanelBody = null; // grid holding the knob cells
|
||||
let paramsCurrentValues = {}; // {paramId: value} — mirror of the visible variant's live values
|
||||
let tuneOpen = false; // whether the Tune popover is open right now
|
||||
|
||||
// Theme-aware Tune popover. Appears as a drawer that slides out from the
|
||||
// contextual bar's bar-facing edge (below if the bar sits below the
|
||||
// element, above otherwise). Same width as the bar. Auto-wraps to extra
|
||||
// rows when the knobs exceed one row. The bar's border-radius on the
|
||||
// popover side goes flat while open so the two shapes read as one.
|
||||
let paramsPanelPalette = null;
|
||||
|
||||
function initParamsPanel() {
|
||||
paramsPanelPalette = barPaletteForTheme(detectPageTheme());
|
||||
const P = paramsPanelPalette;
|
||||
|
||||
// Single element, always in the DOM. The slide animation is a CSS mask
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis — no
|
||||
// display toggle, no opacity toggle, no transform trickery. The mask
|
||||
// hides everything initially; as it grows, content is revealed from
|
||||
// the bar edge outward.
|
||||
paramsPanelEl = document.createElement('div');
|
||||
paramsPanelEl.id = PREFIX + '-params-panel';
|
||||
Object.assign(paramsPanelEl.style, {
|
||||
position: 'fixed', zIndex: String(Z.bar - 1),
|
||||
background: P.surfaceDeep,
|
||||
color: P.text,
|
||||
fontFamily: FONT,
|
||||
padding: '14px 18px',
|
||||
boxSizing: 'border-box',
|
||||
borderRadius: '0 0 10px 10px',
|
||||
pointerEvents: 'none',
|
||||
backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
|
||||
|
||||
// clip-path is the same conceptual reveal as mask but with rock-solid
|
||||
// transition support across engines. Closed state clips from the far
|
||||
// edge; open = inset(0) shows everything.
|
||||
clipPath: 'inset(0 0 100% 0)',
|
||||
transition: 'clip-path 0.44s ' + EASE,
|
||||
|
||||
// Park off-screen until positionParamsPanel places it. These are NOT
|
||||
// in the transition list, so they snap instantly — no fly-in from the
|
||||
// top-left when first shown.
|
||||
top: '-9999px', left: '-9999px', width: '0',
|
||||
});
|
||||
|
||||
paramsPanelBody = el('div', {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
|
||||
gap: '12px 16px',
|
||||
});
|
||||
|
||||
paramsPanelEl.appendChild(paramsPanelBody);
|
||||
document.body.appendChild(paramsPanelEl);
|
||||
paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
|
||||
}
|
||||
|
||||
function getVisibleVariantEl() {
|
||||
if (!currentSessionId) return null;
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
|
||||
function parseVariantParams(variantEl) {
|
||||
if (!variantEl) return [];
|
||||
const raw = variantEl.getAttribute('data-impeccable-params');
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] Invalid data-impeccable-params JSON:', err.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function applyParamValue(variantEl, param, value) {
|
||||
if (!variantEl) return;
|
||||
const attr = 'data-p-' + param.id;
|
||||
if (param.kind === 'range') {
|
||||
variantEl.style.setProperty('--p-' + param.id, String(value));
|
||||
} else if (param.kind === 'toggle') {
|
||||
const on = !!value;
|
||||
variantEl.style.setProperty('--p-' + param.id, on ? '1' : '0');
|
||||
if (on) variantEl.setAttribute(attr, 'on');
|
||||
else variantEl.removeAttribute(attr);
|
||||
} else if (param.kind === 'steps') {
|
||||
variantEl.setAttribute(attr, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
function applyParamDefaults(variantEl, params) {
|
||||
paramsCurrentValues = {};
|
||||
for (const p of params) {
|
||||
paramsCurrentValues[p.id] = p.default;
|
||||
applyParamValue(variantEl, p, p.default);
|
||||
}
|
||||
}
|
||||
|
||||
function formatRangeValue(input) {
|
||||
const max = parseFloat(input.max), min = parseFloat(input.min);
|
||||
const v = parseFloat(input.value);
|
||||
if (!isFinite(v)) return input.value;
|
||||
return (max - min) <= 2 ? v.toFixed(2) : String(Math.round(v));
|
||||
}
|
||||
|
||||
function buildParamsPanel(variantEl, params) {
|
||||
const P = paramsPanelPalette || barPaletteForTheme(detectPageTheme());
|
||||
paramsPanelBody.innerHTML = '';
|
||||
for (const p of params) {
|
||||
const row = el('div', { display: 'flex', flexDirection: 'column', gap: '6px' });
|
||||
const labelRow = el('div', {
|
||||
display: 'flex', justifyContent: 'space-between',
|
||||
alignItems: 'baseline', gap: '8px',
|
||||
});
|
||||
const lbl = el('span', {
|
||||
fontSize: '10.5px', fontWeight: '600', color: P.text,
|
||||
letterSpacing: '0.03em',
|
||||
});
|
||||
lbl.textContent = p.label || p.id;
|
||||
labelRow.appendChild(lbl);
|
||||
const readout = el('span', {
|
||||
fontSize: '10.5px', color: P.textDim,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
});
|
||||
labelRow.appendChild(readout);
|
||||
row.appendChild(labelRow);
|
||||
|
||||
if (p.kind === 'range') {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'range';
|
||||
input.min = String(p.min != null ? p.min : 0);
|
||||
input.max = String(p.max != null ? p.max : 1);
|
||||
input.step = String(p.step != null ? p.step : 0.05);
|
||||
input.value = String(p.default);
|
||||
Object.assign(input.style, {
|
||||
width: '100%', accentColor: C.brand, cursor: 'pointer',
|
||||
});
|
||||
readout.textContent = formatRangeValue(input);
|
||||
input.addEventListener('input', (e) => {
|
||||
e.stopPropagation();
|
||||
const v = parseFloat(input.value);
|
||||
paramsCurrentValues[p.id] = v;
|
||||
readout.textContent = formatRangeValue(input);
|
||||
applyParamValue(variantEl, p, v);
|
||||
});
|
||||
row.appendChild(input);
|
||||
} else if (p.kind === 'toggle') {
|
||||
const initial = !!p.default;
|
||||
readout.textContent = initial ? 'On' : 'Off';
|
||||
const track = el('button', {
|
||||
position: 'relative', width: '36px', height: '20px',
|
||||
borderRadius: '10px', border: 'none', padding: '0',
|
||||
cursor: 'pointer',
|
||||
background: initial ? C.brand : P.hairline,
|
||||
transition: 'background 0.15s ease',
|
||||
alignSelf: 'flex-start',
|
||||
});
|
||||
const knob = el('span', {
|
||||
position: 'absolute', top: '2px',
|
||||
left: initial ? '18px' : '2px',
|
||||
width: '16px', height: '16px', borderRadius: '50%',
|
||||
background: 'oklch(98% 0 0)',
|
||||
transition: 'left 0.18s ' + EASE,
|
||||
boxShadow: '0 1px 2px oklch(0% 0 0 / 0.2)',
|
||||
});
|
||||
track.appendChild(knob);
|
||||
track.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const next = !paramsCurrentValues[p.id];
|
||||
paramsCurrentValues[p.id] = next;
|
||||
track.style.background = next ? C.brand : P.hairline;
|
||||
knob.style.left = next ? '18px' : '2px';
|
||||
readout.textContent = next ? 'On' : 'Off';
|
||||
applyParamValue(variantEl, p, next);
|
||||
});
|
||||
row.appendChild(track);
|
||||
} else if (p.kind === 'steps') {
|
||||
const opts = (p.options || []).map(o =>
|
||||
typeof o === 'string' ? { value: o, label: o } : o
|
||||
);
|
||||
const activeOpt = opts.find(o => o.value === p.default) || opts[0];
|
||||
readout.textContent = activeOpt ? activeOpt.label : String(p.default);
|
||||
const segRow = el('div', {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(' + opts.length + ', 1fr)',
|
||||
gap: '1px', padding: '2px',
|
||||
background: P.hairline, borderRadius: '5px',
|
||||
});
|
||||
const segBtns = [];
|
||||
opts.forEach(o => {
|
||||
const active = o.value === p.default;
|
||||
const b = el('button', {
|
||||
padding: '5px 4px', border: 'none', borderRadius: '3px',
|
||||
background: active ? C.brand : 'transparent',
|
||||
color: active ? 'oklch(98% 0 0)' : P.text,
|
||||
fontFamily: FONT, fontSize: '10.5px', fontWeight: '500',
|
||||
cursor: 'pointer', whiteSpace: 'nowrap',
|
||||
transition: 'background 0.1s ease, color 0.1s ease',
|
||||
});
|
||||
b.textContent = o.label;
|
||||
b.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
paramsCurrentValues[p.id] = o.value;
|
||||
readout.textContent = o.label;
|
||||
segBtns.forEach(({ btn, val }) => {
|
||||
const on = val === o.value;
|
||||
btn.style.background = on ? C.brand : 'transparent';
|
||||
btn.style.color = on ? 'oklch(98% 0 0)' : P.text;
|
||||
});
|
||||
applyParamValue(variantEl, p, o.value);
|
||||
});
|
||||
segRow.appendChild(b);
|
||||
segBtns.push({ btn: b, val: o.value });
|
||||
});
|
||||
row.appendChild(segRow);
|
||||
}
|
||||
|
||||
paramsPanelBody.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
// Decide which way the popover opens: away from the picked element. If the
|
||||
// bar landed below the element, popover slides DOWN from the bar's bottom.
|
||||
// If the bar landed above, popover slides UP from the bar's top.
|
||||
function popoverDirection() {
|
||||
if (!barEl || !selectedElement) return 'below';
|
||||
const br = barEl.getBoundingClientRect();
|
||||
const er = selectedElement.getBoundingClientRect();
|
||||
return br.top >= er.bottom - 4 ? 'below' : 'above';
|
||||
}
|
||||
|
||||
// The popover overlaps the bar by OVERLAP px on the bar-facing side. With
|
||||
// popover z-index below bar, that overlap sits behind bar (invisible) and
|
||||
// reinforces the "tucked behind" feel. Padding compensates so the real
|
||||
// content starts flush with bar's outer edge.
|
||||
const TUNE_OVERLAP = 6;
|
||||
|
||||
// Closed clip-path depends on direction: for 'below' clip from the far
|
||||
// (bottom) edge so the reveal grows downward from the bar; for 'above'
|
||||
// clip from the top edge so the reveal grows upward from the bar.
|
||||
function closedClipPath(direction) {
|
||||
return direction === 'below' ? 'inset(0 0 100% 0)' : 'inset(100% 0 0 0)';
|
||||
}
|
||||
|
||||
function setClipPath(value, withTransition) {
|
||||
const saved = paramsPanelEl.style.transition;
|
||||
if (!withTransition) paramsPanelEl.style.transition = 'none';
|
||||
paramsPanelEl.style.clipPath = value;
|
||||
if (!withTransition) {
|
||||
void paramsPanelEl.offsetHeight;
|
||||
paramsPanelEl.style.transition = saved;
|
||||
}
|
||||
}
|
||||
|
||||
function positionParamsPanel() {
|
||||
if (!paramsPanelEl || !barEl || barEl.style.display === 'none') return;
|
||||
const br = barEl.getBoundingClientRect();
|
||||
const direction = popoverDirection();
|
||||
const prevDirection = paramsPanelEl.dataset.tuneDirection;
|
||||
|
||||
// top/left/width are NOT in the transition list, so they snap instantly.
|
||||
paramsPanelEl.style.left = br.left + 'px';
|
||||
paramsPanelEl.style.width = br.width + 'px';
|
||||
|
||||
if (direction === 'below') {
|
||||
paramsPanelEl.style.top = (br.bottom - TUNE_OVERLAP) + 'px';
|
||||
paramsPanelEl.style.borderRadius = '0 0 10px 10px';
|
||||
paramsPanelEl.style.paddingTop = (14 + TUNE_OVERLAP) + 'px';
|
||||
paramsPanelEl.style.paddingBottom = '14px';
|
||||
} else {
|
||||
const ih = paramsPanelEl.offsetHeight || 80;
|
||||
paramsPanelEl.style.top = (br.top - ih + TUNE_OVERLAP) + 'px';
|
||||
paramsPanelEl.style.borderRadius = '10px 10px 0 0';
|
||||
paramsPanelEl.style.paddingTop = '14px';
|
||||
paramsPanelEl.style.paddingBottom = (14 + TUNE_OVERLAP) + 'px';
|
||||
}
|
||||
paramsPanelEl.dataset.tuneDirection = direction;
|
||||
|
||||
// If currently closed and direction flipped (or first-time setup),
|
||||
// snap the clip-path to the new direction's closed pose without
|
||||
// transitioning (so the clip doesn't slide across the element).
|
||||
if (!tuneOpen && (!prevDirection || prevDirection !== direction)) {
|
||||
setClipPath(closedClipPath(direction), false);
|
||||
}
|
||||
}
|
||||
|
||||
function showParamsPanel() {
|
||||
if (!paramsPanelEl) return;
|
||||
positionParamsPanel();
|
||||
paramsPanelEl.style.pointerEvents = 'auto';
|
||||
// rAF so the positioning paint commits before the transition fires.
|
||||
requestAnimationFrame(() => {
|
||||
setClipPath('inset(0 0 0 0)', true);
|
||||
});
|
||||
}
|
||||
|
||||
function hideParamsPanel() {
|
||||
if (!paramsPanelEl) return;
|
||||
paramsPanelEl.style.pointerEvents = 'none';
|
||||
const direction = paramsPanelEl.dataset.tuneDirection || 'below';
|
||||
setClipPath(closedClipPath(direction), true);
|
||||
}
|
||||
|
||||
// Build/rebuild the panel's contents for the current variant AND apply
|
||||
// its defaults to the variant wrapper (so scoped CSS responds even before
|
||||
// the user opens the popover). Visibility is governed by tuneOpen.
|
||||
function refreshParamsPanel() {
|
||||
if (state !== 'CYCLING') {
|
||||
paramsCurrentValues = {};
|
||||
tuneOpen = false;
|
||||
hideParamsPanel();
|
||||
return;
|
||||
}
|
||||
const variantEl = getVisibleVariantEl();
|
||||
const params = parseVariantParams(variantEl);
|
||||
if (!variantEl || params.length === 0) {
|
||||
paramsCurrentValues = {};
|
||||
tuneOpen = false;
|
||||
hideParamsPanel();
|
||||
return;
|
||||
}
|
||||
applyParamDefaults(variantEl, params);
|
||||
buildParamsPanel(variantEl, params);
|
||||
if (tuneOpen) {
|
||||
// If already visible (variant cycled while open), refresh in place
|
||||
// instead of re-running the clip-path animation.
|
||||
const alreadyVisible = paramsPanelEl.style.display === 'block'
|
||||
&& paramsPanelEl.style.opacity === '1';
|
||||
if (alreadyVisible) positionParamsPanel();
|
||||
else showParamsPanel();
|
||||
} else {
|
||||
hideParamsPanel();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTunePopover() {
|
||||
if (tuneOpen) { closeTunePopover(); return; }
|
||||
openTunePopover();
|
||||
}
|
||||
|
||||
function openTunePopover() {
|
||||
if (state !== 'CYCLING') return;
|
||||
const variantEl = getVisibleVariantEl();
|
||||
const params = parseVariantParams(variantEl);
|
||||
if (!variantEl || params.length === 0) return;
|
||||
// Build fresh to ensure the current variant's controls are shown.
|
||||
applyParamDefaults(variantEl, params);
|
||||
buildParamsPanel(variantEl, params);
|
||||
tuneOpen = true;
|
||||
showParamsPanel();
|
||||
// Kill the bar's shadow on the popover-facing side so the dark popover
|
||||
// doesn't pick up a bright glow line.
|
||||
if (barEl) {
|
||||
const direction = paramsPanelEl?.dataset.tuneDirection || 'below';
|
||||
barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN;
|
||||
}
|
||||
// Re-render the bar so the Tune chip picks up the active styling.
|
||||
updateBarContent('cycling');
|
||||
}
|
||||
|
||||
function closeTunePopover() {
|
||||
tuneOpen = false;
|
||||
hideParamsPanel();
|
||||
if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT;
|
||||
if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') {
|
||||
updateBarContent('cycling');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variant cycling in DOM
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1253,6 +1762,10 @@
|
||||
if (!v) continue;
|
||||
child.style.display = (v === String(num)) ? '' : 'none';
|
||||
}
|
||||
// Unconditional refresh — covers first-reveal (no-op if state isn't
|
||||
// CYCLING yet, the subsequent CYCLING transition triggers its own
|
||||
// refresh) and every cycle step.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1316,6 +1829,7 @@
|
||||
state = 'CYCLING';
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
refreshParamsPanel();
|
||||
saveSession();
|
||||
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
|
||||
})
|
||||
@@ -1329,7 +1843,7 @@
|
||||
const next = visibleVariant + dir;
|
||||
if (next < 1 || next > arrivedVariants) return;
|
||||
visibleVariant = next;
|
||||
showVariantInDOM(currentSessionId, next);
|
||||
showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself
|
||||
updateSelectedElement();
|
||||
updateBarContent('cycling');
|
||||
saveSession();
|
||||
@@ -1540,6 +2054,7 @@
|
||||
state = 'CYCLING';
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
refreshParamsPanel();
|
||||
} else if (state === 'GENERATING') {
|
||||
updateBarContent('generating');
|
||||
}
|
||||
@@ -1560,6 +2075,7 @@
|
||||
if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') {
|
||||
positionBar();
|
||||
showHighlight(selectedElement);
|
||||
if (tuneOpen) positionParamsPanel();
|
||||
}
|
||||
if (annotActive) positionAnnotOverlay(selectedElement);
|
||||
// Shader overlay (via debug P toggle or generation) is repositioned
|
||||
@@ -1606,6 +2122,7 @@
|
||||
if (state === 'GENERATING') {
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
refreshParamsPanel();
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -1691,6 +2208,10 @@
|
||||
if (pickerEl?.style.display !== 'none' && !own(e.target)) {
|
||||
hideActionPicker();
|
||||
}
|
||||
// Close Tune popover on outside click (anything outside panel + bar)
|
||||
if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) {
|
||||
closeTunePopover();
|
||||
}
|
||||
// In CONFIGURING: click outside the bar and selected element returns to PICKING
|
||||
if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) {
|
||||
hideBar();
|
||||
@@ -2258,7 +2779,11 @@ void main() {
|
||||
|
||||
function handleAccept() {
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
sendEvent({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) });
|
||||
const acceptPayload = { type: 'accept', id: currentSessionId, variantId: String(visibleVariant) };
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
sendEvent(acceptPayload);
|
||||
markSessionHandled();
|
||||
|
||||
// The accepted variant is already the only visible child of the wrapper
|
||||
@@ -2477,6 +3002,11 @@ void main() {
|
||||
state = arrivedVariants >= expectedVariants ? 'CYCLING' : 'GENERATING';
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -2522,6 +3052,11 @@ void main() {
|
||||
// dark pages. This keeps the bar from fighting with the host design.
|
||||
function detectPageTheme() {
|
||||
try {
|
||||
// Dev override: set localStorage 'impeccable-dev-theme' to 'light' or
|
||||
// 'dark' to preview the opposite palette without actually changing the
|
||||
// page bg. Used for screenshots and theme QA.
|
||||
const override = localStorage.getItem('impeccable-dev-theme');
|
||||
if (override === 'light' || override === 'dark') return override;
|
||||
const bg = getComputedStyle(document.body).backgroundColor
|
||||
|| getComputedStyle(document.documentElement).backgroundColor;
|
||||
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
||||
@@ -2538,6 +3073,7 @@ void main() {
|
||||
// Light bar on dark page
|
||||
return {
|
||||
surface: 'oklch(98% 0 0 / 0.92)',
|
||||
surfaceDeep: 'oklch(92% 0.005 60 / 0.96)', // slightly deeper, faint warm
|
||||
hairline: 'oklch(70% 0 0 / 0.35)',
|
||||
text: 'oklch(15% 0 0)',
|
||||
textDim: 'oklch(45% 0 0)',
|
||||
@@ -2552,6 +3088,7 @@ void main() {
|
||||
// deeper so the rounded-right shape reads as a clear sculpted mark.
|
||||
return {
|
||||
surface: 'oklch(26% 0 0 / 0.94)',
|
||||
surfaceDeep: 'oklch(18% 0 0 / 0.96)', // darker sand for Tune popover
|
||||
hairline: 'oklch(42% 0 0 / 0.5)',
|
||||
text: 'oklch(96% 0 0)',
|
||||
textDim: 'oklch(72% 0 0)',
|
||||
@@ -2883,6 +3420,7 @@ void main() {
|
||||
if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; }
|
||||
if (barEl) { barEl.remove(); barEl = null; }
|
||||
if (pickerEl) { pickerEl.remove(); pickerEl = null; }
|
||||
if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; }
|
||||
if (evtSource) { evtSource.close(); evtSource = null; }
|
||||
document.removeEventListener('mousemove', handleMouseMove, true);
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
@@ -3956,6 +4494,7 @@ void main() {
|
||||
initAnnotOverlay();
|
||||
initBar();
|
||||
initActionPicker();
|
||||
initParamsPanel();
|
||||
initGlobalBar();
|
||||
initDesignPanel();
|
||||
document.addEventListener('mousemove', handleMouseMove, true);
|
||||
|
||||
@@ -145,6 +145,11 @@ Options:
|
||||
const scriptArgs = event.type === 'discard'
|
||||
? ['--id', event.id, '--discard']
|
||||
: ['--id', event.id, '--variant', event.variantId];
|
||||
if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
|
||||
// Pass through a JSON blob; the shell-safe wrap uses single quotes because
|
||||
// values are finite {id, number|string|boolean} pairs from a validated payload.
|
||||
scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
|
||||
}
|
||||
try {
|
||||
const out = execSync(
|
||||
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
|
||||
|
||||
@@ -147,6 +147,11 @@ function validateEvent(msg) {
|
||||
case 'accept':
|
||||
if (!msg.id) return 'accept: missing id';
|
||||
if (!msg.variantId) return 'accept: missing variantId';
|
||||
if (msg.paramValues !== undefined) {
|
||||
if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
|
||||
return 'accept: paramValues must be an object';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
case 'discard':
|
||||
return msg.id ? null : 'discard: missing id';
|
||||
|
||||
@@ -142,3 +142,13 @@ Test that colorization improves the experience:
|
||||
- **Not overwhelming**: Is color balanced and purposeful?
|
||||
|
||||
Remember: Color is emotional and powerful. Use it to create warmth, guide attention, communicate meaning, and express personality. But restraint and strategy matter more than saturation and variety. Be colorful, but be intentional.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
When invoked from live mode, each variant MUST declare a `color-amount` param so the user can dial between a restrained accent and a drenched surface without regeneration. Author the variant's CSS against `var(--p-color-amount, 0.5)` — typically as the alpha multiplier on backgrounds, or as a scaling factor on the chroma axis in an OKLCH expression. 0 = neutral/monochrome, 1 = full saturation / dominant coverage.
|
||||
|
||||
```json
|
||||
{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"}
|
||||
```
|
||||
|
||||
Layer 1-2 variant-specific params on top: palette selection (`steps` with named options), temperature warmth, or tint vs. true color. See `reference/live.md` for the full params contract.
|
||||
|
||||
@@ -119,3 +119,23 @@ Create a systematic plan:
|
||||
- **Responsiveness**: Does the layout adapt gracefully across screen sizes?
|
||||
|
||||
Remember: Space is the most underused design tool. A layout with the right rhythm and hierarchy can make even simple content feel polished and intentional.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
Each variant MUST declare a `density` param. Drive all spacing tokens in the variant's scoped CSS through `calc(var(--p-density, 1) * <base>)` — paddings, gaps, column widths. Users slide from airy to packed and see layout re-breathe with no regeneration.
|
||||
|
||||
```json
|
||||
{"id":"density","kind":"range","min":0.6,"max":1.4,"step":0.05,"default":1,"label":"Density"}
|
||||
```
|
||||
|
||||
For variants whose topology genuinely changes (stacked vs. side-by-side, grid vs. bento), use a `steps` param whose scoped CSS branches via `:scope[data-p-structure="X"]`. One structure param + one density param is a powerful combo; resist adding a third.
|
||||
|
||||
```json
|
||||
{"id":"structure","kind":"steps","default":"grid","label":"Structure","options":[
|
||||
{"value":"stacked","label":"Stacked"},
|
||||
{"value":"grid","label":"Grid"},
|
||||
{"value":"bento","label":"Bento"}
|
||||
]}
|
||||
```
|
||||
|
||||
See `reference/live.md` for the full params contract.
|
||||
|
||||
@@ -179,7 +179,56 @@ The first variant has no `display: none` (visible by default). All others do. If
|
||||
|
||||
One edit, all variants — the browser's MutationObserver picks everything up in one pass.
|
||||
|
||||
### 7. Signal done
|
||||
### 7. Parameters (optional, 2-5 per variant)
|
||||
|
||||
Each variant can expose coarse knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against.
|
||||
|
||||
**When to use.** Any time the variant has a meaningful axis the user might want to dial in: color amount, density, motion intensity, scale ratio. Not micro-level margin tweaks; those defeat the point.
|
||||
|
||||
**Budget scales with the element's visual weight, not the user's curiosity.** Knobs need real estate to produce noticeably different output; slapping three on a small element just crowds the panel without making anything feel tunable.
|
||||
|
||||
- **Leaf / tiny** — a single button, icon, input, bare heading, solitary paragraph: **0 params.** A slider can't meaningfully reshape one element; just use variants.
|
||||
- **Small composition** — labeled input, simple card, short callout (≤ ~5 visual children): **0-1 params.** Only add one if it's a clear dominant axis (e.g. density on a card with visible internal rhythm).
|
||||
- **Medium composition** — section component, nav cluster, dense card, short feature block (6-15 visual children): **2 params.**
|
||||
- **Large composition** — hero section, full page region, spread layout, anything with strong internal structure (16+ visual children or multiple sub-sections): **3-4 params.**
|
||||
|
||||
When in doubt, fewer. The user can always ask for more variants to explore an axis you didn't expose as a knob. Count by visual children, not by DOM-node depth; a deeply-nested-but-visually-simple card still counts as small.
|
||||
|
||||
**How to declare.** Put a JSON manifest on the variant wrapper:
|
||||
|
||||
```html
|
||||
<div data-impeccable-variant="1" data-impeccable-params='[
|
||||
{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"},
|
||||
{"id":"density","kind":"steps","default":"snug","label":"Density","options":[
|
||||
{"value":"airy","label":"Airy"},
|
||||
{"value":"snug","label":"Snug"},
|
||||
{"value":"packed","label":"Packed"}
|
||||
]},
|
||||
{"id":"serif","kind":"toggle","default":false,"label":"Serif display"}
|
||||
]'>
|
||||
...variant content...
|
||||
</div>
|
||||
```
|
||||
|
||||
**Three kinds:**
|
||||
|
||||
- `range` — smooth slider. Drives a CSS custom property `--p-<id>` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`.
|
||||
- `steps` — segmented radio. Drives a data attribute `data-p-<id>` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`.
|
||||
- `toggle` — on/off switch. Drives BOTH a CSS var (`--p-<id>: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`.
|
||||
|
||||
**Signature params per action.** Each action has one or two signature params that MUST be exposed when the variant can meaningfully express them. Check the action's reference file for the list. Layer 1-2 variant-specific params on top.
|
||||
|
||||
**Reset on variant switch.** User dials density on v1, flips to v2, v2 starts at v2's declared defaults. Known limitation; preservation across variants may land later.
|
||||
|
||||
**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment:
|
||||
|
||||
```html
|
||||
<!-- impeccable-param-values SESSION_ID: {"color-amount":0.7,"density":"packed"} -->
|
||||
```
|
||||
|
||||
The carbonize cleanup step (see below) reads that comment and bakes the chosen values into the final CSS. For `steps`/`toggle` attribute selectors: keep only the branch matching the chosen value, drop the others, collapse `:scope[data-p-density="packed"] .grid` to a semantic class rule. For `range` vars: either substitute the literal or keep the var with the chosen value as its new default.
|
||||
|
||||
### 8. Signal done
|
||||
|
||||
```bash
|
||||
node .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 `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element.
|
||||
1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values — read it first; it drives steps 3 and 4 below.
|
||||
2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `public/css/workflow.css` for this repo, or the component's co-located CSS file for a Vite/Next project — pick whichever already owns styling for the surrounding element).
|
||||
3. **Rewrite `@scope ([data-impeccable-variant="N"])` selectors** to target real, semantic classes on the accepted HTML. Example: `@scope ([data-impeccable-variant="2"]) { .v2-label { … } }` becomes `.why-visual--v2 .v2-label { … }` if the accepted element already carries `.why-visual--v2`, or pick/add a suitable class if it doesn't.
|
||||
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it.
|
||||
5. **Delete the inline `<style>` block and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one — those are dead code now.
|
||||
3. **Bake in parameter values while rewriting selectors.** For `@scope ([data-impeccable-variant="N"])` wrappers: retarget to real, semantic classes on the accepted HTML (`.why-visual--v2 .v2-label { … }`). For `:scope[data-p-<id>="VALUE"]` selectors: keep only the branch matching the chosen value from the param-values comment; drop the others (they're dead after accept). For `var(--p-<id>, DEFAULT)` in the CSS: either substitute the literal value, or if the param is still useful as a knob going forward, leave the var and update its initial declaration to the chosen value.
|
||||
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it. Drop `data-impeccable-params` and any `data-p-*` attributes from it — those are live-mode plumbing, not source.
|
||||
5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one — those are dead code now.
|
||||
|
||||
Then poll again.
|
||||
|
||||
|
||||
@@ -110,3 +110,15 @@ Build a clear type scale:
|
||||
- **Accessibility**: Does text meet WCAG contrast ratios? Is it zoomable to 200%?
|
||||
|
||||
Remember: Typography is the foundation of interface design — it carries the majority of information. Getting it right is the highest-leverage improvement you can make.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
Each variant MUST declare a `scale` param controlling the hierarchy ratio. Express all font sizes in the variant's scoped CSS through `calc(var(--p-scale, 1) * <base>)` or, better, scale the type ramp via `clamp(min, calc(var(--p-scale, 1) * Npx), max)`. Users slide from subdued to commanding.
|
||||
|
||||
```json
|
||||
{"id":"scale","kind":"range","min":0.85,"max":1.3,"step":0.05,"default":1,"label":"Scale"}
|
||||
```
|
||||
|
||||
Where the variant riffs on a specific pairing, expose the pairing choice as a `steps` param (e.g. "serif display + sans body" vs. "mono display + sans body" vs. "all-sans"). Each branch routes through `:scope[data-p-pairing="X"]` selectors in scoped CSS.
|
||||
|
||||
See `reference/live.md` for the full params contract.
|
||||
|
||||
@@ -45,11 +45,18 @@ Output (JSON):
|
||||
|
||||
const id = argVal(args, '--id');
|
||||
const variantNum = argVal(args, '--variant');
|
||||
const paramValuesRaw = argVal(args, '--param-values');
|
||||
const isDiscard = args.includes('--discard');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); }
|
||||
|
||||
let paramValues = null;
|
||||
if (paramValuesRaw) {
|
||||
try { paramValues = JSON.parse(paramValuesRaw); }
|
||||
catch { paramValues = null; } // malformed blob: skip the comment rather than failing the accept
|
||||
}
|
||||
|
||||
// Find the file containing this session's markers
|
||||
const found = findSessionFile(id, process.cwd());
|
||||
if (!found) {
|
||||
@@ -78,7 +85,7 @@ Output (JSON):
|
||||
const result = handleDiscard(id, lines, targetFile);
|
||||
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
|
||||
} else {
|
||||
const result = handleAccept(id, variantNum, lines, targetFile);
|
||||
const result = handleAccept(id, variantNum, lines, targetFile, paramValues);
|
||||
// Single-line attention-grabber when cleanup is required. The full
|
||||
// five-step checklist lives in reference/live.md (loaded once per
|
||||
// session); repeating it per-event would waste tokens.
|
||||
@@ -116,7 +123,7 @@ function handleDiscard(id, lines, targetFile) {
|
||||
// Accept
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function handleAccept(id, variantNum, lines, targetFile) {
|
||||
function handleAccept(id, variantNum, lines, targetFile, paramValues) {
|
||||
const block = findMarkerBlock(id, lines);
|
||||
if (!block) return { handled: false, error: 'Markers not found' };
|
||||
|
||||
@@ -149,6 +156,11 @@ function handleAccept(id, variantNum, lines, targetFile) {
|
||||
replacement.push(indent + cssLine.trimStart());
|
||||
}
|
||||
replacement.push(indent + '</style>');
|
||||
if (paramValues && Object.keys(paramValues).length > 0) {
|
||||
// Preserve the user's knob positions for the carbonize-cleanup agent
|
||||
// to bake into the final CSS when it collapses scoped rules.
|
||||
replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
|
||||
}
|
||||
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,10 +30,13 @@
|
||||
// Design tokens
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Brand magenta is pinned to the site token (--color-accent in main.css)
|
||||
// so Accept / knobs / cycle-dots match the site's accent, not a washed
|
||||
// theme-adjusted one.
|
||||
const C = {
|
||||
brand: 'oklch(55% 0.25 350)',
|
||||
brandHov: 'oklch(48% 0.25 350)',
|
||||
brandSoft: 'oklch(55% 0.25 350 / 0.12)',
|
||||
brand: 'oklch(60% 0.25 350)',
|
||||
brandHov: 'oklch(52% 0.25 350)',
|
||||
brandSoft: 'oklch(60% 0.25 350 / 0.15)',
|
||||
ink: 'oklch(15% 0.01 350)',
|
||||
ash: 'oklch(55% 0 0)',
|
||||
paper: 'oklch(98% 0.005 350 / 0.92)',
|
||||
@@ -60,6 +63,25 @@
|
||||
'html', 'head', 'body', 'script', 'style', 'link', 'meta', 'noscript', 'br', 'wbr',
|
||||
]);
|
||||
|
||||
// SVG icons stack above each chip label. All strokes use currentColor so the
|
||||
// icon recolors to C.brand when its chip is selected. 20x20 render, 24-viewBox,
|
||||
// 1.5 stroke — visually consistent with the Foundation grid on the homepage.
|
||||
const ICON_ATTRS = 'width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="display:block"';
|
||||
const ICONS = {
|
||||
impeccable: `<svg ${ICON_ATTRS}><path d="M4 20l4-1L18 9l-3-3L5 16z"/><path d="M14 7l3 3"/></svg>`,
|
||||
bolder: `<svg ${ICON_ATTRS}><rect x="6" y="12" width="4" height="7" rx="0.5"/><rect x="14" y="5" width="4" height="14" rx="0.5"/></svg>`,
|
||||
quieter: `<svg ${ICON_ATTRS}><rect x="6" y="5" width="4" height="14" rx="0.5"/><rect x="14" y="12" width="4" height="7" rx="0.5"/></svg>`,
|
||||
distill: `<svg ${ICON_ATTRS}><path d="M4 5h16l-6 8v7l-4-2v-5z"/></svg>`,
|
||||
polish: `<svg ${ICON_ATTRS}><path d="M15 3l1 3 3 1-3 1-1 3-1-3-3-1 3-1z"/><path d="M7 13l0.6 1.8 1.8 0.6-1.8 0.6-0.6 1.8-0.6-1.8-1.8-0.6 1.8-0.6z"/></svg>`,
|
||||
typeset: `<svg ${ICON_ATTRS}><path d="M5 6h14" stroke-width="2.6"/><path d="M5 12h9" stroke-width="1.9"/><path d="M5 18h5" stroke-width="1.3"/></svg>`,
|
||||
colorize: `<svg ${ICON_ATTRS}><circle cx="9" cy="10" r="5"/><circle cx="15" cy="10" r="5"/><circle cx="12" cy="15" r="5"/></svg>`,
|
||||
layout: `<svg ${ICON_ATTRS}><rect x="3" y="4" width="8" height="16" rx="0.5"/><rect x="13" y="4" width="8" height="7" rx="0.5"/><rect x="13" y="13" width="8" height="7" rx="0.5"/></svg>`,
|
||||
adapt: `<svg ${ICON_ATTRS}><rect x="2.5" y="5" width="12" height="11" rx="1"/><line x1="2.5" y1="19" x2="14.5" y2="19"/><rect x="16.5" y="8" width="5" height="11" rx="1"/></svg>`,
|
||||
animate: `<svg ${ICON_ATTRS}><path d="M3 18c4-4 6-10 10-10"/><path d="M13 8c3 0 5 5 8 10"/><circle cx="13" cy="8" r="1.6" fill="currentColor" stroke="none"/></svg>`,
|
||||
delight: `<svg ${ICON_ATTRS}><path d="M12 3l2 6 6 2-6 2-2 6-2-6-6-2 6-2z"/></svg>`,
|
||||
overdrive: `<svg ${ICON_ATTRS}><path d="M13 3L5 13h5l-1 8 9-12h-6z"/></svg>`,
|
||||
};
|
||||
|
||||
const ACTIONS = [
|
||||
{ value: 'impeccable', label: 'Freeform' },
|
||||
{ value: 'bolder', label: 'Bolder' },
|
||||
@@ -756,7 +778,21 @@
|
||||
// The Bar — one floating element, three modes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Contextual-bar palette. Cached at init so every build*Row reads a
|
||||
// consistent set of colors; detectPageTheme runs once rather than on every
|
||||
// phase transition.
|
||||
let BP = null;
|
||||
|
||||
// Bar shadow variants. The default projects down + subtle around. When
|
||||
// the Tune popover opens below the bar, a downward shadow lands on the
|
||||
// dark popover and reads as a bright ghost line. We swap to UP-only while
|
||||
// tune is open below so the popover's top edge is clean.
|
||||
const BAR_SHADOW_DEFAULT = '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)';
|
||||
const BAR_SHADOW_UP = '0 -4px 20px oklch(0% 0 0 / 0.08), 0 -1px 3px oklch(0% 0 0 / 0.06)';
|
||||
const BAR_SHADOW_DOWN = BAR_SHADOW_DEFAULT;
|
||||
|
||||
function initBar() {
|
||||
BP = barPaletteForTheme(detectPageTheme());
|
||||
barEl = document.createElement('div');
|
||||
barEl.id = PREFIX + '-bar';
|
||||
Object.assign(barEl.style, {
|
||||
@@ -764,15 +800,15 @@
|
||||
display: 'none', opacity: '0',
|
||||
transform: 'translateY(6px)',
|
||||
transition: 'opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
|
||||
// Visual
|
||||
background: C.paper,
|
||||
background: BP.surface,
|
||||
backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
|
||||
border: '1px solid ' + C.mist,
|
||||
border: '1px solid ' + BP.hairline,
|
||||
borderRadius: '10px',
|
||||
boxShadow: '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)',
|
||||
fontFamily: FONT, fontSize: '13px', color: C.ink,
|
||||
boxShadow: BAR_SHADOW_DEFAULT,
|
||||
transition: 'box-shadow 0.2s ease, opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
|
||||
fontFamily: FONT, fontSize: '13px', color: BP.text,
|
||||
padding: '6px',
|
||||
maxWidth: '460px', minWidth: '320px',
|
||||
maxWidth: '520px', minWidth: '320px',
|
||||
});
|
||||
document.body.appendChild(barEl);
|
||||
}
|
||||
@@ -824,14 +860,15 @@
|
||||
barEl.style.transform = 'translateY(6px)';
|
||||
setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250);
|
||||
hideActionPicker();
|
||||
closeTunePopover();
|
||||
}
|
||||
|
||||
function updateBarContent(mode) {
|
||||
if (!barEl || barEl.style.display === 'none') return;
|
||||
barEl.innerHTML = '';
|
||||
// Reset bar styling
|
||||
barEl.style.background = C.paper;
|
||||
barEl.style.border = '1px solid ' + C.mist;
|
||||
// Reset bar styling to the theme-aware palette
|
||||
barEl.style.background = BP.surface;
|
||||
barEl.style.border = '1px solid ' + BP.hairline;
|
||||
if (mode === 'configure') barEl.appendChild(buildConfigureRow());
|
||||
else if (mode === 'generating') barEl.appendChild(buildGeneratingRow());
|
||||
else if (mode === 'cycling') barEl.appendChild(buildCyclingRow());
|
||||
@@ -854,15 +891,15 @@
|
||||
const pill = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
||||
padding: '5px 10px', borderRadius: '6px',
|
||||
background: C.ink, color: C.white,
|
||||
background: BP.mark, color: BP.markText,
|
||||
fontFamily: FONT, fontSize: '12px', fontWeight: '500',
|
||||
border: 'none', cursor: 'pointer',
|
||||
transition: 'background 0.12s ease, transform 0.1s ease',
|
||||
whiteSpace: 'nowrap', flexShrink: '0',
|
||||
});
|
||||
pill.textContent = actionLabel() + ' \u25BE';
|
||||
pill.addEventListener('mouseenter', () => pill.style.background = C.brandHov);
|
||||
pill.addEventListener('mouseleave', () => pill.style.background = C.ink);
|
||||
pill.addEventListener('mouseenter', () => pill.style.background = BP.accent);
|
||||
pill.addEventListener('mouseleave', () => pill.style.background = BP.mark);
|
||||
pill.addEventListener('mousedown', () => pill.style.transform = 'scale(0.97)');
|
||||
pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)');
|
||||
pill.addEventListener('click', (e) => { e.stopPropagation(); toggleActionPicker(); });
|
||||
@@ -877,13 +914,13 @@
|
||||
flex: '1', minWidth: '0',
|
||||
padding: '5px 8px', borderRadius: '6px',
|
||||
border: '1px solid transparent', background: 'transparent',
|
||||
fontFamily: FONT, fontSize: '12px', color: C.ink,
|
||||
fontFamily: FONT, fontSize: '12px', color: BP.text,
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.15s ease, background 0.15s ease',
|
||||
});
|
||||
input.addEventListener('focus', () => {
|
||||
input.style.borderColor = C.mist;
|
||||
input.style.background = C.white;
|
||||
input.style.borderColor = BP.hairline;
|
||||
input.style.background = BP.accentSoft;
|
||||
});
|
||||
input.addEventListener('blur', () => {
|
||||
input.style.borderColor = 'transparent';
|
||||
@@ -901,16 +938,16 @@
|
||||
// Variant count toggle
|
||||
const count = el('button', {
|
||||
padding: '4px 6px', borderRadius: '5px',
|
||||
border: '1px solid ' + C.mist, background: 'transparent',
|
||||
border: '1px solid ' + BP.hairline, background: 'transparent',
|
||||
fontFamily: MONO, fontSize: '11px', fontWeight: '600',
|
||||
color: C.ash, cursor: 'pointer',
|
||||
color: BP.textDim, cursor: 'pointer',
|
||||
transition: 'color 0.12s ease, border-color 0.12s ease',
|
||||
flexShrink: '0', whiteSpace: 'nowrap',
|
||||
});
|
||||
count.textContent = '\u00D7' + selectedCount;
|
||||
count.title = 'Variants: click to change';
|
||||
count.addEventListener('mouseenter', () => { count.style.color = C.ink; count.style.borderColor = C.ink; });
|
||||
count.addEventListener('mouseleave', () => { count.style.color = C.ash; count.style.borderColor = C.mist; });
|
||||
count.addEventListener('mouseenter', () => { count.style.color = BP.text; count.style.borderColor = BP.text; });
|
||||
count.addEventListener('mouseleave', () => { count.style.color = BP.textDim; count.style.borderColor = BP.hairline; });
|
||||
count.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1;
|
||||
@@ -921,15 +958,15 @@
|
||||
// Go button
|
||||
const go = el('button', {
|
||||
padding: '5px 12px', borderRadius: '6px',
|
||||
border: 'none', background: C.brand, color: C.white,
|
||||
border: 'none', background: BP.accent, color: BP.mark,
|
||||
fontFamily: FONT, fontSize: '12px', fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.12s ease, transform 0.1s ease',
|
||||
transition: 'filter 0.12s ease, transform 0.1s ease',
|
||||
flexShrink: '0', whiteSpace: 'nowrap',
|
||||
});
|
||||
go.textContent = 'Go \u2192';
|
||||
go.addEventListener('mouseenter', () => go.style.background = C.brandHov);
|
||||
go.addEventListener('mouseleave', () => go.style.background = C.brand);
|
||||
go.addEventListener('mouseenter', () => go.style.filter = 'brightness(1.1)');
|
||||
go.addEventListener('mouseleave', () => go.style.filter = 'none');
|
||||
go.addEventListener('mousedown', () => go.style.transform = 'scale(0.97)');
|
||||
go.addEventListener('mouseup', () => go.style.transform = 'scale(1)');
|
||||
go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); });
|
||||
@@ -950,7 +987,7 @@
|
||||
|
||||
// Action label
|
||||
const label = el('span', {
|
||||
fontWeight: '600', fontSize: '12px', color: C.ink,
|
||||
fontWeight: '600', fontSize: '12px', color: BP.text,
|
||||
flexShrink: '0', whiteSpace: 'nowrap',
|
||||
});
|
||||
label.textContent = actionLabel();
|
||||
@@ -961,7 +998,7 @@
|
||||
|
||||
// Status
|
||||
const status = el('span', {
|
||||
fontSize: '11px', color: C.ash, whiteSpace: 'nowrap',
|
||||
fontSize: '11px', color: BP.textDim, whiteSpace: 'nowrap',
|
||||
marginLeft: 'auto',
|
||||
});
|
||||
// Variants currently arrive atomically in a single file edit, so a
|
||||
@@ -976,6 +1013,8 @@
|
||||
|
||||
// --- Cycling row ---
|
||||
|
||||
const TUNE_ICON_SVG = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" style="flex-shrink:0"><line x1="4" y1="8" x2="20" y2="8"/><circle cx="14" cy="8" r="2.4" fill="currentColor" stroke="none"/><line x1="4" y1="16" x2="20" y2="16"/><circle cx="10" cy="16" r="2.4" fill="currentColor" stroke="none"/></svg>';
|
||||
|
||||
function buildCyclingRow() {
|
||||
const row = el('div', {
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
@@ -994,7 +1033,7 @@
|
||||
// Counter
|
||||
const counter = el('span', {
|
||||
fontFamily: MONO, fontSize: '11px', fontWeight: '500',
|
||||
color: C.ash, minWidth: '24px', textAlign: 'center',
|
||||
color: BP.textDim, minWidth: '24px', textAlign: 'center',
|
||||
});
|
||||
counter.textContent = visibleVariant + '/' + arrivedVariants;
|
||||
row.appendChild(counter);
|
||||
@@ -1005,20 +1044,67 @@
|
||||
if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
|
||||
row.appendChild(next);
|
||||
|
||||
// Tune chip — only when the visible variant exposes params
|
||||
const visParams = parseVariantParams(getVisibleVariantEl());
|
||||
const hasParams = visParams.length > 0;
|
||||
if (hasParams) {
|
||||
const tune = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '6px',
|
||||
padding: '4px 10px', borderRadius: '5px',
|
||||
border: '1px solid transparent',
|
||||
background: tuneOpen ? BP.accentSoft : 'transparent',
|
||||
color: tuneOpen ? BP.accent : BP.text,
|
||||
fontFamily: FONT, fontSize: '11px', fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'color 0.12s ease, background 0.12s ease',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
tune.innerHTML = TUNE_ICON_SVG;
|
||||
const tuneLabel = document.createElement('span');
|
||||
tuneLabel.textContent = 'Tune';
|
||||
tune.appendChild(tuneLabel);
|
||||
const tuneBadge = document.createElement('span');
|
||||
Object.assign(tuneBadge.style, {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
minWidth: '16px', height: '16px', padding: '0 4px',
|
||||
borderRadius: '999px',
|
||||
background: tuneOpen ? C.brand : BP.hairline,
|
||||
color: tuneOpen ? 'oklch(98% 0 0)' : 'inherit',
|
||||
fontFamily: MONO, fontSize: '9.5px', fontWeight: '600',
|
||||
lineHeight: '1',
|
||||
boxSizing: 'border-box',
|
||||
});
|
||||
tuneBadge.textContent = String(visParams.length);
|
||||
tune.appendChild(tuneBadge);
|
||||
tune.title = 'Tune this variant (' + visParams.length + ' knob' + (visParams.length === 1 ? '' : 's') + ')';
|
||||
tune.addEventListener('mouseenter', () => {
|
||||
if (!tuneOpen) tune.style.background = BP.accentSoft;
|
||||
});
|
||||
tune.addEventListener('mouseleave', () => {
|
||||
if (!tuneOpen) tune.style.background = 'transparent';
|
||||
});
|
||||
tune.addEventListener('click', (e) => { e.stopPropagation(); toggleTunePopover(); });
|
||||
tune.dataset.iceqTune = '1';
|
||||
row.appendChild(tune);
|
||||
}
|
||||
|
||||
// Spacer
|
||||
row.appendChild(el('div', { flex: '1' }));
|
||||
|
||||
// Accept
|
||||
// Accept — primary action, uses the site's saturated brand magenta
|
||||
// with paper-white text, not the theme-muted BP.accent.
|
||||
const accept = el('button', {
|
||||
padding: '4px 10px', borderRadius: '5px',
|
||||
border: 'none', background: C.ink, color: C.white,
|
||||
padding: '5px 14px', borderRadius: '5px',
|
||||
border: 'none', background: C.brand, color: 'oklch(98% 0 0)',
|
||||
fontFamily: FONT, fontSize: '11px', fontWeight: '600',
|
||||
cursor: 'pointer', transition: 'background 0.12s ease',
|
||||
cursor: 'pointer', transition: 'filter 0.12s ease, transform 0.1s ease',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
accept.textContent = '\u2713 Accept';
|
||||
accept.addEventListener('mouseenter', () => accept.style.background = C.brand);
|
||||
accept.addEventListener('mouseleave', () => accept.style.background = C.ink);
|
||||
accept.addEventListener('mouseenter', () => accept.style.filter = 'brightness(1.08)');
|
||||
accept.addEventListener('mouseleave', () => accept.style.filter = 'none');
|
||||
accept.addEventListener('mousedown', () => accept.style.transform = 'scale(0.97)');
|
||||
accept.addEventListener('mouseup', () => accept.style.transform = 'scale(1)');
|
||||
accept.addEventListener('click', (e) => { e.stopPropagation(); handleAccept(); });
|
||||
if (arrivedVariants === 0) { accept.style.opacity = '0.3'; accept.style.pointerEvents = 'none'; }
|
||||
row.appendChild(accept);
|
||||
@@ -1026,14 +1112,14 @@
|
||||
// Discard
|
||||
const discard = el('button', {
|
||||
padding: '4px 6px', borderRadius: '5px',
|
||||
border: '1px solid ' + C.mist, background: 'transparent',
|
||||
fontFamily: FONT, fontSize: '11px', color: C.ash,
|
||||
border: '1px solid ' + BP.hairline, background: 'transparent',
|
||||
fontFamily: FONT, fontSize: '11px', color: BP.textDim,
|
||||
cursor: 'pointer', transition: 'color 0.12s ease, border-color 0.12s ease',
|
||||
});
|
||||
discard.textContent = '\u2715';
|
||||
discard.title = 'Discard all variants';
|
||||
discard.addEventListener('mouseenter', () => { discard.style.color = C.ink; discard.style.borderColor = C.ink; });
|
||||
discard.addEventListener('mouseleave', () => { discard.style.color = C.ash; discard.style.borderColor = C.mist; });
|
||||
discard.addEventListener('mouseenter', () => { discard.style.color = BP.text; discard.style.borderColor = BP.text; });
|
||||
discard.addEventListener('mouseleave', () => { discard.style.color = BP.textDim; discard.style.borderColor = BP.hairline; });
|
||||
discard.addEventListener('click', (e) => { e.stopPropagation(); handleDiscard(); });
|
||||
row.appendChild(discard);
|
||||
|
||||
@@ -1051,14 +1137,14 @@
|
||||
});
|
||||
const spinner = el('div', {
|
||||
width: '14px', height: '14px', borderRadius: '50%',
|
||||
border: '2px solid ' + C.mist,
|
||||
borderTopColor: C.brand,
|
||||
border: '2px solid ' + BP.hairline,
|
||||
borderTopColor: BP.accent,
|
||||
animation: 'impeccable-spin 0.6s linear infinite',
|
||||
flexShrink: '0',
|
||||
});
|
||||
row.appendChild(spinner);
|
||||
const label = el('span', {
|
||||
fontSize: '12px', color: C.ash, fontWeight: '500',
|
||||
fontSize: '12px', color: BP.textDim, fontWeight: '500',
|
||||
});
|
||||
label.textContent = 'Applying variant...';
|
||||
row.appendChild(label);
|
||||
@@ -1103,14 +1189,26 @@
|
||||
for (let i = 1; i <= expectedVariants; i++) {
|
||||
const arrived = i <= arrivedVariants;
|
||||
const active = i === visibleVariant;
|
||||
// active: solid site-brand magenta dot. arrived+inactive: muted neutral.
|
||||
// pending (not yet arrived): faint outline ring. No borders on arrived
|
||||
// dots — the previous "accent ring + ash fill" combo read as noisy
|
||||
// magenta chips, especially when all variants had arrived and every
|
||||
// dot wore an accent ring.
|
||||
const dotBg = active ? C.brand
|
||||
: arrived ? BP.textDim
|
||||
: 'transparent';
|
||||
const dotBorder = arrived ? 'none' : '1.5px solid ' + BP.hairline;
|
||||
const dot = el('div', {
|
||||
width: '7px', height: '7px', borderRadius: '50%',
|
||||
background: active ? C.brand : (arrived ? C.ash : 'transparent'),
|
||||
border: '1.5px solid ' + (arrived ? C.brand : C.mist),
|
||||
width: active ? '8px' : '6px',
|
||||
height: active ? '8px' : '6px',
|
||||
borderRadius: '50%',
|
||||
background: dotBg,
|
||||
border: dotBorder,
|
||||
boxSizing: 'border-box',
|
||||
transition: 'all 0.2s ' + EASE,
|
||||
cursor: (clickable && arrived) ? 'pointer' : 'default',
|
||||
transform: arrived ? 'scale(1)' : 'scale(0.6)',
|
||||
opacity: arrived ? '1' : '0.4',
|
||||
transform: arrived ? 'scale(1)' : 'scale(0.85)',
|
||||
opacity: arrived ? (active ? '1' : '0.6') : '0.4',
|
||||
});
|
||||
if (clickable && arrived) {
|
||||
const idx = i;
|
||||
@@ -1130,15 +1228,15 @@
|
||||
function navBtn(text) {
|
||||
const b = el('button', {
|
||||
width: '26px', height: '26px', borderRadius: '5px',
|
||||
border: '1px solid ' + C.mist, background: 'transparent',
|
||||
color: C.ink, fontFamily: FONT, fontSize: '13px',
|
||||
border: '1px solid ' + BP.hairline, background: 'transparent',
|
||||
color: BP.text, fontFamily: FONT, fontSize: '13px',
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'border-color 0.12s ease, background 0.12s ease',
|
||||
padding: '0', lineHeight: '1',
|
||||
});
|
||||
b.textContent = text;
|
||||
b.addEventListener('mouseenter', () => { b.style.borderColor = C.ink; });
|
||||
b.addEventListener('mouseleave', () => { b.style.borderColor = C.mist; });
|
||||
b.addEventListener('mouseenter', () => { b.style.borderColor = BP.text; });
|
||||
b.addEventListener('mouseleave', () => { b.style.borderColor = BP.hairline; });
|
||||
return b;
|
||||
}
|
||||
|
||||
@@ -1158,6 +1256,7 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initActionPicker() {
|
||||
const P = barPaletteForTheme(detectPageTheme());
|
||||
pickerEl = document.createElement('div');
|
||||
pickerEl.id = PREFIX + '-picker';
|
||||
Object.assign(pickerEl.style, {
|
||||
@@ -1166,12 +1265,14 @@
|
||||
transform: 'scale(0.96) translateY(4px)',
|
||||
transformOrigin: 'bottom left',
|
||||
transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE,
|
||||
background: C.paperSolid,
|
||||
border: '1px solid ' + C.mist,
|
||||
background: P.surface,
|
||||
border: '1px solid ' + P.hairline,
|
||||
borderRadius: '10px',
|
||||
boxShadow: '0 8px 30px oklch(0% 0 0 / 0.10), 0 2px 6px oklch(0% 0 0 / 0.06)',
|
||||
padding: '6px',
|
||||
fontFamily: FONT,
|
||||
backdropFilter: 'blur(10px)',
|
||||
WebkitBackdropFilter: 'blur(10px)',
|
||||
});
|
||||
|
||||
// Build the chip grid
|
||||
@@ -1181,22 +1282,32 @@
|
||||
|
||||
ACTIONS.forEach(action => {
|
||||
const chip = el('button', {
|
||||
padding: '6px 8px', borderRadius: '6px',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '8px 6px', borderRadius: '6px',
|
||||
border: 'none',
|
||||
background: action.value === selectedAction ? C.brandSoft : 'transparent',
|
||||
color: action.value === selectedAction ? C.brand : C.ink,
|
||||
background: action.value === selectedAction ? P.accentSoft : 'transparent',
|
||||
color: action.value === selectedAction ? P.accent : P.text,
|
||||
fontFamily: FONT, fontSize: '11px', fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.1s ease, color 0.1s ease',
|
||||
textAlign: 'center', whiteSpace: 'nowrap',
|
||||
});
|
||||
chip.textContent = action.label;
|
||||
const iconWrap = el('span', {
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
height: '20px', opacity: '0.9',
|
||||
});
|
||||
iconWrap.innerHTML = ICONS[action.value] || '';
|
||||
const labelEl = el('span', { lineHeight: '1' });
|
||||
labelEl.textContent = action.label;
|
||||
chip.appendChild(iconWrap);
|
||||
chip.appendChild(labelEl);
|
||||
chip.dataset.action = action.value;
|
||||
chip.addEventListener('mouseenter', () => {
|
||||
if (action.value !== selectedAction) chip.style.background = C.brandSoft;
|
||||
if (action.value !== selectedAction) chip.style.background = P.accentSoft;
|
||||
});
|
||||
chip.addEventListener('mouseleave', () => {
|
||||
chip.style.background = action.value === selectedAction ? C.brandSoft : 'transparent';
|
||||
chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent';
|
||||
});
|
||||
chip.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -1209,19 +1320,24 @@
|
||||
|
||||
pickerEl.appendChild(grid);
|
||||
document.body.appendChild(pickerEl);
|
||||
|
||||
// Cache the palette on the picker so toggleActionPicker's state refresh
|
||||
// uses the same theme-aware colors when it repaints chips.
|
||||
pickerEl.__iceq_palette = P;
|
||||
}
|
||||
|
||||
function toggleActionPicker() {
|
||||
if (pickerEl.style.display !== 'none') { hideActionPicker(); return; }
|
||||
// Rebuild chips to reflect current selection
|
||||
const P = pickerEl.__iceq_palette || barPaletteForTheme(detectPageTheme());
|
||||
pickerEl.querySelectorAll('button').forEach(chip => {
|
||||
const isActive = chip.dataset.action === selectedAction;
|
||||
chip.style.background = isActive ? C.brandSoft : 'transparent';
|
||||
chip.style.color = isActive ? C.brand : C.ink;
|
||||
chip.style.background = isActive ? P.accentSoft : 'transparent';
|
||||
chip.style.color = isActive ? P.accent : P.text;
|
||||
});
|
||||
// Position above the bar
|
||||
const barRect = barEl.getBoundingClientRect();
|
||||
const pickerH = 90; // approximate
|
||||
const pickerH = 170; // approximate; grows with icon + label rows
|
||||
let top = barRect.top - pickerH - 6;
|
||||
if (top < 8) top = barRect.bottom + 6;
|
||||
Object.assign(pickerEl.style, {
|
||||
@@ -1241,6 +1357,399 @@
|
||||
setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Params panel (per-variant coarse controls)
|
||||
//
|
||||
// Variants may declare a parameter manifest via a JSON attribute on the
|
||||
// variant wrapper:
|
||||
//
|
||||
// <div data-impeccable-variant="1"
|
||||
// data-impeccable-params='[{"id":"density","kind":"steps",...}]'>
|
||||
//
|
||||
// The panel docks to the right edge of the outline during CYCLING and
|
||||
// exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped
|
||||
// CSS can respond instantly without regeneration:
|
||||
//
|
||||
// range / numeric toggle → CSS var (`--p-<id>`) used via var(--p-foo, N)
|
||||
// steps / boolean toggle → data-p-<id> attribute used via :scope[data-p-foo="..."]
|
||||
//
|
||||
// On variant switch, values reset to that variant's declared defaults.
|
||||
// On accept, current values are sent in the event payload so the agent
|
||||
// can bake them into the source-file write.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide)
|
||||
let paramsPanelInner = null; // translating content (carries bg, padding, knobs)
|
||||
let paramsPanelBody = null; // grid holding the knob cells
|
||||
let paramsCurrentValues = {}; // {paramId: value} — mirror of the visible variant's live values
|
||||
let tuneOpen = false; // whether the Tune popover is open right now
|
||||
|
||||
// Theme-aware Tune popover. Appears as a drawer that slides out from the
|
||||
// contextual bar's bar-facing edge (below if the bar sits below the
|
||||
// element, above otherwise). Same width as the bar. Auto-wraps to extra
|
||||
// rows when the knobs exceed one row. The bar's border-radius on the
|
||||
// popover side goes flat while open so the two shapes read as one.
|
||||
let paramsPanelPalette = null;
|
||||
|
||||
function initParamsPanel() {
|
||||
paramsPanelPalette = barPaletteForTheme(detectPageTheme());
|
||||
const P = paramsPanelPalette;
|
||||
|
||||
// Single element, always in the DOM. The slide animation is a CSS mask
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis — no
|
||||
// display toggle, no opacity toggle, no transform trickery. The mask
|
||||
// hides everything initially; as it grows, content is revealed from
|
||||
// the bar edge outward.
|
||||
paramsPanelEl = document.createElement('div');
|
||||
paramsPanelEl.id = PREFIX + '-params-panel';
|
||||
Object.assign(paramsPanelEl.style, {
|
||||
position: 'fixed', zIndex: String(Z.bar - 1),
|
||||
background: P.surfaceDeep,
|
||||
color: P.text,
|
||||
fontFamily: FONT,
|
||||
padding: '14px 18px',
|
||||
boxSizing: 'border-box',
|
||||
borderRadius: '0 0 10px 10px',
|
||||
pointerEvents: 'none',
|
||||
backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
|
||||
|
||||
// clip-path is the same conceptual reveal as mask but with rock-solid
|
||||
// transition support across engines. Closed state clips from the far
|
||||
// edge; open = inset(0) shows everything.
|
||||
clipPath: 'inset(0 0 100% 0)',
|
||||
transition: 'clip-path 0.44s ' + EASE,
|
||||
|
||||
// Park off-screen until positionParamsPanel places it. These are NOT
|
||||
// in the transition list, so they snap instantly — no fly-in from the
|
||||
// top-left when first shown.
|
||||
top: '-9999px', left: '-9999px', width: '0',
|
||||
});
|
||||
|
||||
paramsPanelBody = el('div', {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
|
||||
gap: '12px 16px',
|
||||
});
|
||||
|
||||
paramsPanelEl.appendChild(paramsPanelBody);
|
||||
document.body.appendChild(paramsPanelEl);
|
||||
paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
|
||||
}
|
||||
|
||||
function getVisibleVariantEl() {
|
||||
if (!currentSessionId) return null;
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
|
||||
function parseVariantParams(variantEl) {
|
||||
if (!variantEl) return [];
|
||||
const raw = variantEl.getAttribute('data-impeccable-params');
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] Invalid data-impeccable-params JSON:', err.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function applyParamValue(variantEl, param, value) {
|
||||
if (!variantEl) return;
|
||||
const attr = 'data-p-' + param.id;
|
||||
if (param.kind === 'range') {
|
||||
variantEl.style.setProperty('--p-' + param.id, String(value));
|
||||
} else if (param.kind === 'toggle') {
|
||||
const on = !!value;
|
||||
variantEl.style.setProperty('--p-' + param.id, on ? '1' : '0');
|
||||
if (on) variantEl.setAttribute(attr, 'on');
|
||||
else variantEl.removeAttribute(attr);
|
||||
} else if (param.kind === 'steps') {
|
||||
variantEl.setAttribute(attr, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
function applyParamDefaults(variantEl, params) {
|
||||
paramsCurrentValues = {};
|
||||
for (const p of params) {
|
||||
paramsCurrentValues[p.id] = p.default;
|
||||
applyParamValue(variantEl, p, p.default);
|
||||
}
|
||||
}
|
||||
|
||||
function formatRangeValue(input) {
|
||||
const max = parseFloat(input.max), min = parseFloat(input.min);
|
||||
const v = parseFloat(input.value);
|
||||
if (!isFinite(v)) return input.value;
|
||||
return (max - min) <= 2 ? v.toFixed(2) : String(Math.round(v));
|
||||
}
|
||||
|
||||
function buildParamsPanel(variantEl, params) {
|
||||
const P = paramsPanelPalette || barPaletteForTheme(detectPageTheme());
|
||||
paramsPanelBody.innerHTML = '';
|
||||
for (const p of params) {
|
||||
const row = el('div', { display: 'flex', flexDirection: 'column', gap: '6px' });
|
||||
const labelRow = el('div', {
|
||||
display: 'flex', justifyContent: 'space-between',
|
||||
alignItems: 'baseline', gap: '8px',
|
||||
});
|
||||
const lbl = el('span', {
|
||||
fontSize: '10.5px', fontWeight: '600', color: P.text,
|
||||
letterSpacing: '0.03em',
|
||||
});
|
||||
lbl.textContent = p.label || p.id;
|
||||
labelRow.appendChild(lbl);
|
||||
const readout = el('span', {
|
||||
fontSize: '10.5px', color: P.textDim,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
});
|
||||
labelRow.appendChild(readout);
|
||||
row.appendChild(labelRow);
|
||||
|
||||
if (p.kind === 'range') {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'range';
|
||||
input.min = String(p.min != null ? p.min : 0);
|
||||
input.max = String(p.max != null ? p.max : 1);
|
||||
input.step = String(p.step != null ? p.step : 0.05);
|
||||
input.value = String(p.default);
|
||||
Object.assign(input.style, {
|
||||
width: '100%', accentColor: C.brand, cursor: 'pointer',
|
||||
});
|
||||
readout.textContent = formatRangeValue(input);
|
||||
input.addEventListener('input', (e) => {
|
||||
e.stopPropagation();
|
||||
const v = parseFloat(input.value);
|
||||
paramsCurrentValues[p.id] = v;
|
||||
readout.textContent = formatRangeValue(input);
|
||||
applyParamValue(variantEl, p, v);
|
||||
});
|
||||
row.appendChild(input);
|
||||
} else if (p.kind === 'toggle') {
|
||||
const initial = !!p.default;
|
||||
readout.textContent = initial ? 'On' : 'Off';
|
||||
const track = el('button', {
|
||||
position: 'relative', width: '36px', height: '20px',
|
||||
borderRadius: '10px', border: 'none', padding: '0',
|
||||
cursor: 'pointer',
|
||||
background: initial ? C.brand : P.hairline,
|
||||
transition: 'background 0.15s ease',
|
||||
alignSelf: 'flex-start',
|
||||
});
|
||||
const knob = el('span', {
|
||||
position: 'absolute', top: '2px',
|
||||
left: initial ? '18px' : '2px',
|
||||
width: '16px', height: '16px', borderRadius: '50%',
|
||||
background: 'oklch(98% 0 0)',
|
||||
transition: 'left 0.18s ' + EASE,
|
||||
boxShadow: '0 1px 2px oklch(0% 0 0 / 0.2)',
|
||||
});
|
||||
track.appendChild(knob);
|
||||
track.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const next = !paramsCurrentValues[p.id];
|
||||
paramsCurrentValues[p.id] = next;
|
||||
track.style.background = next ? C.brand : P.hairline;
|
||||
knob.style.left = next ? '18px' : '2px';
|
||||
readout.textContent = next ? 'On' : 'Off';
|
||||
applyParamValue(variantEl, p, next);
|
||||
});
|
||||
row.appendChild(track);
|
||||
} else if (p.kind === 'steps') {
|
||||
const opts = (p.options || []).map(o =>
|
||||
typeof o === 'string' ? { value: o, label: o } : o
|
||||
);
|
||||
const activeOpt = opts.find(o => o.value === p.default) || opts[0];
|
||||
readout.textContent = activeOpt ? activeOpt.label : String(p.default);
|
||||
const segRow = el('div', {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(' + opts.length + ', 1fr)',
|
||||
gap: '1px', padding: '2px',
|
||||
background: P.hairline, borderRadius: '5px',
|
||||
});
|
||||
const segBtns = [];
|
||||
opts.forEach(o => {
|
||||
const active = o.value === p.default;
|
||||
const b = el('button', {
|
||||
padding: '5px 4px', border: 'none', borderRadius: '3px',
|
||||
background: active ? C.brand : 'transparent',
|
||||
color: active ? 'oklch(98% 0 0)' : P.text,
|
||||
fontFamily: FONT, fontSize: '10.5px', fontWeight: '500',
|
||||
cursor: 'pointer', whiteSpace: 'nowrap',
|
||||
transition: 'background 0.1s ease, color 0.1s ease',
|
||||
});
|
||||
b.textContent = o.label;
|
||||
b.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
paramsCurrentValues[p.id] = o.value;
|
||||
readout.textContent = o.label;
|
||||
segBtns.forEach(({ btn, val }) => {
|
||||
const on = val === o.value;
|
||||
btn.style.background = on ? C.brand : 'transparent';
|
||||
btn.style.color = on ? 'oklch(98% 0 0)' : P.text;
|
||||
});
|
||||
applyParamValue(variantEl, p, o.value);
|
||||
});
|
||||
segRow.appendChild(b);
|
||||
segBtns.push({ btn: b, val: o.value });
|
||||
});
|
||||
row.appendChild(segRow);
|
||||
}
|
||||
|
||||
paramsPanelBody.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
// Decide which way the popover opens: away from the picked element. If the
|
||||
// bar landed below the element, popover slides DOWN from the bar's bottom.
|
||||
// If the bar landed above, popover slides UP from the bar's top.
|
||||
function popoverDirection() {
|
||||
if (!barEl || !selectedElement) return 'below';
|
||||
const br = barEl.getBoundingClientRect();
|
||||
const er = selectedElement.getBoundingClientRect();
|
||||
return br.top >= er.bottom - 4 ? 'below' : 'above';
|
||||
}
|
||||
|
||||
// The popover overlaps the bar by OVERLAP px on the bar-facing side. With
|
||||
// popover z-index below bar, that overlap sits behind bar (invisible) and
|
||||
// reinforces the "tucked behind" feel. Padding compensates so the real
|
||||
// content starts flush with bar's outer edge.
|
||||
const TUNE_OVERLAP = 6;
|
||||
|
||||
// Closed clip-path depends on direction: for 'below' clip from the far
|
||||
// (bottom) edge so the reveal grows downward from the bar; for 'above'
|
||||
// clip from the top edge so the reveal grows upward from the bar.
|
||||
function closedClipPath(direction) {
|
||||
return direction === 'below' ? 'inset(0 0 100% 0)' : 'inset(100% 0 0 0)';
|
||||
}
|
||||
|
||||
function setClipPath(value, withTransition) {
|
||||
const saved = paramsPanelEl.style.transition;
|
||||
if (!withTransition) paramsPanelEl.style.transition = 'none';
|
||||
paramsPanelEl.style.clipPath = value;
|
||||
if (!withTransition) {
|
||||
void paramsPanelEl.offsetHeight;
|
||||
paramsPanelEl.style.transition = saved;
|
||||
}
|
||||
}
|
||||
|
||||
function positionParamsPanel() {
|
||||
if (!paramsPanelEl || !barEl || barEl.style.display === 'none') return;
|
||||
const br = barEl.getBoundingClientRect();
|
||||
const direction = popoverDirection();
|
||||
const prevDirection = paramsPanelEl.dataset.tuneDirection;
|
||||
|
||||
// top/left/width are NOT in the transition list, so they snap instantly.
|
||||
paramsPanelEl.style.left = br.left + 'px';
|
||||
paramsPanelEl.style.width = br.width + 'px';
|
||||
|
||||
if (direction === 'below') {
|
||||
paramsPanelEl.style.top = (br.bottom - TUNE_OVERLAP) + 'px';
|
||||
paramsPanelEl.style.borderRadius = '0 0 10px 10px';
|
||||
paramsPanelEl.style.paddingTop = (14 + TUNE_OVERLAP) + 'px';
|
||||
paramsPanelEl.style.paddingBottom = '14px';
|
||||
} else {
|
||||
const ih = paramsPanelEl.offsetHeight || 80;
|
||||
paramsPanelEl.style.top = (br.top - ih + TUNE_OVERLAP) + 'px';
|
||||
paramsPanelEl.style.borderRadius = '10px 10px 0 0';
|
||||
paramsPanelEl.style.paddingTop = '14px';
|
||||
paramsPanelEl.style.paddingBottom = (14 + TUNE_OVERLAP) + 'px';
|
||||
}
|
||||
paramsPanelEl.dataset.tuneDirection = direction;
|
||||
|
||||
// If currently closed and direction flipped (or first-time setup),
|
||||
// snap the clip-path to the new direction's closed pose without
|
||||
// transitioning (so the clip doesn't slide across the element).
|
||||
if (!tuneOpen && (!prevDirection || prevDirection !== direction)) {
|
||||
setClipPath(closedClipPath(direction), false);
|
||||
}
|
||||
}
|
||||
|
||||
function showParamsPanel() {
|
||||
if (!paramsPanelEl) return;
|
||||
positionParamsPanel();
|
||||
paramsPanelEl.style.pointerEvents = 'auto';
|
||||
// rAF so the positioning paint commits before the transition fires.
|
||||
requestAnimationFrame(() => {
|
||||
setClipPath('inset(0 0 0 0)', true);
|
||||
});
|
||||
}
|
||||
|
||||
function hideParamsPanel() {
|
||||
if (!paramsPanelEl) return;
|
||||
paramsPanelEl.style.pointerEvents = 'none';
|
||||
const direction = paramsPanelEl.dataset.tuneDirection || 'below';
|
||||
setClipPath(closedClipPath(direction), true);
|
||||
}
|
||||
|
||||
// Build/rebuild the panel's contents for the current variant AND apply
|
||||
// its defaults to the variant wrapper (so scoped CSS responds even before
|
||||
// the user opens the popover). Visibility is governed by tuneOpen.
|
||||
function refreshParamsPanel() {
|
||||
if (state !== 'CYCLING') {
|
||||
paramsCurrentValues = {};
|
||||
tuneOpen = false;
|
||||
hideParamsPanel();
|
||||
return;
|
||||
}
|
||||
const variantEl = getVisibleVariantEl();
|
||||
const params = parseVariantParams(variantEl);
|
||||
if (!variantEl || params.length === 0) {
|
||||
paramsCurrentValues = {};
|
||||
tuneOpen = false;
|
||||
hideParamsPanel();
|
||||
return;
|
||||
}
|
||||
applyParamDefaults(variantEl, params);
|
||||
buildParamsPanel(variantEl, params);
|
||||
if (tuneOpen) {
|
||||
// If already visible (variant cycled while open), refresh in place
|
||||
// instead of re-running the clip-path animation.
|
||||
const alreadyVisible = paramsPanelEl.style.display === 'block'
|
||||
&& paramsPanelEl.style.opacity === '1';
|
||||
if (alreadyVisible) positionParamsPanel();
|
||||
else showParamsPanel();
|
||||
} else {
|
||||
hideParamsPanel();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTunePopover() {
|
||||
if (tuneOpen) { closeTunePopover(); return; }
|
||||
openTunePopover();
|
||||
}
|
||||
|
||||
function openTunePopover() {
|
||||
if (state !== 'CYCLING') return;
|
||||
const variantEl = getVisibleVariantEl();
|
||||
const params = parseVariantParams(variantEl);
|
||||
if (!variantEl || params.length === 0) return;
|
||||
// Build fresh to ensure the current variant's controls are shown.
|
||||
applyParamDefaults(variantEl, params);
|
||||
buildParamsPanel(variantEl, params);
|
||||
tuneOpen = true;
|
||||
showParamsPanel();
|
||||
// Kill the bar's shadow on the popover-facing side so the dark popover
|
||||
// doesn't pick up a bright glow line.
|
||||
if (barEl) {
|
||||
const direction = paramsPanelEl?.dataset.tuneDirection || 'below';
|
||||
barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN;
|
||||
}
|
||||
// Re-render the bar so the Tune chip picks up the active styling.
|
||||
updateBarContent('cycling');
|
||||
}
|
||||
|
||||
function closeTunePopover() {
|
||||
tuneOpen = false;
|
||||
hideParamsPanel();
|
||||
if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT;
|
||||
if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') {
|
||||
updateBarContent('cycling');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variant cycling in DOM
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1253,6 +1762,10 @@
|
||||
if (!v) continue;
|
||||
child.style.display = (v === String(num)) ? '' : 'none';
|
||||
}
|
||||
// Unconditional refresh — covers first-reveal (no-op if state isn't
|
||||
// CYCLING yet, the subsequent CYCLING transition triggers its own
|
||||
// refresh) and every cycle step.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1316,6 +1829,7 @@
|
||||
state = 'CYCLING';
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
refreshParamsPanel();
|
||||
saveSession();
|
||||
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
|
||||
})
|
||||
@@ -1329,7 +1843,7 @@
|
||||
const next = visibleVariant + dir;
|
||||
if (next < 1 || next > arrivedVariants) return;
|
||||
visibleVariant = next;
|
||||
showVariantInDOM(currentSessionId, next);
|
||||
showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself
|
||||
updateSelectedElement();
|
||||
updateBarContent('cycling');
|
||||
saveSession();
|
||||
@@ -1540,6 +2054,7 @@
|
||||
state = 'CYCLING';
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
refreshParamsPanel();
|
||||
} else if (state === 'GENERATING') {
|
||||
updateBarContent('generating');
|
||||
}
|
||||
@@ -1560,6 +2075,7 @@
|
||||
if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') {
|
||||
positionBar();
|
||||
showHighlight(selectedElement);
|
||||
if (tuneOpen) positionParamsPanel();
|
||||
}
|
||||
if (annotActive) positionAnnotOverlay(selectedElement);
|
||||
// Shader overlay (via debug P toggle or generation) is repositioned
|
||||
@@ -1606,6 +2122,7 @@
|
||||
if (state === 'GENERATING') {
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
refreshParamsPanel();
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -1691,6 +2208,10 @@
|
||||
if (pickerEl?.style.display !== 'none' && !own(e.target)) {
|
||||
hideActionPicker();
|
||||
}
|
||||
// Close Tune popover on outside click (anything outside panel + bar)
|
||||
if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) {
|
||||
closeTunePopover();
|
||||
}
|
||||
// In CONFIGURING: click outside the bar and selected element returns to PICKING
|
||||
if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) {
|
||||
hideBar();
|
||||
@@ -2258,7 +2779,11 @@ void main() {
|
||||
|
||||
function handleAccept() {
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
sendEvent({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) });
|
||||
const acceptPayload = { type: 'accept', id: currentSessionId, variantId: String(visibleVariant) };
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
sendEvent(acceptPayload);
|
||||
markSessionHandled();
|
||||
|
||||
// The accepted variant is already the only visible child of the wrapper
|
||||
@@ -2477,6 +3002,11 @@ void main() {
|
||||
state = arrivedVariants >= expectedVariants ? 'CYCLING' : 'GENERATING';
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -2522,6 +3052,11 @@ void main() {
|
||||
// dark pages. This keeps the bar from fighting with the host design.
|
||||
function detectPageTheme() {
|
||||
try {
|
||||
// Dev override: set localStorage 'impeccable-dev-theme' to 'light' or
|
||||
// 'dark' to preview the opposite palette without actually changing the
|
||||
// page bg. Used for screenshots and theme QA.
|
||||
const override = localStorage.getItem('impeccable-dev-theme');
|
||||
if (override === 'light' || override === 'dark') return override;
|
||||
const bg = getComputedStyle(document.body).backgroundColor
|
||||
|| getComputedStyle(document.documentElement).backgroundColor;
|
||||
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
||||
@@ -2538,6 +3073,7 @@ void main() {
|
||||
// Light bar on dark page
|
||||
return {
|
||||
surface: 'oklch(98% 0 0 / 0.92)',
|
||||
surfaceDeep: 'oklch(92% 0.005 60 / 0.96)', // slightly deeper, faint warm
|
||||
hairline: 'oklch(70% 0 0 / 0.35)',
|
||||
text: 'oklch(15% 0 0)',
|
||||
textDim: 'oklch(45% 0 0)',
|
||||
@@ -2552,6 +3088,7 @@ void main() {
|
||||
// deeper so the rounded-right shape reads as a clear sculpted mark.
|
||||
return {
|
||||
surface: 'oklch(26% 0 0 / 0.94)',
|
||||
surfaceDeep: 'oklch(18% 0 0 / 0.96)', // darker sand for Tune popover
|
||||
hairline: 'oklch(42% 0 0 / 0.5)',
|
||||
text: 'oklch(96% 0 0)',
|
||||
textDim: 'oklch(72% 0 0)',
|
||||
@@ -2883,6 +3420,7 @@ void main() {
|
||||
if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; }
|
||||
if (barEl) { barEl.remove(); barEl = null; }
|
||||
if (pickerEl) { pickerEl.remove(); pickerEl = null; }
|
||||
if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; }
|
||||
if (evtSource) { evtSource.close(); evtSource = null; }
|
||||
document.removeEventListener('mousemove', handleMouseMove, true);
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
@@ -3956,6 +4494,7 @@ void main() {
|
||||
initAnnotOverlay();
|
||||
initBar();
|
||||
initActionPicker();
|
||||
initParamsPanel();
|
||||
initGlobalBar();
|
||||
initDesignPanel();
|
||||
document.addEventListener('mousemove', handleMouseMove, true);
|
||||
|
||||
@@ -145,6 +145,11 @@ Options:
|
||||
const scriptArgs = event.type === 'discard'
|
||||
? ['--id', event.id, '--discard']
|
||||
: ['--id', event.id, '--variant', event.variantId];
|
||||
if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
|
||||
// Pass through a JSON blob; the shell-safe wrap uses single quotes because
|
||||
// values are finite {id, number|string|boolean} pairs from a validated payload.
|
||||
scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
|
||||
}
|
||||
try {
|
||||
const out = execSync(
|
||||
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
|
||||
|
||||
@@ -147,6 +147,11 @@ function validateEvent(msg) {
|
||||
case 'accept':
|
||||
if (!msg.id) return 'accept: missing id';
|
||||
if (!msg.variantId) return 'accept: missing variantId';
|
||||
if (msg.paramValues !== undefined) {
|
||||
if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
|
||||
return 'accept: paramValues must be an object';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
case 'discard':
|
||||
return msg.id ? null : 'discard: missing id';
|
||||
|
||||
@@ -142,3 +142,13 @@ Test that colorization improves the experience:
|
||||
- **Not overwhelming**: Is color balanced and purposeful?
|
||||
|
||||
Remember: Color is emotional and powerful. Use it to create warmth, guide attention, communicate meaning, and express personality. But restraint and strategy matter more than saturation and variety. Be colorful, but be intentional.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
When invoked from live mode, each variant MUST declare a `color-amount` param so the user can dial between a restrained accent and a drenched surface without regeneration. Author the variant's CSS against `var(--p-color-amount, 0.5)` — typically as the alpha multiplier on backgrounds, or as a scaling factor on the chroma axis in an OKLCH expression. 0 = neutral/monochrome, 1 = full saturation / dominant coverage.
|
||||
|
||||
```json
|
||||
{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"}
|
||||
```
|
||||
|
||||
Layer 1-2 variant-specific params on top: palette selection (`steps` with named options), temperature warmth, or tint vs. true color. See `reference/live.md` for the full params contract.
|
||||
|
||||
@@ -119,3 +119,23 @@ Create a systematic plan:
|
||||
- **Responsiveness**: Does the layout adapt gracefully across screen sizes?
|
||||
|
||||
Remember: Space is the most underused design tool. A layout with the right rhythm and hierarchy can make even simple content feel polished and intentional.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
Each variant MUST declare a `density` param. Drive all spacing tokens in the variant's scoped CSS through `calc(var(--p-density, 1) * <base>)` — paddings, gaps, column widths. Users slide from airy to packed and see layout re-breathe with no regeneration.
|
||||
|
||||
```json
|
||||
{"id":"density","kind":"range","min":0.6,"max":1.4,"step":0.05,"default":1,"label":"Density"}
|
||||
```
|
||||
|
||||
For variants whose topology genuinely changes (stacked vs. side-by-side, grid vs. bento), use a `steps` param whose scoped CSS branches via `:scope[data-p-structure="X"]`. One structure param + one density param is a powerful combo; resist adding a third.
|
||||
|
||||
```json
|
||||
{"id":"structure","kind":"steps","default":"grid","label":"Structure","options":[
|
||||
{"value":"stacked","label":"Stacked"},
|
||||
{"value":"grid","label":"Grid"},
|
||||
{"value":"bento","label":"Bento"}
|
||||
]}
|
||||
```
|
||||
|
||||
See `reference/live.md` for the full params contract.
|
||||
|
||||
@@ -179,7 +179,56 @@ The first variant has no `display: none` (visible by default). All others do. If
|
||||
|
||||
One edit, all variants — the browser's MutationObserver picks everything up in one pass.
|
||||
|
||||
### 7. Signal done
|
||||
### 7. Parameters (optional, 2-5 per variant)
|
||||
|
||||
Each variant can expose coarse knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against.
|
||||
|
||||
**When to use.** Any time the variant has a meaningful axis the user might want to dial in: color amount, density, motion intensity, scale ratio. Not micro-level margin tweaks; those defeat the point.
|
||||
|
||||
**Budget scales with the element's visual weight, not the user's curiosity.** Knobs need real estate to produce noticeably different output; slapping three on a small element just crowds the panel without making anything feel tunable.
|
||||
|
||||
- **Leaf / tiny** — a single button, icon, input, bare heading, solitary paragraph: **0 params.** A slider can't meaningfully reshape one element; just use variants.
|
||||
- **Small composition** — labeled input, simple card, short callout (≤ ~5 visual children): **0-1 params.** Only add one if it's a clear dominant axis (e.g. density on a card with visible internal rhythm).
|
||||
- **Medium composition** — section component, nav cluster, dense card, short feature block (6-15 visual children): **2 params.**
|
||||
- **Large composition** — hero section, full page region, spread layout, anything with strong internal structure (16+ visual children or multiple sub-sections): **3-4 params.**
|
||||
|
||||
When in doubt, fewer. The user can always ask for more variants to explore an axis you didn't expose as a knob. Count by visual children, not by DOM-node depth; a deeply-nested-but-visually-simple card still counts as small.
|
||||
|
||||
**How to declare.** Put a JSON manifest on the variant wrapper:
|
||||
|
||||
```html
|
||||
<div data-impeccable-variant="1" data-impeccable-params='[
|
||||
{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"},
|
||||
{"id":"density","kind":"steps","default":"snug","label":"Density","options":[
|
||||
{"value":"airy","label":"Airy"},
|
||||
{"value":"snug","label":"Snug"},
|
||||
{"value":"packed","label":"Packed"}
|
||||
]},
|
||||
{"id":"serif","kind":"toggle","default":false,"label":"Serif display"}
|
||||
]'>
|
||||
...variant content...
|
||||
</div>
|
||||
```
|
||||
|
||||
**Three kinds:**
|
||||
|
||||
- `range` — smooth slider. Drives a CSS custom property `--p-<id>` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`.
|
||||
- `steps` — segmented radio. Drives a data attribute `data-p-<id>` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`.
|
||||
- `toggle` — on/off switch. Drives BOTH a CSS var (`--p-<id>: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`.
|
||||
|
||||
**Signature params per action.** Each action has one or two signature params that MUST be exposed when the variant can meaningfully express them. Check the action's reference file for the list. Layer 1-2 variant-specific params on top.
|
||||
|
||||
**Reset on variant switch.** User dials density on v1, flips to v2, v2 starts at v2's declared defaults. Known limitation; preservation across variants may land later.
|
||||
|
||||
**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment:
|
||||
|
||||
```html
|
||||
<!-- impeccable-param-values SESSION_ID: {"color-amount":0.7,"density":"packed"} -->
|
||||
```
|
||||
|
||||
The carbonize cleanup step (see below) reads that comment and bakes the chosen values into the final CSS. For `steps`/`toggle` attribute selectors: keep only the branch matching the chosen value, drop the others, collapse `:scope[data-p-density="packed"] .grid` to a semantic class rule. For `range` vars: either substitute the literal or keep the var with the chosen value as its new default.
|
||||
|
||||
### 8. Signal done
|
||||
|
||||
```bash
|
||||
node .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 `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element.
|
||||
1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values — read it first; it drives steps 3 and 4 below.
|
||||
2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `public/css/workflow.css` for this repo, or the component's co-located CSS file for a Vite/Next project — pick whichever already owns styling for the surrounding element).
|
||||
3. **Rewrite `@scope ([data-impeccable-variant="N"])` selectors** to target real, semantic classes on the accepted HTML. Example: `@scope ([data-impeccable-variant="2"]) { .v2-label { … } }` becomes `.why-visual--v2 .v2-label { … }` if the accepted element already carries `.why-visual--v2`, or pick/add a suitable class if it doesn't.
|
||||
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it.
|
||||
5. **Delete the inline `<style>` block and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one — those are dead code now.
|
||||
3. **Bake in parameter values while rewriting selectors.** For `@scope ([data-impeccable-variant="N"])` wrappers: retarget to real, semantic classes on the accepted HTML (`.why-visual--v2 .v2-label { … }`). For `:scope[data-p-<id>="VALUE"]` selectors: keep only the branch matching the chosen value from the param-values comment; drop the others (they're dead after accept). For `var(--p-<id>, DEFAULT)` in the CSS: either substitute the literal value, or if the param is still useful as a knob going forward, leave the var and update its initial declaration to the chosen value.
|
||||
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it. Drop `data-impeccable-params` and any `data-p-*` attributes from it — those are live-mode plumbing, not source.
|
||||
5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one — those are dead code now.
|
||||
|
||||
Then poll again.
|
||||
|
||||
|
||||
@@ -110,3 +110,15 @@ Build a clear type scale:
|
||||
- **Accessibility**: Does text meet WCAG contrast ratios? Is it zoomable to 200%?
|
||||
|
||||
Remember: Typography is the foundation of interface design — it carries the majority of information. Getting it right is the highest-leverage improvement you can make.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
Each variant MUST declare a `scale` param controlling the hierarchy ratio. Express all font sizes in the variant's scoped CSS through `calc(var(--p-scale, 1) * <base>)` or, better, scale the type ramp via `clamp(min, calc(var(--p-scale, 1) * Npx), max)`. Users slide from subdued to commanding.
|
||||
|
||||
```json
|
||||
{"id":"scale","kind":"range","min":0.85,"max":1.3,"step":0.05,"default":1,"label":"Scale"}
|
||||
```
|
||||
|
||||
Where the variant riffs on a specific pairing, expose the pairing choice as a `steps` param (e.g. "serif display + sans body" vs. "mono display + sans body" vs. "all-sans"). Each branch routes through `:scope[data-p-pairing="X"]` selectors in scoped CSS.
|
||||
|
||||
See `reference/live.md` for the full params contract.
|
||||
|
||||
@@ -45,11 +45,18 @@ Output (JSON):
|
||||
|
||||
const id = argVal(args, '--id');
|
||||
const variantNum = argVal(args, '--variant');
|
||||
const paramValuesRaw = argVal(args, '--param-values');
|
||||
const isDiscard = args.includes('--discard');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); }
|
||||
|
||||
let paramValues = null;
|
||||
if (paramValuesRaw) {
|
||||
try { paramValues = JSON.parse(paramValuesRaw); }
|
||||
catch { paramValues = null; } // malformed blob: skip the comment rather than failing the accept
|
||||
}
|
||||
|
||||
// Find the file containing this session's markers
|
||||
const found = findSessionFile(id, process.cwd());
|
||||
if (!found) {
|
||||
@@ -78,7 +85,7 @@ Output (JSON):
|
||||
const result = handleDiscard(id, lines, targetFile);
|
||||
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
|
||||
} else {
|
||||
const result = handleAccept(id, variantNum, lines, targetFile);
|
||||
const result = handleAccept(id, variantNum, lines, targetFile, paramValues);
|
||||
// Single-line attention-grabber when cleanup is required. The full
|
||||
// five-step checklist lives in reference/live.md (loaded once per
|
||||
// session); repeating it per-event would waste tokens.
|
||||
@@ -116,7 +123,7 @@ function handleDiscard(id, lines, targetFile) {
|
||||
// Accept
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function handleAccept(id, variantNum, lines, targetFile) {
|
||||
function handleAccept(id, variantNum, lines, targetFile, paramValues) {
|
||||
const block = findMarkerBlock(id, lines);
|
||||
if (!block) return { handled: false, error: 'Markers not found' };
|
||||
|
||||
@@ -149,6 +156,11 @@ function handleAccept(id, variantNum, lines, targetFile) {
|
||||
replacement.push(indent + cssLine.trimStart());
|
||||
}
|
||||
replacement.push(indent + '</style>');
|
||||
if (paramValues && Object.keys(paramValues).length > 0) {
|
||||
// Preserve the user's knob positions for the carbonize-cleanup agent
|
||||
// to bake into the final CSS when it collapses scoped rules.
|
||||
replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
|
||||
}
|
||||
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,10 +30,13 @@
|
||||
// Design tokens
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Brand magenta is pinned to the site token (--color-accent in main.css)
|
||||
// so Accept / knobs / cycle-dots match the site's accent, not a washed
|
||||
// theme-adjusted one.
|
||||
const C = {
|
||||
brand: 'oklch(55% 0.25 350)',
|
||||
brandHov: 'oklch(48% 0.25 350)',
|
||||
brandSoft: 'oklch(55% 0.25 350 / 0.12)',
|
||||
brand: 'oklch(60% 0.25 350)',
|
||||
brandHov: 'oklch(52% 0.25 350)',
|
||||
brandSoft: 'oklch(60% 0.25 350 / 0.15)',
|
||||
ink: 'oklch(15% 0.01 350)',
|
||||
ash: 'oklch(55% 0 0)',
|
||||
paper: 'oklch(98% 0.005 350 / 0.92)',
|
||||
@@ -60,6 +63,25 @@
|
||||
'html', 'head', 'body', 'script', 'style', 'link', 'meta', 'noscript', 'br', 'wbr',
|
||||
]);
|
||||
|
||||
// SVG icons stack above each chip label. All strokes use currentColor so the
|
||||
// icon recolors to C.brand when its chip is selected. 20x20 render, 24-viewBox,
|
||||
// 1.5 stroke — visually consistent with the Foundation grid on the homepage.
|
||||
const ICON_ATTRS = 'width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="display:block"';
|
||||
const ICONS = {
|
||||
impeccable: `<svg ${ICON_ATTRS}><path d="M4 20l4-1L18 9l-3-3L5 16z"/><path d="M14 7l3 3"/></svg>`,
|
||||
bolder: `<svg ${ICON_ATTRS}><rect x="6" y="12" width="4" height="7" rx="0.5"/><rect x="14" y="5" width="4" height="14" rx="0.5"/></svg>`,
|
||||
quieter: `<svg ${ICON_ATTRS}><rect x="6" y="5" width="4" height="14" rx="0.5"/><rect x="14" y="12" width="4" height="7" rx="0.5"/></svg>`,
|
||||
distill: `<svg ${ICON_ATTRS}><path d="M4 5h16l-6 8v7l-4-2v-5z"/></svg>`,
|
||||
polish: `<svg ${ICON_ATTRS}><path d="M15 3l1 3 3 1-3 1-1 3-1-3-3-1 3-1z"/><path d="M7 13l0.6 1.8 1.8 0.6-1.8 0.6-0.6 1.8-0.6-1.8-1.8-0.6 1.8-0.6z"/></svg>`,
|
||||
typeset: `<svg ${ICON_ATTRS}><path d="M5 6h14" stroke-width="2.6"/><path d="M5 12h9" stroke-width="1.9"/><path d="M5 18h5" stroke-width="1.3"/></svg>`,
|
||||
colorize: `<svg ${ICON_ATTRS}><circle cx="9" cy="10" r="5"/><circle cx="15" cy="10" r="5"/><circle cx="12" cy="15" r="5"/></svg>`,
|
||||
layout: `<svg ${ICON_ATTRS}><rect x="3" y="4" width="8" height="16" rx="0.5"/><rect x="13" y="4" width="8" height="7" rx="0.5"/><rect x="13" y="13" width="8" height="7" rx="0.5"/></svg>`,
|
||||
adapt: `<svg ${ICON_ATTRS}><rect x="2.5" y="5" width="12" height="11" rx="1"/><line x1="2.5" y1="19" x2="14.5" y2="19"/><rect x="16.5" y="8" width="5" height="11" rx="1"/></svg>`,
|
||||
animate: `<svg ${ICON_ATTRS}><path d="M3 18c4-4 6-10 10-10"/><path d="M13 8c3 0 5 5 8 10"/><circle cx="13" cy="8" r="1.6" fill="currentColor" stroke="none"/></svg>`,
|
||||
delight: `<svg ${ICON_ATTRS}><path d="M12 3l2 6 6 2-6 2-2 6-2-6-6-2 6-2z"/></svg>`,
|
||||
overdrive: `<svg ${ICON_ATTRS}><path d="M13 3L5 13h5l-1 8 9-12h-6z"/></svg>`,
|
||||
};
|
||||
|
||||
const ACTIONS = [
|
||||
{ value: 'impeccable', label: 'Freeform' },
|
||||
{ value: 'bolder', label: 'Bolder' },
|
||||
@@ -756,7 +778,21 @@
|
||||
// The Bar — one floating element, three modes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Contextual-bar palette. Cached at init so every build*Row reads a
|
||||
// consistent set of colors; detectPageTheme runs once rather than on every
|
||||
// phase transition.
|
||||
let BP = null;
|
||||
|
||||
// Bar shadow variants. The default projects down + subtle around. When
|
||||
// the Tune popover opens below the bar, a downward shadow lands on the
|
||||
// dark popover and reads as a bright ghost line. We swap to UP-only while
|
||||
// tune is open below so the popover's top edge is clean.
|
||||
const BAR_SHADOW_DEFAULT = '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)';
|
||||
const BAR_SHADOW_UP = '0 -4px 20px oklch(0% 0 0 / 0.08), 0 -1px 3px oklch(0% 0 0 / 0.06)';
|
||||
const BAR_SHADOW_DOWN = BAR_SHADOW_DEFAULT;
|
||||
|
||||
function initBar() {
|
||||
BP = barPaletteForTheme(detectPageTheme());
|
||||
barEl = document.createElement('div');
|
||||
barEl.id = PREFIX + '-bar';
|
||||
Object.assign(barEl.style, {
|
||||
@@ -764,15 +800,15 @@
|
||||
display: 'none', opacity: '0',
|
||||
transform: 'translateY(6px)',
|
||||
transition: 'opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
|
||||
// Visual
|
||||
background: C.paper,
|
||||
background: BP.surface,
|
||||
backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
|
||||
border: '1px solid ' + C.mist,
|
||||
border: '1px solid ' + BP.hairline,
|
||||
borderRadius: '10px',
|
||||
boxShadow: '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)',
|
||||
fontFamily: FONT, fontSize: '13px', color: C.ink,
|
||||
boxShadow: BAR_SHADOW_DEFAULT,
|
||||
transition: 'box-shadow 0.2s ease, opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
|
||||
fontFamily: FONT, fontSize: '13px', color: BP.text,
|
||||
padding: '6px',
|
||||
maxWidth: '460px', minWidth: '320px',
|
||||
maxWidth: '520px', minWidth: '320px',
|
||||
});
|
||||
document.body.appendChild(barEl);
|
||||
}
|
||||
@@ -824,14 +860,15 @@
|
||||
barEl.style.transform = 'translateY(6px)';
|
||||
setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250);
|
||||
hideActionPicker();
|
||||
closeTunePopover();
|
||||
}
|
||||
|
||||
function updateBarContent(mode) {
|
||||
if (!barEl || barEl.style.display === 'none') return;
|
||||
barEl.innerHTML = '';
|
||||
// Reset bar styling
|
||||
barEl.style.background = C.paper;
|
||||
barEl.style.border = '1px solid ' + C.mist;
|
||||
// Reset bar styling to the theme-aware palette
|
||||
barEl.style.background = BP.surface;
|
||||
barEl.style.border = '1px solid ' + BP.hairline;
|
||||
if (mode === 'configure') barEl.appendChild(buildConfigureRow());
|
||||
else if (mode === 'generating') barEl.appendChild(buildGeneratingRow());
|
||||
else if (mode === 'cycling') barEl.appendChild(buildCyclingRow());
|
||||
@@ -854,15 +891,15 @@
|
||||
const pill = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
||||
padding: '5px 10px', borderRadius: '6px',
|
||||
background: C.ink, color: C.white,
|
||||
background: BP.mark, color: BP.markText,
|
||||
fontFamily: FONT, fontSize: '12px', fontWeight: '500',
|
||||
border: 'none', cursor: 'pointer',
|
||||
transition: 'background 0.12s ease, transform 0.1s ease',
|
||||
whiteSpace: 'nowrap', flexShrink: '0',
|
||||
});
|
||||
pill.textContent = actionLabel() + ' \u25BE';
|
||||
pill.addEventListener('mouseenter', () => pill.style.background = C.brandHov);
|
||||
pill.addEventListener('mouseleave', () => pill.style.background = C.ink);
|
||||
pill.addEventListener('mouseenter', () => pill.style.background = BP.accent);
|
||||
pill.addEventListener('mouseleave', () => pill.style.background = BP.mark);
|
||||
pill.addEventListener('mousedown', () => pill.style.transform = 'scale(0.97)');
|
||||
pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)');
|
||||
pill.addEventListener('click', (e) => { e.stopPropagation(); toggleActionPicker(); });
|
||||
@@ -877,13 +914,13 @@
|
||||
flex: '1', minWidth: '0',
|
||||
padding: '5px 8px', borderRadius: '6px',
|
||||
border: '1px solid transparent', background: 'transparent',
|
||||
fontFamily: FONT, fontSize: '12px', color: C.ink,
|
||||
fontFamily: FONT, fontSize: '12px', color: BP.text,
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.15s ease, background 0.15s ease',
|
||||
});
|
||||
input.addEventListener('focus', () => {
|
||||
input.style.borderColor = C.mist;
|
||||
input.style.background = C.white;
|
||||
input.style.borderColor = BP.hairline;
|
||||
input.style.background = BP.accentSoft;
|
||||
});
|
||||
input.addEventListener('blur', () => {
|
||||
input.style.borderColor = 'transparent';
|
||||
@@ -901,16 +938,16 @@
|
||||
// Variant count toggle
|
||||
const count = el('button', {
|
||||
padding: '4px 6px', borderRadius: '5px',
|
||||
border: '1px solid ' + C.mist, background: 'transparent',
|
||||
border: '1px solid ' + BP.hairline, background: 'transparent',
|
||||
fontFamily: MONO, fontSize: '11px', fontWeight: '600',
|
||||
color: C.ash, cursor: 'pointer',
|
||||
color: BP.textDim, cursor: 'pointer',
|
||||
transition: 'color 0.12s ease, border-color 0.12s ease',
|
||||
flexShrink: '0', whiteSpace: 'nowrap',
|
||||
});
|
||||
count.textContent = '\u00D7' + selectedCount;
|
||||
count.title = 'Variants: click to change';
|
||||
count.addEventListener('mouseenter', () => { count.style.color = C.ink; count.style.borderColor = C.ink; });
|
||||
count.addEventListener('mouseleave', () => { count.style.color = C.ash; count.style.borderColor = C.mist; });
|
||||
count.addEventListener('mouseenter', () => { count.style.color = BP.text; count.style.borderColor = BP.text; });
|
||||
count.addEventListener('mouseleave', () => { count.style.color = BP.textDim; count.style.borderColor = BP.hairline; });
|
||||
count.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1;
|
||||
@@ -921,15 +958,15 @@
|
||||
// Go button
|
||||
const go = el('button', {
|
||||
padding: '5px 12px', borderRadius: '6px',
|
||||
border: 'none', background: C.brand, color: C.white,
|
||||
border: 'none', background: BP.accent, color: BP.mark,
|
||||
fontFamily: FONT, fontSize: '12px', fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.12s ease, transform 0.1s ease',
|
||||
transition: 'filter 0.12s ease, transform 0.1s ease',
|
||||
flexShrink: '0', whiteSpace: 'nowrap',
|
||||
});
|
||||
go.textContent = 'Go \u2192';
|
||||
go.addEventListener('mouseenter', () => go.style.background = C.brandHov);
|
||||
go.addEventListener('mouseleave', () => go.style.background = C.brand);
|
||||
go.addEventListener('mouseenter', () => go.style.filter = 'brightness(1.1)');
|
||||
go.addEventListener('mouseleave', () => go.style.filter = 'none');
|
||||
go.addEventListener('mousedown', () => go.style.transform = 'scale(0.97)');
|
||||
go.addEventListener('mouseup', () => go.style.transform = 'scale(1)');
|
||||
go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); });
|
||||
@@ -950,7 +987,7 @@
|
||||
|
||||
// Action label
|
||||
const label = el('span', {
|
||||
fontWeight: '600', fontSize: '12px', color: C.ink,
|
||||
fontWeight: '600', fontSize: '12px', color: BP.text,
|
||||
flexShrink: '0', whiteSpace: 'nowrap',
|
||||
});
|
||||
label.textContent = actionLabel();
|
||||
@@ -961,7 +998,7 @@
|
||||
|
||||
// Status
|
||||
const status = el('span', {
|
||||
fontSize: '11px', color: C.ash, whiteSpace: 'nowrap',
|
||||
fontSize: '11px', color: BP.textDim, whiteSpace: 'nowrap',
|
||||
marginLeft: 'auto',
|
||||
});
|
||||
// Variants currently arrive atomically in a single file edit, so a
|
||||
@@ -976,6 +1013,8 @@
|
||||
|
||||
// --- Cycling row ---
|
||||
|
||||
const TUNE_ICON_SVG = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" style="flex-shrink:0"><line x1="4" y1="8" x2="20" y2="8"/><circle cx="14" cy="8" r="2.4" fill="currentColor" stroke="none"/><line x1="4" y1="16" x2="20" y2="16"/><circle cx="10" cy="16" r="2.4" fill="currentColor" stroke="none"/></svg>';
|
||||
|
||||
function buildCyclingRow() {
|
||||
const row = el('div', {
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
@@ -994,7 +1033,7 @@
|
||||
// Counter
|
||||
const counter = el('span', {
|
||||
fontFamily: MONO, fontSize: '11px', fontWeight: '500',
|
||||
color: C.ash, minWidth: '24px', textAlign: 'center',
|
||||
color: BP.textDim, minWidth: '24px', textAlign: 'center',
|
||||
});
|
||||
counter.textContent = visibleVariant + '/' + arrivedVariants;
|
||||
row.appendChild(counter);
|
||||
@@ -1005,20 +1044,67 @@
|
||||
if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
|
||||
row.appendChild(next);
|
||||
|
||||
// Tune chip — only when the visible variant exposes params
|
||||
const visParams = parseVariantParams(getVisibleVariantEl());
|
||||
const hasParams = visParams.length > 0;
|
||||
if (hasParams) {
|
||||
const tune = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '6px',
|
||||
padding: '4px 10px', borderRadius: '5px',
|
||||
border: '1px solid transparent',
|
||||
background: tuneOpen ? BP.accentSoft : 'transparent',
|
||||
color: tuneOpen ? BP.accent : BP.text,
|
||||
fontFamily: FONT, fontSize: '11px', fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'color 0.12s ease, background 0.12s ease',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
tune.innerHTML = TUNE_ICON_SVG;
|
||||
const tuneLabel = document.createElement('span');
|
||||
tuneLabel.textContent = 'Tune';
|
||||
tune.appendChild(tuneLabel);
|
||||
const tuneBadge = document.createElement('span');
|
||||
Object.assign(tuneBadge.style, {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
minWidth: '16px', height: '16px', padding: '0 4px',
|
||||
borderRadius: '999px',
|
||||
background: tuneOpen ? C.brand : BP.hairline,
|
||||
color: tuneOpen ? 'oklch(98% 0 0)' : 'inherit',
|
||||
fontFamily: MONO, fontSize: '9.5px', fontWeight: '600',
|
||||
lineHeight: '1',
|
||||
boxSizing: 'border-box',
|
||||
});
|
||||
tuneBadge.textContent = String(visParams.length);
|
||||
tune.appendChild(tuneBadge);
|
||||
tune.title = 'Tune this variant (' + visParams.length + ' knob' + (visParams.length === 1 ? '' : 's') + ')';
|
||||
tune.addEventListener('mouseenter', () => {
|
||||
if (!tuneOpen) tune.style.background = BP.accentSoft;
|
||||
});
|
||||
tune.addEventListener('mouseleave', () => {
|
||||
if (!tuneOpen) tune.style.background = 'transparent';
|
||||
});
|
||||
tune.addEventListener('click', (e) => { e.stopPropagation(); toggleTunePopover(); });
|
||||
tune.dataset.iceqTune = '1';
|
||||
row.appendChild(tune);
|
||||
}
|
||||
|
||||
// Spacer
|
||||
row.appendChild(el('div', { flex: '1' }));
|
||||
|
||||
// Accept
|
||||
// Accept — primary action, uses the site's saturated brand magenta
|
||||
// with paper-white text, not the theme-muted BP.accent.
|
||||
const accept = el('button', {
|
||||
padding: '4px 10px', borderRadius: '5px',
|
||||
border: 'none', background: C.ink, color: C.white,
|
||||
padding: '5px 14px', borderRadius: '5px',
|
||||
border: 'none', background: C.brand, color: 'oklch(98% 0 0)',
|
||||
fontFamily: FONT, fontSize: '11px', fontWeight: '600',
|
||||
cursor: 'pointer', transition: 'background 0.12s ease',
|
||||
cursor: 'pointer', transition: 'filter 0.12s ease, transform 0.1s ease',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
accept.textContent = '\u2713 Accept';
|
||||
accept.addEventListener('mouseenter', () => accept.style.background = C.brand);
|
||||
accept.addEventListener('mouseleave', () => accept.style.background = C.ink);
|
||||
accept.addEventListener('mouseenter', () => accept.style.filter = 'brightness(1.08)');
|
||||
accept.addEventListener('mouseleave', () => accept.style.filter = 'none');
|
||||
accept.addEventListener('mousedown', () => accept.style.transform = 'scale(0.97)');
|
||||
accept.addEventListener('mouseup', () => accept.style.transform = 'scale(1)');
|
||||
accept.addEventListener('click', (e) => { e.stopPropagation(); handleAccept(); });
|
||||
if (arrivedVariants === 0) { accept.style.opacity = '0.3'; accept.style.pointerEvents = 'none'; }
|
||||
row.appendChild(accept);
|
||||
@@ -1026,14 +1112,14 @@
|
||||
// Discard
|
||||
const discard = el('button', {
|
||||
padding: '4px 6px', borderRadius: '5px',
|
||||
border: '1px solid ' + C.mist, background: 'transparent',
|
||||
fontFamily: FONT, fontSize: '11px', color: C.ash,
|
||||
border: '1px solid ' + BP.hairline, background: 'transparent',
|
||||
fontFamily: FONT, fontSize: '11px', color: BP.textDim,
|
||||
cursor: 'pointer', transition: 'color 0.12s ease, border-color 0.12s ease',
|
||||
});
|
||||
discard.textContent = '\u2715';
|
||||
discard.title = 'Discard all variants';
|
||||
discard.addEventListener('mouseenter', () => { discard.style.color = C.ink; discard.style.borderColor = C.ink; });
|
||||
discard.addEventListener('mouseleave', () => { discard.style.color = C.ash; discard.style.borderColor = C.mist; });
|
||||
discard.addEventListener('mouseenter', () => { discard.style.color = BP.text; discard.style.borderColor = BP.text; });
|
||||
discard.addEventListener('mouseleave', () => { discard.style.color = BP.textDim; discard.style.borderColor = BP.hairline; });
|
||||
discard.addEventListener('click', (e) => { e.stopPropagation(); handleDiscard(); });
|
||||
row.appendChild(discard);
|
||||
|
||||
@@ -1051,14 +1137,14 @@
|
||||
});
|
||||
const spinner = el('div', {
|
||||
width: '14px', height: '14px', borderRadius: '50%',
|
||||
border: '2px solid ' + C.mist,
|
||||
borderTopColor: C.brand,
|
||||
border: '2px solid ' + BP.hairline,
|
||||
borderTopColor: BP.accent,
|
||||
animation: 'impeccable-spin 0.6s linear infinite',
|
||||
flexShrink: '0',
|
||||
});
|
||||
row.appendChild(spinner);
|
||||
const label = el('span', {
|
||||
fontSize: '12px', color: C.ash, fontWeight: '500',
|
||||
fontSize: '12px', color: BP.textDim, fontWeight: '500',
|
||||
});
|
||||
label.textContent = 'Applying variant...';
|
||||
row.appendChild(label);
|
||||
@@ -1103,14 +1189,26 @@
|
||||
for (let i = 1; i <= expectedVariants; i++) {
|
||||
const arrived = i <= arrivedVariants;
|
||||
const active = i === visibleVariant;
|
||||
// active: solid site-brand magenta dot. arrived+inactive: muted neutral.
|
||||
// pending (not yet arrived): faint outline ring. No borders on arrived
|
||||
// dots — the previous "accent ring + ash fill" combo read as noisy
|
||||
// magenta chips, especially when all variants had arrived and every
|
||||
// dot wore an accent ring.
|
||||
const dotBg = active ? C.brand
|
||||
: arrived ? BP.textDim
|
||||
: 'transparent';
|
||||
const dotBorder = arrived ? 'none' : '1.5px solid ' + BP.hairline;
|
||||
const dot = el('div', {
|
||||
width: '7px', height: '7px', borderRadius: '50%',
|
||||
background: active ? C.brand : (arrived ? C.ash : 'transparent'),
|
||||
border: '1.5px solid ' + (arrived ? C.brand : C.mist),
|
||||
width: active ? '8px' : '6px',
|
||||
height: active ? '8px' : '6px',
|
||||
borderRadius: '50%',
|
||||
background: dotBg,
|
||||
border: dotBorder,
|
||||
boxSizing: 'border-box',
|
||||
transition: 'all 0.2s ' + EASE,
|
||||
cursor: (clickable && arrived) ? 'pointer' : 'default',
|
||||
transform: arrived ? 'scale(1)' : 'scale(0.6)',
|
||||
opacity: arrived ? '1' : '0.4',
|
||||
transform: arrived ? 'scale(1)' : 'scale(0.85)',
|
||||
opacity: arrived ? (active ? '1' : '0.6') : '0.4',
|
||||
});
|
||||
if (clickable && arrived) {
|
||||
const idx = i;
|
||||
@@ -1130,15 +1228,15 @@
|
||||
function navBtn(text) {
|
||||
const b = el('button', {
|
||||
width: '26px', height: '26px', borderRadius: '5px',
|
||||
border: '1px solid ' + C.mist, background: 'transparent',
|
||||
color: C.ink, fontFamily: FONT, fontSize: '13px',
|
||||
border: '1px solid ' + BP.hairline, background: 'transparent',
|
||||
color: BP.text, fontFamily: FONT, fontSize: '13px',
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'border-color 0.12s ease, background 0.12s ease',
|
||||
padding: '0', lineHeight: '1',
|
||||
});
|
||||
b.textContent = text;
|
||||
b.addEventListener('mouseenter', () => { b.style.borderColor = C.ink; });
|
||||
b.addEventListener('mouseleave', () => { b.style.borderColor = C.mist; });
|
||||
b.addEventListener('mouseenter', () => { b.style.borderColor = BP.text; });
|
||||
b.addEventListener('mouseleave', () => { b.style.borderColor = BP.hairline; });
|
||||
return b;
|
||||
}
|
||||
|
||||
@@ -1158,6 +1256,7 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initActionPicker() {
|
||||
const P = barPaletteForTheme(detectPageTheme());
|
||||
pickerEl = document.createElement('div');
|
||||
pickerEl.id = PREFIX + '-picker';
|
||||
Object.assign(pickerEl.style, {
|
||||
@@ -1166,12 +1265,14 @@
|
||||
transform: 'scale(0.96) translateY(4px)',
|
||||
transformOrigin: 'bottom left',
|
||||
transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE,
|
||||
background: C.paperSolid,
|
||||
border: '1px solid ' + C.mist,
|
||||
background: P.surface,
|
||||
border: '1px solid ' + P.hairline,
|
||||
borderRadius: '10px',
|
||||
boxShadow: '0 8px 30px oklch(0% 0 0 / 0.10), 0 2px 6px oklch(0% 0 0 / 0.06)',
|
||||
padding: '6px',
|
||||
fontFamily: FONT,
|
||||
backdropFilter: 'blur(10px)',
|
||||
WebkitBackdropFilter: 'blur(10px)',
|
||||
});
|
||||
|
||||
// Build the chip grid
|
||||
@@ -1181,22 +1282,32 @@
|
||||
|
||||
ACTIONS.forEach(action => {
|
||||
const chip = el('button', {
|
||||
padding: '6px 8px', borderRadius: '6px',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '8px 6px', borderRadius: '6px',
|
||||
border: 'none',
|
||||
background: action.value === selectedAction ? C.brandSoft : 'transparent',
|
||||
color: action.value === selectedAction ? C.brand : C.ink,
|
||||
background: action.value === selectedAction ? P.accentSoft : 'transparent',
|
||||
color: action.value === selectedAction ? P.accent : P.text,
|
||||
fontFamily: FONT, fontSize: '11px', fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.1s ease, color 0.1s ease',
|
||||
textAlign: 'center', whiteSpace: 'nowrap',
|
||||
});
|
||||
chip.textContent = action.label;
|
||||
const iconWrap = el('span', {
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
height: '20px', opacity: '0.9',
|
||||
});
|
||||
iconWrap.innerHTML = ICONS[action.value] || '';
|
||||
const labelEl = el('span', { lineHeight: '1' });
|
||||
labelEl.textContent = action.label;
|
||||
chip.appendChild(iconWrap);
|
||||
chip.appendChild(labelEl);
|
||||
chip.dataset.action = action.value;
|
||||
chip.addEventListener('mouseenter', () => {
|
||||
if (action.value !== selectedAction) chip.style.background = C.brandSoft;
|
||||
if (action.value !== selectedAction) chip.style.background = P.accentSoft;
|
||||
});
|
||||
chip.addEventListener('mouseleave', () => {
|
||||
chip.style.background = action.value === selectedAction ? C.brandSoft : 'transparent';
|
||||
chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent';
|
||||
});
|
||||
chip.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -1209,19 +1320,24 @@
|
||||
|
||||
pickerEl.appendChild(grid);
|
||||
document.body.appendChild(pickerEl);
|
||||
|
||||
// Cache the palette on the picker so toggleActionPicker's state refresh
|
||||
// uses the same theme-aware colors when it repaints chips.
|
||||
pickerEl.__iceq_palette = P;
|
||||
}
|
||||
|
||||
function toggleActionPicker() {
|
||||
if (pickerEl.style.display !== 'none') { hideActionPicker(); return; }
|
||||
// Rebuild chips to reflect current selection
|
||||
const P = pickerEl.__iceq_palette || barPaletteForTheme(detectPageTheme());
|
||||
pickerEl.querySelectorAll('button').forEach(chip => {
|
||||
const isActive = chip.dataset.action === selectedAction;
|
||||
chip.style.background = isActive ? C.brandSoft : 'transparent';
|
||||
chip.style.color = isActive ? C.brand : C.ink;
|
||||
chip.style.background = isActive ? P.accentSoft : 'transparent';
|
||||
chip.style.color = isActive ? P.accent : P.text;
|
||||
});
|
||||
// Position above the bar
|
||||
const barRect = barEl.getBoundingClientRect();
|
||||
const pickerH = 90; // approximate
|
||||
const pickerH = 170; // approximate; grows with icon + label rows
|
||||
let top = barRect.top - pickerH - 6;
|
||||
if (top < 8) top = barRect.bottom + 6;
|
||||
Object.assign(pickerEl.style, {
|
||||
@@ -1241,6 +1357,399 @@
|
||||
setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Params panel (per-variant coarse controls)
|
||||
//
|
||||
// Variants may declare a parameter manifest via a JSON attribute on the
|
||||
// variant wrapper:
|
||||
//
|
||||
// <div data-impeccable-variant="1"
|
||||
// data-impeccable-params='[{"id":"density","kind":"steps",...}]'>
|
||||
//
|
||||
// The panel docks to the right edge of the outline during CYCLING and
|
||||
// exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped
|
||||
// CSS can respond instantly without regeneration:
|
||||
//
|
||||
// range / numeric toggle → CSS var (`--p-<id>`) used via var(--p-foo, N)
|
||||
// steps / boolean toggle → data-p-<id> attribute used via :scope[data-p-foo="..."]
|
||||
//
|
||||
// On variant switch, values reset to that variant's declared defaults.
|
||||
// On accept, current values are sent in the event payload so the agent
|
||||
// can bake them into the source-file write.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide)
|
||||
let paramsPanelInner = null; // translating content (carries bg, padding, knobs)
|
||||
let paramsPanelBody = null; // grid holding the knob cells
|
||||
let paramsCurrentValues = {}; // {paramId: value} — mirror of the visible variant's live values
|
||||
let tuneOpen = false; // whether the Tune popover is open right now
|
||||
|
||||
// Theme-aware Tune popover. Appears as a drawer that slides out from the
|
||||
// contextual bar's bar-facing edge (below if the bar sits below the
|
||||
// element, above otherwise). Same width as the bar. Auto-wraps to extra
|
||||
// rows when the knobs exceed one row. The bar's border-radius on the
|
||||
// popover side goes flat while open so the two shapes read as one.
|
||||
let paramsPanelPalette = null;
|
||||
|
||||
function initParamsPanel() {
|
||||
paramsPanelPalette = barPaletteForTheme(detectPageTheme());
|
||||
const P = paramsPanelPalette;
|
||||
|
||||
// Single element, always in the DOM. The slide animation is a CSS mask
|
||||
// with mask-size growing from 0% to 100% along the bar-facing axis — no
|
||||
// display toggle, no opacity toggle, no transform trickery. The mask
|
||||
// hides everything initially; as it grows, content is revealed from
|
||||
// the bar edge outward.
|
||||
paramsPanelEl = document.createElement('div');
|
||||
paramsPanelEl.id = PREFIX + '-params-panel';
|
||||
Object.assign(paramsPanelEl.style, {
|
||||
position: 'fixed', zIndex: String(Z.bar - 1),
|
||||
background: P.surfaceDeep,
|
||||
color: P.text,
|
||||
fontFamily: FONT,
|
||||
padding: '14px 18px',
|
||||
boxSizing: 'border-box',
|
||||
borderRadius: '0 0 10px 10px',
|
||||
pointerEvents: 'none',
|
||||
backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
|
||||
|
||||
// clip-path is the same conceptual reveal as mask but with rock-solid
|
||||
// transition support across engines. Closed state clips from the far
|
||||
// edge; open = inset(0) shows everything.
|
||||
clipPath: 'inset(0 0 100% 0)',
|
||||
transition: 'clip-path 0.44s ' + EASE,
|
||||
|
||||
// Park off-screen until positionParamsPanel places it. These are NOT
|
||||
// in the transition list, so they snap instantly — no fly-in from the
|
||||
// top-left when first shown.
|
||||
top: '-9999px', left: '-9999px', width: '0',
|
||||
});
|
||||
|
||||
paramsPanelBody = el('div', {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
|
||||
gap: '12px 16px',
|
||||
});
|
||||
|
||||
paramsPanelEl.appendChild(paramsPanelBody);
|
||||
document.body.appendChild(paramsPanelEl);
|
||||
paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
|
||||
}
|
||||
|
||||
function getVisibleVariantEl() {
|
||||
if (!currentSessionId) return null;
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return null;
|
||||
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||||
}
|
||||
|
||||
function parseVariantParams(variantEl) {
|
||||
if (!variantEl) return [];
|
||||
const raw = variantEl.getAttribute('data-impeccable-params');
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] Invalid data-impeccable-params JSON:', err.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function applyParamValue(variantEl, param, value) {
|
||||
if (!variantEl) return;
|
||||
const attr = 'data-p-' + param.id;
|
||||
if (param.kind === 'range') {
|
||||
variantEl.style.setProperty('--p-' + param.id, String(value));
|
||||
} else if (param.kind === 'toggle') {
|
||||
const on = !!value;
|
||||
variantEl.style.setProperty('--p-' + param.id, on ? '1' : '0');
|
||||
if (on) variantEl.setAttribute(attr, 'on');
|
||||
else variantEl.removeAttribute(attr);
|
||||
} else if (param.kind === 'steps') {
|
||||
variantEl.setAttribute(attr, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
function applyParamDefaults(variantEl, params) {
|
||||
paramsCurrentValues = {};
|
||||
for (const p of params) {
|
||||
paramsCurrentValues[p.id] = p.default;
|
||||
applyParamValue(variantEl, p, p.default);
|
||||
}
|
||||
}
|
||||
|
||||
function formatRangeValue(input) {
|
||||
const max = parseFloat(input.max), min = parseFloat(input.min);
|
||||
const v = parseFloat(input.value);
|
||||
if (!isFinite(v)) return input.value;
|
||||
return (max - min) <= 2 ? v.toFixed(2) : String(Math.round(v));
|
||||
}
|
||||
|
||||
function buildParamsPanel(variantEl, params) {
|
||||
const P = paramsPanelPalette || barPaletteForTheme(detectPageTheme());
|
||||
paramsPanelBody.innerHTML = '';
|
||||
for (const p of params) {
|
||||
const row = el('div', { display: 'flex', flexDirection: 'column', gap: '6px' });
|
||||
const labelRow = el('div', {
|
||||
display: 'flex', justifyContent: 'space-between',
|
||||
alignItems: 'baseline', gap: '8px',
|
||||
});
|
||||
const lbl = el('span', {
|
||||
fontSize: '10.5px', fontWeight: '600', color: P.text,
|
||||
letterSpacing: '0.03em',
|
||||
});
|
||||
lbl.textContent = p.label || p.id;
|
||||
labelRow.appendChild(lbl);
|
||||
const readout = el('span', {
|
||||
fontSize: '10.5px', color: P.textDim,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
});
|
||||
labelRow.appendChild(readout);
|
||||
row.appendChild(labelRow);
|
||||
|
||||
if (p.kind === 'range') {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'range';
|
||||
input.min = String(p.min != null ? p.min : 0);
|
||||
input.max = String(p.max != null ? p.max : 1);
|
||||
input.step = String(p.step != null ? p.step : 0.05);
|
||||
input.value = String(p.default);
|
||||
Object.assign(input.style, {
|
||||
width: '100%', accentColor: C.brand, cursor: 'pointer',
|
||||
});
|
||||
readout.textContent = formatRangeValue(input);
|
||||
input.addEventListener('input', (e) => {
|
||||
e.stopPropagation();
|
||||
const v = parseFloat(input.value);
|
||||
paramsCurrentValues[p.id] = v;
|
||||
readout.textContent = formatRangeValue(input);
|
||||
applyParamValue(variantEl, p, v);
|
||||
});
|
||||
row.appendChild(input);
|
||||
} else if (p.kind === 'toggle') {
|
||||
const initial = !!p.default;
|
||||
readout.textContent = initial ? 'On' : 'Off';
|
||||
const track = el('button', {
|
||||
position: 'relative', width: '36px', height: '20px',
|
||||
borderRadius: '10px', border: 'none', padding: '0',
|
||||
cursor: 'pointer',
|
||||
background: initial ? C.brand : P.hairline,
|
||||
transition: 'background 0.15s ease',
|
||||
alignSelf: 'flex-start',
|
||||
});
|
||||
const knob = el('span', {
|
||||
position: 'absolute', top: '2px',
|
||||
left: initial ? '18px' : '2px',
|
||||
width: '16px', height: '16px', borderRadius: '50%',
|
||||
background: 'oklch(98% 0 0)',
|
||||
transition: 'left 0.18s ' + EASE,
|
||||
boxShadow: '0 1px 2px oklch(0% 0 0 / 0.2)',
|
||||
});
|
||||
track.appendChild(knob);
|
||||
track.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const next = !paramsCurrentValues[p.id];
|
||||
paramsCurrentValues[p.id] = next;
|
||||
track.style.background = next ? C.brand : P.hairline;
|
||||
knob.style.left = next ? '18px' : '2px';
|
||||
readout.textContent = next ? 'On' : 'Off';
|
||||
applyParamValue(variantEl, p, next);
|
||||
});
|
||||
row.appendChild(track);
|
||||
} else if (p.kind === 'steps') {
|
||||
const opts = (p.options || []).map(o =>
|
||||
typeof o === 'string' ? { value: o, label: o } : o
|
||||
);
|
||||
const activeOpt = opts.find(o => o.value === p.default) || opts[0];
|
||||
readout.textContent = activeOpt ? activeOpt.label : String(p.default);
|
||||
const segRow = el('div', {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(' + opts.length + ', 1fr)',
|
||||
gap: '1px', padding: '2px',
|
||||
background: P.hairline, borderRadius: '5px',
|
||||
});
|
||||
const segBtns = [];
|
||||
opts.forEach(o => {
|
||||
const active = o.value === p.default;
|
||||
const b = el('button', {
|
||||
padding: '5px 4px', border: 'none', borderRadius: '3px',
|
||||
background: active ? C.brand : 'transparent',
|
||||
color: active ? 'oklch(98% 0 0)' : P.text,
|
||||
fontFamily: FONT, fontSize: '10.5px', fontWeight: '500',
|
||||
cursor: 'pointer', whiteSpace: 'nowrap',
|
||||
transition: 'background 0.1s ease, color 0.1s ease',
|
||||
});
|
||||
b.textContent = o.label;
|
||||
b.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
paramsCurrentValues[p.id] = o.value;
|
||||
readout.textContent = o.label;
|
||||
segBtns.forEach(({ btn, val }) => {
|
||||
const on = val === o.value;
|
||||
btn.style.background = on ? C.brand : 'transparent';
|
||||
btn.style.color = on ? 'oklch(98% 0 0)' : P.text;
|
||||
});
|
||||
applyParamValue(variantEl, p, o.value);
|
||||
});
|
||||
segRow.appendChild(b);
|
||||
segBtns.push({ btn: b, val: o.value });
|
||||
});
|
||||
row.appendChild(segRow);
|
||||
}
|
||||
|
||||
paramsPanelBody.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
// Decide which way the popover opens: away from the picked element. If the
|
||||
// bar landed below the element, popover slides DOWN from the bar's bottom.
|
||||
// If the bar landed above, popover slides UP from the bar's top.
|
||||
function popoverDirection() {
|
||||
if (!barEl || !selectedElement) return 'below';
|
||||
const br = barEl.getBoundingClientRect();
|
||||
const er = selectedElement.getBoundingClientRect();
|
||||
return br.top >= er.bottom - 4 ? 'below' : 'above';
|
||||
}
|
||||
|
||||
// The popover overlaps the bar by OVERLAP px on the bar-facing side. With
|
||||
// popover z-index below bar, that overlap sits behind bar (invisible) and
|
||||
// reinforces the "tucked behind" feel. Padding compensates so the real
|
||||
// content starts flush with bar's outer edge.
|
||||
const TUNE_OVERLAP = 6;
|
||||
|
||||
// Closed clip-path depends on direction: for 'below' clip from the far
|
||||
// (bottom) edge so the reveal grows downward from the bar; for 'above'
|
||||
// clip from the top edge so the reveal grows upward from the bar.
|
||||
function closedClipPath(direction) {
|
||||
return direction === 'below' ? 'inset(0 0 100% 0)' : 'inset(100% 0 0 0)';
|
||||
}
|
||||
|
||||
function setClipPath(value, withTransition) {
|
||||
const saved = paramsPanelEl.style.transition;
|
||||
if (!withTransition) paramsPanelEl.style.transition = 'none';
|
||||
paramsPanelEl.style.clipPath = value;
|
||||
if (!withTransition) {
|
||||
void paramsPanelEl.offsetHeight;
|
||||
paramsPanelEl.style.transition = saved;
|
||||
}
|
||||
}
|
||||
|
||||
function positionParamsPanel() {
|
||||
if (!paramsPanelEl || !barEl || barEl.style.display === 'none') return;
|
||||
const br = barEl.getBoundingClientRect();
|
||||
const direction = popoverDirection();
|
||||
const prevDirection = paramsPanelEl.dataset.tuneDirection;
|
||||
|
||||
// top/left/width are NOT in the transition list, so they snap instantly.
|
||||
paramsPanelEl.style.left = br.left + 'px';
|
||||
paramsPanelEl.style.width = br.width + 'px';
|
||||
|
||||
if (direction === 'below') {
|
||||
paramsPanelEl.style.top = (br.bottom - TUNE_OVERLAP) + 'px';
|
||||
paramsPanelEl.style.borderRadius = '0 0 10px 10px';
|
||||
paramsPanelEl.style.paddingTop = (14 + TUNE_OVERLAP) + 'px';
|
||||
paramsPanelEl.style.paddingBottom = '14px';
|
||||
} else {
|
||||
const ih = paramsPanelEl.offsetHeight || 80;
|
||||
paramsPanelEl.style.top = (br.top - ih + TUNE_OVERLAP) + 'px';
|
||||
paramsPanelEl.style.borderRadius = '10px 10px 0 0';
|
||||
paramsPanelEl.style.paddingTop = '14px';
|
||||
paramsPanelEl.style.paddingBottom = (14 + TUNE_OVERLAP) + 'px';
|
||||
}
|
||||
paramsPanelEl.dataset.tuneDirection = direction;
|
||||
|
||||
// If currently closed and direction flipped (or first-time setup),
|
||||
// snap the clip-path to the new direction's closed pose without
|
||||
// transitioning (so the clip doesn't slide across the element).
|
||||
if (!tuneOpen && (!prevDirection || prevDirection !== direction)) {
|
||||
setClipPath(closedClipPath(direction), false);
|
||||
}
|
||||
}
|
||||
|
||||
function showParamsPanel() {
|
||||
if (!paramsPanelEl) return;
|
||||
positionParamsPanel();
|
||||
paramsPanelEl.style.pointerEvents = 'auto';
|
||||
// rAF so the positioning paint commits before the transition fires.
|
||||
requestAnimationFrame(() => {
|
||||
setClipPath('inset(0 0 0 0)', true);
|
||||
});
|
||||
}
|
||||
|
||||
function hideParamsPanel() {
|
||||
if (!paramsPanelEl) return;
|
||||
paramsPanelEl.style.pointerEvents = 'none';
|
||||
const direction = paramsPanelEl.dataset.tuneDirection || 'below';
|
||||
setClipPath(closedClipPath(direction), true);
|
||||
}
|
||||
|
||||
// Build/rebuild the panel's contents for the current variant AND apply
|
||||
// its defaults to the variant wrapper (so scoped CSS responds even before
|
||||
// the user opens the popover). Visibility is governed by tuneOpen.
|
||||
function refreshParamsPanel() {
|
||||
if (state !== 'CYCLING') {
|
||||
paramsCurrentValues = {};
|
||||
tuneOpen = false;
|
||||
hideParamsPanel();
|
||||
return;
|
||||
}
|
||||
const variantEl = getVisibleVariantEl();
|
||||
const params = parseVariantParams(variantEl);
|
||||
if (!variantEl || params.length === 0) {
|
||||
paramsCurrentValues = {};
|
||||
tuneOpen = false;
|
||||
hideParamsPanel();
|
||||
return;
|
||||
}
|
||||
applyParamDefaults(variantEl, params);
|
||||
buildParamsPanel(variantEl, params);
|
||||
if (tuneOpen) {
|
||||
// If already visible (variant cycled while open), refresh in place
|
||||
// instead of re-running the clip-path animation.
|
||||
const alreadyVisible = paramsPanelEl.style.display === 'block'
|
||||
&& paramsPanelEl.style.opacity === '1';
|
||||
if (alreadyVisible) positionParamsPanel();
|
||||
else showParamsPanel();
|
||||
} else {
|
||||
hideParamsPanel();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTunePopover() {
|
||||
if (tuneOpen) { closeTunePopover(); return; }
|
||||
openTunePopover();
|
||||
}
|
||||
|
||||
function openTunePopover() {
|
||||
if (state !== 'CYCLING') return;
|
||||
const variantEl = getVisibleVariantEl();
|
||||
const params = parseVariantParams(variantEl);
|
||||
if (!variantEl || params.length === 0) return;
|
||||
// Build fresh to ensure the current variant's controls are shown.
|
||||
applyParamDefaults(variantEl, params);
|
||||
buildParamsPanel(variantEl, params);
|
||||
tuneOpen = true;
|
||||
showParamsPanel();
|
||||
// Kill the bar's shadow on the popover-facing side so the dark popover
|
||||
// doesn't pick up a bright glow line.
|
||||
if (barEl) {
|
||||
const direction = paramsPanelEl?.dataset.tuneDirection || 'below';
|
||||
barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN;
|
||||
}
|
||||
// Re-render the bar so the Tune chip picks up the active styling.
|
||||
updateBarContent('cycling');
|
||||
}
|
||||
|
||||
function closeTunePopover() {
|
||||
tuneOpen = false;
|
||||
hideParamsPanel();
|
||||
if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT;
|
||||
if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') {
|
||||
updateBarContent('cycling');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variant cycling in DOM
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1253,6 +1762,10 @@
|
||||
if (!v) continue;
|
||||
child.style.display = (v === String(num)) ? '' : 'none';
|
||||
}
|
||||
// Unconditional refresh — covers first-reveal (no-op if state isn't
|
||||
// CYCLING yet, the subsequent CYCLING transition triggers its own
|
||||
// refresh) and every cycle step.
|
||||
refreshParamsPanel();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1316,6 +1829,7 @@
|
||||
state = 'CYCLING';
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
refreshParamsPanel();
|
||||
saveSession();
|
||||
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
|
||||
})
|
||||
@@ -1329,7 +1843,7 @@
|
||||
const next = visibleVariant + dir;
|
||||
if (next < 1 || next > arrivedVariants) return;
|
||||
visibleVariant = next;
|
||||
showVariantInDOM(currentSessionId, next);
|
||||
showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself
|
||||
updateSelectedElement();
|
||||
updateBarContent('cycling');
|
||||
saveSession();
|
||||
@@ -1540,6 +2054,7 @@
|
||||
state = 'CYCLING';
|
||||
hideShaderOverlay();
|
||||
updateBarContent('cycling');
|
||||
refreshParamsPanel();
|
||||
} else if (state === 'GENERATING') {
|
||||
updateBarContent('generating');
|
||||
}
|
||||
@@ -1560,6 +2075,7 @@
|
||||
if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') {
|
||||
positionBar();
|
||||
showHighlight(selectedElement);
|
||||
if (tuneOpen) positionParamsPanel();
|
||||
}
|
||||
if (annotActive) positionAnnotOverlay(selectedElement);
|
||||
// Shader overlay (via debug P toggle or generation) is repositioned
|
||||
@@ -1606,6 +2122,7 @@
|
||||
if (state === 'GENERATING') {
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
refreshParamsPanel();
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -1691,6 +2208,10 @@
|
||||
if (pickerEl?.style.display !== 'none' && !own(e.target)) {
|
||||
hideActionPicker();
|
||||
}
|
||||
// Close Tune popover on outside click (anything outside panel + bar)
|
||||
if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) {
|
||||
closeTunePopover();
|
||||
}
|
||||
// In CONFIGURING: click outside the bar and selected element returns to PICKING
|
||||
if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) {
|
||||
hideBar();
|
||||
@@ -2258,7 +2779,11 @@ void main() {
|
||||
|
||||
function handleAccept() {
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
sendEvent({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) });
|
||||
const acceptPayload = { type: 'accept', id: currentSessionId, variantId: String(visibleVariant) };
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
sendEvent(acceptPayload);
|
||||
markSessionHandled();
|
||||
|
||||
// The accepted variant is already the only visible child of the wrapper
|
||||
@@ -2477,6 +3002,11 @@ void main() {
|
||||
state = arrivedVariants >= expectedVariants ? 'CYCLING' : 'GENERATING';
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
// Build the params panel for the restored visible variant. Previously
|
||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
@@ -2522,6 +3052,11 @@ void main() {
|
||||
// dark pages. This keeps the bar from fighting with the host design.
|
||||
function detectPageTheme() {
|
||||
try {
|
||||
// Dev override: set localStorage 'impeccable-dev-theme' to 'light' or
|
||||
// 'dark' to preview the opposite palette without actually changing the
|
||||
// page bg. Used for screenshots and theme QA.
|
||||
const override = localStorage.getItem('impeccable-dev-theme');
|
||||
if (override === 'light' || override === 'dark') return override;
|
||||
const bg = getComputedStyle(document.body).backgroundColor
|
||||
|| getComputedStyle(document.documentElement).backgroundColor;
|
||||
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
||||
@@ -2538,6 +3073,7 @@ void main() {
|
||||
// Light bar on dark page
|
||||
return {
|
||||
surface: 'oklch(98% 0 0 / 0.92)',
|
||||
surfaceDeep: 'oklch(92% 0.005 60 / 0.96)', // slightly deeper, faint warm
|
||||
hairline: 'oklch(70% 0 0 / 0.35)',
|
||||
text: 'oklch(15% 0 0)',
|
||||
textDim: 'oklch(45% 0 0)',
|
||||
@@ -2552,6 +3088,7 @@ void main() {
|
||||
// deeper so the rounded-right shape reads as a clear sculpted mark.
|
||||
return {
|
||||
surface: 'oklch(26% 0 0 / 0.94)',
|
||||
surfaceDeep: 'oklch(18% 0 0 / 0.96)', // darker sand for Tune popover
|
||||
hairline: 'oklch(42% 0 0 / 0.5)',
|
||||
text: 'oklch(96% 0 0)',
|
||||
textDim: 'oklch(72% 0 0)',
|
||||
@@ -2883,6 +3420,7 @@ void main() {
|
||||
if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; }
|
||||
if (barEl) { barEl.remove(); barEl = null; }
|
||||
if (pickerEl) { pickerEl.remove(); pickerEl = null; }
|
||||
if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; }
|
||||
if (evtSource) { evtSource.close(); evtSource = null; }
|
||||
document.removeEventListener('mousemove', handleMouseMove, true);
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
@@ -3956,6 +4494,7 @@ void main() {
|
||||
initAnnotOverlay();
|
||||
initBar();
|
||||
initActionPicker();
|
||||
initParamsPanel();
|
||||
initGlobalBar();
|
||||
initDesignPanel();
|
||||
document.addEventListener('mousemove', handleMouseMove, true);
|
||||
|
||||
@@ -145,6 +145,11 @@ Options:
|
||||
const scriptArgs = event.type === 'discard'
|
||||
? ['--id', event.id, '--discard']
|
||||
: ['--id', event.id, '--variant', event.variantId];
|
||||
if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
|
||||
// Pass through a JSON blob; the shell-safe wrap uses single quotes because
|
||||
// values are finite {id, number|string|boolean} pairs from a validated payload.
|
||||
scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
|
||||
}
|
||||
try {
|
||||
const out = execSync(
|
||||
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
|
||||
|
||||
@@ -147,6 +147,11 @@ function validateEvent(msg) {
|
||||
case 'accept':
|
||||
if (!msg.id) return 'accept: missing id';
|
||||
if (!msg.variantId) return 'accept: missing variantId';
|
||||
if (msg.paramValues !== undefined) {
|
||||
if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
|
||||
return 'accept: paramValues must be an object';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
case 'discard':
|
||||
return msg.id ? null : 'discard: missing id';
|
||||
|
||||
@@ -9,11 +9,12 @@
|
||||
<a href="/" data-nav="home">Home</a>
|
||||
<a href="/docs" data-nav="docs">Docs</a>
|
||||
<a href="/anti-patterns" data-nav="anti-patterns">Anti-Patterns</a>
|
||||
<a href="/visual-mode" data-nav="visual-mode">Visual Mode</a>
|
||||
<a href="/docs/live" data-nav="live">Live</a>
|
||||
<a href="/visual-mode" data-nav="visual-mode">Overlay</a>
|
||||
</nav>
|
||||
<a href="https://github.com/pbakaus/impeccable" class="site-header-github" target="_blank" rel="noopener" aria-label="Impeccable on GitHub, 18k stars">
|
||||
<a href="https://github.com/pbakaus/impeccable" class="site-header-github" target="_blank" rel="noopener" aria-label="Impeccable on GitHub, 21k stars">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"/></svg>
|
||||
<span class="site-header-github-label">18k</span>
|
||||
<span class="site-header-github-label">21k</span>
|
||||
<svg class="site-header-github-star" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2l2.76 6.36L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l7.24-.91L12 2z"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -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.
|
||||
@@ -2,6 +2,55 @@
|
||||
tagline: "Five-dimension technical quality check with P0 to P3 severity."
|
||||
---
|
||||
|
||||
<div class="docs-viz-hero">
|
||||
<div class="docs-viz-report">
|
||||
<div class="docs-viz-report-head">
|
||||
<div>
|
||||
<div class="docs-viz-report-title">/impeccable audit the checkout flow</div>
|
||||
<div class="docs-viz-report-target">src/checkout/**</div>
|
||||
</div>
|
||||
<div class="docs-viz-report-score">
|
||||
<span class="docs-viz-report-score-num">2.6</span>
|
||||
<span class="docs-viz-report-score-out">/ 4</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="docs-viz-report-dims">
|
||||
<div class="docs-viz-report-dim">
|
||||
<span class="docs-viz-report-dim-name">Accessibility</span>
|
||||
<span class="docs-viz-report-dim-bar"><span class="docs-viz-report-dim-fill docs-viz-report-dim-fill--fail" style="width:50%"></span></span>
|
||||
<span class="docs-viz-report-dim-score">2 / 4</span>
|
||||
</div>
|
||||
<div class="docs-viz-report-dim">
|
||||
<span class="docs-viz-report-dim-name">Performance</span>
|
||||
<span class="docs-viz-report-dim-bar"><span class="docs-viz-report-dim-fill" style="width:75%"></span></span>
|
||||
<span class="docs-viz-report-dim-score">3 / 4</span>
|
||||
</div>
|
||||
<div class="docs-viz-report-dim">
|
||||
<span class="docs-viz-report-dim-name">Theming</span>
|
||||
<span class="docs-viz-report-dim-bar"><span class="docs-viz-report-dim-fill docs-viz-report-dim-fill--warn" style="width:62%"></span></span>
|
||||
<span class="docs-viz-report-dim-score">2.5 / 4</span>
|
||||
</div>
|
||||
<div class="docs-viz-report-dim">
|
||||
<span class="docs-viz-report-dim-name">Responsive</span>
|
||||
<span class="docs-viz-report-dim-bar"><span class="docs-viz-report-dim-fill" style="width:75%"></span></span>
|
||||
<span class="docs-viz-report-dim-score">3 / 4</span>
|
||||
</div>
|
||||
<div class="docs-viz-report-dim">
|
||||
<span class="docs-viz-report-dim-name">Anti-patterns</span>
|
||||
<span class="docs-viz-report-dim-bar"><span class="docs-viz-report-dim-fill docs-viz-report-dim-fill--warn" style="width:70%"></span></span>
|
||||
<span class="docs-viz-report-dim-score">2.8 / 4</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="docs-viz-report-issues">
|
||||
<span class="docs-viz-report-sev docs-viz-report-sev--p0">P0<span class="docs-viz-report-sev-n">2</span></span>
|
||||
<span class="docs-viz-report-sev docs-viz-report-sev--p1">P1<span class="docs-viz-report-sev-n">5</span></span>
|
||||
<span class="docs-viz-report-sev docs-viz-report-sev--p2">P2<span class="docs-viz-report-sev-n">8</span></span>
|
||||
<span class="docs-viz-report-sev docs-viz-report-sev--p3">P3<span class="docs-viz-report-sev-n">14</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="docs-viz-caption">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 <code>/impeccable harden</code>, <code>/impeccable polish</code>, or <code>/impeccable optimize</code>.</p>
|
||||
</div>
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -2,6 +2,32 @@
|
||||
tagline: "Shape the design, then build it, all in one flow."
|
||||
---
|
||||
|
||||
<div class="docs-viz-hero">
|
||||
<div class="docs-viz-flow">
|
||||
<div class="docs-viz-flow-step">
|
||||
<span class="docs-viz-flow-num">01</span>
|
||||
<span class="docs-viz-flow-name">Shape</span>
|
||||
<span class="docs-viz-flow-hint">Discovery interview. Purpose, users, constraints, direction.</span>
|
||||
</div>
|
||||
<div class="docs-viz-flow-step">
|
||||
<span class="docs-viz-flow-num">02</span>
|
||||
<span class="docs-viz-flow-name">Load references</span>
|
||||
<span class="docs-viz-flow-hint">Spatial, typography, motion, color, interaction.</span>
|
||||
</div>
|
||||
<div class="docs-viz-flow-step">
|
||||
<span class="docs-viz-flow-num">03</span>
|
||||
<span class="docs-viz-flow-name">Build</span>
|
||||
<span class="docs-viz-flow-hint">Structure, hierarchy, type, color, states, motion, responsive.</span>
|
||||
</div>
|
||||
<div class="docs-viz-flow-step docs-viz-flow-step--accent">
|
||||
<span class="docs-viz-flow-num">04</span>
|
||||
<span class="docs-viz-flow-name">Iterate visually</span>
|
||||
<span class="docs-viz-flow-hint">Check in browser, refine until it matches the brief.</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="docs-viz-caption">Every phase is non-skippable. The discovery step is where most AI output fails: by the time code exists, the thinking is locked in.</p>
|
||||
</div>
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -2,6 +2,72 @@
|
||||
tagline: "A design review with scoring, persona tests, and automated detection."
|
||||
---
|
||||
|
||||
<div class="docs-viz-hero">
|
||||
<div class="docs-viz-critique">
|
||||
<div class="docs-viz-critique-head">
|
||||
<div class="docs-viz-critique-verdict">
|
||||
<span class="docs-viz-critique-verdict-label">AI slop verdict</span>
|
||||
<span class="docs-viz-critique-verdict-value">FAIL</span>
|
||||
</div>
|
||||
<span class="docs-viz-report-target">gradient-text · ai-color-palette · nested-cards</span>
|
||||
</div>
|
||||
<div class="docs-viz-critique-cols">
|
||||
<div>
|
||||
<div class="docs-viz-critique-col-title">Heuristics (Nielsen)</div>
|
||||
<div class="docs-viz-critique-heuristics">
|
||||
<div class="docs-viz-critique-heur">
|
||||
<span>Visibility of status</span>
|
||||
<span class="docs-viz-critique-heur-score docs-viz-critique-heur-score--good">3</span>
|
||||
</div>
|
||||
<div class="docs-viz-critique-heur">
|
||||
<span>Match with real world</span>
|
||||
<span class="docs-viz-critique-heur-score docs-viz-critique-heur-score--ok">2</span>
|
||||
</div>
|
||||
<div class="docs-viz-critique-heur">
|
||||
<span>Consistency & standards</span>
|
||||
<span class="docs-viz-critique-heur-score docs-viz-critique-heur-score--ok">2</span>
|
||||
</div>
|
||||
<div class="docs-viz-critique-heur">
|
||||
<span>Error prevention</span>
|
||||
<span class="docs-viz-critique-heur-score docs-viz-critique-heur-score--good">3</span>
|
||||
</div>
|
||||
<div class="docs-viz-critique-heur">
|
||||
<span>Recognition over recall</span>
|
||||
<span class="docs-viz-critique-heur-score docs-viz-critique-heur-score--bad">1</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="docs-viz-critique-col-title">Personas</div>
|
||||
<div class="docs-viz-critique-personas">
|
||||
<div class="docs-viz-critique-persona">
|
||||
<div>
|
||||
<span class="docs-viz-critique-persona-name">The evaluator</span>
|
||||
<span class="docs-viz-critique-persona-note">Comparing us to two alternatives on a Tuesday evening.</span>
|
||||
</div>
|
||||
<span class="docs-viz-critique-persona-score">2 / 4</span>
|
||||
</div>
|
||||
<div class="docs-viz-critique-persona">
|
||||
<div>
|
||||
<span class="docs-viz-critique-persona-name">The returning user</span>
|
||||
<span class="docs-viz-critique-persona-note">Knows the product, on mobile, in a hurry.</span>
|
||||
</div>
|
||||
<span class="docs-viz-critique-persona-score">3 / 4</span>
|
||||
</div>
|
||||
<div class="docs-viz-critique-persona">
|
||||
<div>
|
||||
<span class="docs-viz-critique-persona-name">The skeptic</span>
|
||||
<span class="docs-viz-critique-persona-note">Has seen every SaaS landing and is bored.</span>
|
||||
</div>
|
||||
<span class="docs-viz-critique-persona-score">1 / 4</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="docs-viz-caption">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.</p>
|
||||
</div>
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
tagline: "Generate a spec-compliant DESIGN.md that captures your visual system so every AI agent stays on-brand."
|
||||
---
|
||||
|
||||
<div class="docs-viz-hero">
|
||||
<div class="docs-viz-file">
|
||||
<div class="docs-viz-file-header">
|
||||
<span class="docs-viz-file-name">DESIGN.md</span>
|
||||
<span class="docs-viz-file-status">Google Stitch format</span>
|
||||
</div>
|
||||
<div class="docs-viz-designmd-section">
|
||||
<div class="docs-viz-designmd-head">
|
||||
<span class="docs-viz-designmd-num">01</span>
|
||||
<span class="docs-viz-designmd-title">Overview</span>
|
||||
</div>
|
||||
<p class="docs-viz-designmd-note">Creative North Star: <em>"The Editorial Sanctuary."</em> Quiet type, generous air, one committed accent.</p>
|
||||
</div>
|
||||
<div class="docs-viz-designmd-section">
|
||||
<div class="docs-viz-designmd-head">
|
||||
<span class="docs-viz-designmd-num">02</span>
|
||||
<span class="docs-viz-designmd-title">Colors</span>
|
||||
</div>
|
||||
<div class="docs-viz-designmd-swatches" aria-hidden="true">
|
||||
<span class="docs-viz-designmd-swatch" style="background:#1a1a1a"></span>
|
||||
<span class="docs-viz-designmd-swatch" style="background:#f5f3ef"></span>
|
||||
<span class="docs-viz-designmd-swatch" style="background:oklch(60% 0.22 30)"></span>
|
||||
<span class="docs-viz-designmd-swatch" style="background:oklch(90% 0.02 30)"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="docs-viz-designmd-section">
|
||||
<div class="docs-viz-designmd-head">
|
||||
<span class="docs-viz-designmd-num">03</span>
|
||||
<span class="docs-viz-designmd-title">Typography</span>
|
||||
</div>
|
||||
<div class="docs-viz-designmd-type">
|
||||
<span class="docs-viz-designmd-type-display">Aa</span>
|
||||
<span class="docs-viz-designmd-type-body">Cormorant Garamond · Instrument Sans</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="docs-viz-designmd-section">
|
||||
<div class="docs-viz-designmd-head">
|
||||
<span class="docs-viz-designmd-num">04</span>
|
||||
<span class="docs-viz-designmd-title">Elevation</span>
|
||||
</div>
|
||||
<p class="docs-viz-designmd-note">Flat by default. Shadows appear only as a response to state.</p>
|
||||
</div>
|
||||
<div class="docs-viz-designmd-section">
|
||||
<div class="docs-viz-designmd-head">
|
||||
<span class="docs-viz-designmd-num">05</span>
|
||||
<span class="docs-viz-designmd-title">Components</span>
|
||||
</div>
|
||||
<div class="docs-viz-designmd-comps" aria-hidden="true">
|
||||
<span class="docs-viz-designmd-btn">Subscribe</span>
|
||||
<span class="docs-viz-designmd-chip">filter</span>
|
||||
<span class="docs-viz-designmd-card">card</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="docs-viz-designmd-section">
|
||||
<div class="docs-viz-designmd-head">
|
||||
<span class="docs-viz-designmd-num">06</span>
|
||||
<span class="docs-viz-designmd-title">Do's and Don'ts</span>
|
||||
</div>
|
||||
<div class="docs-viz-designmd-rules">
|
||||
<span class="docs-viz-designmd-do">Tint neutrals toward the accent hue.</span>
|
||||
<span class="docs-viz-designmd-dont">Gradient text for emphasis.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="docs-viz-caption">The six sections are fixed, in a fixed order, with fixed names. Alongside, <code>DESIGN.json</code> ships as a machine-readable sidecar for the Live Mode design panel.</p>
|
||||
</div>
|
||||
|
||||
## 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 `<!-- SEED -->` 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.
|
||||
@@ -2,6 +2,27 @@
|
||||
tagline: "Pull reusable components, tokens, and patterns into the design system."
|
||||
---
|
||||
|
||||
<div class="docs-viz-hero">
|
||||
<div class="docs-viz-flow">
|
||||
<div class="docs-viz-flow-step">
|
||||
<span class="docs-viz-flow-num">01</span>
|
||||
<span class="docs-viz-flow-name">Discover drift</span>
|
||||
<span class="docs-viz-flow-hint">Repeated hex values, button variants, spacing scales, text styles.</span>
|
||||
</div>
|
||||
<div class="docs-viz-flow-step">
|
||||
<span class="docs-viz-flow-num">02</span>
|
||||
<span class="docs-viz-flow-name">Propose primitives</span>
|
||||
<span class="docs-viz-flow-hint">Token names, component APIs with variant + size, text styles.</span>
|
||||
</div>
|
||||
<div class="docs-viz-flow-step docs-viz-flow-step--accent">
|
||||
<span class="docs-viz-flow-num">03</span>
|
||||
<span class="docs-viz-flow-name">Migrate call sites</span>
|
||||
<span class="docs-viz-flow-hint">Replace duplicated CSS with the new primitives. No orphan code left behind.</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="docs-viz-caption">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.</p>
|
||||
</div>
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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."
|
||||
---
|
||||
|
||||
<div class="docs-viz-hero docs-viz-hero--plain">
|
||||
<div class="docs-viz-live-frame">
|
||||
<div class="docs-viz-live-chrome">
|
||||
<span class="docs-viz-live-dot"></span>
|
||||
<span class="docs-viz-live-dot"></span>
|
||||
<span class="docs-viz-live-dot"></span>
|
||||
<span class="docs-viz-live-url">localhost:3000</span>
|
||||
</div>
|
||||
<div class="docs-viz-live-stage docs-viz-live-stage--tall">
|
||||
<div class="docs-viz-live-target">
|
||||
<span class="docs-viz-live-kicker">No. 04</span>
|
||||
<h3 class="docs-viz-live-title">Letters, <em>occasionally</em>.</h3>
|
||||
<p class="docs-viz-live-body">A postcard from the editor, about once a month. No tracking pixels, no "just checking in."</p>
|
||||
<button class="docs-viz-live-btn" type="button">Send me one</button>
|
||||
</div>
|
||||
<div class="docs-viz-live-outline" aria-hidden="true"></div>
|
||||
<div class="docs-viz-live-ctx" aria-hidden="true">
|
||||
<button class="docs-viz-live-ctx-nav" type="button" aria-label="Previous">‹</button>
|
||||
<span class="docs-viz-live-ctx-counter">2 / 3</span>
|
||||
<button class="docs-viz-live-ctx-nav" type="button" aria-label="Next">›</button>
|
||||
<span class="docs-viz-live-ctx-divider"></span>
|
||||
<button class="docs-viz-live-ctx-accept" type="button">Accept</button>
|
||||
</div>
|
||||
<div class="docs-viz-live-gbar" aria-hidden="true">
|
||||
<span class="docs-viz-live-gbar-brand">/</span>
|
||||
<span class="docs-viz-live-gbar-btn is-active">Pick</span>
|
||||
<span class="docs-viz-live-gbar-divider"></span>
|
||||
<span class="docs-viz-live-gbar-x">✕</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="docs-viz-caption">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.</p>
|
||||
</div>
|
||||
|
||||
## 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.
|
||||
@@ -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 `<Button variant="primary">` from the shared library
|
||||
- Form layout restructured to use the shared `FormRow` component
|
||||
- Font weight for section headings unified (was mixing 500 and 600 across similar headings)
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Using normalize to redesign.** If you find yourself wanting to change how things look, not just make them consistent, you want `/polish` or `/arrange`. Normalize enforces consistency with what exists.
|
||||
- **Skipping the token gap analysis.** When normalize flags "no token fits", that is signal. Extend the token system before shipping, or the drift will come back.
|
||||
- **Running normalize on a prototype.** If the feature is still exploring, consistency is premature optimization. Normalize ships-ready features.
|
||||
@@ -2,6 +2,39 @@
|
||||
tagline: "Think before you build. Produce a design brief through discovery, not guesswork."
|
||||
---
|
||||
|
||||
<div class="docs-viz-hero">
|
||||
<div class="docs-viz-file">
|
||||
<div class="docs-viz-file-header">
|
||||
<span class="docs-viz-file-name">brief.md</span>
|
||||
<span class="docs-viz-file-status">Output of /impeccable shape</span>
|
||||
</div>
|
||||
<div class="docs-viz-file-body">
|
||||
<div class="docs-viz-file-row">
|
||||
<span class="docs-viz-file-k">Purpose</span>
|
||||
<span class="docs-viz-file-v">Let committed subscribers change what they get without losing them to unsubscribe.</span>
|
||||
</div>
|
||||
<div class="docs-viz-file-row">
|
||||
<span class="docs-viz-file-k">User</span>
|
||||
<span class="docs-viz-file-v">Rushed, on mobile, mid-meeting. Reading fast, low patience.</span>
|
||||
</div>
|
||||
<div class="docs-viz-file-row">
|
||||
<span class="docs-viz-file-k">Content</span>
|
||||
<span class="docs-viz-file-v">4 digest types, 2 cadences, one opt-out-all at the bottom.</span>
|
||||
</div>
|
||||
<div class="docs-viz-file-row">
|
||||
<span class="docs-viz-file-k">Feeling</span>
|
||||
<span class="docs-viz-file-v">Calm, trustworthy, no dark patterns.</span>
|
||||
</div>
|
||||
<div class="docs-viz-file-row">
|
||||
<span class="docs-viz-file-k">Constraints</span>
|
||||
<span class="docs-viz-file-v">Mobile-first. WCAG AA contrast. One column, no modals.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="docs-viz-file-footer">Hand it to <code>/impeccable</code>, <code>/impeccable craft</code>, or any implementation flow.</div>
|
||||
</div>
|
||||
<p class="docs-viz-caption">A shape brief is a compass, not a spec. It captures intent, not UI. Implementation skills read it before writing a line of code.</p>
|
||||
</div>
|
||||
|
||||
## When to use it
|
||||
|
||||
`/impeccable shape` is where a feature starts. Before anyone writes code, before anyone argues about the hero treatment, before anyone picks a font. Use it to force a discovery conversation about purpose, users, content, and constraints, then capture the answers as a design brief the implementation skills can lean on.
|
||||
|
||||
@@ -1,3 +1,70 @@
|
||||
---
|
||||
tagline: "Teach Impeccable who your product is for, once per project."
|
||||
---
|
||||
|
||||
<div class="docs-viz-hero">
|
||||
<div class="docs-viz-file">
|
||||
<div class="docs-viz-file-header">
|
||||
<span class="docs-viz-file-name">PRODUCT.md</span>
|
||||
<span class="docs-viz-file-status">Loaded on every command</span>
|
||||
</div>
|
||||
<div class="docs-viz-file-body">
|
||||
<div class="docs-viz-file-row">
|
||||
<span class="docs-viz-file-k">Register</span>
|
||||
<span class="docs-viz-file-v">Product. Design serves the task.</span>
|
||||
</div>
|
||||
<div class="docs-viz-file-row">
|
||||
<span class="docs-viz-file-k">Users</span>
|
||||
<span class="docs-viz-file-v">SREs on call, reading fast, often in the dark.</span>
|
||||
</div>
|
||||
<div class="docs-viz-file-row">
|
||||
<span class="docs-viz-file-k">Brand voice</span>
|
||||
<span class="docs-viz-file-v">Calm, clinical, no hype.</span>
|
||||
</div>
|
||||
<div class="docs-viz-file-row">
|
||||
<span class="docs-viz-file-k">Anti-references</span>
|
||||
<span class="docs-viz-file-v">Purple gradients. Glassmorphism. "Boost your productivity."</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="docs-viz-file-footer">Every command reads this before writing a line of code.</div>
|
||||
</div>
|
||||
<p class="docs-viz-caption">A finished PRODUCT.md. Strategy only: who, what, why. No colors, no fonts, no pixel values, those live in DESIGN.md.</p>
|
||||
</div>
|
||||
|
||||
## When to use it
|
||||
|
||||
Run `/impeccable teach` once at the start of a project. It is the onramp. Without it, every other command will produce design that is technically competent but generically toned: stock SaaS voice, safe-default fonts, the AI color palette. With it, every command reads your answers before it generates.
|
||||
|
||||
Reach for it when:
|
||||
|
||||
- **You just installed Impeccable in a new project.** First thing to run. Other commands will nudge you toward it if you skip.
|
||||
- **The project's brand direction has shifted.** New positioning, new audience, new voice. Re-run `teach` and the updated context flows through every command.
|
||||
- **Another command said "no design context found"** and stopped. That is the signal: run teach, then resume.
|
||||
|
||||
## How it works
|
||||
|
||||
Teach writes two complementary files at the project root:
|
||||
|
||||
- **`PRODUCT.md`** is the strategic file. Register (brand or product), target users, product purpose, brand personality, anti-references, design principles, accessibility needs. Answers "who, what, why".
|
||||
- **`DESIGN.md`** is the visual file. Colors, typography, elevation, components, do's and don'ts. Answers "how it looks". Written by the delegated `/impeccable document` command, which teach invokes at the end.
|
||||
|
||||
The flow scans the codebase first (README, package.json, components, tokens, brand assets) and forms a **register hypothesis**: brand (landing, marketing, portfolio, where design IS the product) or product (app UI, dashboards, tools, where design SERVES the product). Register is the first question, because it shapes every downstream answer: typography defaults, motion energy, color strategy, the reference set commands like `/impeccable typeset` pull from. After register, teach asks only what it could not infer: users, personality in three real words, references and anti-references, accessibility requirements.
|
||||
|
||||
PRODUCT.md is strategic only. No colors, no fonts, no pixel values. Those live in DESIGN.md. Keeping the two files separate is deliberate: strategy can stay stable while the visual system evolves.
|
||||
|
||||
## Try it
|
||||
|
||||
```
|
||||
/impeccable teach
|
||||
```
|
||||
|
||||
Expect a 5 to 8 minute interview. The first question is usually about register; the rest are short. Teach will quote back what it inferred from your code ("from the routes, this looks like a product surface, match?") so you are confirming, not starting from scratch.
|
||||
|
||||
At the end, teach offers to run `/impeccable document` for you. Say yes unless you have a specific reason to hold off. A real DESIGN.md is what keeps variants, polishes, and audits on-brand.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Skipping it to "just try a command quickly".** Every other command will interview you mid-flight instead. Running teach first is faster, not slower.
|
||||
- **Giving generic answers.** "Modern and clean" is not useful. "Warm, mechanical, opinionated" is. Be specific. Be willing to disagree with safe defaults.
|
||||
- **Treating PRODUCT.md as immutable.** The file is yours. If teach put something in there that is not quite right, edit it. Every command reads the current file.
|
||||
- **Listing only adjectives for references.** Brands, products, printed objects: named, not described. "Klim Type Foundry specimen pages", not "technical and clean". Anti-references should be equally specific.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user