···11----
22-name: adapt
33-description: "Adapt designs to work across different screen sizes, devices, contexts, or platforms. Implements breakpoints, fluid layouts, and touch targets. Use when the user mentions responsive design, mobile layouts, breakpoints, viewport adaptation, or cross-device compatibility."
44-version: 2.1.1
55-user-invocable: true
66-argument-hint: "[target] [context (mobile, tablet, print...)]"
77----
11+> **Additional context needed**: target platforms/devices and usage contexts.
8293Adapt existing designs to work effectively across different contexts - different screen sizes, devices, platforms, or use cases.
1041111-## MANDATORY PREPARATION
1212-1313-Invoke /impeccable — it contains design principles, anti-patterns, and the **Context Gathering Protocol**. Follow the protocol before proceeding — if no design context exists yet, you MUST run /impeccable teach first. Additionally gather: target platforms/devices and usage contexts.
145156---
167···4536### Mobile Adaptation (Desktop → Mobile)
46374738**Layout Strategy**:
4848-4939- Single column instead of multi-column
5040- Vertical stacking instead of side-by-side
5141- Full-width components instead of fixed widths
5242- Bottom navigation instead of top/side navigation
53435444**Interaction Strategy**:
5555-5645- Touch targets 44x44px minimum (not hover-dependent)
5746- Swipe gestures where appropriate (lists, carousels)
5847- Bottom sheets instead of dropdowns
···6049- Larger tap areas with more spacing
61506251**Content Strategy**:
6363-6452- Progressive disclosure (don't show everything at once)
6553- Prioritize primary content (secondary content in tabs/accordions)
6654- Shorter text (more concise)
6755- Larger text (16px minimum)
68566957**Navigation Strategy**:
7070-7158- Hamburger menu or bottom navigation
7259- Reduce navigation complexity
7360- Sticky headers for context
···7663### Tablet Adaptation (Hybrid Approach)
77647865**Layout Strategy**:
7979-8066- Two-column layouts (not single or three-column)
8167- Side panels for secondary content
8268- Master-detail views (list + detail)
8369- Adaptive based on orientation (portrait vs landscape)
84708571**Interaction Strategy**:
8686-8772- Support both touch and pointer
8873- Touch targets 44x44px but allow denser layouts than phone
8974- Side navigation drawers
···9277### Desktop Adaptation (Mobile → Desktop)
93789479**Layout Strategy**:
9595-9680- Multi-column layouts (use horizontal space)
9781- Side navigation always visible
9882- Multiple information panels simultaneously
9983- Fixed widths with max-width constraints (don't stretch to 4K)
1008410185**Interaction Strategy**:
102102-10386- Hover states for additional information
10487- Keyboard shortcuts
10588- Right-click context menus
···10790- Multi-select with Shift/Cmd
1089110992**Content Strategy**:
110110-11193- Show more information upfront (less progressive disclosure)
11294- Data tables with many columns
11395- Richer visualizations
···11698### Print Adaptation (Screen → Print)
11799118100**Layout Strategy**:
119119-120101- Page breaks at logical points
121102- Remove navigation, footer, interactive elements
122103- Black and white (or limited color)
123104- Proper margins for binding
124105125106**Content Strategy**:
126126-127107- Expand shortened content (show full URLs, hidden sections)
128108- Add page numbers, headers, footers
129109- Include metadata (print date, page title)
···132112### Email Adaptation (Web → Email)
133113134114**Layout Strategy**:
135135-136115- Narrow width (600px max)
137116- Single column only
138117- Inline CSS (no external stylesheets)
139118- Table-based layouts (for email client compatibility)
140119141120**Interaction Strategy**:
142142-143121- Large, obvious CTAs (buttons not text links)
144122- No hover states (not reliable)
145123- Deep links to web app for complex interactions
···151129### Responsive Breakpoints
152130153131Choose appropriate breakpoints:
154154-155132- Mobile: 320px-767px
156133- Tablet: 768px-1023px
157134- Desktop: 1024px+
···190167**IMPORTANT**: Test on real devices, not just browser DevTools. Device emulation is helpful but not perfect.
191168192169**NEVER**:
193193-194170- Hide core functionality on mobile (if it matters, make it work)
195171- Assume desktop = powerful device (consider accessibility, older machines)
196172- Use different information architecture across contexts (confusing)
···11----
22-name: animate
33-description: "Review a feature and enhance it with purposeful animations, micro-interactions, and motion effects that improve usability and delight. Use when the user mentions adding animation, transitions, micro-interactions, motion design, hover effects, or making the UI feel more alive."
44-version: 2.1.1
55-user-invocable: true
66-argument-hint: "[target]"
77----
11+> **Additional context needed**: performance constraints.
8293Analyze a feature and strategically add animations and micro-interactions that enhance understanding, provide feedback, and create delight.
1041111-## MANDATORY PREPARATION
55+---
1261313-Invoke /impeccable — it contains design principles, anti-patterns, and the **Context Gathering Protocol**. Follow the protocol before proceeding — if no design context exists yet, you MUST run /impeccable teach first. Additionally gather: performance constraints.
77+## Register
88+99+Brand: orchestrated page-load sequences, staggered reveals, scroll-driven animation. Motion is part of the voice; one well-rehearsed entrance beats scattered micro-interactions.
1010+1111+Product: 150–250 ms on most transitions. Motion conveys state — feedback, reveal, loading, transitions between views. No page-load choreography; users are in a task and won't wait for it.
14121513---
1614···3129 - Who's the audience? (Motion-sensitive users? Power users who want speed?)
3230 - What matters most? (One hero animation vs many micro-interactions?)
33313434-If any of these are unclear from the codebase, ask the user directly to clarify what you cannot infer.
3232+If any of these are unclear from the codebase, STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer.
35333634**CRITICAL**: Respect `prefers-reduced-motion`. Always provide non-animated alternatives for users who need them.
3735···5149Add motion systematically across these categories:
52505351### Entrance Animations
5454-5552- **Page load choreography**: Stagger element reveals (100-150ms delays), fade + slide combinations
5653- **Hero section**: Dramatic entrance for primary content (scale, parallax, or creative effects)
5754- **Content reveals**: Scroll-triggered animations using intersection observer
5855- **Modal/drawer entry**: Smooth slide + fade, backdrop fade, focus management
59566057### Micro-interactions
6161-6258- **Button feedback**:
6359 - Hover: Subtle scale (1.02-1.05), color shift, shadow increase
6460 - Click: Quick scale down then up (0.95 → 1), ripple effect
···7167- **Like/favorite**: Scale + rotation, particle effects, color transition
72687369### State Transitions
7474-7570- **Show/hide**: Fade + slide (not instant), appropriate timing (200-300ms)
7671- **Expand/collapse**: Height transition with overflow handling, icon rotation
7772- **Loading states**: Skeleton screen fades, spinner animations, progress bars
···7974- **Enable/disable**: Opacity transitions, cursor changes
80758176### Navigation & Flow
8282-8377- **Page transitions**: Crossfade between routes, shared element transitions
8478- **Tab switching**: Slide indicator, content fade/slide
8579- **Carousel/slider**: Smooth transforms, snap points, momentum
8680- **Scroll effects**: Parallax layers, sticky headers with state changes, scroll progress indicators
87818882### Feedback & Guidance
8989-9083- **Hover hints**: Tooltip fade-ins, cursor changes, element highlights
9184- **Drag & drop**: Lift effect (shadow + scale), drop zone highlights, smooth repositioning
9285- **Copy/paste**: Brief highlight flash on paste, "copied" confirmation
9386- **Focus flow**: Highlight path through form or workflow
94879588### Delight Moments
9696-9789- **Empty states**: Subtle floating animations on illustrations
9890- **Completed actions**: Confetti, check mark flourish, success celebrations
9991- **Easter eggs**: Hidden interactions for discovery
···10698### Timing & Easing
10799108100**Durations by purpose:**
109109-110101- **100-150ms**: Instant feedback (button press, toggle)
111102- **200-300ms**: State changes (hover, menu open)
112103- **300-500ms**: Layout changes (accordion, modal)
113104- **500-800ms**: Entrance animations (page load)
114105115106**Easing curves (use these, not CSS defaults):**
116116-117107```css
118108/* Recommended - natural deceleration */
119119---ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1); /* Smooth, refined */
120120---ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1); /* Slightly snappier */
121121---ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); /* Confident, decisive */
109109+--ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1); /* Smooth, refined */
110110+--ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1); /* Slightly snappier */
111111+--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); /* Confident, decisive */
122112123113/* AVOID - feel dated and tacky */
124114/* bounce: cubic-bezier(0.34, 1.56, 0.64, 1); */
···128118**Exit animations are faster than entrances.** Use ~75% of enter duration.
129119130120### CSS Animations
131131-132121```css
133122/* Prefer for simple, declarative animations */
134123- transitions for state changes
135124- @keyframes for complex sequences
136136-- transform + opacity only (GPU-accelerated)
125125+- transform and opacity for reliable movement
126126+- blur, filters, masks, clip paths, shadows, and color shifts for premium atmospheric effects when verified smooth
137127```
138128139129### JavaScript Animation
140140-141130```javascript
142131/* Use for complex, interactive animations */
143132- Web Animations API for programmatic control
···146135```
147136148137### Performance
149149-150150-- **GPU acceleration**: Use `transform` and `opacity`, avoid layout properties
138138+- **Motion materials**: Use transform/opacity for reliable movement, but use blur, filters, masks, shadows, and color shifts when they materially improve the effect
139139+- **Layout safety**: Avoid casual animation of layout-driving properties (`width`, `height`, `top`, `left`, margins)
151140- **will-change**: Add sparingly for known expensive animations
152152-- **Reduce paint**: Minimize repaints, use `contain` where appropriate
141141+- **Bound expensive effects**: Keep blur/filter/shadow areas small or isolated, use `contain` where appropriate
153142- **Monitor FPS**: Ensure 60fps on target devices
154143155144### Accessibility
156156-157145```css
158146@media (prefers-reduced-motion: reduce) {
159147 * {
···165153```
166154167155**NEVER**:
168168-169156- Use bounce or elastic easing curves—they feel dated and draw attention to the animation itself
170170-- Animate layout properties (width, height, top, left)—use transform instead
157157+- Animate layout properties casually (`width`, `height`, `top`, `left`, margins) when transform, FLIP, or grid-based techniques would work
171158- Use durations over 500ms for feedback—it feels laggy
172159- Animate without purpose—every animation needs a reason
173160- Ignore `prefers-reduced-motion`—this is an accessibility violation
···11----
22-name: bolder
33-description: "Amplify safe or boring designs to make them more visually interesting and stimulating. Increases impact while maintaining usability. Use when the user says the design looks bland, generic, too safe, lacks personality, or wants more visual impact and character."
44-version: 2.1.1
55-user-invocable: true
66-argument-hint: "[target]"
11+Increase visual impact and personality in designs that are too safe, generic, or visually underwhelming, creating more engaging and memorable experiences.
22+73---
8499-Increase visual impact and personality in designs that are too safe, generic, or visually underwhelming, creating more engaging and memorable experiences.
55+## Register
1061111-## MANDATORY PREPARATION
77+Brand: "bolder" means distinctive. Extreme scale, unexpected color, typographic risk, committed POV.
1281313-Invoke /impeccable — it contains design principles, anti-patterns, and the **Context Gathering Protocol**. Follow the protocol before proceeding — if no design context exists yet, you MUST run /impeccable teach first.
99+Product: "bolder" rarely means theatrics — those undermine trust. It means stronger hierarchy, clearer weight contrast, one sharper accent, more committed density. The amplification is in clarity, not drama.
14101511---
1612···3228 - Who's the audience? (What will resonate?)
3329 - What are the constraints? (Brand guidelines, accessibility, performance)
34303535-If any of these are unclear from the codebase, ask the user directly to clarify what you cannot infer.
3131+If any of these are unclear from the codebase, STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer.
36323733**CRITICAL**: "Bolder" doesn't mean chaotic or garish. It means distinctive, memorable, and confident. Think intentional drama, not random chaos.
38343939-**WARNING - AI SLOP TRAP**: When making things "bolder," AI defaults to the same tired tricks: cyan/purple gradients, glassmorphism, neon accents on dark backgrounds, gradient text on metrics. These are the OPPOSITE of bold—they're generic. Review ALL the DON'T guidelines in the impeccable skill before proceeding. Bold means distinctive, not "more effects."
3535+**WARNING - AI SLOP TRAP**: When making things "bolder," AI defaults to the same tired tricks: cyan/purple gradients, glassmorphism, neon accents on dark backgrounds, gradient text on metrics. These are the OPPOSITE of bold. They're generic. Review ALL the DON'T guidelines from the parent impeccable skill (already loaded in this context) before proceeding. Bold means distinctive, not "more effects."
40364137## Plan Amplification
4238···5450Systematically increase impact across these dimensions:
55515652### Typography Amplification
5757-5858-- **Replace generic fonts**: Swap system fonts for distinctive choices (see impeccable skill for inspiration)
5353+- **Replace generic fonts**: Swap system fonts for distinctive choices (see the parent skill's typography guidelines and [typography.md](typography.md) for inspiration)
5954- **Extreme scale**: Create dramatic size jumps (3x-5x differences, not 1.5x)
6055- **Weight contrast**: Pair 900 weights with 200 weights, not 600 with 400
6156- **Unexpected choices**: Variable fonts, display fonts for headlines, condensed/extended widths, monospace as intentional accent (not as lazy "dev tool" default)
62576358### Color Intensification
6464-6559- **Increase saturation**: Shift to more vibrant, energetic colors (but not neon)
6660- **Bold palette**: Introduce unexpected color combinations—avoid the purple-blue gradient AI slop
6761- **Dominant color strategy**: Let one bold color own 60% of the design
···7064- **Rich gradients**: Intentional multi-stop gradients (not generic purple-to-blue)
71657266### Spatial Drama
7373-7467- **Extreme scale jumps**: Make important elements 3-5x larger than surroundings
7568- **Break the grid**: Let hero elements escape containers and cross boundaries
7669- **Asymmetric layouts**: Replace centered, balanced layouts with tension-filled asymmetry
···7871- **Overlap**: Layer elements intentionally for depth
79728073### Visual Effects
8181-8274- **Dramatic shadows**: Large, soft shadows for elevation (but not generic drop shadows on rounded rectangles)
8375- **Background treatments**: Mesh patterns, noise textures, geometric patterns, intentional gradients (not purple-to-blue)
8476- **Texture & depth**: Grain, halftone, duotone, layered elements—NOT glassmorphism (it's overused AI slop)
···8678- **Custom elements**: Illustrative elements, custom icons, decorative details that reinforce brand
87798880### Motion & Animation
8989-9081- **Entrance choreography**: Staggered, dramatic page load animations with 50-100ms delays
9182- **Scroll effects**: Parallax, reveal animations, scroll-triggered sequences
9283- **Micro-interactions**: Satisfying hover effects, click feedback, state changes
9384- **Transitions**: Smooth, noticeable transitions using ease-out-quart/quint/expo (not bounce or elastic—they cheapen the effect)
94859586### Composition Boldness
9696-9787- **Hero moments**: Create clear focal points with dramatic treatment
9888- **Diagonal flows**: Escape horizontal/vertical rigidity with diagonal arrangements
9989- **Full-bleed elements**: Use full viewport width/height for impact
10090- **Unexpected proportions**: Golden ratio? Throw it out. Try 70/30, 80/20 splits
1019110292**NEVER**:
103103-10493- Add effects randomly without purpose (chaos ≠ bold)
10594- Sacrifice readability for aesthetics (body text must be readable)
10695- Make everything bold (then nothing is bold - need contrast)
···11----
22-name: clarify
33-description: "Improve unclear UX copy, error messages, microcopy, labels, and instructions to make interfaces easier to understand. Use when the user mentions confusing text, unclear labels, bad error messages, hard-to-follow instructions, or wanting better UX writing."
44-version: 2.1.1
55-user-invocable: true
66-argument-hint: "[target]"
77----
11+> **Additional context needed**: audience technical level and users' mental state in context.
8293Identify and improve unclear, confusing, or poorly written interface text to make the product easier to understand and use.
1041111-## MANDATORY PREPARATION
1212-1313-Invoke /impeccable — it contains design principles, anti-patterns, and the **Context Gathering Protocol**. Follow the protocol before proceeding — if no design context exists yet, you MUST run /impeccable teach first. Additionally gather: audience technical level and users' mental state in context.
145156---
167···5142Refine text across these common areas:
52435344### Error Messages
5454-5545**Bad**: "Error 403: Forbidden"
5646**Good**: "You don't have permission to view this page. Contact your admin for access."
5747···5949**Good**: "Email addresses need an @ symbol. Try: name@example.com"
60506151**Principles**:
6262-6352- Explain what went wrong in plain language
6453- Suggest how to fix it
6554- Don't blame the user
···6756- Link to help/support if applicable
68576958### Form Labels & Instructions
7070-7159**Bad**: "DOB (MM/DD/YYYY)"
7260**Good**: "Date of birth" (with placeholder showing format)
7361···7563**Good**: "Your email address" or "Company name"
76647765**Principles**:
7878-7966- Use clear, specific labels (not generic placeholders)
8067- Show format expectations with examples
8168- Explain why you're asking (when not obvious)
···8370- Keep required field indicators clear
84718572### Button & CTA Text
8686-8773**Bad**: "Click here" | "Submit" | "OK"
8874**Good**: "Create account" | "Save changes" | "Got it, thanks"
89759076**Principles**:
9191-9277- Describe the action specifically
9378- Use active voice (verb + noun)
9479- Match user's mental model
9580- Be specific ("Save" is better than "OK")
96819782### Help Text & Tooltips
9898-9983**Bad**: "This is the username field"
10084**Good**: "Choose a username. You can change this later in Settings."
1018510286**Principles**:
103103-10487- Add value (don't just repeat the label)
10588- Answer the implicit question ("What is this?" or "Why do you need this?")
10689- Keep it brief but complete
10790- Link to detailed docs if needed
1089110992### Empty States
110110-11193**Bad**: "No items"
11294**Good**: "No projects yet. Create your first project to get started."
1139511496**Principles**:
115115-11697- Explain why it's empty (if not obvious)
11798- Show next action clearly
11899- Make it welcoming, not dead-end
119100120101### Success Messages
121121-122102**Bad**: "Success"
123103**Good**: "Settings saved! Your changes will take effect immediately."
124104125105**Principles**:
126126-127106- Confirm what happened
128107- Explain what happens next (if relevant)
129108- Be brief but complete
130109- Match the user's emotional moment (celebrate big wins)
131110132111### Loading States
133133-134112**Bad**: "Loading..." (for 30+ seconds)
135113**Good**: "Analyzing your data... this usually takes 30-60 seconds"
136114137115**Principles**:
138138-139116- Set expectations (how long?)
140117- Explain what's happening (when it's not obvious)
141118- Show progress when possible
142119- Offer escape hatch if appropriate ("Cancel")
143120144121### Confirmation Dialogs
145145-146122**Bad**: "Are you sure?"
147123**Good**: "Delete 'Project Alpha'? This can't be undone."
148124149125**Principles**:
150150-151126- State the specific action
152127- Explain consequences (especially for destructive actions)
153128- Use clear button labels ("Delete project" not "Yes")
154129- Don't overuse confirmations (only for risky actions)
155130156131### Navigation & Wayfinding
157157-158132**Bad**: Generic labels like "Items" | "Things" | "Stuff"
159133**Good**: Specific labels like "Your projects" | "Team members" | "Settings"
160134161135**Principles**:
162162-163136- Be specific and descriptive
164137- Use language users understand (not internal jargon)
165138- Make hierarchy clear
···1771506. **Be consistent**: Use same terms throughout (don't vary for variety)
178151179152**NEVER**:
180180-181153- Use jargon without explanation
182154- Blame users ("You made an error" → "This field is required")
183155- Be vague ("Something went wrong" without explanation)
···11----
22-name: colorize
33-description: "Add strategic color to features that are too monochromatic or lack visual interest, making interfaces more engaging and expressive. Use when the user mentions the design looking gray, dull, lacking warmth, needing more color, or wanting a more vibrant or expressive palette."
44-version: 2.1.1
55-user-invocable: true
66-argument-hint: "[target]"
77----
11+> **Additional context needed**: existing brand colors.
8293Strategically introduce color to designs that are too monochromatic, gray, or lacking in visual warmth and personality.
1041111-## MANDATORY PREPARATION
55+---
66+77+## Register
88+99+Brand: palette IS voice. Pick a color strategy first per SKILL.md (Restrained / Committed / Full palette / Drenched) and follow its dosage. Committed, Full palette, and Drenched deliberately exceed the ≤10% rule — that rule is Restrained only. Unexpected combinations are allowed; a dominant color can own the page when the chosen strategy calls for it.
12101313-Invoke /impeccable — it contains design principles, anti-patterns, and the **Context Gathering Protocol**. Follow the protocol before proceeding — if no design context exists yet, you MUST run /impeccable teach first. Additionally gather: existing brand colors.
1111+Product: semantic-first and almost always Restrained. Accent color is reserved for primary action, current selection, and state indicators — not decoration. Every color has a consistent meaning across every screen.
14121513---
1614···3230 - **Wayfinding**: Helping users navigate and understand structure
3331 - **Delight**: Moments of visual interest and personality
34323535-If any of these are unclear from the codebase, ask the user directly to clarify what you cannot infer.
3333+If any of these are unclear from the codebase, STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer.
36343735**CRITICAL**: More color ≠ better. Strategic color beats rainbow vomit every time. Every color should have a purpose.
3836···5250Add color systematically across these dimensions:
53515452### Semantic Color
5555-5653- **State indicators**:
5754 - Success: Green tones (emerald, forest, mint)
5855 - Error: Red/pink tones (rose, crimson, coral)
···6461- **Progress indicators**: Colored bars, rings, or charts showing completion or health
65626663### Accent Color Application
6767-6864- **Primary actions**: Color the most important buttons/CTAs
6965- **Links**: Add color to clickable text (maintain accessibility)
7066- **Icons**: Colorize key icons for recognition and personality
···7268- **Hover states**: Introduce color on interaction
73697470### Background & Surfaces
7575-7671- **Tinted backgrounds**: Replace pure gray (`#f5f5f5`) with warm neutrals (`oklch(97% 0.01 60)`) or cool tints (`oklch(97% 0.01 250)`)
7772- **Colored sections**: Use subtle background colors to separate areas
7873- **Gradient backgrounds**: Add depth with subtle, intentional gradients (not generic purple-blue)
7974- **Cards & surfaces**: Tint cards or surfaces slightly for warmth
80758181-**Use OKLCH for color**: It's perceptually uniform, meaning equal steps in lightness _look_ equal. Great for generating harmonious scales.
7676+**Use OKLCH for color**: It's perceptually uniform, meaning equal steps in lightness *look* equal. Great for generating harmonious scales.
82778378### Data Visualization
8484-8579- **Charts & graphs**: Use color to encode categories or values
8680- **Heatmaps**: Color intensity shows density or importance
8781- **Comparison**: Color coding for different datasets or timeframes
88828983### Borders & Accents
9090-9191-- **Accent borders**: Add colored left/top borders to cards or sections
8484+- **Hairline borders**: 1px colored borders on full perimeter (not side-stripes — see the absolute ban on `border-left/right > 1px`)
9285- **Underlines**: Color underlines for emphasis or active states
9386- **Dividers**: Subtle colored dividers instead of gray lines
9487- **Focus rings**: Colored focus indicators matching brand
8888+- **Surface tints**: A 4-8% background wash of the accent color instead of a stripe
95899696-### Typography Color
9090+**NEVER**: `border-left` or `border-right` greater than 1px as a colored accent stripe. This is one of the three absolute bans in the parent skill. If you want to mark a card as "active" or "warning", use a full hairline border, a background tint, a leading glyph, or a numbered prefix — not a side stripe.
97919292+### Typography Color
9893- **Colored headings**: Use brand colors for section headings (maintain contrast)
9994- **Highlight text**: Color for emphasis or categories
10095- **Labels & tags**: Small colored labels for metadata or categories
1019610297### Decorative Elements
103103-10498- **Illustrations**: Add colored illustrations or icons
10599- **Shapes**: Geometric shapes in brand colors as background elements
106100- **Gradients**: Colorful gradient overlays or mesh backgrounds
···111105Ensure color addition improves rather than overwhelms:
112106113107### Maintain Hierarchy
114114-115108- **Dominant color** (60%): Primary brand color or most used accent
116109- **Secondary color** (30%): Supporting color for variety
117110- **Accent color** (10%): High contrast for key moments
118111- **Neutrals** (remaining): Gray/black/white for structure
119112120113### Accessibility
121121-122114- **Contrast ratios**: Ensure WCAG compliance (4.5:1 for text, 3:1 for UI components)
123115- **Don't rely on color alone**: Use icons, labels, or patterns alongside color
124116- **Test for color blindness**: Verify red/green combinations work for all users
125117126118### Cohesion
127127-128119- **Consistent palette**: Use colors from defined palette, not arbitrary choices
129120- **Systematic application**: Same color meanings throughout (green always = success)
130121- **Temperature consistency**: Warm palette stays warm, cool stays cool
131122132123**NEVER**:
133133-134124- Use every color in the rainbow (choose 2-4 colors beyond neutrals)
135125- Apply color randomly without semantic meaning
136126- Put gray text on colored backgrounds—it looks washed out; use a darker shade of the background color or transparency instead
···152142- **Not overwhelming**: Is color balanced and purposeful?
153143154144Remember: 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.
145145+146146+## Live-mode signature params
147147+148148+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.
149149+150150+```json
151151+{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"}
152152+```
153153+154154+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.
···11----
22-name: critique
33-description: "Evaluate design from a UX perspective, assessing visual hierarchy, information architecture, emotional resonance, cognitive load, and overall quality with quantitative scoring, persona-based testing, automated anti-pattern detection, and actionable feedback. Use when the user asks to review, critique, evaluate, or give feedback on a design or component."
44-version: 2.1.1
55-user-invocable: true
66-argument-hint: "[area (feature, page, component...)]"
77----
11+> **Additional context needed**: what the interface is trying to accomplish.
8299-## STEPS
33+### Gather Assessments
1041111-### Step 1: Preparation
55+Launch two independent assessments. **Neither may see the other's output** — this isolation is what makes the combined score honest. Running both in one head silently anchors them to each other; do not shortcut it for cost, speed, or context-size reasons.
1261313-Invoke /impeccable, which contains design principles, anti-patterns, and the **Context Gathering Protocol**. Follow the protocol before proceeding. If no design context exists yet, you MUST run /impeccable teach first. Additionally gather: what the interface is trying to accomplish.
77+Delegate each assessment to a separate sub-agent (Claude Code's `Agent` tool, Codex's subagent spawning, etc.). Each returns structured findings as text. Do NOT output findings to the user yet.
1481515-### Step 2: Gather Assessments
1616-1717-Launch two independent assessments. **Neither must see the other's output** to avoid bias.
1818-1919-You SHOULD delegate each assessment to a separate sub-agent for independence. Use your environment's agent spawning mechanism (e.g., Claude Code's `Agent` tool, or Codex's subagent spawning). Sub-agents should return their findings as structured text. Do NOT output findings to the user yet.
2020-2121-If sub-agents are not available in the current environment, complete each assessment sequentially, writing findings to internal notes before proceeding.
99+Fall back to sequential in-head work only if the environment genuinely cannot spawn sub-agents.
22102311**Tab isolation**: When browser automation is available, each assessment MUST create its own new tab. Never reuse an existing tab, even if one is already open at the correct URL. This prevents the two assessments from interfering with each other's page state.
24122513#### Assessment A: LLM Design Review
26142715Read the relevant source files (HTML, CSS, JS/TS) and, if browser automation is available, visually inspect the live page. **Create a new tab** for this; do not reuse existing tabs. After navigation, label the tab by setting the document title:
2828-2916```javascript
3030-document.title = "[LLM] " + document.title;
1717+document.title = '[LLM] ' + document.title;
3118```
3232-3319Think like a design director. Evaluate:
34203535-**AI Slop Detection (CRITICAL)**: Does this look like every other AI-generated interface? Review against ALL **DON'T** guidelines in the impeccable skill. Check for AI color palette, gradient text, dark glows, glassmorphism, hero metric layouts, identical card grids, generic fonts, and all other tells. **The test**: If someone said "AI made this," would you believe them immediately?
2121+**AI Slop Detection (CRITICAL)**: Does this look like every other AI-generated interface? Review against ALL **DON'T** guidelines from the parent impeccable skill (already loaded in this context). Check for AI color palette, gradient text, dark glows, glassmorphism, hero metric layouts, identical card grids, generic fonts, and all other tells. **The test**: If someone said "AI made this," would you believe them immediately?
36223723**Holistic Design Review**: visual hierarchy (eye flow, primary action clarity), information architecture (structure, grouping, cognitive load), emotional resonance (does it match brand and audience?), discoverability (are interactive elements obvious?), composition (balance, whitespace, rhythm), typography (hierarchy, readability, font choices), color (purposeful use, cohesion, accessibility), states & edge cases (empty, loading, error, success), microcopy (clarity, tone, helpfulness).
38243939-**Cognitive Load** (consult [cognitive-load](reference/cognitive-load.md)):
4040-2525+**Cognitive Load** (consult [cognitive-load](cognitive-load.md)):
4126- Run the 8-item cognitive load checklist. Report failure count: 0-1 = low (good), 2-3 = moderate, 4+ = critical.
4227- Count visible options at each decision point. If >4, flag it.
4328- Check for progressive disclosure: is complexity revealed only when needed?
44294530**Emotional Journey**:
4646-4731- What emotion does this interface evoke? Is that intentional?
4832- **Peak-end rule**: Is the most intense moment positive? Does the experience end well?
4933- **Emotional valleys**: Check for anxiety spikes at high-stakes moments (payment, delete, commit). Are there design interventions (progress indicators, reassurance copy, undo options)?
50345151-**Nielsen's Heuristics** (consult [heuristics-scoring](reference/heuristics-scoring.md)):
3535+**Nielsen's Heuristics** (consult [heuristics-scoring](heuristics-scoring.md)):
5236Score each of the 10 heuristics 0-4. This scoring will be presented in the report.
53375438Return structured findings covering: AI slop verdict, heuristic scores, cognitive load assessment, what's working (2-3 items), priority issues (3-5 with what/why/fix), minor observations, and provocative questions.
···5842Run the bundled deterministic detector, which flags 25 specific patterns (AI slop tells + general design quality).
59436044**CLI scan**:
6161-6245```bash
6346npx impeccable --json [--fast] [target]
6447```
···6952- For 500+ files, narrow scope or ask the user
7053- Exit code 0 = clean, 2 = findings
71547272-**Browser visualization** (when browser automation tools are available AND the target is a viewable page):
5555+**Browser visualization** — **required** when browser automation tools are available AND the target is a viewable page. The `[Human]` overlay tab is the user-facing deliverable; the critique is incomplete without it. Skip only if the target is not a viewable page (CSS-only file, non-browser target).
73567457The overlay is a **visual aid for the user**. It highlights issues directly in their browser. Do NOT scroll through the page to screenshot overlays. Instead, read the console output to get the results programmatically.
7558···81642. **Create a new tab** and navigate to the page (use dev server URL for local files, or direct URL). Do not reuse existing tabs.
82653. **Label the tab** via `javascript_tool` so the user can distinguish it:
8366 ```javascript
8484- document.title = "[Human] " + document.title;
6767+ document.title = '[Human] ' + document.title;
8568 ```
86694. **Scroll to top** to ensure the page is scrolled to the very top before injection
87705. **Inject** via `javascript_tool` (replace PORT with the port from step 1):
8871 ```javascript
8989- const s = document.createElement("script");
9090- s.src = "http://localhost:PORT/detect.js";
9191- document.head.appendChild(s);
7272+ const s = document.createElement('script'); s.src = 'http://localhost:PORT/detect.js'; document.head.appendChild(s);
9273 ```
93746. Wait 2-3 seconds for the detector to render overlays
94757. **Read results from console** using `read_console_messages` with pattern `impeccable`. The detector logs all findings with the `[impeccable]` prefix. Do NOT scroll through the page to take screenshots of the overlays.
···1018210283Return: CLI findings (JSON), browser console findings (if applicable), and any false positives noted.
10384104104-### Step 3: Generate Combined Critique Report
8585+### Generate Combined Critique Report
1058610687Synthesize both assessments into a single report. Do NOT simply concatenate. Weave the findings together, noting where the LLM review and detector agree, where the detector caught issues the LLM missed, and where detector findings are false positives.
1078810889Structure your feedback as a design director would:
1099011091#### Design Health Score
111111-112112-> _Consult [heuristics-scoring](reference/heuristics-scoring.md)_
9292+> *Consult [heuristics-scoring](heuristics-scoring.md)*
1139311494Present the Nielsen's 10 heuristics scores as a table:
11595116116-| # | Heuristic | Score | Key Issue |
117117-| --------- | ------------------------------- | --------- | ------------------------------------ |
118118-| 1 | Visibility of System Status | ? | [specific finding or "n/a" if solid] |
119119-| 2 | Match System / Real World | ? | |
120120-| 3 | User Control and Freedom | ? | |
121121-| 4 | Consistency and Standards | ? | |
122122-| 5 | Error Prevention | ? | |
123123-| 6 | Recognition Rather Than Recall | ? | |
124124-| 7 | Flexibility and Efficiency | ? | |
125125-| 8 | Aesthetic and Minimalist Design | ? | |
126126-| 9 | Error Recovery | ? | |
127127-| 10 | Help and Documentation | ? | |
128128-| **Total** | | **??/40** | **[Rating band]** |
9696+| # | Heuristic | Score | Key Issue |
9797+|---|-----------|-------|-----------|
9898+| 1 | Visibility of System Status | ? | [specific finding or "n/a" if solid] |
9999+| 2 | Match System / Real World | ? | |
100100+| 3 | User Control and Freedom | ? | |
101101+| 4 | Consistency and Standards | ? | |
102102+| 5 | Error Prevention | ? | |
103103+| 6 | Recognition Rather Than Recall | ? | |
104104+| 7 | Flexibility and Efficiency | ? | |
105105+| 8 | Aesthetic and Minimalist Design | ? | |
106106+| 9 | Error Recovery | ? | |
107107+| 10 | Help and Documentation | ? | |
108108+| **Total** | | **??/40** | **[Rating band]** |
129109130110Be honest with scores. A 4 means genuinely excellent. Most real interfaces score 20-32.
131111···140120**Visual overlays** (if browser was used): Tell the user that overlays are now visible in the **[Human]** tab in their browser, highlighting the detected issues. Summarize what the console output reported.
141121142122#### Overall Impression
143143-144123A brief gut reaction: what works, what doesn't, and the single biggest opportunity.
145124146125#### What's Working
147147-148126Highlight 2-3 things done well. Be specific about why they work.
149127150128#### Priority Issues
151151-152129The 3-5 most impactful design problems, ordered by importance.
153130154154-For each issue, tag with **P0-P3 severity** (consult [heuristics-scoring](reference/heuristics-scoring.md) for severity definitions):
155155-131131+For each issue, tag with **P0-P3 severity** (consult [heuristics-scoring](heuristics-scoring.md) for severity definitions):
156132- **[P?] What**: Name the problem clearly
157133- **Why it matters**: How this hurts users or undermines goals
158134- **Fix**: What to do about it (be concrete)
159159-- **Suggested command**: Which command could address this (from: /polish, /typeset, /colorize, /quieter, /critique, /overdrive, /clarify, /bolder, /audit, /distill, /harden, /layout, /shape, /animate, /optimize, /adapt, /delight)
135135+- **Suggested command**: Which command could address this (from: $impeccable adapt, $impeccable animate, $impeccable audit, $impeccable bolder, $impeccable clarify, $impeccable colorize, $impeccable critique, $impeccable delight, $impeccable distill, $impeccable document, $impeccable harden, $impeccable layout, $impeccable onboard, $impeccable optimize, $impeccable overdrive, $impeccable polish, $impeccable quieter, $impeccable shape, $impeccable typeset)
160136161137#### Persona Red Flags
138138+> *Consult [personas](personas.md)*
162139163163-> _Consult [personas](reference/personas.md)_
164164-165165-Auto-select 2-3 personas most relevant to this interface type (use the selection table in the reference). If `.github/copilot-instructions.md` contains a `## Design Context` section from `impeccable teach`, also generate 1-2 project-specific personas from the audience/brand info.
140140+Auto-select 2-3 personas most relevant to this interface type (use the selection table in the reference). If `AGENTS.md` contains a `## Design Context` section from `impeccable teach`, also generate 1-2 project-specific personas from the audience/brand info.
166141167142For each selected persona, walk through the primary user action and list specific red flags found:
168143···173148Be specific. Name the exact elements and interactions that fail each persona. Don't write generic persona descriptions; write what broke for them.
174149175150#### Minor Observations
176176-177151Quick notes on smaller issues worth addressing.
178152179153#### Questions to Consider
180180-181154Provocative questions that might unlock better solutions:
182182-183155- "What if the primary action were more prominent?"
184156- "Does this need to feel this complex?"
185157- "What would a confident version of this look like?"
186158187159**Remember**:
188188-189160- Be direct. Vague feedback wastes everyone's time.
190161- Be specific. "The submit button," not "some elements."
191162- Say what's wrong AND why it matters to users.
···193164- Prioritize ruthlessly. If everything is important, nothing is.
194165- Don't soften criticism. Developers need honest feedback to ship great design.
195166196196-### Step 4: Ask the User
167167+### Ask the User
197168198198-**After presenting findings**, use targeted questions based on what was actually found. ask the user directly to clarify what you cannot infer. These answers will shape the action plan.
169169+**After presenting findings**, use targeted questions based on what was actually found. STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. These answers will shape the action plan.
199170200171Ask questions along these lines (adapt to the specific findings; do NOT ask generic questions):
201172···2081794. **Constraints** (optional; only ask if relevant): If the findings touch many areas, ask if anything is off-limits. For example: "Should any sections stay as-is?" This prevents the plan from touching things the user considers done.
209180210181**Rules for questions**:
211211-212182- Every question must reference specific findings from the report. Never ask generic "who is your audience?" questions.
213183- Keep it to 2-4 questions maximum. Respect the user's time.
214184- Offer concrete options, not open-ended prompts.
215215-- If findings are straightforward (e.g., only 1-2 clear issues), skip questions and go directly to Step 5.
185185+- If findings are straightforward (e.g., only 1-2 clear issues), skip questions and go directly to Recommended Actions.
216186217217-### Step 5: Recommended Actions
187187+### Recommended Actions
218188219219-**After receiving the user's answers**, present a prioritized action summary reflecting the user's priorities and scope from Step 4.
189189+**After receiving the user's answers**, present a prioritized action summary reflecting the user's priorities and scope from Ask the User.
220190221191#### Action Summary
222192223193List recommended commands in priority order, based on the user's answers:
224194225225-1. **`/command-name`**: Brief description of what to fix (specific context from critique findings)
226226-2. **`/command-name`**: Brief description (specific context)
227227- ...
195195+1. **`$command-name`**: Brief description of what to fix (specific context from critique findings)
196196+2. **`$command-name`**: Brief description (specific context)
197197+...
228198229199**Rules for recommendations**:
230230-231231-- Only recommend commands from: /polish, /typeset, /colorize, /quieter, /critique, /overdrive, /clarify, /bolder, /audit, /distill, /harden, /layout, /shape, /animate, /optimize, /adapt, /delight
200200+- Only recommend commands from: $impeccable adapt, $impeccable animate, $impeccable audit, $impeccable bolder, $impeccable clarify, $impeccable colorize, $impeccable critique, $impeccable delight, $impeccable distill, $impeccable document, $impeccable harden, $impeccable layout, $impeccable onboard, $impeccable optimize, $impeccable overdrive, $impeccable polish, $impeccable quieter, $impeccable shape, $impeccable typeset
232201- Order by the user's stated priorities first, then by impact
233202- Each item's description should carry enough context that the command knows what to focus on
234203- Map each Priority Issue to the appropriate command
235204- Skip commands that would address zero issues
236205- If the user chose a limited scope, only include items within that scope
237206- If the user marked areas as off-limits, exclude commands that would touch those areas
238238-- End with `/polish` as the final step if any fixes were recommended
207207+- End with `$impeccable polish` as the final step if any fixes were recommended
239208240209After presenting the summary, tell the user:
241210242211> You can ask me to run these one at a time, all at once, or in any order you prefer.
243212>
244244-> Re-run `/critique` after fixes to see your score improve.
213213+> Re-run `$impeccable critique` after fixes to see your score improve.
···77## Three Types of Cognitive Load
8899### Intrinsic Load — The Task Itself
1010-1110Complexity inherent to what the user is trying to do. You can't eliminate this, but you can structure it.
12111312**Manage it by**:
1414-1513- Breaking complex tasks into discrete steps
1614- Providing scaffolding (templates, defaults, examples)
1715- Progressive disclosure — show what's needed now, hide the rest
1816- Grouping related decisions together
19172018### Extraneous Load — Bad Design
2121-2219Mental effort caused by poor design choices. **Eliminate this ruthlessly** — it's pure waste.
23202421**Common sources**:
2525-2622- Confusing navigation that requires mental mapping
2723- Unclear labels that force users to guess meaning
2824- Visual clutter competing for attention
···3026- Unnecessary steps between user intent and result
31273228### Germane Load — Learning Effort
3333-3434-Mental effort spent building understanding. This is _good_ cognitive load — it leads to mastery.
2929+Mental effort spent building understanding. This is *good* cognitive load — it leads to mastery.
35303631**Support it by**:
3737-3832- Progressive disclosure that reveals complexity gradually
3933- Consistent patterns that reward learning
4034- Feedback that confirms correct understanding
···6458**Humans can hold ≤4 items in working memory at once** (Miller's Law revised by Cowan, 2001).
65596660At any decision point, count the number of distinct options, actions, or pieces of information a user must simultaneously consider:
6767-6861- **≤4 items**: Within working memory limits — manageable
6962- **5–7 items**: Pushing the boundary — consider grouping or progressive disclosure
7063- **8+ items**: Overloaded — users will skip, misclick, or abandon
71647265**Practical applications**:
7373-7466- Navigation menus: ≤5 top-level items (group the rest under clear categories)
7567- Form sections: ≤4 fields visible per group before a visual break
7668- Action buttons: 1 primary, 1–2 secondary, group the rest in a menu
···8274## Common Cognitive Load Violations
83758476### 1. The Wall of Options
8585-8677**Problem**: Presenting 10+ choices at once with no hierarchy.
8778**Fix**: Group into categories, highlight recommended, use progressive disclosure.
88798980### 2. The Memory Bridge
9090-9181**Problem**: User must remember info from step 1 to complete step 3.
9282**Fix**: Keep relevant context visible, or repeat it where it's needed.
93839484### 3. The Hidden Navigation
9595-9685**Problem**: User must build a mental map of where things are.
9786**Fix**: Always show current location (breadcrumbs, active states, progress indicators).
98879988### 4. The Jargon Barrier
100100-10189**Problem**: Technical or domain language forces translation effort.
10290**Fix**: Use plain language. If domain terms are unavoidable, define them inline.
1039110492### 5. The Visual Noise Floor
105105-10693**Problem**: Every element has the same visual weight — nothing stands out.
10794**Fix**: Establish clear hierarchy: one primary element, 2–3 secondary, everything else muted.
1089510996### 6. The Inconsistent Pattern
110110-11197**Problem**: Similar actions work differently in different places.
11298**Fix**: Standardize interaction patterns. Same type of action = same type of UI.
11399114100### 7. The Multi-Task Demand
115115-116101**Problem**: Interface requires processing multiple simultaneous inputs (reading + deciding + navigating).
117102**Fix**: Sequence the steps. Let the user do one thing at a time.
118103119104### 8. The Context Switch
120120-121105**Problem**: User must jump between screens/tabs/modals to gather info for a single decision.
122106**Fix**: Co-locate the information needed for each decision. Reduce back-and-forth.
···99Keep users informed about what's happening through timely, appropriate feedback.
10101111**Check for**:
1212-1312- Loading indicators during async operations
1413- Confirmation of user actions (save, submit, delete)
1514- Progress indicators for multi-step processes
···3029Speak the user's language. Follow real-world conventions. Information appears in natural, logical order.
31303231**Check for**:
3333-3432- Familiar terminology (no unexplained jargon)
3533- Logical information order matching user expectations
3634- Recognizable icons and metaphors
···5149Users need a clear "emergency exit" from unwanted states without extended dialogue.
52505351**Check for**:
5454-5552- Undo/redo functionality
5653- Cancel buttons on forms and modals
5754- Clear navigation back to safety (home, previous)
···7269Users shouldn't wonder whether different words, situations, or actions mean the same thing.
73707471**Check for**:
7575-7672- Consistent terminology throughout the interface
7773- Same actions produce same results everywhere
7874- Platform conventions followed (standard UI patterns)
···9389Better than good error messages is a design that prevents problems in the first place.
94909591**Check for**:
9696-9792- Confirmation before destructive actions (delete, overwrite)
9893- Constraints preventing invalid input (date pickers, dropdowns)
9994- Smart defaults that reduce errors
···114109Minimize memory load. Make objects, actions, and options visible or easily retrievable.
115110116111**Check for**:
117117-118112- Visible options (not buried in hidden menus)
119113- Contextual help when needed (tooltips, inline hints)
120114- Recent items and history
···135129Accelerators — invisible to novices — speed up expert interaction.
136130137131**Check for**:
138138-139132- Keyboard shortcuts for common actions
140133- Customizable interface elements
141134- Recent items and favorites
···156149Interfaces should not contain irrelevant or rarely needed information. Every element should serve a purpose.
157150158151**Check for**:
159159-160152- Only necessary information visible at each step
161153- Clear visual hierarchy directing attention
162154- Purposeful use of color and emphasis
···177169Error messages should use plain language, precisely indicate the problem, and constructively suggest a solution.
178170179171**Check for**:
180180-181172- Plain language error messages (no error codes for users)
182173- Specific problem identification ("Email is missing @" not "Invalid input")
183174- Actionable recovery suggestions
···198189Even if the system is usable without docs, help should be easy to find, task-focused, and concise.
199190200191**Check for**:
201201-202192- Searchable help or documentation
203193- Contextual help (tooltips, inline hints, guided tours)
204194- Task-focused organization (not feature-organized)
···220210221211**Total possible**: 40 points (10 heuristics × 4 max)
222212223223-| Score Range | Rating | What It Means |
224224-| ----------- | ---------- | ------------------------------------------------------ |
225225-| 36–40 | Excellent | Minor polish only — ship it |
226226-| 28–35 | Good | Address weak areas, solid foundation |
227227-| 20–27 | Acceptable | Significant improvements needed before users are happy |
228228-| 12–19 | Poor | Major UX overhaul required — core experience broken |
229229-| 0–11 | Critical | Redesign needed — unusable in current state |
213213+| Score Range | Rating | What It Means |
214214+|-------------|--------|---------------|
215215+| 36–40 | Excellent | Minor polish only — ship it |
216216+| 28–35 | Good | Address weak areas, solid foundation |
217217+| 20–27 | Acceptable | Significant improvements needed before users are happy |
218218+| 12–19 | Poor | Major UX overhaul required — core experience broken |
219219+| 0–11 | Critical | Redesign needed — unusable in current state |
230220231221---
232222···234224235225Tag each individual issue found during scoring with a priority level:
236226237237-| Priority | Name | Description | Action |
238238-| -------- | -------- | ------------------------------------------ | --------------------------------------- |
239239-| **P0** | Blocking | Prevents task completion entirely | Fix immediately — this is a showstopper |
240240-| **P1** | Major | Causes significant difficulty or confusion | Fix before release |
241241-| **P2** | Minor | Annoyance, but workaround exists | Fix in next pass |
242242-| **P3** | Polish | Nice-to-fix, no real user impact | Fix if time permits |
227227+| Priority | Name | Description | Action |
228228+|----------|------|-------------|--------|
229229+| **P0** | Blocking | Prevents task completion entirely | Fix immediately — this is a showstopper |
230230+| **P1** | Major | Causes significant difficulty or confusion | Fix before release |
231231+| **P2** | Minor | Annoyance, but workaround exists | Fix in next pass |
232232+| **P3** | Polish | Nice-to-fix, no real user impact | Fix if time permits |
243233244234**Tip**: If you're unsure between two levels, ask: "Would a user contact support about this?" If yes, it's at least P1.
···1111**Profile**: Expert with similar products. Expects efficiency, hates hand-holding. Will find shortcuts or leave.
12121313**Behaviors**:
1414-1514- Skips all onboarding and instructions
1615- Looks for keyboard shortcuts immediately
1716- Tries to bulk-select, batch-edit, and automate
···1918- Abandons if anything feels slow or patronizing
20192120**Test Questions**:
2222-2321- Can Alex complete the core task in under 60 seconds?
2422- Are there keyboard shortcuts for common actions?
2523- Can onboarding be skipped entirely?
···2725- Is there a "power user" path (shortcuts, bulk actions)?
28262927**Red Flags** (report these specifically):
3030-3128- Forced tutorials or unskippable onboarding
3229- No keyboard navigation for primary actions
3330- Slow animations that can't be skipped
···4138**Profile**: Never used this type of product. Needs guidance at every step. Will abandon rather than figure it out.
42394340**Behaviors**:
4444-4541- Reads all instructions carefully
4642- Hesitates before clicking anything unfamiliar
4743- Looks for help or support constantly
···4945- Takes the most literal interpretation of any label
50465147**Test Questions**:
5252-5348- Is the first action obviously clear within 5 seconds?
5449- Are all icons labeled with text?
5550- Is there contextual help at decision points?
···5752- Is there a clear "back" or "undo" at every step?
58535954**Red Flags** (report these specifically):
6060-6155- Icon-only navigation with no labels
6256- Technical jargon without explanation
6357- No visible help option or guidance
···7165**Profile**: Uses screen reader (VoiceOver/NVDA), keyboard-only navigation. May have low vision, motor impairment, or cognitive differences.
72667367**Behaviors**:
7474-7568- Tabs through the interface linearly
7669- Relies on ARIA labels and heading structure
7770- Cannot see hover states or visual-only indicators
···7972- May use browser zoom up to 200%
80738174**Test Questions**:
8282-8375- Can the entire primary flow be completed keyboard-only?
8476- Are all interactive elements focusable with visible focus indicators?
8577- Do images have meaningful alt text?
···8779- Does the screen reader announce state changes (loading, success, errors)?
88808981**Red Flags** (report these specifically):
9090-9182- Click-only interactions with no keyboard alternative
9283- Missing or invisible focus indicators
9384- Meaning conveyed by color alone (red = error, green = success)
···10293**Profile**: Methodical user who pushes interfaces beyond the happy path. Tests edge cases, tries unexpected inputs, and probes for gaps in the experience.
1039410495**Behaviors**:
105105-10696- Tests edge cases intentionally (empty states, long strings, special characters)
10797- Submits forms with unexpected data (emoji, RTL text, very long values)
10898- Tries to break workflows by navigating backwards, refreshing mid-flow, or opening in multiple tabs
···110100- Documents problems methodically
111101112102**Test Questions**:
113113-114103- What happens at the edges (0 items, 1000 items, very long text)?
115104- Do error states recover gracefully or leave the UI in a broken state?
116105- What happens on refresh mid-workflow? Is state preserved?
···118107- How does the UI handle unexpected input (emoji, special chars, paste from Excel)?
119108120109**Red Flags** (report these specifically):
121121-122110- Features that appear to work but silently fail or produce wrong results
123111- Error handling that exposes technical details or leaves UI in a broken state
124112- Empty states that show nothing useful ("No results" with no guidance)
···132120**Profile**: Using phone one-handed on the go. Frequently interrupted. Possibly on a slow connection.
133121134122**Behaviors**:
135135-136123- Uses thumb only — prefers bottom-of-screen actions
137124- Gets interrupted mid-flow and returns later
138125- Switches between apps frequently
···140127- Types as little as possible, prefers taps and selections
141128142129**Test Questions**:
143143-144130- Are primary actions in the thumb zone (bottom half of screen)?
145131- Is state preserved if the user leaves and returns?
146132- Does it work on slow connections (3G)?
···148134- Are touch targets at least 44×44pt?
149135150136**Red Flags** (report these specifically):
151151-152137- Important actions positioned at the top of the screen (unreachable by thumb)
153138- No state persistence — progress lost on tab switch or interruption
154139- Large text inputs required where selection would work
···161146162147Choose personas based on the interface type:
163148164164-| Interface Type | Primary Personas | Why |
165165-| ------------------------ | -------------------- | -------------------------------- |
149149+| Interface Type | Primary Personas | Why |
150150+|---------------|-----------------|-----|
166151| Landing page / marketing | Jordan, Riley, Casey | First impressions, trust, mobile |
167167-| Dashboard / admin | Alex, Sam | Power users, accessibility |
168168-| E-commerce / checkout | Casey, Riley, Jordan | Mobile, edge cases, clarity |
169169-| Onboarding flow | Jordan, Casey | Confusion, interruption |
170170-| Data-heavy / analytics | Alex, Sam | Efficiency, keyboard nav |
171171-| Form-heavy / wizard | Jordan, Sam, Casey | Clarity, accessibility, mobile |
152152+| Dashboard / admin | Alex, Sam | Power users, accessibility |
153153+| E-commerce / checkout | Casey, Riley, Jordan | Mobile, edge cases, clarity |
154154+| Onboarding flow | Jordan, Casey | Confusion, interruption |
155155+| Data-heavy / analytics | Alex, Sam | Efficiency, keyboard nav |
156156+| Form-heavy / wizard | Jordan, Sam, Casey | Clarity, accessibility, mobile |
172157173158---
174159175160## Project-Specific Personas
176161177177-If `.github/copilot-instructions.md` contains a `## Design Context` section (generated by `impeccable teach`), derive 1–2 additional personas from the audience and brand information:
162162+If `AGENTS.md` contains a `## Design Context` section (generated by `impeccable teach`), derive 1–2 additional personas from the audience and brand information:
1781631791641. Read the target audience description
1801652. Identify the primary user archetype not covered by the 5 predefined personas
···11----
22-name: delight
33-description: "Add moments of joy, personality, and unexpected touches that make interfaces memorable and enjoyable to use. Elevates functional to delightful. Use when the user asks to add polish, personality, animations, micro-interactions, delight, or make an interface feel fun or memorable."
44-version: 2.1.1
55-user-invocable: true
66-argument-hint: "[target]"
77----
11+> **Additional context needed**: what's appropriate for the domain (playful vs professional vs quirky vs elegant).
8293Identify opportunities to add moments of joy, personality, and unexpected polish that transform functional interfaces into delightful experiences.
1041111-## MANDATORY PREPARATION
55+---
66+77+## Register
88+99+Brand: delight can be distributed — copy voice, section transitions, discovery rewards, seasonal touches, personality across the whole surface.
12101313-Invoke /impeccable — it contains design principles, anti-patterns, and the **Context Gathering Protocol**. Follow the protocol before proceeding — if no design context exists yet, you MUST run /impeccable teach first. Additionally gather: what's appropriate for the domain (playful vs professional vs quirky vs elegant).
1111+Product: delight at specific moments, not pages. Completion, first-time actions, error recovery, milestone crossings. Reliability and consistency carry the rest of the experience; delight pushed everywhere reads as noise.
14121513---
1614···3937 - **Helpful surprises**: Anticipating needs before users ask (productivity tools)
4038 - **Sensory richness**: Satisfying sounds, smooth animations (creative tools)
41394242-If any of these are unclear from the codebase, ask the user directly to clarify what you cannot infer.
4040+If any of these are unclear from the codebase, STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer.
43414442**CRITICAL**: Delight should enhance usability, never obscure it. If users notice the delight more than accomplishing their goal, you've gone too far.
4543···4846Follow these guidelines:
49475048### Delight Amplifies, Never Blocks
5151-5249- Delight moments should be quick (< 1 second)
5350- Never delay core functionality for delight
5451- Make delight skippable or subtle
5552- Respect user's time and task focus
56535754### Surprise and Discovery
5858-5955- Hide delightful details for users to discover
6056- Reward exploration and curiosity
6157- Don't announce every delight moment
6258- Let users share discoveries with others
63596460### Appropriate to Context
6565-6661- Match delight to emotional moment (celebrate success, empathize with errors)
6762- Respect the user's state (don't be playful during critical errors)
6863- Match brand personality and audience expectations
6964- Cultural sensitivity (what's delightful varies by culture)
70657166### Compound Over Time
7272-7367- Delight should remain fresh with repeated use
7468- Vary responses (not same animation every time)
7569- Reveal deeper layers with continued use
···8276### Micro-interactions & Animation
83778478**Button delight**:
8585-8679```css
8780/* Satisfying button press */
8881.button {
8989- transition:
9090- transform 0.1s,
9191- box-shadow 0.1s;
8282+ transition: transform 0.1s, box-shadow 0.1s;
9283}
9384.button:active {
9485 transform: translateY(2px);
9595- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
8686+ box-shadow: 0 2px 4px rgba(0,0,0,0.2);
9687}
97889889/* Ripple effect on click */
···10495```
1059610697**Loading delight**:
107107-10898- Playful loading animations (not just spinners)
10999- Personality in loading messages (write product-specific ones, not generic AI filler)
110100- Progress indication with encouraging messages
111101- Skeleton screens with subtle animations
112102113103**Success animations**:
114114-115104- Checkmark draw animation
116105- Confetti burst for major achievements
117106- Gentle scale + fade for confirmation
118107- Satisfying sound effects (subtle)
119108120109**Hover surprises**:
121121-122110- Icons that animate on hover
123111- Color shifts or glow effects
124112- Tooltip reveals with personality
···127115### Personality in Copy
128116129117**Playful error messages**:
130130-131118```
132119"Error 404"
133120"This page is playing hide and seek. (And winning)"
···137124```
138125139126**Encouraging empty states**:
140140-141127```
142128"No projects"
143129"Your canvas awaits. Create something amazing."
···147133```
148134149135**Playful labels & tooltips**:
150150-151136```
152137"Delete"
153138"Send to void" (for playful brand)
···161146### Illustrations & Visual Personality
162147163148**Custom illustrations**:
164164-165149- Empty state illustrations (not stock icons)
166150- Error state illustrations (friendly monsters, quirky characters)
167151- Loading state illustrations (animated characters)
168152- Success state illustrations (celebrations)
169153170154**Icon personality**:
171171-172155- Custom icon set matching brand personality
173156- Animated icons (subtle motion on hover/click)
174157- Illustrative icons (more detailed than generic)
175158- Consistent style across all icons
176159177160**Background effects**:
178178-179161- Subtle particle effects
180162- Gradient mesh backgrounds
181163- Geometric patterns
···185167### Satisfying Interactions
186168187169**Drag and drop delight**:
188188-189170- Lift effect on drag (shadow, scale)
190171- Snap animation when dropped
191172- Satisfying placement sound
192173- Undo toast ("Dropped in wrong place? [Undo]")
193174194175**Toggle switches**:
195195-196176- Smooth slide with spring physics
197177- Color transition
198178- Haptic feedback on mobile
199179- Optional sound effect
200180201181**Progress & achievements**:
202202-203182- Streak counters with celebratory milestones
204183- Progress bars that "celebrate" at 100%
205184- Badge unlocks with animation
206185- Playful stats ("You're on fire! 5 days in a row")
207186208187**Form interactions**:
209209-210188- Input fields that animate on focus
211189- Checkboxes with a satisfying scale pulse when checked
212190- Success state that celebrates valid input
···215193### Sound Design
216194217195**Subtle audio cues** (when appropriate):
218218-219196- Notification sounds (distinctive but not annoying)
220197- Success sounds (satisfying "ding")
221198- Error sounds (empathetic, not harsh)
···223200- Ambient background audio (very subtle)
224201225202**IMPORTANT**:
226226-227203- Respect system sound settings
228204- Provide mute option
229205- Keep volumes quiet (subtle cues, not alarms)
···232208### Easter Eggs & Hidden Delights
233209234210**Discovery rewards**:
235235-236211- Konami code unlocks special theme
237212- Hidden keyboard shortcuts (Cmd+K for special features)
238213- Hover reveals on logos or illustrations
···240215- Console messages for developers ("Like what you see? We're hiring!")
241216242217**Seasonal touches**:
243243-244218- Holiday themes (subtle, tasteful)
245219- Seasonal color shifts
246220- Weather-based variations
247221- Time-based changes (dark at night, light during day)
248222249223**Contextual personality**:
250250-251224- Different messages based on time of day
252225- Responses to specific user actions
253226- Randomized variations (not same every time)
···256229### Loading & Waiting States
257230258231**Make waiting engaging**:
259259-260232- Interesting loading messages that rotate
261233- Progress bars with personality
262234- Mini-games during long loads
···276248### Celebration Moments
277249278250**Success celebrations**:
279279-280251- Confetti for major milestones
281252- Animated checkmarks for completions
282253- Progress bar celebrations at 100%
···284255- Personalized messages ("You published your 10th article!")
285256286257**Milestone recognition**:
287287-288258- First-time actions get special treatment
289259- Streak tracking and celebration
290260- Progress toward goals
···293263## Implementation Patterns
294264295265**Animation libraries**:
296296-297266- Framer Motion (React)
298267- GSAP (universal)
299268- Lottie (After Effects animations)
300269- Canvas confetti (party effects)
301270302271**Sound libraries**:
303303-304272- Howler.js (audio management)
305273- Use-sound (React hook)
306274307275**Physics libraries**:
308308-309276- React Spring (spring physics)
310277- Popmotion (animation primitives)
311278312279**IMPORTANT**: File size matters. Compress images, optimize animations, lazy load delight features.
313280314281**NEVER**:
315315-316282- Delay core functionality for delight
317283- Force users through delightful moments (make skippable)
318284- Use delight to hide poor UX
···11----
22-name: distill
33-description: "Strip designs to their essence by removing unnecessary complexity. Great design is simple, powerful, and clean. Use when the user asks to simplify, declutter, reduce noise, remove elements, or make a UI cleaner and more focused."
44-version: 2.1.1
55-user-invocable: true
66-argument-hint: "[target]"
77----
88-91Remove unnecessary complexity from designs, revealing the essential elements and creating clarity through ruthless simplification.
1021111-## MANDATORY PREPARATION
1212-1313-Invoke /impeccable — it contains design principles, anti-patterns, and the **Context Gathering Protocol**. Follow the protocol before proceeding — if no design context exists yet, you MUST run /impeccable teach first.
143154---
165···3221 - What can be removed, hidden, or combined?
3322 - What's the 20% that delivers 80% of value?
34233535-If any of these are unclear from the codebase, ask the user directly to clarify what you cannot infer.
2424+If any of these are unclear from the codebase, STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer.
36253726**CRITICAL**: Simplicity is not about removing features - it's about removing obstacles between users and their goals. Every element should justify its existence.
3827···5241Systematically remove complexity across these dimensions:
53425443### Information Architecture
5555-5644- **Reduce scope**: Remove secondary actions, optional features, redundant information
5745- **Progressive disclosure**: Hide complexity behind clear entry points (accordions, modals, step-through flows)
5846- **Combine related actions**: Merge similar buttons, consolidate forms, group related content
···6048- **Remove redundancy**: If it's said elsewhere, don't repeat it here
61496250### Visual Simplification
6363-6451- **Reduce color palette**: Use 1-2 colors plus neutrals, not 5-7 colors
6552- **Limit typography**: One font family, 3-4 sizes maximum, 2-3 weights
6653- **Remove decorations**: Eliminate borders, shadows, backgrounds that don't serve hierarchy or function
···6956- **Consistent spacing**: Use one spacing scale, remove arbitrary gaps
70577158### Layout Simplification
7272-7359- **Linear flow**: Replace complex grids with simple vertical flow where possible
7460- **Remove sidebars**: Move secondary content inline or hide it
7561- **Full-width**: Use available space generously instead of complex multi-column layouts
···7763- **Generous white space**: Let content breathe, don't pack everything tight
78647965### Interaction Simplification
8080-8166- **Reduce choices**: Fewer buttons, fewer options, clearer path forward (paradox of choice is real)
8267- **Smart defaults**: Make common choices automatic, only ask when necessary
8368- **Inline actions**: Replace modal flows with inline editing where possible
···8570- **Clear CTAs**: ONE obvious next step, not five competing actions
86718772### Content Simplification
8888-8973- **Shorter copy**: Cut every sentence in half, then do it again
9074- **Active voice**: "Save changes" not "Changes will be saved"
9175- **Remove jargon**: Plain language always wins
···9478- **Remove redundant copy**: No headers restating intros, no repeated explanations, say it once
95799680### Code Simplification
9797-9881- **Remove unused code**: Dead CSS, unused components, orphaned files
9982- **Flatten component trees**: Reduce nesting depth
10083- **Consolidate styles**: Merge similar styles, use utilities consistently
10184- **Reduce variants**: Does that component need 12 variations, or can 3 cover 90% of cases?
1028510386**NEVER**:
104104-10587- Remove necessary functionality (simplicity ≠ feature-less)
10688- Sacrifice accessibility for simplicity (clear labels and ARIA still required)
10789- Make things so simple they're unclear (mystery ≠ minimalism)
···122104## Document Removed Complexity
123105124106If you removed features or options:
125125-126107- Document why they were removed
127108- Consider if they need alternative access points
128109- Note any user feedback to monitor
···11----
22-name: harden
33-description: "Make interfaces production-ready: error handling, empty states, onboarding flows, i18n, text overflow, and edge case management. Use when the user asks to harden, make production-ready, handle edge cases, add error states, design empty states, improve onboarding, or fix overflow and i18n issues."
44-version: 2.1.1
55-user-invocable: true
66-argument-hint: "[target]"
77----
88-91Strengthen interfaces against edge cases, errors, internationalization issues, and real-world usage scenarios that break idealized designs.
102113## Assess Hardening Needs
···4537### Text Overflow & Wrapping
46384739**Long text handling**:
4848-4940```css
5041/* Single line with ellipsis */
5142.truncate {
···7162```
72637364**Flex/Grid overflow**:
7474-7565```css
7666/* Prevent flex items from overflowing */
7767.flex-item {
···8777```
88788979**Responsive text sizing**:
9090-9180- Use `clamp()` for fluid typography
9281- Set minimum readable sizes (14px on mobile)
9382- Test text scaling (zoom to 200%)
···9685### Internationalization (i18n)
97869887**Text expansion**:
9999-10088- Add 30-40% space budget for translations
10189- Use flexbox/grid that adapts to content
10290- Test with longest language (usually German)
···11199```
112100113101**RTL (Right-to-Left) support**:
114114-115102```css
116103/* Use logical properties */
117104margin-inline-start: 1rem; /* Not margin-left */
···119106border-inline-end: 1px solid; /* Not border-right */
120107121108/* Or use dir attribute */
122122-[dir="rtl"] .arrow {
123123- transform: scaleX(-1);
124124-}
109109+[dir="rtl"] .arrow { transform: scaleX(-1); }
125110```
126111127112**Character set support**:
128128-129113- Use UTF-8 encoding everywhere
130114- Test with Chinese/Japanese/Korean (CJK) characters
131115- Test with emoji (they can be 2-4 bytes)
132116- Handle different scripts (Latin, Cyrillic, Arabic, etc.)
133117134118**Date/Time formatting**:
135135-136119```javascript
137120// ✅ Use Intl API for proper formatting
138138-new Intl.DateTimeFormat("en-US").format(date); // 1/15/2024
139139-new Intl.DateTimeFormat("de-DE").format(date); // 15.1.2024
121121+new Intl.DateTimeFormat('en-US').format(date); // 1/15/2024
122122+new Intl.DateTimeFormat('de-DE').format(date); // 15.1.2024
140123141141-new Intl.NumberFormat("en-US", {
142142- style: "currency",
143143- currency: "USD",
124124+new Intl.NumberFormat('en-US', {
125125+ style: 'currency',
126126+ currency: 'USD'
144127}).format(1234.56); // $1,234.56
145128```
146129147130**Pluralization**:
148148-149131```javascript
150132// ❌ Bad: Assumes English pluralization
151151-`${count} item${count !== 1 ? "s" : ""}`;
133133+`${count} item${count !== 1 ? 's' : ''}`
152134153135// ✅ Good: Use proper i18n library
154154-t("items", { count }); // Handles complex plural rules
136136+t('items', { count }) // Handles complex plural rules
155137```
156138157139### Error Handling
158140159141**Network errors**:
160160-161142- Show clear error messages
162143- Provide retry button
163144- Explain what happened
···166147167148```jsx
168149// Error states with recovery
169169-{
170170- error && (
171171- <ErrorMessage>
172172- <p>Failed to load data. {error.message}</p>
173173- <button onClick={retry}>Try again</button>
174174- </ErrorMessage>
175175- );
176176-}
150150+{error && (
151151+ <ErrorMessage>
152152+ <p>Failed to load data. {error.message}</p>
153153+ <button onClick={retry}>Try again</button>
154154+ </ErrorMessage>
155155+)}
177156```
178157179158**Form validation errors**:
180180-181159- Inline errors near fields
182160- Clear, specific messages
183161- Suggest corrections
···185163- Preserve user input on error
186164187165**API errors**:
188188-189166- Handle each status code appropriately
190167 - 400: Show validation errors
191168 - 401: Redirect to login
···195172 - 500: Show generic error, offer support
196173197174**Graceful degradation**:
198198-199175- Core functionality works without JavaScript
200176- Images have alt text
201177- Progressive enhancement
···204180### Edge Cases & Boundary Conditions
205181206182**Empty states**:
207207-208183- No items in list
209184- No search results
210185- No notifications
···212187- Provide clear next action
213188214189**Loading states**:
215215-216190- Initial load
217191- Pagination load
218192- Refresh
···220194- Time estimates for long operations
221195222196**Large datasets**:
223223-224197- Pagination or virtual scrolling
225198- Search/filter capabilities
226199- Performance optimization
227200- Don't load all 10,000 items at once
228201229202**Concurrent operations**:
230230-231203- Prevent double-submission (disable button while loading)
232204- Handle race conditions
233205- Optimistic updates with rollback
234206- Conflict resolution
235207236208**Permission states**:
237237-238209- No permission to view
239210- No permission to edit
240211- Read-only mode
241212- Clear explanation of why
242213243214**Browser compatibility**:
244244-245215- Polyfills for modern features
246216- Fallbacks for unsupported CSS
247217- Feature detection (not browser detection)
248218- Test in target browsers
249219250250-### Onboarding & First-Run Experience
251251-252252-Production-ready features work for first-time users, not just power users. Design the paths that get new users to value:
253253-254254-**Empty states**: Every zero-data screen needs:
255255-256256-- What will appear here (description or illustration)
257257-- Why it matters to the user
258258-- Clear CTA to create the first item or start from a template
259259-- Visual interest (not just blank space with "No items yet")
260260-261261-Empty state types to handle:
262262-263263-- **First use**: emphasize value, provide templates
264264-- **User cleared**: light touch, easy to recreate
265265-- **No results**: suggest a different query, offer to clear filters
266266-- **No permissions**: explain why, how to get access
267267-268268-**First-run experience**: Get users to their "aha moment" as quickly as possible.
269269-270270-- Show, don't tell -- working examples over descriptions
271271-- Progressive disclosure -- teach one thing at a time, not everything upfront
272272-- Make onboarding optional -- let experienced users skip
273273-- Provide smart defaults so required setup is minimal
274274-275275-**Feature discovery**: Teach features when users need them, not upfront.
276276-277277-- Contextual tooltips at point of use (brief, dismissable, one-time)
278278-- Badges or indicators on new or unused features
279279-- Celebrate activation events quietly (a toast, not a modal)
280280-281281-**NEVER**:
282282-283283-- Force long onboarding before users can touch the product
284284-- Show the same tooltip repeatedly (track and respect dismissals)
285285-- Block the entire UI during a guided tour
286286-- Create separate tutorial modes disconnected from the real product
287287-- Design empty states that just say "No items" with no next action
288288-289220### Input Validation & Sanitization
290221291222**Client-side validation**:
292292-293223- Required fields
294224- Format validation (email, phone, URL)
295225- Length limits
···297227- Custom validation rules
298228299229**Server-side validation** (always):
300300-301230- Never trust client-side only
302231- Validate and sanitize all inputs
303232- Protect against injection attacks
304233- Rate limiting
305234306235**Constraint handling**:
307307-308236```html
309237<!-- Set clear constraints -->
310310-<input
238238+<input
311239 type="text"
312240 maxlength="100"
313241 pattern="[A-Za-z0-9]+"
314242 required
315243 aria-describedby="username-hint"
316244/>
317317-<small id="username-hint"> Letters and numbers only, up to 100 characters </small>
245245+<small id="username-hint">
246246+ Letters and numbers only, up to 100 characters
247247+</small>
318248```
319249320250### Accessibility Resilience
321251322252**Keyboard navigation**:
323323-324253- All functionality accessible via keyboard
325254- Logical tab order
326255- Focus management in modals
327256- Skip links for long content
328257329258**Screen reader support**:
330330-331259- Proper ARIA labels
332260- Announce dynamic changes (live regions)
333261- Descriptive alt text
334262- Semantic HTML
335263336264**Motion sensitivity**:
337337-338265```css
339266@media (prefers-reduced-motion: reduce) {
340267 * {
···346273```
347274348275**High contrast mode**:
349349-350276- Test in Windows high contrast mode
351277- Don't rely only on color
352278- Provide alternative visual cues
···354280### Performance Resilience
355281356282**Slow connections**:
357357-358283- Progressive image loading
359284- Skeleton screens
360285- Optimistic UI updates
361286- Offline support (service workers)
362287363288**Memory leaks**:
364364-365289- Clean up event listeners
366290- Cancel subscriptions
367291- Clear timers/intervals
368292- Abort pending requests on unmount
369293370294**Throttling & Debouncing**:
371371-372295```javascript
373296// Debounce search input
374297const debouncedSearch = debounce(handleSearch, 300);
···380303## Testing Strategies
381304382305**Manual testing**:
383383-384306- Test with extreme data (very long, very short, empty)
385307- Test in different languages
386308- Test offline
···390312- Test on old browsers
391313392314**Automated testing**:
393393-394315- Unit tests for edge cases
395316- Integration tests for error scenarios
396317- E2E tests for critical paths
···400321**IMPORTANT**: Hardening is about expecting the unexpected. Real users will do things you never imagined.
401322402323**NEVER**:
403403-404324- Assume perfect input (validate everything)
405325- Ignore internationalization (design for global)
406326- Leave error messages generic ("Error occurred")
+112-309
.agents/skills/impeccable/SKILL.md
···11---
22name: impeccable
33-description: "Create distinctive, production-grade frontend interfaces with high design quality. Generates creative, polished code that avoids generic AI aesthetics. Use when the user asks to build web components, pages, artifacts, posters, or applications, or when any design skill requires project context. Call with 'craft' for shape-then-build, 'teach' for design context setup, or 'extract' to pull reusable components and tokens into the design system."
44-version: 2.1.1
55-user-invocable: true
66-argument-hint: "[craft|teach|extract]"
77-license: Apache 2.0. Based on Anthropic's frontend-design skill. See NOTICE.md for attribution.
33+description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
84---
951010-This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
66+Designs and iterates production-grade frontend interfaces. Real working code, committed design choices, exceptional craft.
1171212-## Context Gathering Protocol
88+## Setup (non-optional)
1391414-Design skills produce generic output without project context. You MUST have confirmed design context before doing any design work.
1010+Before any design work or file edits, pass these gates. Skipping them produces generic output that ignores the project.
15111616-**Required context** (every design skill needs at minimum):
1212+| Gate | Required check | If fail |
1313+|---|---|---|
1414+| Context | The PRODUCT.md / DESIGN.md loader result is known from `node .agents/skills/impeccable/scripts/load-context.mjs`. | Run the loader before continuing. |
1515+| Product | PRODUCT.md exists and is not empty or placeholder (`[TODO]` markers, <200 chars). | Run `$impeccable teach`, refresh context, then resume. Never synthesize PRODUCT.md from the user's original prompt alone. |
1616+| Command | The matching command reference is loaded when a sub-command is used. | Load the reference before continuing. |
1717+| Craft | `$impeccable craft` has a user-confirmed shape brief for this task. `teach` / PRODUCT.md never counts as shape. | Run `$impeccable shape` and wait for explicit brief confirmation. |
1818+| Image | Required visual probes / mocks are generated or skipped with a reason. | Resolve the image-generation gate in `shape.md` or `craft.md` before code. |
1919+| Mutation | All active gates above pass. | Do not edit project files yet. |
17201818-- **Target audience**: Who uses this product and in what context?
1919-- **Use cases**: What jobs are they trying to get done?
2020-- **Brand personality/tone**: How should the interface feel?
2121+Codex-style agents must state this before editing files:
21222222-Individual skills may require additional context. Check the skill's preparation section for specifics.
2323+```text
2424+IMPECCABLE_PREFLIGHT: context=pass product=pass command_reference=pass shape=pass|not_required image_gate=pass|skipped:<reason> mutation=open
2525+```
23262424-**CRITICAL**: You cannot infer this context by reading the codebase. Code tells you what was built, not who it's for or what it should feel like. Only the creator can provide this context.
2727+For `$impeccable craft`, `shape=pass` is only valid after a separate user response approving the shape design brief, or when the user provided an already-confirmed brief in the request. Do not mark `shape=pass` after writing PRODUCT.md, summarizing assumptions, or drafting an unconfirmed brief yourself.
25282626-**Gathering order:**
2929+Other harnesses should follow the same checklist when they can expose this state.
27302828-1. **Check current instructions (instant)**: If your loaded instructions already contain a **Design Context** section, proceed immediately.
2929-2. **Check .impeccable.md (fast)**: If not in instructions, read `.impeccable.md` from the project root. If it exists and contains the required context, proceed.
3030-3. **Run impeccable teach (REQUIRED)**: If neither source has context, you MUST run /impeccable teach NOW before doing anything else. Do NOT skip this step. Do NOT attempt to infer context from the codebase instead.
3131+### 1. Context gathering
31323232----
3333+Two files at the project root, case-insensitive:
33343434-## Design Direction
3535+- **PRODUCT.md** — required. Users, brand, tone, anti-references, strategic principles.
3636+- **DESIGN.md** — optional, strongly recommended. Colors, typography, elevation, components.
35373636-Commit to a BOLD aesthetic direction:
3838+Load both in one call:
37393838-- **Purpose**: What problem does this interface solve? Who uses it?
3939-- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
4040-- **Constraints**: Technical requirements (framework, performance, accessibility).
4141-- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
4040+```bash
4141+node .agents/skills/impeccable/scripts/load-context.mjs
4242+```
42434343-**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work. The key is intentionality, not intensity.
4444+Consume the full JSON output. Never pipe through `head`, `tail`, `grep`, or `jq`.
44454545-Then implement working code that is:
4646+If the output is already in this session's conversation history, don't re-run. Exceptions requiring a fresh load: you just ran `$impeccable teach` or `$impeccable document` (they rewrite the files), or the user manually edited one.
46474747-- Production-grade and functional
4848-- Visually striking and memorable
4949-- Cohesive with a clear aesthetic point-of-view
5050-- Meticulously refined in every detail
4848+`$impeccable live` already warms context via `live.mjs` — if you've run `live.mjs`, don't also run `load-context.mjs` this session.
51495252-## Frontend Aesthetics Guidelines
5050+If PRODUCT.md is missing, empty, or placeholder (`[TODO]` markers, <200 chars): run `$impeccable teach`, then resume the user's original task with the fresh context. If the original task was `$impeccable craft`, resume into `$impeccable shape` before any implementation work.
53515454-### Typography
5252+If DESIGN.md is missing: nudge once per session (*"Run `$impeccable document` for more on-brand output"*), then proceed.
55535656-→ _Consult [typography reference](reference/typography.md) for OpenType features, web font loading, and the deeper material on scales._
5454+### 2. Register
57555858-Choose fonts that are beautiful, unique, and interesting. Pair a distinctive display font with a refined body font.
5656+Every design task is **brand** (marketing, landing, campaign, long-form content, portfolio — design IS the product) or **product** (app UI, admin, dashboard, tool — design SERVES the product).
59576060-<typography_principles>
6161-Always apply these — do not consult a reference, just do them:
5858+Identify before designing. Priority: (1) cue in the task itself ("landing page" vs "dashboard"); (2) the surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. First match wins.
62596363-- Use a modular type scale with fluid sizing (clamp) for headings on marketing/content pages. Use fixed `rem` scales for app UIs and dashboards (no major design system uses fluid type in product UI).
6464-- Use fewer sizes with more contrast. A 5-step scale with at least a 1.25 ratio between steps creates clearer hierarchy than 8 sizes that are 1.1× apart.
6565-- Line-height scales inversely with line length. Narrow columns want tighter leading, wide columns want more. For light text on dark backgrounds, ADD 0.05-0.1 to your normal line-height — light type reads as lighter weight and needs more breathing room.
6666-- Cap line length at ~65-75ch. Body text wider than that is fatiguing.
6767- </typography_principles>
6060+If PRODUCT.md lacks the `register` field (legacy), infer it once from its "Users" and "Product Purpose" sections, then cache the inferred value for the session. Suggest the user run `$impeccable teach` to add the field explicitly.
68616969-<font_selection_procedure>
7070-DO THIS BEFORE TYPING ANY FONT NAME.
6262+Load the matching reference: [reference/brand.md](reference/brand.md) or [reference/product.md](reference/product.md). The shared design laws below apply to both.
71637272-The model's natural failure mode is "I was told not to use Inter, so I will pick my next favorite font, which becomes the new monoculture." Avoid this by performing the following procedure on every project, in order:
6464+## Shared design laws
73657474-Step 1. Read the brief once. Write down 3 concrete words for the brand voice (e.g., "warm and mechanical and opinionated", "calm and clinical and careful", "fast and dense and unimpressed", "handmade and a little weird"). NOT "modern" or "elegant" — those are dead categories.
6666+Apply to every design, both registers. Match implementation complexity to the aesthetic vision — maximalism needs elaborate code, minimalism needs precision. Interpret creatively. Vary across projects; never converge on the same choices. GPT is capable of extraordinary work — don't hold back.
75677676-Step 2. List the 3 fonts you would normally reach for given those words. Write them down. They are most likely from this list:
6868+### Color
77697878-<reflex_fonts_to_reject>
7979-Fraunces
8080-Newsreader
8181-Lora
8282-Crimson
8383-Crimson Pro
8484-Crimson Text
8585-Playfair Display
8686-Cormorant
8787-Cormorant Garamond
8888-Syne
8989-IBM Plex Mono
9090-IBM Plex Sans
9191-IBM Plex Serif
9292-Space Mono
9393-Space Grotesk
9494-Inter
9595-DM Sans
9696-DM Serif Display
9797-DM Serif Text
9898-Outfit
9999-Plus Jakarta Sans
100100-Instrument Sans
101101-Instrument Serif
102102-</reflex_fonts_to_reject>
7070+- Use OKLCH. Reduce chroma as lightness approaches 0 or 100 — high chroma at extremes looks garish.
7171+- Never use `#000` or `#fff`. Tint every neutral toward the brand hue (chroma 0.005–0.01 is enough).
7272+- Pick a **color strategy** before picking colors. Four steps on the commitment axis:
7373+ - **Restrained** — tinted neutrals + one accent ≤10%. Product default; brand minimalism.
7474+ - **Committed** — one saturated color carries 30–60% of the surface. Brand default for identity-driven pages.
7575+ - **Full palette** — 3–4 named roles, each used deliberately. Brand campaigns; product data viz.
7676+ - **Drenched** — the surface IS the color. Brand heroes, campaign pages.
7777+- The "one accent ≤10%" rule is Restrained only. Committed / Full palette / Drenched exceed it on purpose. Don't collapse every design to Restrained by reflex.
10378104104-Reject every font that appears in the reflex_fonts_to_reject list. They are your training-data defaults and they create monoculture across projects.
7979+### Theme
10580106106-Step 3. Browse a font catalog with the 3 brand words in mind. Sources: Google Fonts, Pangram Pangram, Future Fonts, Adobe Fonts, ABC Dinamo, Klim Type Foundry, Velvetyne. Look for something that fits the brand as a _physical object_ — a museum exhibit caption, a hand-painted shop sign, a 1970s mainframe terminal manual, a fabric label on the inside of a coat, a children's book printed on cheap newsprint. Reject the first thing that "looks designy" — that's the trained reflex too. Keep looking.
8181+Dark vs. light is never a default. Not dark "because tools look cool dark." Not light "to be safe."
10782108108-Step 4. Cross-check the result. The right font for an "elegant" brief is NOT necessarily a serif. The right font for a "technical" brief is NOT necessarily a sans-serif. The right font for a "warm" brief is NOT Fraunces. If your final pick lines up with your reflex pattern, go back to Step 3.
109109-</font_selection_procedure>
8383+Before choosing, write one sentence of physical scene: who uses this, where, under what ambient light, in what mood. If the sentence doesn't force the answer, it's not concrete enough — add detail until it does.
11084111111-<typography_rules>
112112-DO use a modular type scale with fluid sizing (clamp) on headings.
113113-DO vary font weights and sizes to create clear visual hierarchy.
114114-DO vary your font choices across projects. If you used a serif display font on the last project, look for a sans, monospace, or display face on this one.
8585+"Observability dashboard" does not force an answer. "SRE glancing at incident severity on a 27-inch monitor at 2am in a dim room" does. Run the sentence, not the category.
11586116116-DO NOT use overused fonts like Inter, Roboto, Arial, Open Sans, or system defaults — but also do not simply switch to your second-favorite. Every font in the reflex_fonts_to_reject list above is banned. Look further.
117117-DO NOT use monospace typography as lazy shorthand for "technical/developer" vibes.
118118-DO NOT put large icons with rounded corners above every heading. They rarely add value and make sites look templated.
119119-DO NOT use only one font family for the entire page. Pair a distinctive display font with a refined body font.
120120-DO NOT use a flat type hierarchy where sizes are too close together. Aim for at least a 1.25 ratio between steps.
121121-DO NOT set long body passages in uppercase. Reserve all-caps for short labels and headings.
122122-</typography_rules>
8787+### Typography
12388124124-### Color & Theme
125125-126126-→ _Consult [color reference](reference/color-and-contrast.md) for the deeper material on contrast, accessibility, and palette construction._
127127-128128-Commit to a cohesive palette. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
129129-130130-<color_principles>
131131-Always apply these — do not consult a reference, just do them:
8989+- Cap body line length at 65–75ch.
9090+- Hierarchy through scale + weight contrast (≥1.25 ratio between steps). Avoid flat scales.
13291133133-- Use OKLCH, not HSL. OKLCH is perceptually uniform: equal steps in lightness _look_ equal, which HSL does not deliver. As you move toward white or black, REDUCE chroma — high chroma at extreme lightness looks garish. A light blue at 85% lightness wants ~0.08 chroma, not the 0.15 of your base color.
134134-- Tint your neutrals toward your brand hue. Even a chroma of 0.005-0.01 is perceptible and creates subconscious cohesion between brand color and UI surfaces. The hue you tint toward should come from THIS brand, not from a "warm = friendly" or "cool = tech" formula. Pick the brand's actual hue first, then tint everything toward it.
135135-- The 60-30-10 rule is about visual _weight_, not pixel count. 60% neutral / surface, 30% secondary text and borders, 10% accent. Accents work BECAUSE they're rare. Overuse kills their power.
136136- </color_principles>
9292+### Layout
13793138138-<theme_selection>
139139-Theme (light vs dark) should be DERIVED from audience and viewing context, not picked from a default. Read the brief and ask: when is this product used, by whom, in what physical setting?
140140-141141-- A perp DEX consumed during fast trading sessions → dark
142142-- A hospital portal consumed by anxious patients on phones late at night → light
143143-- A children's reading app → light
144144-- A vintage motorcycle forum where users sit in their garage at 9pm → dark
145145-- An observability dashboard for SREs in a dark office → dark
146146-- A wedding planning checklist for couples on a Sunday morning → light
147147-- A music player app for headphone listening at night → dark
148148-- A food magazine homepage browsed during a coffee break → light
149149-150150-Do not default everything to light "to play it safe." Do not default everything to dark "to look cool." Both defaults are the lazy reflex. The correct theme is the one the actual user wants in their actual context.
151151-</theme_selection>
152152-153153-<color_rules>
154154-DO use modern CSS color functions (oklch, color-mix, light-dark) for perceptually uniform, maintainable palettes.
155155-DO tint your neutrals toward your brand hue. Even a subtle hint creates subconscious cohesion.
156156-157157-DO NOT use gray text on colored backgrounds; it looks washed out. Use a shade of the background color instead.
158158-DO NOT use pure black (#000) or pure white (#fff). Always tint; pure black/white never appears in nature.
159159-DO NOT use the AI color palette: cyan-on-dark, purple-to-blue gradients, neon accents on dark backgrounds.
160160-DO NOT use gradient text for impact — see <absolute_bans> below for the strict definition. Solid colors only for text.
161161-DO NOT default to dark mode with glowing accents. It looks "cool" without requiring actual design decisions.
162162-DO NOT default to light mode "to be safe" either. The point is to choose, not to retreat to a safe option.
163163-</color_rules>
164164-165165-### Layout & Space
166166-167167-→ _Consult [spatial reference](reference/spatial-design.md) for the deeper material on grids, container queries, and optical adjustments._
168168-169169-Create visual rhythm through varied spacing, not the same padding everywhere. Embrace asymmetry and unexpected compositions. Break the grid intentionally for emphasis.
170170-171171-<spatial_principles>
172172-Always apply these — do not consult a reference, just do them:
173173-174174-- Use a 4pt spacing scale with semantic token names (`--space-sm`, `--space-md`), not pixel-named (`--spacing-8`). Scale: 4, 8, 12, 16, 24, 32, 48, 64, 96. 8pt is too coarse — you'll often want 12px between two values.
175175-- Use `gap` instead of margins for sibling spacing. It eliminates margin collapse and the cleanup hacks that come with it.
176176-- Vary spacing for hierarchy. A heading with extra space above it reads as more important — make use of that. Don't apply the same padding everywhere.
177177-- Self-adjusting grid pattern: `grid-template-columns: repeat(auto-fit, minmax(280px, 1fr))` is the breakpoint-free responsive grid for card-style content.
178178-- Container queries are for components, viewport queries are for page layout. A card in a sidebar should adapt to the sidebar's width, not the viewport's.
179179- </spatial_principles>
180180-181181-<spatial_rules>
182182-DO create visual rhythm through varied spacing: tight groupings, generous separations.
183183-DO use fluid spacing with clamp() that breathes on larger screens.
184184-DO use asymmetry and unexpected compositions; break the grid intentionally for emphasis.
185185-186186-DO NOT wrap everything in cards. Not everything needs a container.
187187-DO NOT nest cards inside cards. Visual noise; flatten the hierarchy.
188188-DO NOT use identical card grids (same-sized cards with icon + heading + text, repeated endlessly).
189189-DO NOT use the hero metric layout template (big number, small label, supporting stats, gradient accent).
190190-DO NOT center everything. Left-aligned text with asymmetric layouts feels more designed.
191191-DO NOT use the same spacing everywhere. Without rhythm, layouts feel monotonous.
192192-DO NOT let body text wrap beyond ~80 characters per line. Add a max-width like 65–75ch so the eye can track easily.
193193-</spatial_rules>
194194-195195-### Visual Details
196196-197197-<absolute_bans>
198198-These CSS patterns are NEVER acceptable. They are the most recognizable AI design tells. Match-and-refuse: if you find yourself about to write any of these, stop and rewrite the element with a different structure entirely.
199199-200200-BAN 1: Side-stripe borders on cards/list items/callouts/alerts
201201-202202-- PATTERN: `border-left:` or `border-right:` with width greater than 1px
203203-- INCLUDES: hard-coded colors AND CSS variables
204204-- FORBIDDEN: `border-left: 3px solid red`, `border-left: 4px solid #ff0000`, `border-left: 4px solid var(--color-warning)`, `border-left: 5px solid oklch(...)`, etc.
205205-- WHY: this is the single most overused "design touch" in admin, dashboard, and medical UIs. It never looks intentional regardless of color, radius, opacity, or whether the variable name is "primary" or "warning" or "accent."
206206-- REWRITE: use a different element structure entirely. Do not just swap to box-shadow inset. Reach for full borders, background tints, leading numbers/icons, or no visual indicator at all.
207207-208208-BAN 2: Gradient text
209209-210210-- PATTERN: `background-clip: text` (or `-webkit-background-clip: text`) combined with a gradient background
211211-- FORBIDDEN: any combination that makes text fill come from a `linear-gradient`, `radial-gradient`, or `conic-gradient`
212212-- WHY: gradient text is decorative rather than meaningful and is one of the top three AI design tells
213213-- REWRITE: use a single solid color for text. If you want emphasis, use weight or size, not gradient fill.
214214- </absolute_bans>
215215-216216-DO: Use intentional, purposeful decorative elements that reinforce brand.
217217-DO NOT: Use border-left or border-right greater than 1px as a colored accent stripe on cards, list items, callouts, or alerts. See <absolute_bans> above for the strict CSS pattern.
218218-DO NOT: Use glassmorphism everywhere (blur effects, glass cards, glow borders used decoratively rather than purposefully).
219219-DO NOT: Use sparklines as decoration. Tiny charts that look sophisticated but convey nothing meaningful.
220220-DO NOT: Use rounded rectangles with generic drop shadows. Safe, forgettable, could be any AI output.
221221-DO NOT: Use modals unless there's truly no better alternative. Modals are lazy.
9494+- Vary spacing for rhythm. Same padding everywhere is monotony.
9595+- Cards are the lazy answer. Use them only when they're truly the best affordance. Nested cards are always wrong.
9696+- Don't wrap everything in a container. Most things don't need one.
2229722398### Motion
22499225225-→ _Consult [motion reference](reference/motion-design.md) for timing, easing, and reduced motion._
226226-227227-Focus on high-impact moments: one well-orchestrated page load with staggered reveals creates more delight than scattered micro-interactions.
228228-229229-**DO**: Use motion to convey state changes: entrances, exits, feedback
230230-**DO**: Use exponential easing (ease-out-quart/quint/expo) for natural deceleration
231231-**DO**: For height animations, use grid-template-rows transitions instead of animating height directly
232232-**DON'T**: Animate layout properties (width, height, padding, margin). Use transform and opacity only
233233-**DON'T**: Use bounce or elastic easing. They feel dated and tacky; real objects decelerate smoothly
234234-235235-### Interaction
236236-237237-→ _Consult [interaction reference](reference/interaction-design.md) for forms, focus, and loading patterns._
238238-239239-Make interactions feel fast. Use optimistic UI: update immediately, sync later.
240240-241241-**DO**: Use progressive disclosure. Start simple, reveal sophistication through interaction (basic options first, advanced behind expandable sections; hover states that reveal secondary actions)
242242-**DO**: Design empty states that teach the interface, not just say "nothing here"
243243-**DO**: Make every interactive surface feel intentional and responsive
244244-**DON'T**: Repeat the same information (redundant headers, intros that restate the heading)
245245-**DON'T**: Make every button primary. Use ghost buttons, text links, secondary styles; hierarchy matters
246246-247247-### Responsive
248248-249249-→ _Consult [responsive reference](reference/responsive-design.md) for mobile-first, fluid design, and container queries._
250250-251251-**DO**: Use container queries (@container) for component-level responsiveness
252252-**DO**: Adapt the interface for different contexts, not just shrink it
253253-**DON'T**: Hide critical functionality on mobile. Adapt the interface, don't amputate it
254254-255255-### UX Writing
256256-257257-→ _Consult [ux-writing reference](reference/ux-writing.md) for labels, errors, and empty states._
258258-259259-**DO**: Make every word earn its place
260260-**DON'T**: Repeat information users can already see
261261-262262----
263263-264264-## The AI Slop Test
265265-266266-**Critical quality check**: If you showed this interface to someone and said "AI made this," would they believe you immediately? If yes, that's the problem.
267267-268268-A distinctive interface should make someone ask "how was this made?" not "which AI made this?"
269269-270270-Review the DON'T guidelines above. They are the fingerprints of AI-generated work from 2024-2025.
271271-272272----
273273-274274-## Implementation Principles
275275-276276-Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details.
277277-278278-Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices across generations.
279279-280280-Remember: the model is capable of extraordinary creative work. Don't hold back. Show what can truly be created when thinking outside the box and committing fully to a distinctive vision.
281281-282282----
283283-284284-## Craft Mode
285285-286286-If this skill is invoked with the argument "craft" (e.g., `/impeccable craft [feature description]`), follow the [craft flow](reference/craft.md). Pass any additional arguments as the feature description.
287287-288288----
289289-290290-## Teach Mode
291291-292292-If this skill is invoked with the argument "teach" (e.g., `/impeccable teach`), skip all design work above and instead run the teach flow below. This is a one-time setup that gathers design context for the project.
293293-294294-### Step 1: Explore the Codebase
295295-296296-Before asking questions, thoroughly scan the project to discover what you can:
297297-298298-- **README and docs**: Project purpose, target audience, any stated goals
299299-- **Package.json / config files**: Tech stack, dependencies, existing design libraries
300300-- **Existing components**: Current design patterns, spacing, typography in use
301301-- **Brand assets**: Logos, favicons, color values already defined
302302-- **Design tokens / CSS variables**: Existing color palettes, font stacks, spacing scales
303303-- **Any style guides or brand documentation**
100100+- Don't animate CSS layout properties.
101101+- Ease out with exponential curves (ease-out-quart / quint / expo). No bounce, no elastic.
304102305305-Note what you've learned and what remains unclear.
103103+### Absolute bans
306104307307-### Step 2: Ask UX-Focused Questions
105105+Match-and-refuse. If you're about to write any of these, rewrite the element with different structure.
308106309309-ask the user directly to clarify what you cannot infer. Focus only on what you couldn't infer from the codebase:
107107+- **Side-stripe borders.** `border-left` or `border-right` greater than 1px as a colored accent on cards, list items, callouts, or alerts. Never intentional. Rewrite with full borders, background tints, leading numbers/icons, or nothing.
108108+- **Gradient text.** `background-clip: text` combined with a gradient background. Decorative, never meaningful. Use a single solid color. Emphasis via weight or size.
109109+- **Glassmorphism as default.** Blurs and glass cards used decoratively. Rare and purposeful, or nothing.
110110+- **The hero-metric template.** Big number, small label, supporting stats, gradient accent. SaaS cliché.
111111+- **Identical card grids.** Same-sized cards with icon + heading + text, repeated endlessly.
112112+- **Modal as first thought.** Modals are usually laziness. Exhaust inline / progressive alternatives first.
310113311311-#### Users & Purpose
114114+### Copy
312115313313-- Who uses this? What's their context when using it?
314314-- What job are they trying to get done?
315315-- What emotions should the interface evoke? (confidence, delight, calm, urgency, etc.)
116116+- Every word earns its place. No restated headings, no intros that repeat the title.
117117+- **No em dashes.** Use commas, colons, semicolons, periods, or parentheses. Also not `--`.
316118317317-#### Brand & Personality
119119+### The AI slop test
318120319319-- How would you describe the brand personality in 3 words?
320320-- Any reference sites or apps that capture the right feel? What specifically about them?
321321-- What should this explicitly NOT look like? Any anti-references?
121121+If someone could look at this interface and say "AI made that" without doubt, it's failed. Cross-register failures are the absolute bans above. Register-specific failures live in each reference.
322122323323-#### Aesthetic Preferences
123123+**Category-reflex check.** If someone could guess the theme and palette from the category name alone — "observability → dark blue", "healthcare → white + teal", "finance → navy + gold", "crypto → neon on black" — it's the training-data reflex. Rework the scene sentence and color strategy until the answer is no longer obvious from the domain.
324124325325-- Any strong preferences for visual direction? (minimal, bold, elegant, playful, technical, organic, etc.)
326326-- Light mode, dark mode, or both?
327327-- Any colors that must be used or avoided?
125125+## Commands
328126329329-#### Accessibility & Inclusion
330330-331331-- Specific accessibility requirements? (WCAG level, known user needs)
332332-- Considerations for reduced motion, color blindness, or other accommodations?
333333-334334-Skip questions where the answer is already clear from the codebase exploration.
335335-336336-### Step 3: Write Design Context
337337-338338-Synthesize your findings and the user's answers into a `## Design Context` section:
339339-340340-```markdown
341341-## Design Context
127127+| Command | Category | Description | Reference |
128128+|---|---|---|---|
129129+| `craft [feature]` | Build | Shape, then build a feature end-to-end | [reference/craft.md](reference/craft.md) |
130130+| `shape [feature]` | Build | Plan UX/UI before writing code | [reference/shape.md](reference/shape.md) |
131131+| `teach` | Build | Set up PRODUCT.md and DESIGN.md context | [reference/teach.md](reference/teach.md) |
132132+| `document` | Build | Generate DESIGN.md from existing project code | [reference/document.md](reference/document.md) |
133133+| `extract [target]` | Build | Pull reusable tokens and components into design system | [reference/extract.md](reference/extract.md) |
134134+| `critique [target]` | Evaluate | UX design review with heuristic scoring | [reference/critique.md](reference/critique.md) |
135135+| `audit [target]` | Evaluate | Technical quality checks (a11y, perf, responsive) | [reference/audit.md](reference/audit.md) |
136136+| `polish [target]` | Refine | Final quality pass before shipping | [reference/polish.md](reference/polish.md) |
137137+| `bolder [target]` | Refine | Amplify safe or bland designs | [reference/bolder.md](reference/bolder.md) |
138138+| `quieter [target]` | Refine | Tone down aggressive or overstimulating designs | [reference/quieter.md](reference/quieter.md) |
139139+| `distill [target]` | Refine | Strip to essence, remove complexity | [reference/distill.md](reference/distill.md) |
140140+| `harden [target]` | Refine | Production-ready: errors, i18n, edge cases | [reference/harden.md](reference/harden.md) |
141141+| `onboard [target]` | Refine | Design first-run flows, empty states, activation | [reference/onboard.md](reference/onboard.md) |
142142+| `animate [target]` | Enhance | Add purposeful animations and motion | [reference/animate.md](reference/animate.md) |
143143+| `colorize [target]` | Enhance | Add strategic color to monochromatic UIs | [reference/colorize.md](reference/colorize.md) |
144144+| `typeset [target]` | Enhance | Improve typography hierarchy and fonts | [reference/typeset.md](reference/typeset.md) |
145145+| `layout [target]` | Enhance | Fix spacing, rhythm, and visual hierarchy | [reference/layout.md](reference/layout.md) |
146146+| `delight [target]` | Enhance | Add personality and memorable touches | [reference/delight.md](reference/delight.md) |
147147+| `overdrive [target]` | Enhance | Push past conventional limits | [reference/overdrive.md](reference/overdrive.md) |
148148+| `clarify [target]` | Fix | Improve UX copy, labels, and error messages | [reference/clarify.md](reference/clarify.md) |
149149+| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) |
150150+| `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) |
151151+| `live` | Iterate | Visual variant mode: pick elements in the browser, generate alternatives | [reference/live.md](reference/live.md) |
342152343343-### Users
153153+Plus two management commands — `pin <command>` and `unpin <command>`, detailed below.
344154345345-[Who they are, their context, the job to be done]
155155+### Routing rules
346156347347-### Brand Personality
157157+1. **No argument** — render the table above as the user-facing command menu, grouped by category. Ask what they'd like to do.
158158+2. **First word matches a command** — load its reference file and follow its instructions. Everything after the command name is the target.
159159+3. **First word doesn't match** — general design invocation. Apply the setup steps, shared design laws, and the loaded register reference, using the full argument as context.
348160349349-[Voice, tone, 3-word personality, emotional goals]
161161+Setup (context gathering, register) is already loaded by then; sub-commands don't re-invoke `$impeccable`.
350162351351-### Aesthetic Direction
163163+If the first word is `craft`, setup still runs first, but [reference/craft.md](reference/craft.md) owns the rest of the flow. If setup invokes `teach` as a blocker, finish teach, refresh context, then resume the original command and target.
352164353353-[Visual tone, references, anti-references, theme]
165165+## Pin / Unpin
354166355355-### Design Principles
167167+**Pin** creates a standalone shortcut so `$<command>` invokes `$impeccable <command>` directly. **Unpin** removes it. The script writes to every harness directory present in the project.
356168357357-[3-5 principles derived from the conversation that should guide all design decisions]
169169+```bash
170170+node .agents/skills/impeccable/scripts/pin.mjs <pin|unpin> <command>
358171```
359172360360-Write this section to `.impeccable.md` in the project root. If the file already exists, update the Design Context section in place.
361361-362362-Then ask the user directly to clarify what you cannot infer. whether they'd also like the Design Context appended to .github/copilot-instructions.md. If yes, append or update the section there as well.
363363-364364-Confirm completion and summarize the key design principles that will now guide all future work.
365365-366366----
367367-368368-## Extract Mode
369369-370370-If this skill is invoked with the argument "extract" (e.g., `/impeccable extract [target]`), follow the [extract flow](reference/extract.md). Pass any additional arguments as the extraction target.
173173+Valid `<command>` is any command from the table above. Report the script's result concisely — confirm the new shortcut on success, relay stderr verbatim on error.
+4
.agents/skills/impeccable/agents/openai.yaml
···11+interface:
22+ display_name: Impeccable
33+ short_description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify,...
44+ default_prompt: Use Impeccable to redesign, critique, audit, or polish this frontend.
+104
.agents/skills/impeccable/reference/brand.md
···11+# Brand register
22+33+When design IS the product: brand sites, landing pages, marketing surfaces, campaign pages, portfolios, long-form content, about pages. The deliverable is the design itself — a visitor's impression is the thing being made.
44+55+The register spans every genre. A tech brand (Stripe, Linear, Vercel). A luxury brand (a hotel, a fashion house). A consumer product (a restaurant, a travel site, a CPG packaging page). A creative studio, an agency portfolio, a band's album page. They all share the stance — *communicate, not transact* — and diverge wildly in aesthetic. Don't collapse them into a single look.
66+77+## The brand slop test
88+99+If someone could look at this and say "AI made that" without hesitation, it's failed. The bar is distinctiveness — a visitor should ask "how was this made?", not "which AI made this?"
1010+1111+Brand isn't a neutral register. AI-generated landing pages have flooded the internet, and average is no longer findable. Restraint without intent now reads as mediocre, not refined. Brand surfaces need a POV, a specific audience, a willingness to risk strangeness. Go big or go home.
1212+1313+**The second slop test: aesthetic lane.** Before committing to moves, name the reference. A Klim-style specimen page is one lane; Stripe-minimal is another; Liquid-Death-acid-maximalism is another. Don't drift into editorial-magazine aesthetics on a brief that isn't editorial. A hiking brand with Cormorant italic drop caps has the wrong register within the register.
1414+1515+## Typography
1616+1717+### Font selection procedure
1818+1919+Every project. Never skip.
2020+2121+1. Read the brief. Write three concrete brand-voice words — not "modern" or "elegant," but "warm and mechanical and opinionated" or "calm and clinical and careful." Physical-object words.
2222+2. List the three fonts you'd reach for by reflex. If any appear in the reflex-reject list below, reject them — they are training-data defaults and they create monoculture.
2323+3. Browse a real catalog (Google Fonts, Pangram Pangram, Future Fonts, Adobe Fonts, ABC Dinamo, Klim, Velvetyne) with the three words in mind. Find the font for the brand as a *physical object* — a museum caption, a 1970s terminal manual, a fabric label, a cheap-newsprint children's book, a concert poster, a receipt from a mid-century diner. Reject the first thing that "looks designy."
2424+4. Cross-check. "Elegant" is not necessarily serif. "Technical" is not necessarily sans. "Warm" is not Fraunces. If the final pick lines up with the original reflex, start over.
2525+2626+### Reflex-reject list
2727+2828+Training-data defaults. Ban list — look further:
2929+3030+Fraunces · Newsreader · Lora · Crimson · Crimson Pro · Crimson Text · Playfair Display · Cormorant · Cormorant Garamond · Syne · IBM Plex Mono · IBM Plex Sans · IBM Plex Serif · Space Mono · Space Grotesk · Inter · DM Sans · DM Serif Display · DM Serif Text · Outfit · Plus Jakarta Sans · Instrument Sans · Instrument Serif
3131+3232+### Pairing and voice
3333+3434+Distinctive + refined is the goal — the specific shape depends on the brand:
3535+3636+- **Editorial / long-form / luxury**: display serif + sans body (a magazine shape).
3737+- **Tech / dev tools / fintech**: one committed sans, usually; custom-tight tracking, strong weight contrast inside a single family.
3838+- **Consumer / food / travel**: warmer pairings, often a humanist sans plus a script or display serif.
3939+- **Creative studios / agencies**: rule-breaking welcome — mono-only, or display-only, or custom-drawn type as voice.
4040+4141+Two families minimum is the rule *only* when the voice needs it. A single well-chosen family with committed weight/size contrast is stronger than a timid display+body pair.
4242+4343+Vary across projects. If the last brief was a serif-display landing page, this one isn't.
4444+4545+### Scale
4646+4747+Modular scale, fluid `clamp()` for headings, ≥1.25 ratio between steps. Flat scales (1.1× apart) read as uncommitted.
4848+4949+Light text on dark backgrounds: add 0.05–0.1 to line-height. Light type reads as lighter weight and needs more breathing room.
5050+5151+## Color
5252+5353+Brand surfaces have permission for Committed, Full palette, and Drenched strategies. Use them. A single saturated color spread across a hero is not excess — it's voice. A beige-and-muted-slate landing page ignores the register.
5454+5555+- Name a real reference before picking a strategy. "Klim Type Foundry #ff4500 orange drench", "Stripe purple-on-white restraint", "Liquid Death acid-green full palette", "Mailchimp yellow full palette", "Condé Nast Traveler muted navy restraint", "Vercel pure black monochrome". Unnamed ambition becomes beige.
5656+- Palette IS voice. A calm brand and a restless brand should not share palette mechanics.
5757+- When the strategy is Committed or Drenched, the color is load-bearing. Don't hedge with neutrals around the edges — commit.
5858+- Don't converge across projects. If the last brand surface was restrained-on-cream, this one is not.
5959+6060+## Layout
6161+6262+- Asymmetric compositions are one option. Break the grid intentionally for emphasis.
6363+- Fluid spacing with `clamp()` that breathes on larger viewports. Vary for rhythm — generous separations, tight groupings.
6464+- Alternative: a strict, visible grid as the voice (brutalist / Swiss / tech-spec aesthetics). Either asymmetric or rigorously-gridded can be "designed" — the failure mode is splitting the difference into a generic centered stack.
6565+- Don't default to centering everything. Left-aligned with asymmetric layouts feels more designed; a strict grid reads as confident structure. A centered-stack hero with icon-title-subtitle cards reads as template.
6666+- When cards ARE the right affordance, use `grid-template-columns: repeat(auto-fit, minmax(280px, 1fr))` — breakpoint-free responsiveness.
6767+6868+## Imagery
6969+7070+Brand surfaces lean on imagery. A restaurant, hotel, magazine, or product landing page without any imagery reads as incomplete, not as restrained. A solid-color rectangle where a hero image should go is worse than a representative stock photo.
7171+7272+**When the brief implies imagery (restaurants, hotels, magazines, photography, hobbyist communities, food, travel, fashion, product), you must ship imagery.** Zero images is a bug, not a design choice. "Restraint" is not an excuse.
7373+7474+- **For greenfield work without local assets, use stock imagery** — Unsplash is the default. The URL shape is `https://images.unsplash.com/photo-{id}?auto=format&fit=crop&w=1600&q=80`. Pick real Unsplash photo IDs you're confident exist (`photo-1559339352-11d035aa65de`, `photo-1590490360182-c33d57733427`, etc.); if unsure, pick fewer photos but don't substitute colored `<div>` placeholders.
7575+- **Search for the brand's physical object**, not the generic category: "handmade pasta on a scratched wooden table" beats "Italian food"; "cypress trees above a limestone hotel facade at dusk" beats "luxury hotel".
7676+- **One decisive photo beats five mediocre ones.** Hero imagery should commit to a mood; padding with more stock doesn't rescue an indecisive one.
7777+- **Alt text is part of the voice.** "Coastal fettuccine, hand-cut, served on the terrace" beats "pasta dish".
7878+7979+Tech / dev-tool brands are the exception where zero imagery can be correct — a developer landing page often carries its voice through typography, code samples, diagrams. Know which kind of brand you're working on.
8080+8181+## Motion
8282+8383+- One well-orchestrated page-load with staggered reveals beats scattered micro-interactions — when the brand invites it. Tech-minimal brands often skip entrance motion entirely; the restraint is the voice.
8484+- For collapsing/expanding sections, transition `grid-template-rows` rather than `height`.
8585+8686+## Brand bans (on top of the shared absolute bans)
8787+8888+- Monospace as lazy shorthand for "technical / developer." If the brand isn't technical, mono reads as costume.
8989+- Large rounded-corner icons above every heading. Screams template.
9090+- Single-family pages that picked the family by reflex, not voice. (A single family chosen deliberately is fine.)
9191+- All-caps body copy. Reserve caps for short labels and headings.
9292+- Timid palettes and average layouts. Safe = invisible.
9393+- Zero imagery on a brief that implies imagery (restaurant, hotel, food, travel, fashion, photography, hobbyist). Colored blocks where a hero photo belongs.
9494+- Defaulting to editorial-magazine aesthetics (display serif + italic + drop caps + broadsheet grid) on briefs that aren't magazine-shaped. Editorial is ONE aesthetic lane, not the default brand aesthetic.
9595+9696+## Brand permissions
9797+9898+Brand can afford things product can't. Take them.
9999+100100+- Ambitious first-load motion. Reveals, scroll-triggered transitions, typographic choreography.
101101+- Single-purpose viewports. One dominant idea per fold, long scroll, deliberate pacing.
102102+- Typographic risk. Enormous display type, unexpected italic cuts, mixed cases, hand-drawn headlines, a single oversize word as a hero.
103103+- Unexpected color strategies. Palette IS voice — a calm brand and a restless brand should not share palette mechanics.
104104+- Art direction per section. Different sections can have different visual worlds if the narrative demands it. Consistency of voice beats consistency of treatment.
···2233## Color Spaces: Use OKLCH
4455-**Stop using HSL.** Use OKLCH (or LCH) instead. It's perceptually uniform, meaning equal steps in lightness _look_ equal—unlike HSL where 50% lightness in yellow looks bright while 50% in blue looks dark.
55+**Stop using HSL.** Use OKLCH (or LCH) instead. It's perceptually uniform, meaning equal steps in lightness *look* equal—unlike HSL where 50% lightness in yellow looks bright while 50% in blue looks dark.
6677The OKLCH function takes three components: `oklch(lightness chroma hue)` where lightness is 0-100%, chroma is roughly 0-0.4, and hue is 0-360. To build a primary color and its lighter / darker variants, hold the chroma+hue roughly constant and vary the lightness — but **reduce chroma as you approach white or black**, because high chroma at extreme lightness looks garish.
88···22222323A complete system needs:
24242525-| Role | Purpose | Example |
2626-| ------------ | ----------------------------- | ------------------------- |
2727-| **Primary** | Brand, CTAs, key actions | 1 color, 3-5 shades |
2828-| **Neutral** | Text, backgrounds, borders | 9-11 shade scale |
2525+| Role | Purpose | Example |
2626+|------|---------|---------|
2727+| **Primary** | Brand, CTAs, key actions | 1 color, 3-5 shades |
2828+| **Neutral** | Text, backgrounds, borders | 9-11 shade scale |
2929| **Semantic** | Success, error, warning, info | 4 colors, 2-3 shades each |
3030-| **Surface** | Cards, modals, overlays | 2-3 elevation levels |
3030+| **Surface** | Cards, modals, overlays | 2-3 elevation levels |
31313232**Skip secondary/tertiary unless you need them.** Most apps work fine with one accent color. Adding more creates decision fatigue and visual noise.
3333···3939- **30%**: Secondary colors—text, borders, inactive states
4040- **10%**: Accent—CTAs, highlights, focus states
41414242-The common mistake: using the accent color everywhere because it's "the brand color." Accent colors work _because_ they're rare. Overuse kills their power.
4242+The common mistake: using the accent color everywhere because it's "the brand color." Accent colors work *because* they're rare. Overuse kills their power.
43434444## Contrast & Accessibility
45454646### WCAG Requirements
47474848-| Content Type | AA Minimum | AAA Target |
4949-| ------------------------------- | ---------- | ---------- |
5050-| Body text | 4.5:1 | 7:1 |
5151-| Large text (18px+ or 14px bold) | 3:1 | 4.5:1 |
5252-| UI components, icons | 3:1 | 4.5:1 |
5353-| Non-essential decorations | None | None |
4848+| Content Type | AA Minimum | AAA Target |
4949+|--------------|------------|------------|
5050+| Body text | 4.5:1 | 7:1 |
5151+| Large text (18px+ or 14px bold) | 3:1 | 4.5:1 |
5252+| UI components, icons | 3:1 | 4.5:1 |
5353+| Non-essential decorations | None | None |
54545555**The gotcha**: Placeholder text still needs 4.5:1. That light gray placeholder you see everywhere? Usually fails WCAG.
5656···83838484You can't just swap colors. Dark mode requires different design decisions:
85858686-| Light Mode | Dark Mode |
8787-| ------------------ | --------------------------------------------- |
8888-| Shadows for depth | Lighter surfaces for depth (no shadows) |
8989-| Dark text on light | Light text on dark (reduce font weight) |
9090-| Vibrant accents | Desaturate accents slightly |
9191-| White backgrounds | Never pure black—use dark gray (oklch 12-18%) |
8686+| Light Mode | Dark Mode |
8787+|------------|-----------|
8888+| Shadows for depth | Lighter surfaces for depth (no shadows) |
8989+| Dark text on light | Light text on dark (reduce font weight) |
9090+| Vibrant accents | Desaturate accents slightly |
9191+| White backgrounds | Never pure black—use dark gray (oklch 12-18%) |
92929393In dark mode, depth comes from surface lightness, not shadow. Build a 3-step surface scale where higher elevations are lighter (e.g. 15% / 20% / 25% lightness). Use the SAME hue and chroma as your brand color (whatever it is for THIS project — do not reach for blue) and only vary the lightness. Reduce body text weight slightly (e.g. 350 instead of 400) because light text on dark reads as heavier than dark text on light.
9494
+151-31
.agents/skills/impeccable/reference/craft.md
···11# Craft Flow
2233-Build a feature with impeccable UX and UI quality through a structured process: shape the design, load the right references, then build and iterate visually until the result is delightful.
33+Build a feature with impeccable UX and UI quality through a structured process: shape the design, land the visual direction, build real production code, then inspect and improve in-browser until the result meets a high-end studio bar.
44+55+## Build Gate
66+77+Craft cannot build until all of these are true:
88+99+1. PRODUCT context is valid and current.
1010+2. The shape design brief is explicitly confirmed by the user for this task, unless the user already provided a confirmed brief.
1111+3. Implementation references from the brief are loaded.
1212+4. The shape visual probe decision is recorded: generated, skipped with reason, or already resolved.
1313+5. The north-star mock decision is recorded: generated, skipped with reason, or not applicable.
1414+1515+PRODUCT.md and `teach` answers do **not** satisfy the shape gate. They are project context only. A compact self-authored brief does not satisfy the shape gate either. `shape=pass` requires a separate user response approving the shape brief or an already-confirmed brief supplied by the user.
1616+1717+Invalid image-skip reasons include: "the final implementation will be semantic HTML/CSS/SVG", "the diagram should stay editable", "a raster mock would not be used directly", or "the product is fictional." Generated probes and mocks are direction artifacts; they are not implementation assets.
1818+1919+## Craft Contract
2020+2121+Craft is not a first pass. It is a loop with these required artifacts:
2222+2323+1. Confirmed design brief from `shape`.
2424+2. Approved visual direction, from generated probes / mocks when image generation is available.
2525+3. Mock fidelity inventory: the visible ingredients from the approved direction that must survive into code.
2626+4. Semantic, functional implementation using the project's real stack and conventions.
2727+5. Browser evidence across relevant viewports.
2828+6. At least one critique-and-fix pass after the first browser inspection, unless the first pass has no material defects.
2929+3030+Do not let generated mockups replace interface structure, copy, accessibility, responsive behavior, or state design. But do treat the approved mock as a concrete visual contract for composition, hierarchy, density, atmosphere, signature motifs, image needs, and distinctive visual moves. "North star" means "preserve the important visible ingredients in semantic code," not "use it as loose mood."
431532## Step 1: Shape the Design
63377-Run /shape, passing along whatever feature description the user provided.
3434+Run $impeccable shape, passing along whatever feature description the user provided.
83599-Wait for the design brief to be fully confirmed before proceeding. The brief is your blueprint, and every implementation decision should trace back to it.
3636+Wait for the design brief to be fully confirmed by the user before proceeding. The brief is your blueprint, and every implementation decision should trace back to it.
3737+3838+If this craft run resumed after `teach` created PRODUCT.md, run shape now. Do not treat the teach interview, PRODUCT.md, or a summary of project context as a substitute for shape. Shape is task-specific and must cover scope, content/states, visual direction, constraints, anti-goals, probes when applicable, and explicit brief confirmation.
10391111-If the user has already run /shape and has a confirmed design brief, skip this step and use the existing brief.
4040+If the user has already run $impeccable shape and has a confirmed design brief, skip this step and use the existing brief.
12411342## Step 2: Load References
1443···1847- [typography.md](typography.md) for type hierarchy
19482049Then add references based on the brief's needs:
2121-2250- Complex interactions or forms? Consult [interaction-design.md](interaction-design.md)
2351- Animation or transitions? Consult [motion-design.md](motion-design.md)
2452- Color-heavy or themed? Consult [color-and-contrast.md](color-and-contrast.md)
2553- Responsive requirements? Consult [responsive-design.md](responsive-design.md)
2654- Heavy on copy, labels, or errors? Consult [ux-writing.md](ux-writing.md)
27552828-## Step 3: Build
5656+## Step 3: Land the Visual Direction (Capability-Gated)
5757+5858+Before implementation, generate high-fidelity visual comps when all of these are true:
5959+6060+- The work is **net-new** or visually open-ended enough that composition exploration will improve the build.
6161+- The brief's scope is **mid-fi, high-fi, or production-ready**.
6262+- The current harness has **built-in image generation capability** (for example, Codex with a native image tool). Do **not** ask the user to set up external APIs, shell scripts, or one-off tooling just to do this.
6363+6464+When those conditions are met, this step is mandatory for **both brand and product work** in Codex and any harness with built-in image generation. Use native image generation; in Codex, use the built-in `image_gen` tool via the imagegen skill. If image generation is unavailable, do not ask the user to install APIs or tooling. State in one line that the image step is skipped because the harness lacks native image generation, then proceed.
6565+6666+Do not skip this step because the eventual UI should be semantic, editable, code-native, responsive, or accessible. Those are implementation requirements, not reasons to avoid visual exploration.
6767+6868+### Purpose
6969+7070+Use the mock step to find a stronger visual lane than code-first generation would reliably discover on its own. The brief remains authoritative on user, purpose, content, constraints, states, and anti-goals. The mock clarifies composition, hierarchy, density, typography, and visual tone.
7171+7272+### What to generate
7373+7474+Generate **1 to 3** high-fidelity north-star comps based on the confirmed brief. If shape already produced direction probes, use those results as input and generate a more resolved mock from the winning lane, not another unrelated exploration.
7575+7676+- For brand work, push visual identity, composition, and mood aggressively.
7777+- For product work, still push hierarchy, topology, density, and tone, but keep the comps grounded in realistic product structure and states.
7878+- For landing pages and long-form brand surfaces, show enough of the next section or second fold to establish the system beyond the hero.
7979+8080+The comps must be genuinely different in primary visual direction, not just color variants.
8181+8282+### Approval loop
8383+8484+Show the comps and ask what should carry forward. If the user asks for changes or the best direction is still weak, generate a focused revision before implementation. Continue until one direction is approved, or until the user explicitly delegates the choice.
8585+8686+If the user delegates, pick the strongest direction and explain the decision using the brief, not personal taste.
29873030-Implement the feature following the design brief. Work in this order:
8888+Before moving to implementation, summarize:
31893232-1. **Structure first**: HTML/semantic structure for the primary state. No styling yet.
3333-2. **Layout and spacing**: Establish the spatial rhythm and visual hierarchy.
3434-3. **Typography and color**: Apply the type scale and color system.
3535-4. **Interactive states**: Hover, focus, active, disabled.
3636-5. **Edge case states**: Empty, loading, error, overflow, first-run.
3737-6. **Motion**: Purposeful transitions and animations (if appropriate).
3838-7. **Responsive**: Adapt for different viewports. Don't just shrink; redesign for the context.
9090+- What to carry into code
9191+- What **not** to literalize from the mock
39924040-### During Build
9393+This summary is required before Step 4. It is the handoff between visual exploration and semantic implementation.
41944242-- Test with real (or realistic) data at every step, not placeholder text
4343-- Check each state as you build it, not all at the end
4444-- If you discover a design question, stop and ask rather than guessing
4545-- Every visual choice should trace back to something in the design brief
9595+### Mock fidelity inventory
46964747-## Step 4: Visual Iteration
9797+Before building, inventory the approved mock's major visible ingredients:
9898+9999+- Hero silhouette and dominant composition.
100100+- Signature motifs: planets, devices, portraits, charts, route lines, insets, badges, or other memorable objects.
101101+- Nav and primary CTA treatment.
102102+- Section sequence visible in the mock, especially the second fold.
103103+- Image-native content the concept depends on.
104104+- Typography, density, color/material treatment, and motion cues.
105105+106106+For each ingredient, decide how it will be implemented: semantic HTML/CSS/SVG, generated asset, sourced project asset, icon library, canvas/WebGL, or an explicitly accepted omission. Do not substitute a different hero composition or new visual driver after approval unless the user approves the change.
107107+108108+Treat the mock as a **north star**, not a screenshot to trace. Do **not** rasterize core UI text or let the mock override the confirmed brief. But if the live result lacks the mock's major visible ingredients, the implementation is wrong.
109109+110110+## Step 4: Asset Extraction (Need-Gated)
111111+112112+If the chosen direction includes image-native visual ingredients that would materially improve the implementation, generate them as separate assets before building.
113113+114114+Good candidates:
115115+116116+- stickers
117117+- badges
118118+- seals
119119+- tickets
120120+- graphic labels
121121+- textures
122122+- abstract objects
123123+- decorative marks
124124+- non-semantic scene elements
125125+126126+For travel, editorial, portfolio, venue, product showcase, entertainment, education, or any other image-led brand surface, visual assets are usually core content, not decoration. Do not ship abstract CSS panels where the approved mock or subject matter calls for real imagery, generated plates, illustrations, maps, product/object renders, or destination scenes.
127127+128128+Do **not** export assets for core UI text, navigation, body copy, or any structure that should stay semantic and editable in code.
129129+130130+Usually **1 to 5** extracted assets is enough. If the design can be built cleanly in HTML/CSS/SVG, prefer that over raster assets. If the mock contains major visual content that cannot be built credibly in code, asset extraction is not optional.
131131+132132+## Step 5: Build to Production Quality
133133+134134+Implement the feature following the design brief. Build in passes so structure, visual system, states, motion/media, and responsive behavior each get deliberate attention. The list below is the definition of done, not inspiration.
135135+136136+### Production bar
137137+138138+- Use real or realistic content. Remove placeholder copy, placeholder images, dead links, fake controls, and unused scaffold before presenting.
139139+- Preserve the approved mock's major ingredients. Missing hero objects, missing world/product imagery, different section structure, downgraded CTA/nav treatment, or generic replacements for distinctive motifs are blocking defects unless the user accepted the change.
140140+- Build semantically first: real headings, landmarks, labels, form associations, button/link semantics, accessible names, and state announcements where needed.
141141+- Calibrate spacing, alignment, grid placement, and vertical rhythm deliberately. Do not accept default gaps, arbitrary margins, unbalanced whitespace, or accidental optical misalignment.
142142+- Make typography intentional: chosen font loading strategy, clear hierarchy, readable measure, stable line breaks, tuned wrapping, and no overflow at mobile or large desktop sizes.
143143+- Design realistic state coverage: default, hover where supported, focus-visible, active, disabled, loading, error, success, empty, overflow, long text, short text, and first-run states where relevant.
144144+- Make interaction quality feel finished: keyboard paths, touch targets, feedback timing, scroll behavior, transitions between states, and no hover-only functionality.
145145+- Use icons from the project's established icon set when available. If no set exists, choose a coherent library or use accessible text controls; do not mix unrelated icon styles.
146146+- Optimize imagery and media: correct dimensions, useful alt text, lazy loading below the fold, modern formats when practical, responsive `srcset` / `picture` for raster assets, and no project-referenced asset left outside the workspace.
147147+- Make motion feel premium: use atmospheric blur, filter, mask, shadow, or reveal effects when they improve the experience; avoid casual layout-property animation, bound expensive effects, verify smoothness in-browser, respect reduced motion, and avoid choreography that blocks task completion.
148148+- Preserve maintainability: reusable local patterns, clear component boundaries, project conventions, no rasterized UI text, and no hard-coded one-off hacks when a better local pattern exists.
149149+- Fit the technical context: production build passes, no obvious console errors, no avoidable layout shift, no needless dependency, and no broken asset path.
150150+- If you discover a design question that materially changes the brief or approved direction, stop and ask rather than guessing.
151151+152152+## Step 6: Browser-Based Iteration
4815349154**This step is critical.** Do not stop after the first implementation pass.
501555151-Open the result in a browser window. If browser automation tools are available, use them to navigate to the page and visually inspect the result. If not, ask the user to open it and provide feedback.
156156+Open the result in a browser. In Codex, use browser-use or equivalent browser automation when available; otherwise use Playwright or ask the user for screenshots. Inspect screenshots, not just DOM or terminal output.
157157+158158+### Required viewport pass
159159+160160+Check the experience at the viewports that matter for the brief. Default minimum:
161161+162162+- Mobile narrow
163163+- Tablet or small laptop
164164+- Desktop wide
165165+166166+For each viewport, capture or inspect the rendered state and look for visual defects: overlap, clipping, weak hierarchy, off-grid alignment, awkward whitespace, cramped controls, unreadable type, broken imagery, hover-only functionality, layout shift, and text overflow.
167167+168168+### Critique and fix loop
521695353-Iterate through these checks visually:
170170+After the first browser pass, write a short critique for yourself and patch the implementation. Repeat browser inspection after fixes. Continue until no material issues remain against this checklist:
54171551721. **Does it match the brief?** Compare the live result against every section of the design brief. Fix discrepancies.
5656-2. **Does it pass the AI slop test?** If someone saw this and said "AI made this," would they believe it immediately? If yes, it needs more design intention.
5757-3. **Check against impeccable's DON'T guidelines.** Fix any anti-pattern violations.
5858-4. **Check every state.** Navigate through empty, error, loading, and edge case states. Each one should feel intentional, not like an afterthought.
5959-5. **Check responsive.** Resize the viewport. Does it adapt well or just shrink?
6060-6. **Check the details.** Spacing consistency, type hierarchy clarity, color contrast, interactive feedback, motion timing.
173173+2. **Does it match the approved mock?** Compare screenshots against the mock fidelity inventory: hero silhouette, major motifs, imagery, nav/CTA, section sequence, density, color/materials, and second-fold substance. Missing major ingredients are P0 defects.
174174+3. **Does it pass the AI slop test?** If someone saw this and said "AI made this," would they believe it immediately? If yes, it needs more design intention.
175175+4. **Check against impeccable's DON'T guidelines.** Fix any anti-pattern violations.
176176+5. **Check every state.** Navigate through empty, error, loading, and edge case states. Each one should feel intentional, not like an afterthought.
177177+6. **Check responsive behavior.** The design should adapt compositionally, not merely shrink.
178178+7. **Check craft details.** Spacing consistency, optical alignment, type hierarchy, color contrast, image quality, icon coherence, interactive feedback, motion timing, and focus treatment.
179179+8. **Check performance basics.** No obviously oversized images, avoidable layout thrash, blocking animations, or heavy assets without a reason.
611806262-After each round of fixes, visually verify again. **Repeat until you would be proud to show this to the user.** The bar is not "it works"; the bar is "this delights."
181181+The exit bar is not "it works." It is: the rendered result looks intentional at all checked viewports, all expected states are handled, no placeholders remain unless explicitly accepted, and the implementation quality would be defensible in a high-end studio review.
631826464-## Step 5: Present
183183+## Step 7: Present
6518466185Present the result to the user:
6767-68186- Show the feature in its primary state
187187+- Summarize the browser/viewports checked and the most important fixes made after inspection
69188- Walk through the key states (empty, error, responsive)
7070-- Explain design decisions that connect back to the design brief
189189+- Explain design decisions that connect back to the design brief and, when used, the chosen north-star mock. Include any accepted deviations from the mock; do not hide unimplemented mock ingredients.
190190+- Note any remaining limitations or follow-up risks honestly
71191- Ask: "What's working? What isn't?"
7219273193Iterate based on feedback. Good design is rarely right on the first pass.
+427
.agents/skills/impeccable/reference/document.md
···11+Generate a `DESIGN.md` file at the project root that captures the current visual design system, so AI agents generating new screens stay on-brand.
22+33+DESIGN.md follows the [official Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/): YAML frontmatter carrying machine-readable design tokens, followed by a markdown body with exactly six sections in a fixed order. **Tokens are normative; prose provides context for how to apply them.** Sections may be omitted when not relevant, but **do not reorder them and do not rename them**. Section headers must match the spec character-for-character so the file stays parseable by other DESIGN.md-aware tools (Stitch itself, awesome-design-md, skill-rest, etc.).
44+55+## The frontmatter: token schema
66+77+The YAML frontmatter is the machine-readable layer. It's what Stitch's linter validates and what the live panel renders tiles from. Keep it tight; every entry should correspond to a token the project actually uses.
88+99+```yaml
1010+---
1111+name: <project title>
1212+description: <one-line tagline>
1313+colors:
1414+ primary: "#b8422e"
1515+ neutral-bg: "#faf7f2"
1616+ # ...one entry per extracted color; key = descriptive slug
1717+typography:
1818+ display:
1919+ fontFamily: "Cormorant Garamond, Georgia, serif"
2020+ fontSize: "clamp(2.5rem, 7vw, 4.5rem)"
2121+ fontWeight: 300
2222+ lineHeight: 1
2323+ letterSpacing: "normal"
2424+ body:
2525+ # ...
2626+rounded:
2727+ sm: "4px"
2828+ md: "8px"
2929+spacing:
3030+ sm: "8px"
3131+ md: "16px"
3232+components:
3333+ button-primary:
3434+ backgroundColor: "{colors.primary}"
3535+ textColor: "{colors.neutral-bg}"
3636+ rounded: "{rounded.sm}"
3737+ padding: "16px 48px"
3838+ button-primary-hover:
3939+ backgroundColor: "{colors.primary-deep}"
4040+---
4141+```
4242+4343+Rules that matter:
4444+4545+- **Token refs** use `{path.to.token}` (e.g. `{colors.primary}`, `{rounded.md}`). Components may reference primitives; primitives may not reference each other.
4646+- **Stitch validates colors as hex sRGB only** (`#RGB` / `#RGBA` / `#RRGGBB` / `#RRGGBBAA`); OKLCH/HSL/P3 trigger a linter warning, not a hard error. YAML accepts the string either way and our own parser is format-agnostic. Choose based on project posture: (a) if the project has an "OKLCH-only" doctrine or uses Display-P3 values that don't round-trip through sRGB, put OKLCH directly in the frontmatter and accept the Stitch linter warning; (b) if the project wants strict Stitch compliance or plans to use their Tailwind/DTCG export pipeline, put hex in the frontmatter and keep OKLCH in prose as the canonical reference. Never split the source of truth without explicit reason.
4747+- **Component sub-tokens** are limited to 8 props: `backgroundColor`, `textColor`, `typography`, `rounded`, `padding`, `size`, `height`, `width`. Shadows, motion, focus rings, backdrop-filter — none of those fit. Carry them in the sidecar (Step 4b).
4848+- **Scale keys are open-ended.** Use whatever names the project already uses (`warm-ash-cream`, `surface-container-low`). Don't rename to Material defaults.
4949+- **Variants are naming convention, not schema.** `button-primary` / `button-primary-hover` / `button-primary-active` as sibling keys.
5050+5151+## The markdown body: six sections (exact order)
5252+5353+1. `## Overview`
5454+2. `## Colors`
5555+3. `## Typography`
5656+4. `## Elevation`
5757+5. `## Components`
5858+6. `## Do's and Don'ts`
5959+6060+Optional evocative subtitles are allowed in the form `## 2. Colors: The [Name] Palette` — Stitch's own outputs do this — but the literal word in each header (Overview, Colors, Typography, Elevation, Components, Do's and Don'ts) must be present. Do NOT add extra top-level sections (Layout Principles, Responsive Behavior, Motion, Agent Prompt Guide). Fold that content into the six spec sections where it naturally belongs.
6161+6262+## When to run
6363+6464+- The user just ran `$impeccable teach` and needs the visual side documented.
6565+- The skill noticed no `DESIGN.md` exists and nudged the user to create one.
6666+- An existing `DESIGN.md` is stale (the design has drifted).
6767+- Before a large redesign, to capture the current state as a reference.
6868+6969+If a `DESIGN.md` already exists, **do not silently overwrite it**. Show the user the existing file and STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. whether to refresh, overwrite, or merge.
7070+7171+## Two paths
7272+7373+- **Scan mode** (default): the project has design tokens, components, or rendered output. Extract, then confirm descriptive language. Use when there's code to analyze.
7474+- **Seed mode**: the project is pre-implementation (fresh teach, nothing built yet). Interview for five high-level answers, write a minimal DESIGN.md marked `<!-- SEED -->`. Re-run in scan mode once there's code.
7575+7676+Decide by scanning first (Scan mode Step 1). If the scan finds no tokens, no component files, and no rendered site, offer seed mode — don't silently switch. `$impeccable document --seed` forces seed mode regardless of code presence.
7777+7878+## Scan mode (approach C: auto-extract, then confirm descriptive language)
7979+8080+### Step 1: Find the design assets
8181+8282+Search the codebase in priority order:
8383+8484+1. **CSS custom properties** — grep for `--color-`, `--font-`, `--spacing-`, `--radius-`, `--shadow-`, `--ease-`, `--duration-` declarations in CSS files (usually `src/styles/`, `public/css/`, `app/globals.css`, etc.). Record name, value, and the file it's defined in.
8585+2. **Tailwind config** — if `tailwind.config.{js,ts,mjs}` exists, read the `theme.extend` block for colors, fontFamily, spacing, borderRadius, boxShadow.
8686+3. **CSS-in-JS theme files** — styled-components, emotion, vanilla-extract, stitches: look for `theme.ts`, `tokens.ts`, or equivalent.
8787+4. **Design token files** — `tokens.json`, `design-tokens.json`, Style Dictionary output, W3C token community group format.
8888+5. **Component library** — scan the main button, card, input, navigation, dialog components. Note their variant APIs and default styles.
8989+6. **Global stylesheet** — the root CSS file usually has the base typography and color assignments.
9090+7. **Visible rendered output** — if browser automation tools are available, load the live site and sample computed styles from key elements (body, h1, a, button, .card). This catches values that tokens miss.
9191+9292+### Step 2: Auto-extract what can be auto-extracted
9393+9494+Build a structured draft from the discovered tokens. For each token class:
9595+9696+- **Colors**: Group into Primary / Secondary / Tertiary / Neutral (the Material-derived roles Stitch uses). If the project only has one accent, express it as Primary + Neutral — omit Secondary and Tertiary rather than inventing them.
9797+- **Typography**: Map observed sizes and weights to the Material hierarchy (display / headline / title / body / label). Note font-family stacks and the scale ratio.
9898+- **Elevation**: Catalogue the shadow vocabulary. If the project is flat and uses tonal layering instead, that's a valid answer — state it explicitly.
9999+- **Components**: For each common component (button, card, input, chip, list item, tooltip, nav), extract shape (radius), color assignment, hover/focus treatment, internal padding.
100100+- **Spacing + layout**: Fold into Overview or relevant Components. The spec does NOT have a Layout section.
101101+102102+### Step 2b: Stage the frontmatter
103103+104104+From the auto-extracted tokens, draft the YAML frontmatter now (you'll write it at the top of DESIGN.md in Step 4). This is the machine-readable layer — what the live panel and Stitch's linter consume.
105105+106106+- **Colors**: one entry per extracted color. Key = descriptive slug (`warm-ash-cream`, `editorial-magenta`, not `blue-800`). Value = whichever format the project treats as canonical (OKLCH or hex — see the frontmatter rules above). Don't split the source of truth: one format in the frontmatter, don't redefine the same token in prose with a different value.
107107+- **Typography**: one entry per role (`display`, `headline`, `title`, `body`, `label`). Typography is an object; include only the props that are real for the project (`fontFamily`, `fontSize`, `fontWeight`, `lineHeight`, `letterSpacing`, `fontFeature`, `fontVariation`).
108108+- **Rounded / Spacing**: whatever scale steps the project actually uses, keyed by whatever scale name the project uses (`sm` / `md` / `lg`, or `surface-sm`, or numeric steps).
109109+- **Components**: one entry per variant (`button-primary`, `button-primary-hover`, `button-ghost`). Reference primitives via `{colors.X}`, `{rounded.Y}`. If a variant needs a property Stitch's 8-prop set doesn't cover (shadow, focus ring, backdrop-filter), carry the full snippet in the sidecar instead.
110110+111111+Skip anything the project doesn't have. Empty scale keys or fabricated tokens pollute the spec.
112112+113113+### Step 3: Ask the user for qualitative language
114114+115115+The following require creative input that cannot be auto-extracted. Group them into one `AskUserQuestion` interaction:
116116+117117+- **Creative North Star**: a single named metaphor for the whole system ("The Editorial Sanctuary", "The Golden State Curator", "The Lab Notebook"). Offer 2-3 options that honor PRODUCT.md's brand personality.
118118+- **Overview voice**: mood adjectives, aesthetic philosophy in 2-3 sentences, anti-references (what the system should not feel like).
119119+- **Color character** (for auto-extracted colors): descriptive names ("Deep Muted Teal-Navy", not "blue-800"). Suggest 2-3 options per key color based on hue/saturation.
120120+- **Elevation philosophy**: flat/layered/lifted. If shadows exist, is their role ambient or structural?
121121+- **Component philosophy**: the feel of buttons, cards, inputs in one phrase ("tactile and confident" vs. "refined and restrained").
122122+123123+Quote a line from PRODUCT.md when possible so the user sees their own strategic language carry forward.
124124+125125+### Step 4: Write DESIGN.md
126126+127127+The file opens with the YAML frontmatter staged in Step 2b (schema documented at the top of this reference), then the markdown body using the structure below. Headers must match character-for-character. Optional evocative subtitles (e.g. `## 2. Colors: The Coastal Palette`) are allowed.
128128+129129+```markdown
130130+---
131131+name: [Project Title]
132132+description: [one-line tagline]
133133+colors:
134134+ # ... staged frontmatter from Step 2b
135135+---
136136+137137+# Design System: [Project Title]
138138+139139+## 1. Overview
140140+141141+**Creative North Star: "[Named metaphor in quotes]"**
142142+143143+[2-3 paragraph holistic description: personality, density, aesthetic philosophy. Start from the North Star and work outward. State what this system explicitly rejects (pulled from PRODUCT.md's anti-references). End with a short **Key Characteristics:** bullet list.]
144144+145145+## 2. Colors
146146+147147+[Describe the palette character in one sentence.]
148148+149149+### Primary
150150+- **[Descriptive Name]** (#HEX / oklch(...)): [Where and why this color is used. Be specific about context, not just role.]
151151+152152+### Secondary (optional — omit if the project has only one accent)
153153+- **[Descriptive Name]** (#HEX): [Role.]
154154+155155+### Tertiary (optional)
156156+- **[Descriptive Name]** (#HEX): [Role.]
157157+158158+### Neutral
159159+- **[Descriptive Name]** (#HEX): [Text / background / border / divider role.]
160160+- [...]
161161+162162+### Named Rules (optional, powerful)
163163+**The [Rule Name] Rule.** [Short, forceful prohibition or doctrine — e.g. "The One Voice Rule. The primary accent is used on ≤10% of any given screen. Its rarity is the point."]
164164+165165+## 3. Typography
166166+167167+**Display Font:** [Family] (with [fallback])
168168+**Body Font:** [Family] (with [fallback])
169169+**Label/Mono Font:** [Family, if distinct]
170170+171171+**Character:** [1-2 sentence personality description of the pairing.]
172172+173173+### Hierarchy
174174+- **Display** ([weight], [size/clamp], [line-height]): [Purpose — where it appears.]
175175+- **Headline** ([weight], [size], [line-height]): [Purpose.]
176176+- **Title** ([weight], [size], [line-height]): [Purpose.]
177177+- **Body** ([weight], [size], [line-height]): [Purpose. Include max line length like 65–75ch if relevant.]
178178+- **Label** ([weight], [size], [letter-spacing], [case if uppercase]): [Purpose.]
179179+180180+### Named Rules (optional)
181181+**The [Rule Name] Rule.** [Short doctrine about type use.]
182182+183183+## 4. Elevation
184184+185185+[One paragraph: does this system use shadows, tonal layering, or a hybrid? If "no shadows", say so explicitly and describe how depth is conveyed instead.]
186186+187187+### Shadow Vocabulary (if applicable)
188188+- **[Role name]** (`box-shadow: [exact value]`): [When to use it.]
189189+- [...]
190190+191191+### Named Rules (optional)
192192+**The [Rule Name] Rule.** [e.g. "The Flat-By-Default Rule. Surfaces are flat at rest. Shadows appear only as a response to state (hover, elevation, focus)."]
193193+194194+## 5. Components
195195+196196+For each component, lead with a short character line, then specify shape, color assignment, states, and any distinctive behavior.
197197+198198+### Buttons
199199+- **Shape:** [radius described, exact value in parens]
200200+- **Primary:** [color assignment + padding, in semantic + exact terms]
201201+- **Hover / Focus:** [transitions, treatments]
202202+- **Secondary / Ghost / Tertiary (if applicable):** [brief description]
203203+204204+### Chips (if used)
205205+- **Style:** [background, text color, border treatment]
206206+- **State:** [selected / unselected, filter / action variants]
207207+208208+### Cards / Containers
209209+- **Corner Style:** [radius]
210210+- **Background:** [colors used]
211211+- **Shadow Strategy:** [reference Elevation section]
212212+- **Border:** [if any]
213213+- **Internal Padding:** [scale]
214214+215215+### Inputs / Fields
216216+- **Style:** [stroke, background, radius]
217217+- **Focus:** [treatment — glow, border shift, etc.]
218218+- **Error / Disabled:** [if applicable]
219219+220220+### Navigation
221221+- **Style, typography, default/hover/active states, mobile treatment.**
222222+223223+### [Signature Component] (optional — if the project has a distinctive custom component worth documenting)
224224+[Description.]
225225+226226+## 6. Do's and Don'ts
227227+228228+Concrete, forceful guardrails. Lead each with "Do" or "Don't". Be specific — include exact colors, pixel values, and named anti-patterns the user mentioned in PRODUCT.md. **Every anti-reference in PRODUCT.md should show up here as a "Don't" with the same language**, so the visual spec carries the strategic line through. Quote PRODUCT.md directly where possible: if PRODUCT.md says *"avoid dark mode with purple gradients, neon accents, glassmorphism"*, the Don'ts here should repeat that by name.
229229+230230+### Do:
231231+- **Do** [specific prescription with exact values / named rule].
232232+- **Do** [...]
233233+234234+### Don't:
235235+- **Don't** [specific prohibition — e.g. "use border-left greater than 1px as a colored stripe"].
236236+- **Don't** [...]
237237+- **Don't** [...]
238238+```
239239+240240+### Step 4b: Write DESIGN.json sidecar (extensions only)
241241+242242+The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `DESIGN.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it.
243243+244244+Regenerate the sidecar whenever you regenerate DESIGN.md. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve DESIGN.md and write only DESIGN.json.
245245+246246+#### Schema
247247+248248+```json
249249+{
250250+ "schemaVersion": 2,
251251+ "generatedAt": "ISO-8601 string",
252252+ "title": "Design System: [Project Title]",
253253+ "extensions": {
254254+ "colorMeta": {
255255+ "primary": { "role": "primary", "displayName": "Editorial Magenta", "canonical": "oklch(60% 0.25 350)", "tonalRamp": ["...", "...", "..."] },
256256+ "warm-ash-cream": { "role": "neutral", "displayName": "Warm Ash Cream", "canonical": "oklch(96% 0.005 350)", "tonalRamp": ["...", "...", "..."] }
257257+ },
258258+ "typographyMeta": {
259259+ "display": { "displayName": "Display", "purpose": "Hero headlines only." }
260260+ },
261261+ "shadows": [
262262+ { "name": "ambient-low", "value": "0 4px 24px rgba(0,0,0,0.12)", "purpose": "Diffuse hover glow under accent elements." }
263263+ ],
264264+ "motion": [
265265+ { "name": "ease-standard", "value": "cubic-bezier(0.4, 0, 0.2, 1)", "purpose": "Default easing for state transitions." }
266266+ ],
267267+ "breakpoints": [
268268+ { "name": "sm", "value": "640px" }
269269+ ]
270270+ },
271271+ "components": [
272272+ {
273273+ "name": "Primary Button",
274274+ "kind": "button | input | nav | chip | card | custom",
275275+ "refersTo": "button-primary",
276276+ "description": "One-line what and when.",
277277+ "html": "<button class=\"ds-btn-primary\">GET STARTED</button>",
278278+ "css": ".ds-btn-primary { background: #191c1d; color: #fff; padding: 16px 48px; letter-spacing: 0.05em; text-transform: uppercase; font-weight: 500; border: none; border-radius: 0; transition: background 0.2s, transform 0.2s; } .ds-btn-primary:hover { background: oklch(60% 0.25 350); transform: translateY(-2px); }"
279279+ }
280280+ ],
281281+ "narrative": {
282282+ "northStar": "The Editorial Sanctuary",
283283+ "overview": "2-3 paragraphs of the philosophy — pulled from DESIGN.md Overview section.",
284284+ "keyCharacteristics": ["...", "..."],
285285+ "rules": [{ "name": "The One Voice Rule", "body": "...", "section": "colors|typography|elevation" }],
286286+ "dos": ["Do use ..."],
287287+ "donts": ["Don't use ..."]
288288+ }
289289+}
290290+```
291291+292292+**What changed from schemaVersion 1.** The old sidecar carried token primitive arrays (`tokens.colors[]`, `tokens.typography[]`, etc.). Those values now live in the frontmatter. The sidecar only carries metadata that can't live in the frontmatter — tonal ramps, canonical OKLCH when the hex is an approximation, display names, role hints — keyed by the frontmatter token name (`colorMeta.<token-name>`, `typographyMeta.<token-name>`). Components still carry full HTML/CSS because Stitch's 8-prop set can't hold them.
293293+294294+#### Component translation rules
295295+296296+The `html` and `css` fields must be **self-contained, drop-in snippets** that render correctly when injected into a shadow DOM. The panel applies them directly — no post-processing, no framework runtime.
297297+298298+1. **Tailwind expansion.** If the source uses Tailwind (className="bg-primary text-white rounded-lg px-6 py-3"), expand every utility to literal CSS properties in the `css` string. Do **not** reference Tailwind classes; do **not** assume a Tailwind CSS bundle is loaded. Each component is self-contained.
299299+2. **Token resolution.** If the project exposes tokens as CSS custom properties on `:root` (e.g. `--color-primary`, `--radius-md`), reference them via `var(--color-primary)` — they inherit through the shadow DOM and stay live-bound. If tokens live only in JS theme objects (styled-components, CSS-in-JS), resolve to literal values at generation time.
300300+3. **Icons.** Inline as SVG. Do not reference Lucide/Heroicons packages, icon fonts, or `<img src="...">`. A typical icon is 16-24px; copy the SVG path data directly.
301301+4. **States.** Include `:hover`, `:focus-visible`, and (if meaningful) `:active` rules inline. A static default-only snapshot makes the panel feel dead. Hover + focus rules in the CSS make it feel alive.
302302+5. **Reset bloat.** Extract only the component's *distinctive* CSS (background, color, padding, border-radius, typography, transition). Skip universal resets (`box-sizing: border-box`, `line-height: inherit`, `-webkit-font-smoothing`). The panel already has a neutral canvas; don't re-ship resets.
303303+6. **Scoped class names.** Prefix every class with `ds-` (e.g. `ds-btn-primary`, `ds-input-search`) so component CSS doesn't collide with other components' CSS in the same shadow DOM.
304304+305305+#### What to include
306306+307307+Aim for a tight set of **5-10 components** that best represent the visual system:
308308+309309+- **Canonical primitives (always include if the project has them):** button (each variant as a separate component entry), input/text field, navigation, chip/tag, card.
310310+- **Signature components (include if distinctive):** hero CTA, featured card, filter pill, any custom pattern the user mentioned as important in PRODUCT.md.
311311+- **Skip the rest.** Utility components, form building blocks, wrapper layouts — not worth documenting unless visually distinctive.
312312+313313+If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every DESIGN.json has *something* to render, even on day zero.
314314+315315+#### Tonal ramps
316316+317317+For each color token, generate an 8-step `tonalRamp` array — dark to light, same hue and chroma, stepped lightness from ~15% to ~95%. The panel renders this as a strip under the swatch. If the project already defines a tonal scale (Material `surface-container-low` family, Tailwind-style `blue-50..blue-900`), use those values. Otherwise synthesize in OKLCH.
318318+319319+#### Narrative mapping
320320+321321+Pull directly from the DESIGN.md you just wrote:
322322+323323+- `narrative.northStar` → the `**Creative North Star: "..."**` line from Overview
324324+- `narrative.overview` → the philosophy paragraphs from Overview
325325+- `narrative.keyCharacteristics` → the bulleted `**Key Characteristics:**` list
326326+- `narrative.rules` → every `**The [Name] Rule.** [body]` across all sections, tagged with `section`
327327+- `narrative.dos` / `narrative.donts` → the bullet lists from Do's and Don'ts verbatim
328328+329329+Do not reword. The panel shows these as secondary collapsible context; the same voice that's in the Markdown carries through.
330330+331331+### Step 5: Confirm, refine, and refresh session cache
332332+333333+1. Show the user the full DESIGN.md you wrote. Briefly highlight the non-obvious creative choices (descriptive color names, atmosphere language, named rules).
334334+2. Mention that `DESIGN.json` was also written alongside — the live panel will now render this project's actual button/input/nav primitives instead of generic approximations.
335335+3. Offer to refine any section: "Want me to revise a section, add component patterns I missed, or adjust the atmosphere language?"
336336+4. **Refresh the session cache.** Run `node .agents/skills/impeccable/scripts/load-context.mjs` one final time so the newly-written DESIGN.md lands in conversation. Subsequent commands in this session will use the fresh version automatically without re-reading.
337337+338338+## Seed mode
339339+340340+For projects with no visual system to extract yet. Produces a minimal scaffold, not a full spec.
341341+342342+### Step 1: Confirm seed mode
343343+344344+Before interviewing: "There's no existing visual system to scan. I'll ask five quick questions to seed a starter DESIGN.md. You can re-run `$impeccable document` once there's code, to capture the real tokens and components. OK?"
345345+346346+If the user prefers to skip, stop. No file.
347347+348348+### Step 2: Five questions
349349+350350+Group into one `AskUserQuestion` interaction. Options must be concrete.
351351+352352+1. **Color strategy.** Pick one:
353353+ - Restrained — tinted neutrals + one accent ≤10%
354354+ - Committed — one saturated color carries 30–60% of the surface
355355+ - Full palette — 3–4 named color roles, each deliberate
356356+ - Drenched — the surface IS the color
357357+358358+ Then: one hue family or anchor reference ("deep teal", "mustard", "Klim #ff4500 orange").
359359+360360+2. **Typography direction.** Pick one (specific fonts come later):
361361+ - Serif display + sans body
362362+ - Single sans (warm / technical / geometric / humanist — pick a feel)
363363+ - Display + mono
364364+ - Mono-forward
365365+ - Editorial script + sans
366366+367367+3. **Motion energy.** Pick one:
368368+ - Restrained — state changes only
369369+ - Responsive — feedback + transitions, no choreography
370370+ - Choreographed — orchestrated entrances, scroll-driven sequences
371371+372372+4. **Three named references.** Brands, products, printed objects. Not adjectives.
373373+374374+5. **One anti-reference.** What it should NOT feel like. Also named.
375375+376376+### Step 3: Write seed DESIGN.md
377377+378378+Use the six-section spec from Scan mode. Populate what the interview answers; leave the rest as honest placeholders. The seed is a scaffold, not a fabricated spec.
379379+380380+Lead the file with:
381381+382382+```markdown
383383+<!-- SEED — re-run $impeccable document once there's code to capture the actual tokens and components. -->
384384+```
385385+386386+Per-section guidance in seed mode:
387387+388388+- **Overview**: Creative North Star and philosophy phrased from the answers (color strategy + motion energy + references). Reference the user's anti-reference directly.
389389+- **Colors**: Color strategy as a Named Rule (e.g. *"The Drenched Rule. The surface IS the color."*). Hue family or anchor reference. No hex values — mark as `[to be resolved during implementation]`.
390390+- **Typography**: the direction the user picked (e.g. "Serif display + sans body"). No font names yet — `[font pairing to be chosen at implementation]`.
391391+- **Elevation**: inferred from motion energy. Restrained/Responsive → flat by default; Choreographed → layered. One sentence.
392392+- **Components**: omit entirely — no components exist yet.
393393+- **Do's and Don'ts**: carry PRODUCT.md's anti-references directly plus the anti-reference named in Q5.
394394+395395+Seed mode writes a minimal frontmatter with `name` and `description` only — no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `DESIGN.json` sidecar in seed mode for the same reason: nothing to render.
396396+397397+### Step 4: Confirm and refresh session cache
398398+399399+1. Show the seed DESIGN.md. Call out that it is a seed (the marker is the literal commitment).
400400+2. Tell the user: "Re-run `$impeccable document` once you have some code. That pass will extract real tokens and generate the sidecar."
401401+3. Run `node .agents/skills/impeccable/scripts/load-context.mjs` once so the seed lands in conversation for the rest of the session.
402402+403403+## Style guidelines
404404+405405+- **Frontmatter first, prose second.** Tokens go in the YAML frontmatter; prose contextualizes them. Don't redefine a token value in two places — the frontmatter is normative.
406406+- **Cite PRODUCT.md anti-references by name** in the Do's and Don'ts section. If PRODUCT.md lists "SaaS landing-page clichés" or "generic AI tool marketing" as anti-references, the DESIGN.md Don'ts should repeat those phrases verbatim so the visual spec enforces the strategic line.
407407+- **Match the spec, don't invent new sections.** The six section names are fixed. If you have Layout/Motion/Responsive content to document, fold it into Overview (philosophy-level rules) or Components (per-component behavior).
408408+- **Descriptive > technical**: "Gently curved edges (8px radius)" > "rounded-lg". Include the technical value in parens, lead with the description.
409409+- **Functional > decorative**: for each token, explain WHERE and WHY it's used, not just WHAT it is.
410410+- **Exact values in parens**: hex codes, px/rem values, font weights — always the number in parens alongside the description.
411411+- **Use Named Rules**: `**The [Name] Rule.** [short doctrine]`. These are memorable, citable, and much stickier for AI consumers than bullet lists. Stitch's own outputs use them heavily ("The No-Line Rule", "The Ghost Border Fallback"). Aim for 1-3 per section.
412412+- **Be forceful**. The voice of a design director. "Prohibited", "forbidden", "never", "always" — not "consider", "might", "prefer". Match PRODUCT.md's tone.
413413+- **Concrete anti-pattern tests**. Stitch writes things like *"If it looks like a 2014 app, the shadow is too dark and the blur is too small."* A one-sentence audit test beats a paragraph of principle.
414414+- **Reference PRODUCT.md**. The anti-references section of PRODUCT.md should directly inform the Do's and Don'ts section here. Quote or paraphrase.
415415+- **Group colors by role**, not by hex-order or hue-order. Primary / Secondary / Tertiary / Neutral is the spec ordering.
416416+417417+## Pitfalls
418418+419419+- Don't paste raw CSS class names. Translate to descriptive language.
420420+- Don't extract every token. Stop at what's actually reused — one-offs pollute the system.
421421+- Don't invent components that don't exist. If the project only has buttons and cards, only document those.
422422+- Don't overwrite an existing DESIGN.md without asking.
423423+- Don't duplicate content from PRODUCT.md. DESIGN.md is strictly visual.
424424+- Don't add a "Layout Principles" or "Motion" or "Responsive Behavior" top-level section. The spec has six, not nine. Fold that content where it belongs.
425425+- Don't rename sections even slightly. "Colors" not "Color Palette & Roles". "Typography" not "Typography Rules". Tooling parsing depends on exact headers.
426426+- Don't duplicate token values between frontmatter and prose. If a color is in `colors.primary` as hex, the prose can name it and describe its role but should not reassert a different hex. The frontmatter is normative.
427427+- Don't invent frontmatter token groups outside Stitch's schema (no `motion:`, `breakpoints:`, `shadows:` at the top level). Stitch's Zod schema only accepts `colors`, `typography`, `rounded`, `spacing`, `components`. Anything else belongs in the sidecar's `extensions`.
+1-2
.agents/skills/impeccable/reference/extract.md
···6677Find the design system, component library, or shared UI directory. Understand its structure: component organization, naming conventions, design token structure, import/export conventions.
8899-**CRITICAL**: If no design system exists, ask the user directly to clarify what you cannot infer. before creating one. Understand the preferred location and structure first.
99+**CRITICAL**: If no design system exists, STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. before creating one. Understand the preferred location and structure first.
10101111## Step 2: Identify Patterns
1212···6060- Update any Storybook or component catalog
61616262**NEVER**:
6363-6463- Extract one-off, context-specific implementations without generalization
6564- Create components so generic they are useless
6665- Extract without considering existing design system conventions
···4455Every interactive element needs these states designed:
6677-| State | When | Visual Treatment |
88-| ------------ | --------------------------- | --------------------------- |
99-| **Default** | At rest | Base styling |
1010-| **Hover** | Pointer over (not touch) | Subtle lift, color shift |
1111-| **Focus** | Keyboard/programmatic focus | Visible ring (see below) |
1212-| **Active** | Being pressed | Pressed in, darker |
1313-| **Disabled** | Not interactive | Reduced opacity, no pointer |
1414-| **Loading** | Processing | Spinner, skeleton |
1515-| **Error** | Invalid state | Red border, icon, message |
1616-| **Success** | Completed | Green check, confirmation |
77+| State | When | Visual Treatment |
88+|-------|------|------------------|
99+| **Default** | At rest | Base styling |
1010+| **Hover** | Pointer over (not touch) | Subtle lift, color shift |
1111+| **Focus** | Keyboard/programmatic focus | Visible ring (see below) |
1212+| **Active** | Being pressed | Pressed in, darker |
1313+| **Disabled** | Not interactive | Reduced opacity, no pointer |
1414+| **Loading** | Processing | Spinner, skeleton |
1515+| **Error** | Invalid state | Red border, icon, message |
1616+| **Success** | Completed | Green check, confirmation |
17171818**The common miss**: Designing hover without focus, or vice versa. They're different. Keyboard users never see hover states.
1919···3535```
36363737**Focus ring design**:
3838-3938- High contrast (3:1 minimum against adjacent colors)
4039- 2-3px thick
4140- Offset from element (not inside it)
···6766Or use the native `<dialog>` element:
68676968```javascript
7070-const dialog = document.querySelector("dialog");
7171-dialog.showModal(); // Opens with focus trap, closes on Escape
6969+const dialog = document.querySelector('dialog');
7070+dialog.showModal(); // Opens with focus trap, closes on Escape
7271```
73727473## The Popover API
+513
.agents/skills/impeccable/reference/live.md
···11+Interactive live variant mode: select elements in the browser, pick a design action, and get AI-generated HTML+CSS variants hot-swapped via the dev server's HMR.
22+33+## Prerequisites
44+55+A running dev server with hot module replacement (Vite, Next.js, Bun, etc.), OR a static HTML file open in the browser.
66+77+## The contract (read once)
88+99+Execute in order. No step skipped, no step reordered.
1010+1111+1. `live.mjs` — boot.
1212+2. Navigate to the URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). If you can't infer it confidently, tell the user once to open their dev/preview URL. Never use `serverPort` as that URL — it's the helper, not the app.
1313+3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`.
1414+4. On `generate` — read screenshot if present; load the action's reference; plan three distinct directions; write all variants in one edit; `--reply done`; poll again.
1515+5. On `accept` / `discard` — the poll script already cleaned up; just poll again.
1616+6. On `exit` — run the cleanup at the bottom.
1717+1818+Harness policy:
1919+- **Claude Code**: run the poll as a **background task** (no short timeout). The harness notifies you when it completes, so the main conversation stays free. Do not block the shell.
2020+- **Cursor**: run the poll in the **foreground** (blocking shell — not a background terminal, not a subagent). Cursor background terminals and subagents do not reliably resume the chat with poll stdout.
2121+- **Codex**: run the poll in the **foreground** (blocking shell — not a background task, not a subagent). Codex background exec sessions do not reliably surface poll stdout back into the conversation at the moment events arrive, so a "fire-and-forget" background poll will stall live mode.
2222+- **Other harnesses**: foreground unless you know stdout reliably returns to this session.
2323+2424+Chat is overhead. No recap, no tutorial output, no pasting PRODUCT / DESIGN bodies. Spend tokens on tools and edits; on failure, one or two short sentences.
2525+2626+## Start
2727+2828+```bash
2929+node .agents/skills/impeccable/scripts/live.mjs
3030+```
3131+3232+Output JSON: `{ ok, serverPort, serverToken, pageFiles, hasProduct, product, productPath, hasDesign, design, designPath, migrated }`. `pageFiles` is the list of HTML entries the live script was injected into. Keep PRODUCT.md and DESIGN.md in mind for variant generation — **DESIGN.md wins on visual decisions; PRODUCT.md wins on strategic/voice decisions.** If `migrated: true`, the loader auto-renamed legacy `.impeccable.md` to `PRODUCT.md`; mention this once and suggest `$impeccable document` for the matching DESIGN.md.
3333+3434+`serverPort` and `serverToken` belong to the small **Impeccable live helper** HTTP server (serves `/live.js`, SSE, and `/poll`). That port is **not** your dev server and is usually not the URL you open to view the app. The browser page is whatever origin serves one of the `pageFiles` entries (Vite / Next / Bun / tunnel / LAN hostname).
3535+3636+If output is `{ ok: false, error: "config_missing" | "config_invalid", path }`, this project hasn't been configured for live mode (or its config is stale). See **First-time setup** at the bottom.
3737+3838+## Poll loop
3939+4040+```
4141+LOOP:
4242+ node .agents/skills/impeccable/scripts/live-poll.mjs # default long timeout; no --timeout=
4343+ Read JSON; dispatch on "type"
4444+4545+ "generate" → Handle Generate; reply done; LOOP
4646+ "accept" → Handle Accept; LOOP
4747+ "discard" → Handle Discard; LOOP
4848+ "prefetch" → Handle Prefetch; LOOP
4949+ "timeout" → LOOP
5050+ "exit" → break → Cleanup
5151+```
5252+5353+## Handle `generate`
5454+5555+Event: `{id, action, freeformPrompt?, count, pageUrl, element, screenshotPath?, comments?, strokes?}`.
5656+5757+Speed matters — the user is watching a spinner. Minimize tool calls by using the `wrap` helper and writing all variants in a single edit.
5858+5959+### 1. Read the screenshot (if present)
6060+6161+`event.screenshotPath` is **only sent when the user placed at least one comment or stroke before Go.** When present, it's an absolute path to a PNG of the element as rendered with the annotations baked in. **Read it before planning** — annotations encode user intent not recoverable from `element.outerHTML` alone.
6262+6363+When `screenshotPath` is absent, don't ask for one and don't go looking for the current rendering. The omission is deliberate: without annotations, a screenshot would anchor the model on the existing design and fight the three-distinct-directions brief. Work from `element.outerHTML`, the computed styles in `event.element`, and the freeform prompt if present.
6464+6565+`event.comments` and `event.strokes` carry structured metadata alongside the visual. Treat the screenshot as primary; use the structured data for specifics worth quoting (e.g. the exact text of a comment).
6666+6767+Reading annotations precisely:
6868+6969+- **Comment position is load-bearing.** Its `{x, y}` is element-local CSS px (same coord space as `element.boundingRect`). Find the child under that point and apply the comment text LOCALLY to that sub-element. A comment near the title is about the title, not a global description.
7070+- **Comments and strokes are independent annotations** unless clearly paired by overlap or tight proximity. Don't let the visual weight of a prominent stroke override the precise location of a textually-specific comment elsewhere.
7171+- **Strokes are gestures — read them by shape.** Closed loop = "this thing" (emphasis / focus); arrow = direction (move / point to); cross or slash = delete; free scribble = emphasis or delete depending on context. A loop around region X means "pay attention to X," not "only change pixels inside X."
7272+- **When a stroke's intent is ambiguous** (circle or arrow? emphasis or move?), state your reading in one sentence of rationale rather than silently guessing. If the uncertainty materially changes the brief, ask one short clarifying question before generating.
7373+7474+### 2. Wrap the element
7575+7676+```bash
7777+node .agents/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
7878+```
7979+8080+Flag mapping — keep them separate, don't collapse into `--query`:
8181+8282+- `--element-id` ← `event.element.id`
8383+- `--classes` ← `event.element.classes` joined with commas
8484+- `--tag` ← `event.element.tagName`
8585+8686+The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search. `--query` is a fallback for raw text search only — do not use it for normal element lookups.
8787+8888+Output on success: `{ file, insertLine, commentSyntax }`.
8989+9090+**Fallback errors.** Wrap only writes into files it judges to be source (tracked by git, not marked GENERATED, not listed in config's `generatedFiles`). If it can't land on a source file, it errors without writing — accepting a variant into a generated file is silent data loss. Three shapes:
9191+9292+- `{ error: "file_is_generated", file, hint }` — user-supplied `--file` points at a generated file.
9393+- `{ error: "element_not_in_source", generatedMatch, hint }` — element exists only in a generated file (the next build would wipe any edits).
9494+- `{ error: "element_not_found", hint }` — element isn't in any project file; likely runtime-injected (JS component, data-driven render).
9595+9696+All three carry `fallback: "agent-driven"`. Follow **Handle fallback** below.
9797+9898+### 3. Load the action's reference
9999+100100+If `event.action` is `impeccable` (the default freeform action), use SKILL.md's shared laws plus the loaded register reference (`brand.md` or `product.md`). Do not load a sub-command reference. **Freeform is not a pass to skip parameters:** you still follow the composition budget and the freeform bias in **§7 Parameters** below. Sub-command files list MUST-have signature knobs; freeform has no such file, so sizing knobs from surface weight and primary axes is entirely on you.
101101+102102+Any other `event.action` (`bolder`, `quieter`, `distill`, `polish`, `typeset`, `colorize`, `layout`, `adapt`, `animate`, `delight`, `overdrive`): Read `reference/<action>.md` before planning. Each sub-command encodes a specific discipline; skipping its reference produces generic output. Those files may require specific params; layer them on top of the §7 budget, not instead of it.
103103+104104+### 4. Plan three genuinely distinct directions
105105+106106+Before writing a single line of code, name each variant.
107107+108108+**For freeform (`action` is `impeccable`, or the user supplied a free prompt):** each variant must anchor to a different **archetype** — a real-world design analogue specific enough to be recognizable at a glance. Not "modern landing page." Not "minimal product hero." Examples:
109109+110110+- *Broadsheet masthead with rule-divided columns* (think NYT print edition)
111111+- *Klim Type Foundry specimen page* (dense, technical, catalog-driven)
112112+- *Japanese print-poster minimalism with a single oversize glyph*
113113+- *Bloomberg Terminal status bar*
114114+- *Condé Nast Traveler feature layout*
115115+116116+Then commit each variant to a different **primary axis** of difference:
117117+118118+1. **Hierarchy** — which element commands the eye?
119119+2. **Layout topology** — stacked / side-by-side / grid / asymmetric / overlay
120120+3. **Typographic system** — pairing, scale ratio, case/weight strategy
121121+4. **Color strategy** — Restrained / Committed / Full palette / Drenched
122122+5. **Density** — minimal / comfortable / dense
123123+6. **Structural decomposition** — merge, split, progressive disclosure
124124+125125+Three variants → three DIFFERENT primary axes, not three riffs on color.
126126+127127+**When the primary axis is color or theme, forbid the trio from sharing theme + dominant hue.** Two dark-plus-one-dark is not distinct. Aim for one dark-neutral-accent, one light-drenched, one full-palette-saturated — three color worlds, not three shades of the same.
128128+129129+**The squint test (before writing code).** Write the three one-sentence descriptions side by side:
130130+131131+> V1: Broadsheet masthead, ruled columns, 24px ink on cream.
132132+> V2: Enormous italic title, catalog spec rows, heavy monospace data.
133133+> V3: Card-framed poster with one oversize glyph, magenta veil.
134134+135135+If two of them rhyme ("both use big type" / "both are stacks of sections" / "both feature the CTA prominently"), rework the offender. Freeform variants failing the squint test is the primary failure mode of this flow — three-of-the-same with minor styling tweaks.
136136+137137+**For action-specific invocations**, each variant must vary along the dimension the action names:
138138+139139+- `bolder` — amplify a different dimension per variant (scale / saturation / structural change). Not three "slightly bigger" variants.
140140+- `quieter` — pull back a different dimension (color / ornament / spacing).
141141+- `distill` — remove a different class of excess (visual noise / redundant content / nested structure).
142142+- `polish` — target a different refinement axis (rhythm / hierarchy / micro-details like corner radii, focus states, optical kerning).
143143+- `typeset` — different type pairing AND different scale ratio each. Not three riffs on one pairing.
144144+- `colorize` — different hue family each (not shades of one hue). Vary chroma and contrast strategy.
145145+- `layout` — different structural arrangement (stacked / side-by-side / grid / asymmetric). Not spacing tweaks.
146146+- `adapt` — different target context per variant (mobile-first / tablet / desktop / print or low-data). Don't make three mobile layouts.
147147+- `animate` — different motion vocabulary (cascade stagger / clip wipe / scale-and-focus / morph / parallax). Not three staggered fades.
148148+- `delight` — different flavor of personality (unexpected micro-interaction / typographic surprise / illustrated accent / sonic-or-haptic moment / easter-egg interaction).
149149+- `overdrive` — different convention broken (scale / structure / motion / input model / state transitions). Skip `overdrive.md`'s "propose and ask" step — live mode is non-interactive.
150150+151151+### 5. Apply the freeform prompt (if present)
152152+153153+`event.freeformPrompt` is the user's ceiling on direction — all variants must honor it — but still explore meaningfully different *interpretations*. "Make it feel like a newspaper front page" → variant 1 = broadsheet masthead + rule-divided columns, variant 2 = tabloid headline + single dominant image, variant 3 = minimalist editorial with oversized drop cap. Not three newspapers in the same voice.
154154+155155+### 6. Write all variants in a single edit
156156+157157+Complete HTML replacement of the original element for each variant, not a CSS-only patch. Consider the element's context (computed styles, parent structure, CSS variables from `event.element`).
158158+159159+Write CSS + all variants in ONE edit at the `insertLine` reported by `wrap`. Colocate scoped CSS as a `<style>` tag inside the variant wrapper — `<style>` works anywhere in modern browsers and this ensures CSS and HTML arrive atomically (no FOUC).
160160+161161+```html
162162+<!-- Variants: insert below this line -->
163163+<style data-impeccable-css="SESSION_ID">
164164+ @scope ([data-impeccable-variant="1"]) { ... }
165165+ @scope ([data-impeccable-variant="2"]) { ... }
166166+</style>
167167+<div data-impeccable-variant="1">
168168+ <!-- variant 1: full element replacement (single top-level element) -->
169169+</div>
170170+<div data-impeccable-variant="2" style="display: none">
171171+ <!-- variant 2: full element replacement -->
172172+</div>
173173+<div data-impeccable-variant="3" style="display: none">
174174+ <!-- variant 3: full element replacement -->
175175+</div>
176176+```
177177+178178+**Each variant div contains exactly one top-level element — the full replacement for the original.** Use the same tag as the original (e.g. `<section>` if the user picked a `<section>`). Loose siblings (heading + paragraph + div as direct children of the variant div) break the outline tracking and the accept flow, which both assume one child.
179179+180180+The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no scoped CSS, omit the `<style>` tag entirely. Use `@scope` for CSS isolation (Chrome 118+ / Firefox 128+ / Safari 17.4+).
181181+182182+One edit, all variants — the browser's MutationObserver picks everything up in one pass.
183183+184184+### 7. Parameters (composition-sized, 0–4 per variant)
185185+186186+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.
187187+188188+**What “optional” does not mean.** Parameters are not nice-to-have decoration on large work. The word meant “omit controls that are redundant or cosmetic,” not “default to zero because three variants were enough work.”
189189+190190+**When to add.** As soon as the variant’s scoped CSS has a meaningful continuous or stepped axis: density, color amount, type scale, motion intensity, column weight, and so on. If you can imagine the user muttering “a bit tighter” or “a touch more accent” **without** wanting a full regeneration, wire that axis. **Not** micro-margins or one-off nudges; those are not parameters.
191191+192192+**Freeform (`action` is `impeccable`) bias.** You did not load `reference/bolder.md` (etc.), so you must **choose** 1–2 signature-like axes yourself. Prefer knobs that sit on the same dimensions as your three directions (e.g. all three riffs on editorial density → expose `density` or a `steps` “air / snug / packed”; two directions differ mostly in chroma → add `color-amount`). A hero, section, or other **large** surface that ships with **0** params needs a one-line reason in your head (e.g. “truly a fixed-point A/B/C comparison, no shared dial”), not a default habit.
193193+194194+**Budget scales with the element's visual weight, not token budget.** Knobs need real estate to read as tunable; three sliders on a single control are noise.
195195+196196+- **Leaf / tiny** — a single button, icon, input, bare heading, solitary paragraph: **0 params.**
197197+- **Small composition** — labeled input, simple card, short callout (≤ ~5 visual children): **0–1** params when one dominant axis is obvious; otherwise **0.**
198198+- **Medium composition** — section component, nav cluster, dense card, short feature block (6–15 visual children): **target 2**; **1** is acceptable if the block is simple; **0** only when variants are truly fixed points.
199199+- **Large composition** — hero section, full page region, spread layout, strong internal structure (16+ visual children or multiple sub-sections): **target 2–3**; **up to 4** when several independent axes (e.g. structure `steps` + `density` + one accent) are all authored in scoped CSS.
200200+201201+**When in doubt, ask whether a dial exists before defaulting to zero.** The user can always request more variants, but the point of live mode is instant tuning without another Go. Crowding the panel is bad; **under-shipping** knobs on a dense composition is the more common failure for freeform. Count by **visual** children, not DOM depth; a shallow-but-wide hero is still large.
202202+203203+**Hard cap per variant** — at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it.
204204+205205+**How to declare.** Put a JSON manifest on the variant wrapper:
206206+207207+```html
208208+<div data-impeccable-variant="1" data-impeccable-params='[
209209+ {"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"},
210210+ {"id":"density","kind":"steps","default":"snug","label":"Density","options":[
211211+ {"value":"airy","label":"Airy"},
212212+ {"value":"snug","label":"Snug"},
213213+ {"value":"packed","label":"Packed"}
214214+ ]},
215215+ {"id":"serif","kind":"toggle","default":false,"label":"Serif display"}
216216+]'>
217217+ ...variant content...
218218+</div>
219219+```
220220+221221+**Three kinds:**
222222+223223+- `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`.
224224+- `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`.
225225+- `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`.
226226+227227+**Signature params per action.** For named sub-commands, read that action’s `reference/<action>.md` for one or two **MUST** params (e.g. `layout` → `density`). Those are non-negotiable when the design can express them. **Freeform has no file-level MUST**; the **Freeform (`impeccable`) bias** in this section is the stand-in. If the user’s action is both stylized and sub-command (e.g. `colorize`), the sub-command’s MUST list takes precedence for its axes; still respect the **Hard cap** and add no redundant duplicate knobs.
228228+229229+**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.
230230+231231+**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment:
232232+233233+```html
234234+<!-- impeccable-param-values SESSION_ID: {"color-amount":0.7,"density":"packed"} -->
235235+```
236236+237237+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.
238238+239239+### 8. Signal done
240240+241241+```bash
242242+node .agents/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
243243+```
244244+245245+`RELATIVE_PATH` is relative to project root (`public/index.html`, `src/App.tsx`, etc.) — the browser fetches source directly if the dev server lacks HMR.
246246+247247+Then run `live-poll.mjs` again immediately.
248248+249249+## Handle fallback
250250+251251+When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here.
252252+253253+The goal is the same: give the user three variants to choose from AND persist the accepted one in a place the next build won't wipe. The difference is that you have to pick the right source file yourself.
254254+255255+### Step 1: Identify where the element actually lives
256256+257257+Use the error payload:
258258+259259+- `element_not_in_source` with `generatedMatch: "public/docs/foo.html"` — the served HTML is generated. Find the generator (grep for writers of that path, e.g. `scripts/build-sub-pages.js`, an Astro/Next template) and locate the template or partial that emits this element.
260260+- `element_not_found` — the element is runtime-injected. Look for the component that renders it (React/Vue/Svelte), the JS that assembles it, or the data source that feeds it.
261261+- `file_is_generated` with `file: "..."` — user pointed at a generated file explicitly. Same resolution as `element_not_in_source`.
262262+263263+Read the candidate source until you're confident where a change to the element would belong. If the change is purely visual, that source might be a shared stylesheet, not the template.
264264+265265+### Step 2: Show three variants in the DOM for preview
266266+267267+The browser bar is waiting for variants. Even without a wrapper in source, you still need to show something:
268268+269269+1. Manually write the wrapper scaffold into the **served** file (the one the browser actually loaded). Use the same structure `live-wrap.mjs` produces — `<!-- impeccable-variants-start ID --><div data-impeccable-variants="ID" data-impeccable-variant-count="3" style="display: contents">…</div><!-- end -->`.
270270+2. Insert your three variant divs inside it, same shape as the deterministic path.
271271+3. Signal done with `--reply EVENT_ID done --file <served file>`. The browser's no-HMR fallback will fetch and inject.
272272+273273+This served-file edit is **temporary** — next regen wipes it, and that's fine. The real work happens on accept.
274274+275275+### Step 3: On accept, write to true source
276276+277277+When the accept event arrives (`_acceptResult.handled` will usually be `false` here because accept also refuses to persist into generated files — see Handle accept for the carbonize branch), extract the accepted variant's content and write it into the source you identified in Step 1:
278278+279279+- Structural change → edit the template / component source.
280280+- Visual-only change → add or update rules in the appropriate stylesheet; remove the inline `<style>` scope.
281281+- Data-driven → update the data source or the render logic.
282282+283283+Then remove the temporary wrapper from the served file if it's still there.
284284+285285+### Step 4: On discard, clean up the served file
286286+287287+Remove the wrapper you inserted in Step 2. Nothing else to do.
288288+289289+## Handle `accept`
290290+291291+Event: `{id, variantId, _acceptResult}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically; the browser DOM is already updated.
292292+293293+- `_acceptResult.handled: true` and `carbonize: false` — nothing to do. Poll again.
294294+- `_acceptResult.handled: true` and `carbonize: true` — **post-accept cleanup is required before the next poll.** See the "Required after accept (carbonize)" section below. The `event._acceptResult.todo` field and a stderr banner both list the steps explicitly; neither is decorative.
295295+- `_acceptResult.handled: false, mode: "fallback"` — the session lived in a generated file and the script refused to persist there. You've already written the accepted variant into true source during Handle fallback Step 3; just clean up the temporary wrapper in the served file if any, and poll again.
296296+- `_acceptResult.handled: false` without `mode` — manual cleanup: read file, find markers, edit.
297297+298298+### Required after accept (carbonize)
299299+300300+When `_acceptResult.carbonize === true`, the accepted variant was stitched into source with helper markers and inline CSS so the browser can render it immediately with no visual gap. That stitch-in is **temporary**. The agent must rewrite it into permanent form before doing anything else. Skipping this leaves dead `@scope` rules for unaccepted variants, a pointless `data-impeccable-variant` wrapper, and `impeccable-carbonize-start/end` comment noise in the source file — all of which accumulate across sessions.
301301+302302+Do these five steps in the current thread, synchronously, before the next poll. Do not poll again until the file is clean.
303303+304304+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.
305305+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).
306306+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.
307307+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.
308308+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.
309309+310310+Then poll again.
311311+312312+A background agent may be used for the rewrite, but the current thread is responsible for verifying the five steps are complete before issuing the next poll. In practice, inline is usually faster and less error-prone.
313313+314314+## Handle `discard`
315315+316316+Event: `{id, _acceptResult}`. The poll script already restored the original and removed all variant markers. Nothing to do. Poll again.
317317+318318+## Handle `prefetch`
319319+320320+Event: `{pageUrl}`. The browser fires this the first time the user selects an element on a given route, as a latency shortcut — it signals the user is likely about to Go on a page you haven't read yet.
321321+322322+Resolve `pageUrl` to the underlying file:
323323+324324+- Root `/` → the `pageFile` returned by `live.mjs` (usually `public/index.html` or equivalent).
325325+- Sub-routes (e.g. `/docs`, `/docs/live`) → the generated or source file for that route. Use your knowledge of the project layout (multi-page static sites often resolve `/foo` → `public/foo/index.html`; SPAs may map all routes to a single entry).
326326+327327+Read the file into context, then poll again. No `--reply` — this is speculative pre-work; Go will come later. If you can't confidently resolve the route to a file, skip and poll again.
328328+329329+Dedupe is the browser's job (one prefetch per unique pathname per session) — trust it. If the same file shows up twice from different routes mapping to the same file, the second Read is cached anyway.
330330+331331+## Exit
332332+333333+The user can stop live mode by:
334334+- Saying "stop live mode" / "exit live" in chat
335335+- Closing the browser tab (SSE drops, poll returns `exit` after 8s)
336336+- The browser's exit button
337337+338338+When the poll returns `exit`, proceed to cleanup. If the poll is still running as a background task, kill it first.
339339+340340+## Cleanup
341341+342342+```bash
343343+node .agents/skills/impeccable/scripts/live-server.mjs stop
344344+```
345345+346346+Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `config.json` persists for future sessions.
347347+348348+Then:
349349+- Remove any leftover variant wrappers (search for `impeccable-variants-start` markers).
350350+- Remove any leftover carbonize blocks (search for `impeccable-carbonize-start` markers).
351351+352352+## First-time setup (config missing or invalid)
353353+354354+If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write `config.json` at the reported path.
355355+356356+Schema:
357357+358358+```json
359359+{
360360+ "files": ["<path-or-glob>", "<path-or-glob>", ...],
361361+ "exclude": ["<optional-glob>", ...],
362362+ "insertBefore": "</body>",
363363+ "commentSyntax": "html",
364364+ "cspChecked": true
365365+}
366366+```
367367+368368+`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Each entry is either a literal path (`"public/index.html"`) or a glob pattern (`"public/**/*.html"`). Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
369369+370370+`exclude` (optional) is a list of glob patterns matching files to skip, even if a `files` glob would have included them. Use for email templates, demo fixtures, or any HTML that isn't a live page.
371371+372372+`cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed).
373373+374374+**Hard-excluded paths (cannot be overridden).** `**/node_modules/**` and `**/.git/**` are never matched regardless of what the user writes. These are vendor/metadata directories and injecting into them would silently instrument third-party code.
375375+376376+**Glob syntax.** `**` matches any number of path segments (including zero), `*` matches any characters except `/`, `?` matches a single character except `/`. Paths are always relative to the project root with forward slashes.
377377+378378+| Framework | `files` | `insertBefore` | `commentSyntax` |
379379+|-----------|---------|----------------|-----------------|
380380+| SPA with single shell (Vite / React / Plain HTML) | `["index.html"]` | `</body>` | `html` |
381381+| Next.js (App Router) | `["app/layout.tsx"]` | `</body>` | `jsx` |
382382+| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
383383+| Nuxt | `["app.vue"]` | `</body>` | `html` |
384384+| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
385385+| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
386386+| Multi-page (separate HTML per route) | `["public/**/*.html"]` — a glob covering the served directory | `</body>` | `html` |
387387+388388+Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
389389+390390+For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
391391+392392+For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
393393+394394+### Drift-heal warning
395395+396396+On every `live.mjs` boot, after inject, the project is scanned for HTML files under common page-source roots (`public/`, `src/`, `app/`, `pages/`). If any exist that aren't covered by the resolved `files` list, the output includes a `configDrift` field:
397397+398398+```json
399399+{
400400+ "ok": true,
401401+ "serverPort": 8400,
402402+ "pageFiles": [ "..." ],
403403+ "configDrift": {
404404+ "orphans": ["public/new-section/index.html", "public/docs/new-command.html"],
405405+ "orphanCount": 2,
406406+ "hint": "2 HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like \"public/**/*.html\"."
407407+ }
408408+}
409409+```
410410+411411+When `configDrift` is present, surface it to the user once per session before entering the poll loop:
412412+413413+> Noticed N HTML file(s) in the project that aren't in `config.files`:
414414+>
415415+> - `public/new-section/index.html`
416416+> - `public/docs/new-command.html`
417417+>
418418+> Add them, or switch `files` to a glob like `["public/**/*.html"]` and let it track new pages automatically?
419419+420420+Don't auto-update the config — let the user decide. `configDrift` is `null` when there's no drift.
421421+422422+### CSP detection (first-time only)
423423+424424+If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks.
425425+426426+Otherwise, run the detection helper:
427427+428428+```bash
429429+node .agents/skills/impeccable/scripts/detect-csp.mjs
430430+```
431431+432432+Output: `{ shape, signals }` where `shape` is one of `append-arrays`, `append-string`, `middleware`, `meta-tag`, or `null`. The shape is named by *patch mechanism*, so one template covers many frameworks.
433433+434434+- **`null`** — no CSP; skip to writing `config.json` with `cspChecked: true`.
435435+- **`append-arrays`** — CSP defined as structured directive arrays. Auto-patchable. See *append-arrays* below. Covers:
436436+ - Monorepo helpers with `additionalScriptSrc` / `additionalConnectSrc` options (Next.js + shared config package)
437437+ - SvelteKit `kit.csp.directives`
438438+ - Nuxt `nuxt-security` module's `contentSecurityPolicy`
439439+- **`append-string`** — CSP written as a literal value string. Auto-patchable. See *append-string* below. Covers:
440440+ - Inline `next.config.*` `headers()` with a CSP literal
441441+ - Nuxt `routeRules` / `nitro.routeRules` headers
442442+- **`middleware`** or **`meta-tag`** — rarer. Detected but not auto-patched in v1. Show the user the detected files and ask them to add `http://localhost:8400` to `script-src` and `connect-src` manually, then mark `cspChecked: true` and proceed.
443443+444444+#### Consent prompt template
445445+446446+Use this phrasing so the experience is consistent across agents:
447447+448448+> **CSP patch needed.** I detected a Content Security Policy in your project that blocks `http://localhost:8400` — the live picker won't load without an allowance. Here's the change I'd make:
449449+>
450450+> ```diff
451451+> [file: <patchTarget>]
452452+> [exact diff, 2–5 lines]
453453+> ```
454454+>
455455+> It's guarded by `NODE_ENV === "development"` so the extra entry only appears in dev and never reaches production. You can remove it any time by reverting this file. Apply? [y/n]
456456+457457+On "no": skip the patch, mention live won't work until the user adds the allowance manually, still write `cspChecked: true` (the question's been asked).
458458+459459+On "yes": apply the Shape-specific patch below, then write `cspChecked: true`.
460460+461461+#### append-arrays
462462+463463+CSP expressed as structured directive arrays. Patch mechanism: declare a dev-only array, spread it into the script-src and connect-src arrays.
464464+465465+**Declare near the top of the file that holds the CSP arrays:**
466466+467467+```ts
468468+// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
469469+const __impeccableLiveDev =
470470+ process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
471471+```
472472+473473+**Append `...__impeccableLiveDev` to the script-src and connect-src directive arrays.** Per-framework specifics:
474474+475475+- **Next.js + monorepo helper** — edit the *app's* `next.config.*` (not the shared helper), appending to `additionalScriptSrc` and `additionalConnectSrc` passed into `createBaseNextConfig` (or equivalent). Keeps the shared package clean.
476476+- **SvelteKit** — edit `svelte.config.js`, appending to `kit.csp.directives['script-src']` and `kit.csp.directives['connect-src']`.
477477+- **Nuxt + nuxt-security** — edit `nuxt.config.*`, appending to `security.headers.contentSecurityPolicy['script-src']` and `['connect-src']`.
478478+479479+Reference outputs:
480480+- `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` (Next.js)
481481+- `tests/framework-fixtures/sveltekit-csp/expected-after-patch.js` (SvelteKit)
482482+483483+Idempotency: if `__impeccableLiveDev` already exists in the file, the patch is already applied; skip asking and just mark `cspChecked: true`.
484484+485485+#### append-string
486486+487487+CSP built as a literal value string. Two-point patch: declare a dev-only string near the top, interpolate it into the CSP at the `script-src` and `connect-src` directives.
488488+489489+```ts
490490+// Dev-only allowance so impeccable live mode can load.
491491+const __impeccableLiveDev =
492492+ process.env.NODE_ENV === "development" ? " http://localhost:8400" : "";
493493+```
494494+495495+Then in the CSP value string:
496496+- `script-src 'self' 'unsafe-inline'` → `` `script-src 'self' 'unsafe-inline'${__impeccableLiveDev}` ``
497497+- `connect-src 'self'` → `` `connect-src 'self'${__impeccableLiveDev}` ``
498498+499499+(Leading space on the dev string so it concatenates cleanly into the existing value. Convert the literal CSP directives into template strings as part of the edit if they aren't already.)
500500+501501+Per-framework specifics:
502502+- **Next.js inline `headers()`** — edit `next.config.*`, splicing the variable into the CSP value.
503503+- **Nuxt `routeRules`** — edit `nuxt.config.*`, splicing into the CSP in `routeRules['/**'].headers['Content-Security-Policy']`.
504504+505505+Reference outputs:
506506+- `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` (Next.js)
507507+- `tests/framework-fixtures/nuxt-csp/expected-after-patch.ts` (Nuxt)
508508+509509+### Troubleshooting
510510+511511+If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs` — setup will ask again.
512512+513513+Then re-run `live.mjs`.
···4455Timing matters more than easing. These durations feel right for most UI:
6677-| Duration | Use Case | Examples |
88-| ------------- | ------------------- | ---------------------------------- |
99-| **100-150ms** | Instant feedback | Button press, toggle, color change |
1010-| **200-300ms** | State changes | Menu open, tooltip, hover states |
1111-| **300-500ms** | Layout changes | Accordion, modal, drawer |
1212-| **500-800ms** | Entrance animations | Page load, hero reveals |
77+| Duration | Use Case | Examples |
88+|----------|----------|----------|
99+| **100-150ms** | Instant feedback | Button press, toggle, color change |
1010+| **200-300ms** | State changes | Menu open, tooltip, hover states |
1111+| **300-500ms** | Layout changes | Accordion, modal, drawer |
1212+| **500-800ms** | Entrance animations | Page load, hero reveals |
13131414**Exit animations are faster than entrances**—use ~75% of enter duration.
1515···17171818**Don't use `ease`.** It's a compromise that's rarely optimal. Instead:
19192020-| Curve | Use For | CSS |
2121-| --------------- | ---------------------------- | -------------------------------- |
2222-| **ease-out** | Elements entering | `cubic-bezier(0.16, 1, 0.3, 1)` |
2323-| **ease-in** | Elements leaving | `cubic-bezier(0.7, 0, 0.84, 0)` |
2020+| Curve | Use For | CSS |
2121+|-------|---------|-----|
2222+| **ease-out** | Elements entering | `cubic-bezier(0.16, 1, 0.3, 1)` |
2323+| **ease-in** | Elements leaving | `cubic-bezier(0.7, 0, 0.84, 0)` |
2424| **ease-in-out** | State toggles (there → back) | `cubic-bezier(0.65, 0, 0.35, 1)` |
25252626**For micro-interactions, use exponential curves**—they feel natural because they mimic real physics (friction, deceleration):
···38383939**Avoid bounce and elastic curves.** They were trendy in 2015 but now feel tacky and amateurish. Real objects don't bounce when they stop—they decelerate smoothly. Overshoot effects draw attention to the animation itself rather than the content.
40404141-## The Only Two Properties You Should Animate
4141+## Premium Motion Materials
42424343-**transform** and **opacity** only—everything else causes layout recalculation. For height animations (accordions), use `grid-template-rows: 0fr → 1fr` instead of animating `height` directly.
4343+Transform and opacity are reliable defaults, not the whole palette. Premium interfaces often need atmospheric properties: blur reveals, backdrop-filter panels, saturation or brightness shifts, shadow bloom, SVG filters, masks, clip paths, gradient-position movement, and variable font or shader-driven effects.
4444+4545+Use the right material for the effect:
4646+4747+- **Transform / opacity**: movement, press feedback, simple reveals, list choreography.
4848+- **Blur / filter / backdrop-filter**: focus pulls, depth, glass or lens effects, softened entrances, atmospheric transitions.
4949+- **Clip path / masks**: wipes, reveals, editorial cropping, product-like transitions.
5050+- **Shadow / glow / color filters**: energy, affordance, focus, warmth, active state.
5151+- **Grid-template rows or FLIP-style transforms**: expanding and reflowing layout without animating `height` directly.
5252+5353+The hard rule is not "transform and opacity only." The hard rule is: avoid animating layout-driving properties casually (`width`, `height`, `top`, `left`, margins), keep expensive effects bounded to small or isolated areas, and verify in-browser that the result is smooth on the target viewports. If blur/filter makes the interaction feel significantly more premium and remains smooth, use it.
44544555## Staggered Animations
4656···5969/* Provide alternative for reduced motion */
6070@media (prefers-reduced-motion: reduce) {
6171 .card {
6262- animation: fade-in 200ms ease-out; /* Crossfade instead of motion */
7272+ animation: fade-in 200ms ease-out; /* Crossfade instead of motion */
6373 }
6474}
65756676/* Or disable entirely */
6777@media (prefers-reduced-motion: reduce) {
6868- *,
6969- *::before,
7070- *::after {
7878+ *, *::before, *::after {
7179 animation-duration: 0.01ms !important;
7280 transition-duration: 0.01ms !important;
7381 }
+234
.agents/skills/impeccable/reference/onboard.md
···11+> **Additional context needed**: the "aha moment" you want users to reach, and users' experience level.
22+33+Create or improve onboarding experiences that help users understand, adopt, and succeed with the product quickly.
44+55+## Assess Onboarding Needs
66+77+Understand what users need to learn and why:
88+99+1. **Identify the challenge**:
1010+ - What are users trying to accomplish?
1111+ - What's confusing or unclear about current experience?
1212+ - Where do users get stuck or drop off?
1313+ - What's the "aha moment" we want users to reach?
1414+1515+2. **Understand the users**:
1616+ - What's their experience level? (Beginners, power users, mixed?)
1717+ - What's their motivation? (Excited and exploring? Required by work?)
1818+ - What's their time commitment? (5 minutes? 30 minutes?)
1919+ - What alternatives do they know? (Coming from competitor? New to category?)
2020+2121+3. **Define success**:
2222+ - What's the minimum users need to learn to be successful?
2323+ - What's the key action we want them to take? (First project? First invite?)
2424+ - How do we know onboarding worked? (Completion rate? Time to value?)
2525+2626+**CRITICAL**: Onboarding should get users to value as quickly as possible, not teach everything possible.
2727+2828+## Onboarding Principles
2929+3030+Follow these core principles:
3131+3232+### Show, Don't Tell
3333+- Demonstrate with working examples, not just descriptions
3434+- Provide real functionality in onboarding, not separate tutorial mode
3535+- Use progressive disclosure, teach one thing at a time
3636+3737+### Make It Optional (When Possible)
3838+- Let experienced users skip onboarding
3939+- Don't block access to product
4040+- Provide "Skip" or "I'll explore on my own" options
4141+4242+### Time to Value
4343+- Get users to their "aha moment" ASAP
4444+- Front-load most important concepts
4545+- Teach 20% that delivers 80% of value
4646+- Save advanced features for contextual discovery
4747+4848+### Context Over Ceremony
4949+- Teach features when users need them, not upfront
5050+- Empty states are onboarding opportunities
5151+- Tooltips and hints at point of use
5252+5353+### Respect User Intelligence
5454+- Don't patronize or over-explain
5555+- Be concise and clear
5656+- Assume users can figure out standard patterns
5757+5858+## Design Onboarding Experiences
5959+6060+Create appropriate onboarding for the context:
6161+6262+### Initial Product Onboarding
6363+6464+**Welcome Screen**:
6565+- Clear value proposition (what is this product?)
6666+- What users will learn/accomplish
6767+- Time estimate (honest about commitment)
6868+- Option to skip (for experienced users)
6969+7070+**Account Setup**:
7171+- Minimal required information (collect more later)
7272+- Explain why you're asking for each piece of information
7373+- Smart defaults where possible
7474+- Social login when appropriate
7575+7676+**Core Concept Introduction**:
7777+- Introduce 1-3 core concepts (not everything)
7878+- Use simple language and examples
7979+- Interactive when possible (do, don't just read)
8080+- Progress indication (step 1 of 3)
8181+8282+**First Success**:
8383+- Guide users to accomplish something real
8484+- Pre-populated examples or templates
8585+- Celebrate completion (but don't overdo it)
8686+- Clear next steps
8787+8888+### Feature Discovery & Adoption
8989+9090+**Empty States**:
9191+Instead of blank space, show:
9292+- What will appear here (description + screenshot/illustration)
9393+- Why it's valuable
9494+- Clear CTA to create first item
9595+- Example or template option
9696+9797+Example:
9898+```
9999+No projects yet
100100+Projects help you organize your work and collaborate with your team.
101101+[Create your first project] or [Start from template]
102102+```
103103+104104+**Contextual Tooltips**:
105105+- Appear at relevant moment (first time user sees feature)
106106+- Point directly at relevant UI element
107107+- Brief explanation + benefit
108108+- Dismissable (with "Don't show again" option)
109109+- Optional "Learn more" link
110110+111111+**Feature Announcements**:
112112+- Highlight new features when they're released
113113+- Show what's new and why it matters
114114+- Let users try immediately
115115+- Dismissable
116116+117117+**Progressive Onboarding**:
118118+- Teach features when users encounter them
119119+- Badges or indicators on new/unused features
120120+- Unlock complexity gradually (don't show all options immediately)
121121+122122+### Guided Tours & Walkthroughs
123123+124124+**When to use**:
125125+- Complex interfaces with many features
126126+- Significant changes to existing product
127127+- Industry-specific tools needing domain knowledge
128128+129129+**How to design**:
130130+- Spotlight specific UI elements (dim rest of page)
131131+- Keep steps short (3-7 steps max per tour)
132132+- Allow users to click through tour freely
133133+- Include "Skip tour" option
134134+- Make replayable (help menu)
135135+136136+**Best practices**:
137137+- Interactive over passive (let users click real buttons)
138138+- Focus on workflow, not features ("Create a project" not "This is the project button")
139139+- Provide sample data so actions work
140140+141141+### Interactive Tutorials
142142+143143+**When to use**:
144144+- Users need hands-on practice
145145+- Concepts are complex or unfamiliar
146146+- High stakes (better to practice in safe environment)
147147+148148+**How to design**:
149149+- Sandbox environment with sample data
150150+- Clear objectives ("Create a chart showing sales by region")
151151+- Step-by-step guidance
152152+- Validation (confirm they did it right)
153153+- Graduation moment (you're ready!)
154154+155155+### Documentation & Help
156156+157157+**In-product help**:
158158+- Contextual help links throughout interface
159159+- Keyboard shortcut reference
160160+- Search-able help center
161161+- Video tutorials for complex workflows
162162+163163+**Help patterns**:
164164+- `?` icon near complex features
165165+- "Learn more" links in tooltips
166166+- Keyboard shortcut hints (`⌘K` shown on search box)
167167+168168+## Empty State Design
169169+170170+Every empty state needs:
171171+172172+### What Will Be Here
173173+"Your recent projects will appear here"
174174+175175+### Why It Matters
176176+"Projects help you organize your work and collaborate with your team"
177177+178178+### How to Get Started
179179+[Create project] or [Import from template]
180180+181181+### Visual Interest
182182+Illustration or icon (not just text on blank page)
183183+184184+### Contextual Help
185185+"Need help getting started? [Watch 2-min tutorial]"
186186+187187+**Empty state types**:
188188+- **First use**: Never used this feature (emphasize value, provide template)
189189+- **User cleared**: Intentionally deleted everything (light touch, easy to recreate)
190190+- **No results**: Search or filter returned nothing (suggest different query, clear filters)
191191+- **No permissions**: Can't access (explain why, how to get access)
192192+- **Error state**: Failed to load (explain what happened, retry option)
193193+194194+## Implementation Patterns
195195+196196+### Technical approaches:
197197+198198+**Tooltip libraries**: Tippy.js, Popper.js
199199+**Tour libraries**: Intro.js, Shepherd.js, React Joyride
200200+**Modal patterns**: Focus trap, backdrop, ESC to close
201201+**Progress tracking**: LocalStorage for "seen" states
202202+**Analytics**: Track completion, drop-off points
203203+204204+**Storage patterns**:
205205+```javascript
206206+// Track which onboarding steps user has seen
207207+localStorage.setItem('onboarding-completed', 'true');
208208+localStorage.setItem('feature-tooltip-seen-reports', 'true');
209209+```
210210+211211+**IMPORTANT**: Don't show same onboarding twice (annoying). Track completion and respect dismissals.
212212+213213+**NEVER**:
214214+- Force users through long onboarding before they can use product
215215+- Patronize users with obvious explanations
216216+- Show same tooltip repeatedly (respect dismissals)
217217+- Block all UI during tour (let users explore)
218218+- Create separate tutorial mode disconnected from real product
219219+- Overwhelm with information upfront (progressive disclosure!)
220220+- Hide "Skip" or make it hard to find
221221+- Forget about returning users (don't show initial onboarding again)
222222+223223+## Verify Onboarding Quality
224224+225225+Test with real users:
226226+227227+- **Time to completion**: Can users complete onboarding quickly?
228228+- **Comprehension**: Do users understand after completing?
229229+- **Action**: Do users take desired next step?
230230+- **Skip rate**: Are too many users skipping? (Maybe it's too long or not valuable)
231231+- **Completion rate**: Are users completing? (If low, simplify)
232232+- **Time to value**: How long until users get first value?
233233+234234+Remember: You're a product educator with excellent teaching instincts. Get users to their "aha moment" as quickly as possible. Teach the essential, make it contextual, respect user time and intelligence.
+62
.agents/skills/impeccable/reference/product.md
···11+# Product register
22+33+When design SERVES the product: app UIs, admin dashboards, settings panels, data tables, tools, authenticated surfaces, anything where the user is in a task.
44+55+## The product slop test
66+77+Not "would someone say AI made this" — familiarity is often a feature here. The test is: would a user fluent in the category's best tools (Linear, Figma, Notion, Raycast, Stripe come to mind) sit down and trust this interface, or pause at every subtly-off component?
88+99+Product UI's failure mode isn't flatness, it's strangeness without purpose: over-decorated buttons, mismatched form controls, gratuitous motion, display fonts where labels should be, invented affordances for standard tasks. The bar is earned familiarity. The tool should disappear into the task.
1010+1111+## Typography
1212+1313+- **System fonts are legitimate.** `-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif` gives you native feel on every platform. Inter is the common cross-platform default for a reason.
1414+- **One family is often right.** Product UIs don't need display/body pairing. A well-tuned sans carries headings, buttons, labels, body, data.
1515+- **Fixed rem scale, not fluid.** Clamp-sized headings don't serve product UI. Users view at consistent DPI, and a fluid h1 that shrinks in a sidebar looks worse, not better.
1616+- **Tighter scale ratio.** 1.125–1.2 between steps is typical. More type elements here than on brand surfaces; exaggerated contrast creates noise.
1717+- **Line length still applies for prose** (65–75ch). Data and compact UI can run denser — tables at 120ch+ are fine.
1818+1919+## Color
2020+2121+Product defaults to Restrained. A single surface can earn Committed — a dashboard where one category color carries a report, an onboarding flow with a drenched welcome screen — but Restrained is the floor.
2222+2323+- State-rich semantic vocabulary: hover, focus, active, disabled, selected, loading, error, warning, success, info. Standardize these.
2424+- Accent color used for primary actions, current selection, and state indicators only — not decoration.
2525+- A second neutral layer for sidebars, toolbars, and panels (slightly cooler or warmer than the content surface).
2626+2727+## Layout
2828+2929+- Predictable grids. Consistency IS an affordance — users navigate faster when the structure is expected.
3030+- Familiar patterns are features. Standard navigation (top bar, side nav), breadcrumbs, tabs, and form layouts have established user expectations. Don't reinvent for flavor.
3131+- Responsive behavior is structural (collapse sidebar, responsive table, breakpoint-driven columns), not fluid typography.
3232+3333+## Components
3434+3535+Every interactive component has: default, hover, focus, active, disabled, loading, error. Don't ship with half of these.
3636+3737+- Skeleton states for loading, not spinners in the middle of content.
3838+- Empty states that teach the interface, not "nothing here."
3939+- Consistent affordances across the surface. Same button shape. Same form-control vocabulary. Same icon style.
4040+4141+## Motion
4242+4343+- 150–250 ms on most transitions. Users are in flow — don't make them wait for choreography.
4444+- Motion conveys state, not decoration. State change, feedback, loading, reveal — nothing else.
4545+- No orchestrated page-load sequences. Product loads into a task; users don't want to watch it load.
4646+4747+## Product bans (on top of the shared absolute bans)
4848+4949+- Decorative motion that doesn't convey state.
5050+- Inconsistent component vocabulary across screens. If the "save" button looks different in two places, one is wrong.
5151+- Display fonts in UI labels, buttons, data.
5252+- Reinventing standard affordances for flavor (custom scrollbars, weird form controls, non-standard modals).
5353+- Heavy color or full-saturation accents on inactive states.
5454+5555+## Product permissions
5656+5757+Product can afford things brand surfaces can't.
5858+5959+- System fonts and familiar sans defaults (Inter, SF Pro, system-ui stacks).
6060+- Standard navigation patterns: top bar + side nav, breadcrumbs, tabs, command palettes.
6161+- Density. Tables with many rows, panels with many labels, dense information when users need it.
6262+- Consistency over surprise. The same visual vocabulary screen to screen is a virtue; delight is saved for moments, not pages.
···1515```css
1616/* Fine pointer (mouse, trackpad) */
1717@media (pointer: fine) {
1818- .button {
1919- padding: 8px 16px;
2020- }
1818+ .button { padding: 8px 16px; }
2119}
22202321/* Coarse pointer (touch, stylus) */
2422@media (pointer: coarse) {
2525- .button {
2626- padding: 12px 20px;
2727- } /* Larger touch target */
2323+ .button { padding: 12px 20px; } /* Larger touch target */
2824}
29253026/* Device supports hover */
3127@media (hover: hover) {
3232- .card:hover {
3333- transform: translateY(-2px);
3434- }
2828+ .card:hover { transform: translateY(-2px); }
3529}
36303731/* Device doesn't support hover (touch) */
3832@media (hover: none) {
3939- .card {
4040- /* No hover state - use active instead */
4141- }
3333+ .card { /* No hover state - use active instead */ }
4234}
4335```
4436···6355```
64566557**Enable viewport-fit** in your meta tag:
6666-6758```html
6868-<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
5959+<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6960```
70617162## Responsive Images: Get It Right
···7566```html
7667<img
7768 src="hero-800.jpg"
7878- srcset="hero-400.jpg 400w, hero-800.jpg 800w, hero-1200.jpg 1200w"
6969+ srcset="
7070+ hero-400.jpg 400w,
7171+ hero-800.jpg 800w,
7272+ hero-1200.jpg 1200w
7373+ "
7974 sizes="(max-width: 768px) 100vw, 50vw"
8075 alt="Hero image"
8181-/>
7676+>
8277```
83788479**How it works**:
8585-8680- `srcset` lists available images with their actual widths (`w` descriptors)
8781- `sizes` tells the browser how wide the image will display
8882- Browser picks the best file based on viewport width AND device pixel ratio
···93879488```html
9589<picture>
9696- <source media="(min-width: 768px)" srcset="wide.jpg" />
9797- <source media="(max-width: 767px)" srcset="tall.jpg" />
9898- <img src="fallback.jpg" alt="..." />
9090+ <source media="(min-width: 768px)" srcset="wide.jpg">
9191+ <source media="(max-width: 767px)" srcset="tall.jpg">
9292+ <img src="fallback.jpg" alt="...">
9993</picture>
10094```
10195
+151
.agents/skills/impeccable/reference/shape.md
···11+Shape the UX and UI for a feature before any code is written. This command produces a **design brief**: a structured artifact that guides implementation through discovery, not guesswork.
22+33+**Scope**: Design planning only. This command does NOT write code. It produces the thinking that makes code good.
44+55+**Output**: A design brief that can be handed off to $impeccable craft, or directly to $impeccable for freeform implementation. When visual direction probes are used, the images are supporting artifacts, not the primary output.
66+77+## Philosophy
88+99+Most AI-generated UIs fail not because of bad code, but because of skipped thinking. They jump to "here's a card grid" without asking "what is the user trying to accomplish?" This command inverts that: understand deeply first, so implementation is precise.
1010+1111+## Phase 1: Discovery Interview
1212+1313+**Do NOT write any code or make any design decisions during this phase.** Your only job is to understand the feature deeply enough to make excellent design decisions later.
1414+1515+This is a required interaction, not optional guidance. Ask these questions in conversation, adapting based on answers. Don't dump them all at once; have a natural dialogue. STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer.
1616+1717+### Interview cadence
1818+1919+Discovery must include at least one user-answer round unless PRODUCT.md, DESIGN.md, or an already-confirmed brief directly answers the needed design inputs. With a sparse prompt, do **not** synthesize a complete brief for confirmation on the first response.
2020+2121+- Use the harness's structured question tool when one exists. Otherwise, ask directly in chat and stop.
2222+- Ask **2-3 questions per round**, then wait for answers.
2323+- Treat PRODUCT.md and DESIGN.md as anchors; they reduce repeated questions but do **not** replace shape for craft. Shape is task-specific.
2424+- Round 1 should clarify purpose, audience/context, and success or emotional outcome.
2525+- Round 2 should clarify content/data/states and scope/fidelity.
2626+- Round 3 should clarify visual direction, constraints, and anti-goals when still unresolved.
2727+2828+### Purpose & Context
2929+- What is this feature for? What problem does it solve?
3030+- Who specifically will use it? (Not "users"; be specific: role, context, frequency)
3131+- What does success look like? How will you know this feature is working?
3232+- What's the user's state of mind when they reach this feature? (Rushed? Exploring? Anxious? Focused?)
3333+3434+### Content & Data
3535+- What content or data does this feature display or collect?
3636+- What are the realistic ranges? (Minimum, typical, maximum, e.g., 0 items, 5 items, 500 items)
3737+- What are the edge cases? (Empty state, error state, first-time use, power user)
3838+- Is any content dynamic? What changes and how often?
3939+4040+### Design Direction
4141+4242+Force a visual decision on three fronts. Skip anything PRODUCT.md or DESIGN.md already answers; ask only what's missing.
4343+4444+- **Color strategy for this surface.** Pick one: Restrained / Committed / Full palette / Drenched. Can override the project default if the surface earns it (e.g. a drenched hero inside an otherwise Restrained product).
4545+- **Theme via scene sentence.** Write one sentence of physical context for this surface — who uses it, where, under what ambient light, in what mood. The sentence forces dark vs light. If it doesn't, add detail until it does.
4646+- **Two or three named anchor references.** Specific products, brands, objects — not adjectives like "modern" or "clean."
4747+4848+### Scope
4949+5050+Always ask. Sketch quality and shipped quality are different outputs; don't guess between them.
5151+5252+- **Fidelity.** Sketch / mid-fi / high-fi / production-ready?
5353+- **Breadth.** One screen / a flow / a whole surface?
5454+- **Interactivity.** Static visual / interactive prototype / shipped-quality component?
5555+- **Time intent.** Quick exploration, or polish until it ships?
5656+5757+Scope answers are task-scoped. Don't write them to PRODUCT.md or DESIGN.md — carry them through the design brief only.
5858+5959+### Constraints
6060+- Are there technical constraints? (Framework, performance budget, browser support)
6161+- Are there content constraints? (Localization, dynamic text length, user-generated content)
6262+- Mobile/responsive requirements?
6363+- Accessibility requirements beyond WCAG AA?
6464+6565+### Anti-Goals
6666+- What should this NOT be? What would be a wrong direction?
6767+- What's the biggest risk of getting this wrong?
6868+6969+## Phase 1.5: Visual Direction Probe (Capability-Gated)
7070+7171+After the discovery interview, generate a small set of visual direction probes **before** writing the final brief when all of these are true:
7272+7373+- The work is **net-new** or directionally ambiguous enough that visual exploration will clarify the brief.
7474+- The requested fidelity is **mid-fi, high-fi, or production-ready**. Skip for sketch-only planning.
7575+- The current harness has **built-in image generation capability** (for example, Codex with a native image tool). Do **not** ask the user to set up external APIs, shell scripts, or one-off tooling just to do this.
7676+7777+When those conditions are met, this step is mandatory for Codex and any harness with built-in image generation. Use native image generation; in Codex, use the built-in `image_gen` tool via the imagegen skill. If image generation is unavailable, do not ask the user to install APIs or tooling. State in one line that the image step is skipped because the harness lacks native image generation, then proceed.
7878+7979+Use probes to explore visual lanes, not to replace the brief.
8080+8181+Do not skip probes because the final UI will be semantic, editable, code-native, responsive, or accessible. Those are implementation requirements, not reasons to avoid visual exploration.
8282+8383+### What to generate
8484+8585+Generate **2 to 4** distinct direction probes based on the discovery answers, especially:
8686+8787+- Color strategy
8888+- Theme scene sentence
8989+- Named anchor references
9090+- Scope and fidelity
9191+9292+The probes should differ in primary visual direction (hierarchy, topology, density, typographic voice, or color strategy), not just palette tweaks.
9393+9494+### How to use the probes
9595+9696+- Treat them as **direction tests**, not final designs.
9797+- Use them to pressure-test whether the brief is pointing at the right lane.
9898+- Ask the user which direction feels closest, what feels off, and what should carry forward.
9999+- If the probes reveal a mismatch, revise the brief inputs before finalizing the brief.
100100+101101+### Important limits
102102+103103+- Do **not** skip discovery because image generation is available.
104104+- Do **not** treat generated imagery as final UX specification, final copy, or final accessibility behavior.
105105+- Do **not** use this step for minor refinements of existing work. It's for shaping a new surface or clarifying a big directional choice.
106106+107107+If image generation is unavailable, or the task doesn't benefit from it, skip this phase only with a one-line reason and proceed directly to the design brief.
108108+109109+## Phase 2: Design Brief
110110+111111+After the interview and any required probes, synthesize everything into a structured design brief. Present it to the user for explicit confirmation before considering this command complete. Stop after asking for confirmation; do not proceed to craft or implementation in the same response unless the user has already approved the brief.
112112+113113+### Brief Structure
114114+115115+**1. Feature Summary** (2-3 sentences)
116116+What this is, who it's for, what it needs to accomplish.
117117+118118+**2. Primary User Action**
119119+The single most important thing a user should do or understand here.
120120+121121+**3. Design Direction**
122122+Color strategy (Restrained / Committed / Full palette / Drenched) + the theme scene sentence + 2–3 named anchor references. Reference PRODUCT.md and DESIGN.md where they already answer, and note any per-surface overrides.
123123+124124+If you ran the Visual Direction Probe step, name which probe direction won and what changed in the brief because of it.
125125+126126+**4. Scope**
127127+Fidelity, breadth, interactivity, and time intent from the Scope section of the interview. Task-scoped — these don't persist beyond the brief.
128128+129129+**5. Layout Strategy**
130130+High-level spatial approach: what gets emphasis, what's secondary, how information flows. Describe the visual hierarchy and rhythm, not specific CSS.
131131+132132+**6. Key States**
133133+List every state the feature needs: default, empty, loading, error, success, edge cases. For each, note what the user needs to see and feel.
134134+135135+**7. Interaction Model**
136136+How users interact with this feature. What happens on click, hover, scroll? What feedback do they get? What's the flow from entry to completion?
137137+138138+**8. Content Requirements**
139139+What copy, labels, empty state messages, error messages, and microcopy are needed. Note any dynamic content and its realistic ranges.
140140+141141+**9. Recommended References**
142142+Based on the brief, list which impeccable reference files would be most valuable during implementation (e.g., spatial-design.md for complex layouts, motion-design.md for animated features, interaction-design.md for form-heavy features).
143143+144144+**10. Open Questions**
145145+Anything unresolved that the implementer should resolve during build.
146146+147147+---
148148+149149+STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. Ask for explicit confirmation of the brief before finishing. If the user disagrees with any part, revisit the relevant discovery questions. A shape run is incomplete until the brief is confirmed.
150150+151151+Once confirmed, the brief is complete. The user can now hand it to $impeccable, or use it to guide any other implementation approach. (If the user wants the full discovery-then-build flow in one step, they should use $impeccable craft instead, which runs this command internally.)
···2121### The Squint Test
22222323Blur your eyes (or screenshot and blur). Can you still identify:
2424-2524- The most important element?
2625- The second most important?
2726- Clear groupings?
···32313332Don't rely on size alone. Combine:
34333535-| Tool | Strong Hierarchy | Weak Hierarchy |
3636-| ------------ | ------------------------- | ----------------- |
3737-| **Size** | 3:1 ratio or more | <2:1 ratio |
3838-| **Weight** | Bold vs Regular | Medium vs Regular |
3939-| **Color** | High contrast | Similar tones |
4040-| **Position** | Top/left (primary) | Bottom/right |
4141-| **Space** | Surrounded by white space | Crowded |
3434+| Tool | Strong Hierarchy | Weak Hierarchy |
3535+|------|------------------|----------------|
3636+| **Size** | 3:1 ratio or more | <2:1 ratio |
3737+| **Weight** | Bold vs Regular | Medium vs Regular |
3838+| **Color** | High contrast | Similar tones |
3939+| **Position** | Top/left (primary) | Bottom/right |
4040+| **Space** | Surrounded by white space | Crowded |
42414342**The best hierarchy uses 2-3 dimensions at once**: A heading that's larger, bolder, AND has more space above it.
4443···80798180```css
8281.icon-button {
8383- width: 24px; /* Visual size */
8282+ width: 24px; /* Visual size */
8483 height: 24px;
8584 position: relative;
8685}
87868887.icon-button::before {
8989- content: "";
8888+ content: '';
9089 position: absolute;
9191- inset: -10px; /* Expand tap target to 44px */
9090+ inset: -10px; /* Expand tap target to 44px */
9291}
9392```
9493
+156
.agents/skills/impeccable/reference/teach.md
···11+# Teach Flow
22+33+Gathers design context for a project and writes two complementary files at the project root:
44+55+- **PRODUCT.md** (strategic): register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why".
66+- **DESIGN.md** (visual): visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks".
77+88+Every other impeccable command reads these files before doing any work.
99+1010+## Step 1: Load current state
1111+1212+Run the shared loader first so you know what already exists:
1313+1414+```bash
1515+node .agents/skills/impeccable/scripts/load-context.mjs
1616+```
1717+1818+The output tells you whether PRODUCT.md and/or DESIGN.md already exist. If `migrated: true`, legacy `.impeccable.md` was auto-renamed to `PRODUCT.md`. Mention this once to the user.
1919+2020+Decision tree:
2121+- **Neither file exists (empty project or no context yet)**: do Steps 2-4 (write PRODUCT.md), then decide on DESIGN.md based on whether there's code to analyze.
2222+- **PRODUCT.md exists, DESIGN.md missing**: skip to Step 5 — offer to run `$impeccable document` for DESIGN.md.
2323+- **PRODUCT.md exists but has no `## Register` section (legacy)**: add it. Infer a hypothesis from the codebase (see Step 2), confirm with the user, write the field.
2424+- **Both exist**: STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. Ask which file to refresh. Skip the one the user doesn't want changed.
2525+- **Just DESIGN.md exists (unusual)**: do Steps 2-4 to produce PRODUCT.md.
2626+2727+Never silently overwrite an existing file. Always confirm first.
2828+2929+If teach was invoked as a setup blocker by another command, such as `$impeccable craft landing page`, pause that command here. Complete teach, re-run the loader, then resume the original command with the freshly loaded context. For craft, resume into shape next; teach creates project context, but it is not a substitute for the task-specific shape interview and confirmed design brief.
3030+3131+## Step 2: Explore the codebase
3232+3333+Before asking questions, thoroughly scan the project to discover what you can:
3434+3535+- **README and docs**: Project purpose, target audience, any stated goals
3636+- **Package.json / config files**: Tech stack, dependencies, existing design libraries
3737+- **Existing components**: Current design patterns, spacing, typography in use
3838+- **Brand assets**: Logos, favicons, color values already defined
3939+- **Design tokens / CSS variables**: Existing color palettes, font stacks, spacing scales
4040+- **Any style guides or brand documentation**
4141+4242+Also form a **register hypothesis** from what you find:
4343+4444+- Brand signals: `/`, `/about`, `/pricing`, `/blog/*`, `/docs/*`, hero sections, big typography, scroll-driven sections, landing-page-shaped content.
4545+- Product signals: `/app/*`, `/dashboard`, `/settings`, `/(auth)`, forms, data tables, side/top nav, app-shell components.
4646+4747+Register is a hypothesis at this point, not a decision — Step 3 confirms it.
4848+4949+Note what you've learned and what remains unclear. This exploration feeds both PRODUCT.md and DESIGN.md.
5050+5151+## Step 3: Ask strategic questions (for PRODUCT.md)
5252+5353+STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. Ask only about what you couldn't infer from the codebase.
5454+5555+### Interview mode, not confirmation mode
5656+5757+If the repo is empty or the user's brief is sparse, run a short interview before proposing PRODUCT.md. Do **not** turn a one-sentence request into a complete inferred PRODUCT.md and ask for blanket confirmation.
5858+5959+- Use the harness's structured question tool when one exists. Otherwise, ask directly in chat and stop.
6060+- Ask **2-3 questions per round**, then wait for answers.
6161+- Use inferred answers as hypotheses or options, not as finished facts.
6262+- Complete at least one real user-answer round before drafting PRODUCT.md, unless every required answer is directly discoverable from repo docs.
6363+- Round 1 should establish register, users/purpose, and desired outcome.
6464+- Round 2 should establish brand personality or references, anti-references, and accessibility needs.
6565+6666+### Minimum viable interview
6767+6868+Ask enough to complete PRODUCT.md. At minimum, cover register confirmation, users and purpose, brand personality, anti-references, and accessibility needs unless each answer is directly discoverable from repo context. After at least one interview round, you may propose inferred answers, but the user must confirm them before you write PRODUCT.md. Never synthesize PRODUCT.md from the original task prompt alone.
6969+7070+### Register (ask first — it shapes everything below)
7171+7272+Every design task is either **brand** (marketing, landing, campaign, long-form content, portfolio — design IS the product) or **product** (app UI, admin, dashboards, tools — design SERVES the product).
7373+7474+If Step 2 produced a clear hypothesis, lead with it: *"From the codebase, this looks like a [brand / product] surface — does that match your intent, or should we treat it differently?"*
7575+7676+If the signal is genuinely split (e.g. a product with a big marketing landing), STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. Ask which register describes the **primary** surface. The register can be overridden per task later, but PRODUCT.md carries one default.
7777+7878+### Users & Purpose
7979+- Who uses this? What's their context when using it?
8080+- What job are they trying to get done?
8181+- For brand: what emotions should the interface evoke? (confidence, delight, calm, urgency)
8282+- For product: what workflow are they in? What's the primary task on any given screen?
8383+8484+### Brand & Personality
8585+- How would you describe the brand personality in 3 words?
8686+- Reference sites or apps that capture the right feel? What specifically about them?
8787+ - For brand, push for real-world references in the right lane (tech-minimal, editorial-magazine, consumer-warm, brutalist-grid, etc.) — not generic "modern" adjectives.
8888+ - For product, push for category best-tool references (Linear, Figma, Notion, Raycast, Stripe).
8989+- What should this explicitly NOT look like? Any anti-references?
9090+9191+### Accessibility & Inclusion
9292+- Specific accessibility requirements? (WCAG level, known user needs)
9393+- Considerations for reduced motion, color blindness, or other accommodations?
9494+9595+Skip questions where the answer is already clear. **Do NOT ask about colors, fonts, radii, or visual styling here** — those belong in DESIGN.md, not PRODUCT.md.
9696+9797+## Step 4: Write PRODUCT.md
9898+9999+Write PRODUCT.md only after the user has confirmed the strategic answers from Step 3. If an inferred answer is uncertain or unconfirmed, ask before writing.
100100+101101+Synthesize into a strategic document:
102102+103103+```markdown
104104+# Product
105105+106106+## Register
107107+108108+product
109109+110110+## Users
111111+[Who they are, their context, the job to be done]
112112+113113+## Product Purpose
114114+[What this product does, why it exists, what success looks like]
115115+116116+## Brand Personality
117117+[Voice, tone, 3-word personality, emotional goals]
118118+119119+## Anti-references
120120+[What this should NOT look like. Specific bad-example sites or patterns to avoid.]
121121+122122+## Design Principles
123123+[3-5 strategic principles derived from the conversation. Principles like "practice what you preach", "show, don't tell", "expert confidence" — NOT visual rules like "use OKLCH" or "magenta accent".]
124124+125125+## Accessibility & Inclusion
126126+[WCAG level, known user needs, considerations]
127127+```
128128+129129+Register is either `brand` or `product` as a bare value. No prose, no commentary.
130130+131131+Write to `PROJECT_ROOT/PRODUCT.md`. If `.impeccable.md` existed, the loader already renamed it — merge into that content rather than starting from scratch.
132132+133133+## Step 5: Decide on DESIGN.md
134134+135135+Offer `$impeccable document` either way. Two paths:
136136+137137+- **Code exists** (CSS tokens, components, a running site): "I can generate a DESIGN.md that captures your visual system (colors, typography, components) so variants stay on-brand. Want to do that now?"
138138+- **Pre-implementation** (empty project): "I can seed a starter DESIGN.md from five quick questions about color strategy, type direction, motion energy, and references. You can re-run once there's code, to capture the real tokens. Want to do that now?"
139139+140140+If the user agrees, delegate to `$impeccable document` (it auto-detects scan vs seed). Load its reference and follow that flow.
141141+142142+If the user prefers to skip, mention they can run `$impeccable document` any time later.
143143+144144+## Step 6: Confirm and wrap up
145145+146146+Summarize:
147147+- Register captured (brand / product)
148148+- What was written (PRODUCT.md, DESIGN.md, or both)
149149+- The 3-5 strategic principles from PRODUCT.md that will guide future work
150150+- If DESIGN.md is pending, remind the user how to generate it later
151151+152152+**Critical: re-run the loader to refresh session context.** After writing PRODUCT.md, run `node .agents/skills/impeccable/scripts/load-context.mjs` one final time and let its full JSON output land in conversation. This ensures subsequent commands in this session use the freshly-written PRODUCT.md, not a stale earlier version.
153153+154154+If teach was invoked as a blocker by another impeccable command (e.g. the user ran `$impeccable polish` with no PRODUCT.md), resume that original task now with the fresh context.
155155+156156+Optionally STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. Ask whether they'd like a brief summary of PRODUCT.md appended to AGENTS.md for easier agent reference. If yes, append a short **Design Context** pointer section there.
+52-47
.agents/skills/impeccable/reference/typography.md
···12121313**Use fewer sizes with more contrast.** A 5-size system covers most needs:
14141515-| Role | Typical Ratio | Use Case |
1616-| ---- | ------------- | ---------------------- |
1717-| xs | 0.75rem | Captions, legal |
1818-| sm | 0.875rem | Secondary UI, metadata |
1919-| base | 1rem | Body text |
2020-| lg | 1.25-1.5rem | Subheadings, lead text |
2121-| xl+ | 2-4rem | Headlines, hero text |
1515+| Role | Typical Ratio | Use Case |
1616+|------|---------------|----------|
1717+| xs | 0.75rem | Captions, legal |
1818+| sm | 0.875rem | Secondary UI, metadata |
1919+| base | 1rem | Body text |
2020+| lg | 1.25-1.5rem | Subheadings, lead text |
2121+| xl+ | 2-4rem | Headlines, hero text |
22222323Popular ratios: 1.25 (major third), 1.333 (perfect fourth), 1.5 (perfect fifth). Pick one and commit.
2424···26262727Use `ch` units for character-based measure (`max-width: 65ch`). Line-height scales inversely with line length—narrow columns need tighter leading, wide columns need more.
28282929-**Non-obvious**: Increase line-height for light text on dark backgrounds. The perceived weight is lighter, so text needs more breathing room. Add 0.05-0.1 to your normal line-height.
3030-3131-## Font Selection & Pairing
3232-3333-### Choosing Distinctive Fonts
3434-3535-**Avoid the invisible defaults**: Inter, Roboto, Open Sans, Lato, Montserrat. These are everywhere, making your design feel generic. They're fine for documentation or tools where personality isn't the goal—but if you want distinctive design, look elsewhere.
2929+**Non-obvious**: Light text on dark backgrounds needs compensation on three axes, not just one. Bump line-height by 0.05–0.1, add a touch of letter-spacing (0.01–0.02em), and optionally step the body weight up one notch (regular → medium). The perceived weight drops across all three; fix all three.
36303737-**Pick the font from the brief, not from a category preset.** The most common AI typography failure is reaching for the same "tasteful" font for every editorial brief, the same "modern" font for every tech brief, the same "elegant serif" for every premium brief. Those reflexes produce monoculture across projects. The right font is one whose physical character matches _this specific_ brand, audience, and moment.
3131+**Paragraph rhythm**: Pick either space between paragraphs OR first-line indentation. Never both. Digital usually wants space; editorial/long-form can justify indent-only.
38323939-A working selection process:
3333+## Font Selection & Pairing
40344141-1. Read the brief once. Write down three concrete words for the brand voice. Not "modern" or "elegant" — those are dead categories. Try "warm and mechanical and opinionated" or "calm and clinical and careful" or "fast and dense and unimpressed" or "handmade and a little weird."
4242-2. Now imagine the font as a physical object the brand could ship: a typewriter ribbon, a hand-lettered shop sign, a 1970s mainframe terminal manual, a fabric label on the inside of a coat, a museum exhibit caption, a tax form, a children's book printed on cheap newsprint. Whichever physical object fits the three words is pointing at the right _kind_ of typeface.
4343-3. Browse a font catalog (Google Fonts, Pangram Pangram, Adobe Fonts, Future Fonts, ABC Dinamo) with that physical object in mind. **Reject the first thing that "looks designy."** That's your trained-everywhere reflex. Keep looking.
4444-4. Avoid your defaults from previous projects. If you find yourself reaching for the same display font you used last time, make yourself pick something else.
3535+The tactical selection procedure and the reflex-reject list live in [reference/brand.md](brand.md) under **Font selection procedure** and **Reflex-reject list** (loaded for brand-register tasks). The rest of this section covers the adjacent knowledge: anti-reflex corrections, system font use, and pairing rules.
45364646-**Anti-reflexes worth defending against**:
3737+### Anti-reflexes worth defending against
47384839- A technical/utilitarian brief does NOT need a serif "for warmth." Most tech tools should look like tech tools.
4940- An editorial/premium brief does NOT need the same expressive serif everyone is using right now. Premium can be Swiss-modern, can be neo-grotesque, can be a literal monospace, can be a quiet humanist sans.
5041- A children's product does NOT need a rounded display font. Kids' books use real type.
5151-- A "modern" brief does NOT need a geometric sans. The most modern thing you can do in 2026 is not use the font everyone else is using.
4242+- A "modern" brief does NOT need a geometric sans. The most modern thing you can do is not use the font everyone else is using.
52435344**System fonts are underrated**: `-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui` looks native, loads instantly, and is highly readable. Consider this for apps where performance > personality.
5445···5748**The non-obvious truth**: You often don't need a second font. One well-chosen font family in multiple weights creates cleaner hierarchy than two competing typefaces. Only add a second font when you need genuine contrast (e.g., display headlines + body serif).
58495950When pairing, contrast on multiple axes:
6060-6151- Serif + Sans (structure contrast)
6252- Geometric + Humanist (personality contrast)
6353- Condensed display + Wide body (proportion contrast)
···7161```css
7262/* 1. Use font-display: swap for visibility */
7363@font-face {
7474- font-family: "CustomFont";
7575- src: url("font.woff2") format("woff2");
6464+ font-family: 'CustomFont';
6565+ src: url('font.woff2') format('woff2');
7666 font-display: swap;
7767}
78687969/* 2. Match fallback metrics to minimize shift */
8070@font-face {
8181- font-family: "CustomFont-Fallback";
8282- src: local("Arial");
8383- size-adjust: 105%; /* Scale to match x-height */
8484- ascent-override: 90%; /* Match ascender height */
8585- descent-override: 20%; /* Match descender depth */
8686- line-gap-override: 10%; /* Match line spacing */
7171+ font-family: 'CustomFont-Fallback';
7272+ src: local('Arial');
7373+ size-adjust: 105%; /* Scale to match x-height */
7474+ ascent-override: 90%; /* Match ascender height */
7575+ descent-override: 20%; /* Match descender depth */
7676+ line-gap-override: 10%; /* Match line spacing */
8777}
88788979body {
9090- font-family: "CustomFont", "CustomFont-Fallback", sans-serif;
8080+ font-family: 'CustomFont', 'CustomFont-Fallback', sans-serif;
9181}
9282```
93839484Tools like [Fontaine](https://github.com/unjs/fontaine) calculate these overrides automatically.
95858686+**`swap` vs `optional`**: `swap` shows fallback text immediately and FOUT-swaps when the web font arrives. `optional` uses the fallback if the web font misses a small load budget (~100ms) and avoids the shift entirely. Pick `optional` when zero layout shift matters more than seeing the branded font on slow networks.
8787+8888+**Preload the critical weight only**: typically the regular-weight body font used above the fold. Preloading every weight costs more bandwidth than it saves.
8989+9090+**Variable fonts for 3+ weights or styles**: a single variable font file is usually smaller than three static weight files, gives fractional weight control, and pairs well with `font-optical-sizing: auto`. For 1–2 weights, static is fine.
9191+9692## Modern Web Typography
97939894### Fluid Type
···10399104100**Use fixed `rem` scales for**: App UIs, dashboards, and data-dense interfaces. No major app design system (Material, Polaris, Primer, Carbon) uses fluid type in product UI — fixed scales with optional breakpoint adjustments give the spatial predictability that container-based layouts need. Body text should also be fixed even on marketing pages, since the size difference across viewports is too small to warrant it.
105101102102+**Bound your clamp()**: keep `max-size ≤ ~2.5 × min-size`. Wider ratios break the browser's zoom and reflow behaviour and make large viewports feel like the page is shouting.
103103+104104+**Scale container width and font-size together** so effective character measure stays in the 45–75ch band at every viewport. A heading that widens faster than its container drifts out of the comfortable measure at the top end.
105105+106106### OpenType Features
107107108108Most developers don't know these exist. Use them for polish:
109109110110```css
111111/* Tabular numbers for data alignment */
112112-.data-table {
113113- font-variant-numeric: tabular-nums;
114114-}
112112+.data-table { font-variant-numeric: tabular-nums; }
115113116114/* Proper fractions */
117117-.recipe-amount {
118118- font-variant-numeric: diagonal-fractions;
119119-}
115115+.recipe-amount { font-variant-numeric: diagonal-fractions; }
120116121117/* Small caps for abbreviations */
122122-abbr {
123123- font-variant-caps: all-small-caps;
124124-}
118118+abbr { font-variant-caps: all-small-caps; }
125119126120/* Disable ligatures in code */
127127-code {
128128- font-variant-ligatures: none;
129129-}
121121+code { font-variant-ligatures: none; }
130122131123/* Enable kerning (usually on by default, but be explicit) */
132132-body {
133133- font-kerning: normal;
134134-}
124124+body { font-kerning: normal; }
135125```
136126137127Check what features your font supports at [Wakamai Fondue](https://wakamaifondue.com/).
128128+129129+### Rendering polish
130130+131131+```css
132132+/* Even out heading line lengths (browser picks better break points) */
133133+h1, h2, h3 { text-wrap: balance; }
134134+135135+/* Reduce orphans and ragged endings in long prose */
136136+article p { text-wrap: pretty; }
137137+138138+/* Variable fonts: pick the right optical-size master automatically */
139139+body { font-optical-sizing: auto; }
140140+```
141141+142142+**ALL-CAPS tracking**: capitals sit too close at default spacing. Add 5–12% letter-spacing (`letter-spacing: 0.05em` to `0.12em`) to short all-caps labels, eyebrows, and small headings. Real small caps (via `font-variant-caps`) need the same treatment, slightly gentler.
138143139144## Typography System Architecture
140145
+32-33
.agents/skills/impeccable/reference/ux-writing.md
···4455**Never use "OK", "Submit", or "Yes/No".** These are lazy and ambiguous. Use specific verb + object patterns:
6677-| Bad | Good | Why |
88-| ---------- | -------------- | ----------------------------- |
99-| OK | Save changes | Says what will happen |
1010-| Submit | Create account | Outcome-focused |
1111-| Yes | Delete message | Confirms the action |
1212-| Cancel | Keep editing | Clarifies what "cancel" means |
1313-| Click here | Download PDF | Describes the destination |
77+| Bad | Good | Why |
88+|-----|------|-----|
99+| OK | Save changes | Says what will happen |
1010+| Submit | Create account | Outcome-focused |
1111+| Yes | Delete message | Confirms the action |
1212+| Cancel | Keep editing | Clarifies what "cancel" means |
1313+| Click here | Download PDF | Describes the destination |
14141515**For destructive actions**, name the destruction:
1616-1716- "Delete" not "Remove" (delete is permanent, remove implies recoverable)
1817- "Delete 5 items" not "Delete selected" (show the count)
1918···23222423### Error Message Templates
25242626-| Situation | Template |
2727-| --------------------- | ------------------------------------------------------------------------------ |
2828-| **Format error** | "[Field] needs to be [format]. Example: [example]" |
2929-| **Missing required** | "Please enter [what's missing]" |
3030-| **Permission denied** | "You don't have access to [thing]. [What to do instead]" |
3131-| **Network error** | "We couldn't reach [thing]. Check your connection and [action]." |
3232-| **Server error** | "Something went wrong on our end. We're looking into it. [Alternative action]" |
2525+| Situation | Template |
2626+|-----------|----------|
2727+| **Format error** | "[Field] needs to be [format]. Example: [example]" |
2828+| **Missing required** | "Please enter [what's missing]" |
2929+| **Permission denied** | "You don't have access to [thing]. [What to do instead]" |
3030+| **Network error** | "We couldn't reach [thing]. Check your connection and [action]." |
3131+| **Server error** | "Something went wrong on our end. We're looking into it. [Alternative action]" |
33323433### Don't Blame the User
3534···4443**Voice** is your brand's personality—consistent everywhere.
4544**Tone** adapts to the moment.
46454747-| Moment | Tone Shift |
4848-| ------------------- | -------------------------------------------------------------- |
4949-| Success | Celebratory, brief: "Done! Your changes are live." |
5050-| Error | Empathetic, helpful: "That didn't work. Here's what to try..." |
5151-| Loading | Reassuring: "Saving your work..." |
5252-| Destructive confirm | Serious, clear: "Delete this project? This can't be undone." |
4646+| Moment | Tone Shift |
4747+|--------|------------|
4848+| Success | Celebratory, brief: "Done! Your changes are live." |
4949+| Error | Empathetic, helpful: "That didn't work. Here's what to try..." |
5050+| Loading | Reassuring: "Saving your work..." |
5151+| Destructive confirm | Serious, clear: "Delete this project? This can't be undone." |
53525453**Never use humor for errors.** Users are already frustrated. Be helpful, not cute.
5554···63626463German text is ~30% longer than English. Allocate space:
65646666-| Language | Expansion |
6767-| -------- | ---------------------------------- |
6868-| German | +30% |
6969-| French | +20% |
7070-| Finnish | +30-40% |
7171-| Chinese | -30% (fewer chars, but same width) |
6565+| Language | Expansion |
6666+|----------|-----------|
6767+| German | +30% |
6868+| French | +20% |
6969+| Finnish | +30-40% |
7070+| Chinese | -30% (fewer chars, but same width) |
72717372### Translation-Friendly Patterns
7473···78777978Pick one term and stick with it:
80798181-| Inconsistent | Consistent |
8282-| -------------------------------- | ---------- |
8383-| Delete / Remove / Trash | Delete |
8484-| Settings / Preferences / Options | Settings |
8585-| Sign in / Log in / Enter | Sign in |
8686-| Create / Add / New | Create |
8080+| Inconsistent | Consistent |
8181+|--------------|------------|
8282+| Delete / Remove / Trash | Delete |
8383+| Settings / Preferences / Options | Settings |
8484+| Sign in / Log in / Enter | Sign in |
8585+| Create / Add / New | Create |
87868887Build a terminology glossary and enforce it. Variety creates confusion.
8988
···11+{
22+ "craft": {
33+ "description": "Full confirmed-brief-then-build flow. Runs multi-round shape discovery first, resolves visual probe and north-star mock gates when available, then builds and visually iterates. Use when building a new feature end-to-end.",
44+ "argumentHint": "[feature description]"
55+ },
66+ "teach": {
77+ "description": "Gathers design context for a project. Runs a multi-round discovery interview when context is missing and writes PRODUCT.md (strategic: users, brand, principles) and, when code exists to analyze, DESIGN.md (visual: colors, typography, components). Every other command reads these files before doing work. Use once per project.",
88+ "argumentHint": ""
99+ },
1010+ "document": {
1111+ "description": "Generate a DESIGN.md file that captures the current visual design system. Auto-extracts colors, typography, spacing, radii, and component patterns from the codebase, then asks the user to confirm descriptive language for atmosphere and color character. Follows the Google Stitch DESIGN.md format so the file is tool-compatible. Use when you need a visual design spec an AI agent can follow to stay on-brand.",
1212+ "argumentHint": ""
1313+ },
1414+ "extract": {
1515+ "description": "Pull reusable patterns, components, and design tokens into the design system. Identifies repeated patterns and consolidates them. Use when you have drift across the codebase and want to bring things back to a consistent system.",
1616+ "argumentHint": "[target]"
1717+ },
1818+ "live": {
1919+ "description": "Interactive live variant mode. Select elements in the browser, pick a design action, and get AI-generated HTML+CSS variants hot-swapped via HMR. Requires a running dev server. Use when you want to visually experiment with design alternatives in real time.",
2020+ "argumentHint": ""
2121+ },
2222+ "adapt": {
2323+ "description": "Adapt designs to work across different screen sizes, devices, contexts, or platforms. Implements breakpoints, fluid layouts, and touch targets. Use when the user mentions responsive design, mobile layouts, breakpoints, viewport adaptation, or cross-device compatibility.",
2424+ "argumentHint": "[target] [context (mobile, tablet, print...)]"
2525+ },
2626+ "animate": {
2727+ "description": "Review a feature and enhance it with purposeful animations, micro-interactions, and motion effects that improve usability and delight. Use when the user mentions adding animation, transitions, micro-interactions, motion design, hover effects, or making the UI feel more alive.",
2828+ "argumentHint": "[target]"
2929+ },
3030+ "audit": {
3131+ "description": "Run technical quality checks across accessibility, performance, theming, responsive design, and anti-patterns. Generates a scored report with P0-P3 severity ratings and actionable plan. Use when the user wants an accessibility check, performance audit, or technical quality review.",
3232+ "argumentHint": "[area (feature, page, component...)]"
3333+ },
3434+ "bolder": {
3535+ "description": "Amplify safe or boring designs to make them more visually interesting and stimulating. Increases impact while maintaining usability. Use when the user says the design looks bland, generic, too safe, lacks personality, or wants more visual impact and character.",
3636+ "argumentHint": "[target]"
3737+ },
3838+ "clarify": {
3939+ "description": "Improve unclear UX copy, error messages, microcopy, labels, and instructions to make interfaces easier to understand. Use when the user mentions confusing text, unclear labels, bad error messages, hard-to-follow instructions, or wanting better UX writing.",
4040+ "argumentHint": "[target]"
4141+ },
4242+ "colorize": {
4343+ "description": "Add strategic color to features that are too monochromatic or lack visual interest, making interfaces more engaging and expressive. Use when the user mentions the design looking gray, dull, lacking warmth, needing more color, or wanting a more vibrant or expressive palette.",
4444+ "argumentHint": "[target]"
4545+ },
4646+ "critique": {
4747+ "description": "Evaluate design from a UX perspective, assessing visual hierarchy, information architecture, emotional resonance, cognitive load, and overall quality with quantitative scoring, persona-based testing, automated anti-pattern detection, and actionable feedback. Use when the user asks to review, critique, evaluate, or give feedback on a design or component.",
4848+ "argumentHint": "[area (feature, page, component...)]"
4949+ },
5050+ "delight": {
5151+ "description": "Add moments of joy, personality, and unexpected touches that make interfaces memorable and enjoyable to use. Elevates functional to delightful. Use when the user asks to add polish, personality, animations, micro-interactions, delight, or make an interface feel fun or memorable.",
5252+ "argumentHint": "[target]"
5353+ },
5454+ "distill": {
5555+ "description": "Strip designs to their essence by removing unnecessary complexity. Great design is simple, powerful, and clean. Use when the user asks to simplify, declutter, reduce noise, remove elements, or make a UI cleaner and more focused.",
5656+ "argumentHint": "[target]"
5757+ },
5858+ "harden": {
5959+ "description": "Make interfaces production-ready: error handling, i18n, text overflow, edge case management, and resilience under real-world data. Use when the user asks to harden, make production-ready, handle edge cases, add error states, or fix overflow and i18n issues.",
6060+ "argumentHint": "[target]"
6161+ },
6262+ "onboard": {
6363+ "description": "Design onboarding flows, first-run experiences, and empty states that guide new users to value. Covers welcome screens, account setup, progressive disclosure, contextual tooltips, feature announcements, and activation moments. Use when the user mentions onboarding, first-time users, empty states, activation, getting started, new user flows, or the aha moment.",
6464+ "argumentHint": "[target]"
6565+ },
6666+ "layout": {
6767+ "description": "Improve layout, spacing, and visual rhythm. Fixes monotonous grids, inconsistent spacing, and weak visual hierarchy. Use when the user mentions layout feeling off, spacing issues, visual hierarchy, crowded UI, alignment problems, or wanting better composition.",
6868+ "argumentHint": "[target]"
6969+ },
7070+ "optimize": {
7171+ "description": "Diagnoses and fixes UI performance across loading speed, rendering, animations, images, and bundle size. Use when the user mentions slow, laggy, janky, performance, bundle size, load time, or wants a faster, smoother experience.",
7272+ "argumentHint": "[target]"
7373+ },
7474+ "overdrive": {
7575+ "description": "Pushes interfaces past conventional limits with technically ambitious implementations — shaders, spring physics, scroll-driven reveals, 60fps animations. Use when the user wants to wow, impress, go all-out, or make something that feels extraordinary.",
7676+ "argumentHint": "[target]"
7777+ },
7878+ "polish": {
7979+ "description": "Performs a final quality pass fixing alignment, spacing, consistency, and micro-detail issues before shipping. Use when the user mentions polish, finishing touches, pre-launch review, something looks off, or wants to go from good to great.",
8080+ "argumentHint": "[target]"
8181+ },
8282+ "quieter": {
8383+ "description": "Tones down visually aggressive or overstimulating designs, reducing intensity while preserving quality. Use when the user mentions too bold, too loud, overwhelming, aggressive, garish, or wants a calmer, more refined aesthetic.",
8484+ "argumentHint": "[target]"
8585+ },
8686+ "shape": {
8787+ "description": "Plan UX and UI before code. Runs a required multi-round discovery interview, uses visual probes when available, and produces a user-confirmed design brief for implementation.",
8888+ "argumentHint": "[feature to shape]"
8989+ },
9090+ "typeset": {
9191+ "description": "Improves typography by fixing font choices, hierarchy, sizing, weight, and readability so text feels intentional. Use when the user mentions fonts, type, readability, text hierarchy, sizing looks off, or wants more polished, intentional typography.",
9292+ "argumentHint": "[target]"
9393+ }
9494+}
···11+/**
22+ * Decide whether a given file is "generated" (regenerated by a build step,
33+ * unsafe to write variants into) or "source" (safe to edit, changes persist).
44+ *
55+ * Why this matters: when the user picks an element on a page whose underlying
66+ * file is regenerated by a build step (e.g. `scripts/build-sub-pages.js`
77+ * rewriting `public/docs/*.html`), writing variants or accepted changes into
88+ * that file is silent data loss — the next build wipes them.
99+ *
1010+ * Signals, in order of reliability:
1111+ * 1. Git check-ignore: gitignored files are assumed generated.
1212+ * 2. File-header markers ("GENERATED", "DO NOT EDIT", "AUTO-GENERATED")
1313+ * within the first ~300 characters — catches non-git projects.
1414+ */
1515+1616+import { execSync } from 'node:child_process';
1717+import fs from 'node:fs';
1818+import path from 'node:path';
1919+2020+const HEADER_SCAN_BYTES = 300;
2121+const HEADER_MARKERS = [
2222+ /@generated\b/i,
2323+ /\bGENERATED\s+FILE\b/,
2424+ /\bAUTO-?GENERATED\b/i,
2525+ /\bDO\s+NOT\s+EDIT\b/i,
2626+];
2727+2828+/**
2929+ * @param {string} filePath - absolute or cwd-relative path
3030+ * @param {object} [options]
3131+ * @param {string} [options.cwd] - project root (defaults to process.cwd())
3232+ */
3333+export function isGeneratedFile(filePath, options = {}) {
3434+ const cwd = options.cwd || process.cwd();
3535+ const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
3636+3737+ if (isGitIgnored(absPath, cwd)) return true;
3838+ if (hasGeneratedHeader(absPath)) return true;
3939+ return false;
4040+}
4141+4242+function isGitIgnored(absPath, cwd) {
4343+ try {
4444+ execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, {
4545+ cwd,
4646+ stdio: 'ignore',
4747+ });
4848+ return true; // exit 0 = ignored
4949+ } catch (err) {
5050+ // Exit code 1 = not ignored. Exit code 128 = not a git repo or other error.
5151+ // In both cases, treat as "not known to be ignored."
5252+ return false;
5353+ }
5454+}
5555+5656+function hasGeneratedHeader(absPath) {
5757+ let fd;
5858+ try {
5959+ fd = fs.openSync(absPath, 'r');
6060+ const buf = Buffer.alloc(HEADER_SCAN_BYTES);
6161+ const bytesRead = fs.readSync(fd, buf, 0, HEADER_SCAN_BYTES, 0);
6262+ const head = buf.slice(0, bytesRead).toString('utf-8');
6363+ return HEADER_MARKERS.some((re) => re.test(head));
6464+ } catch {
6565+ return false;
6666+ } finally {
6767+ if (fd !== undefined) { try { fs.closeSync(fd); } catch {} }
6868+ }
6969+}
+465
.agents/skills/impeccable/scripts/live-accept.mjs
···11+/**
22+ * CLI helper: deterministic accept/discard of variant sessions.
33+ *
44+ * Usage:
55+ * node live-accept.mjs --id SESSION_ID --discard
66+ * node live-accept.mjs --id SESSION_ID --variant N
77+ *
88+ * For discard: removes the entire variant wrapper and restores the original.
99+ * For accept: replaces the wrapper with the chosen variant's content. If the
1010+ * session had a colocated <style> block, it's preserved with carbonize markers
1111+ * for a background agent to integrate into the project's CSS.
1212+ *
1313+ * Output: JSON to stdout.
1414+ */
1515+1616+import fs from 'node:fs';
1717+import path from 'node:path';
1818+import { isGeneratedFile } from './is-generated.mjs';
1919+2020+const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
2121+2222+// ---------------------------------------------------------------------------
2323+// CLI
2424+// ---------------------------------------------------------------------------
2525+2626+export async function acceptCli() {
2727+ const args = process.argv.slice(2);
2828+2929+ if (args.includes('--help') || args.includes('-h')) {
3030+ console.log(`Usage: node live-accept.mjs [options]
3131+3232+Deterministic accept/discard for live variant sessions.
3333+3434+Modes:
3535+ --discard Remove variants, restore original
3636+ --variant N Accept variant N, discard the rest
3737+3838+Required:
3939+ --id SESSION_ID Session ID of the variant wrapper
4040+4141+Output (JSON):
4242+ { handled, file, carbonize }`);
4343+ process.exit(0);
4444+ }
4545+4646+ const id = argVal(args, '--id');
4747+ const variantNum = argVal(args, '--variant');
4848+ const paramValuesRaw = argVal(args, '--param-values');
4949+ const isDiscard = args.includes('--discard');
5050+5151+ if (!id) { console.error('Missing --id'); process.exit(1); }
5252+ if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); }
5353+5454+ let paramValues = null;
5555+ if (paramValuesRaw) {
5656+ try { paramValues = JSON.parse(paramValuesRaw); }
5757+ catch { paramValues = null; } // malformed blob: skip the comment rather than failing the accept
5858+ }
5959+6060+ // Find the file containing this session's markers
6161+ const found = findSessionFile(id, process.cwd());
6262+ if (!found) {
6363+ console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));
6464+ process.exit(0);
6565+ }
6666+6767+ const { file: targetFile, content, lines } = found;
6868+ const relFile = path.relative(process.cwd(), targetFile);
6969+7070+ // Bail if the session lives in a generated file. The agent manually wrote
7171+ // the wrapper there for preview, and is responsible for writing the
7272+ // accepted variant to true source (or cleaning up on discard). See
7373+ // "Handle fallback" in live.md.
7474+ if (isGeneratedFile(targetFile, { cwd: process.cwd() })) {
7575+ console.log(JSON.stringify({
7676+ handled: false,
7777+ mode: 'fallback',
7878+ file: relFile,
7979+ hint: 'Session is in a generated file. Persist the accepted variant in source; do not rely on this script.',
8080+ }));
8181+ process.exit(0);
8282+ }
8383+8484+ if (isDiscard) {
8585+ const result = handleDiscard(id, lines, targetFile);
8686+ console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
8787+ } else {
8888+ const result = handleAccept(id, variantNum, lines, targetFile, paramValues);
8989+ // Single-line attention-grabber when cleanup is required. The full
9090+ // five-step checklist lives in reference/live.md (loaded once per
9191+ // session); repeating it per-event would waste tokens.
9292+ if (result.carbonize) {
9393+ result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + relFile + '. See reference/live.md "Required after accept".';
9494+ }
9595+ console.log(JSON.stringify({ handled: true, file: relFile, ...result }));
9696+ }
9797+}
9898+9999+// ---------------------------------------------------------------------------
100100+// Discard
101101+// ---------------------------------------------------------------------------
102102+103103+function handleDiscard(id, lines, targetFile) {
104104+ const block = findMarkerBlock(id, lines);
105105+ if (!block) return { handled: false, error: 'Markers not found' };
106106+107107+ const original = extractOriginal(lines, block);
108108+ const indent = lines[block.start].match(/^(\s*)/)[1];
109109+110110+ // De-indent the original content back to the marker's indentation level
111111+ const restored = deindentContent(original, indent);
112112+113113+ const newLines = [
114114+ ...lines.slice(0, block.start),
115115+ ...restored,
116116+ ...lines.slice(block.end + 1),
117117+ ];
118118+ fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
119119+ return {};
120120+}
121121+122122+// ---------------------------------------------------------------------------
123123+// Accept
124124+// ---------------------------------------------------------------------------
125125+126126+function handleAccept(id, variantNum, lines, targetFile, paramValues) {
127127+ const block = findMarkerBlock(id, lines);
128128+ if (!block) return { handled: false, error: 'Markers not found' };
129129+130130+ const indent = lines[block.start].match(/^(\s*)/)[1];
131131+ const commentSyntax = detectCommentSyntax(targetFile);
132132+133133+ // Extract the chosen variant's inner content
134134+ const variantContent = extractVariant(lines, block, variantNum);
135135+ if (!variantContent) return { handled: false, error: 'Variant ' + variantNum + ' not found' };
136136+137137+ // Extract CSS block if present
138138+ const cssContent = extractCss(lines, block, id);
139139+140140+ // Check if carbonizing is needed:
141141+ // - CSS block exists, OR
142142+ // - variant HTML contains helper classes/attributes that need cleanup
143143+ const variantText = variantContent.join('\n');
144144+ const hasHelperAttrs = variantText.includes('data-impeccable-variant');
145145+ const needsCarbonize = !!(cssContent || hasHelperAttrs);
146146+147147+ // Build the replacement
148148+ const restored = deindentContent(variantContent, indent);
149149+ const replacement = [];
150150+151151+ if (cssContent) {
152152+ const isJsx = commentSyntax.open === '{/*';
153153+ replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
154154+ // JSX targets need the CSS body wrapped in a template literal so that the
155155+ // `{` and `}` in CSS rules don't get parsed as JSX expressions.
156156+ replacement.push(indent + '<style data-impeccable-css="' + id + '">' + (isJsx ? '{`' : ''));
157157+ // Re-indent CSS content to match
158158+ for (const cssLine of cssContent) {
159159+ replacement.push(indent + cssLine.trimStart());
160160+ }
161161+ replacement.push(indent + (isJsx ? '`}</style>' : '</style>'));
162162+ if (paramValues && Object.keys(paramValues).length > 0) {
163163+ // Preserve the user's knob positions for the carbonize-cleanup agent
164164+ // to bake into the final CSS when it collapses scoped rules.
165165+ replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
166166+ }
167167+ replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
168168+ }
169169+170170+ // Keep the `@scope ([data-impeccable-variant="N"])` selectors in the
171171+ // carbonize CSS block working visually by re-wrapping the accepted content
172172+ // in a data-impeccable-variant="N" div with `display: contents` (so layout
173173+ // isn't affected). The carbonize agent strips this attribute + wrapper when
174174+ // it moves the CSS to a proper stylesheet.
175175+ //
176176+ // Style attribute syntax has to follow the host file's flavor — JSX files
177177+ // need the object form, otherwise React 19 throws "Failed to set indexed
178178+ // property [0] on CSSStyleDeclaration" while parsing the string char-by-char.
179179+ if (cssContent) {
180180+ const isJsx = commentSyntax.open === '{/*';
181181+ const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"';
182182+ replacement.push(indent + '<div data-impeccable-variant="' + variantNum + '" ' + styleAttr + '>');
183183+ replacement.push(...restored);
184184+ replacement.push(indent + '</div>');
185185+ } else {
186186+ replacement.push(...restored);
187187+ }
188188+189189+ const newLines = [
190190+ ...lines.slice(0, block.start),
191191+ ...replacement,
192192+ ...lines.slice(block.end + 1),
193193+ ];
194194+ fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
195195+196196+ return { carbonize: needsCarbonize };
197197+}
198198+199199+// ---------------------------------------------------------------------------
200200+// Parsing helpers
201201+// ---------------------------------------------------------------------------
202202+203203+/**
204204+ * Find the start/end marker lines for a session.
205205+ * Returns { start, end } (0-indexed line numbers) or null.
206206+ */
207207+function findMarkerBlock(id, lines) {
208208+ let start = -1;
209209+ let end = -1;
210210+ const startPattern = 'impeccable-variants-start ' + id;
211211+ const endPattern = 'impeccable-variants-end ' + id;
212212+213213+ for (let i = 0; i < lines.length; i++) {
214214+ if (start === -1 && lines[i].includes(startPattern)) start = i;
215215+ if (lines[i].includes(endPattern)) { end = i; break; }
216216+ }
217217+218218+ return (start !== -1 && end !== -1) ? { start, end } : null;
219219+}
220220+221221+/**
222222+ * Join wrapper lines into a single string with `<style>` elements removed so
223223+ * marker matching and div-depth tracking aren't confused by:
224224+ * - CSS `@scope ([data-impeccable-variant="N"])` strings that look like the
225225+ * HTML marker we're searching for
226226+ * - JSX self-closing `<style ... />` (no separate `</style>` to close on)
227227+ * - Same-line `<style>…</style>` blocks
228228+ * - Multi-line `<style>\n…\n</style>` blocks
229229+ */
230230+function stripStyleAndJoin(lines, block) {
231231+ const out = [];
232232+ let inStyle = false;
233233+ for (let i = block.start; i <= block.end; i++) {
234234+ let line = lines[i];
235235+236236+ if (!inStyle) {
237237+ // Strip any complete <style> elements on this line (self-closed or
238238+ // same-line-closed), including their body content.
239239+ line = line
240240+ .replace(/<style\b[^>]*>[\s\S]*?<\/style\s*>/g, '')
241241+ .replace(/<style\b[^>]*\/\s*>/g, '');
242242+243243+ // If a <style> opener remains (multi-line body starts here), strip from
244244+ // the opener to end-of-line and flip into skip mode.
245245+ const openerIdx = line.search(/<style\b/);
246246+ if (openerIdx !== -1) {
247247+ line = line.slice(0, openerIdx);
248248+ inStyle = true;
249249+ }
250250+ out.push(line);
251251+ } else {
252252+ // In multi-line style body; drop everything until we see </style>.
253253+ const closeIdx = line.search(/<\/style\s*>/);
254254+ if (closeIdx !== -1) {
255255+ inStyle = false;
256256+ out.push(line.slice(closeIdx).replace(/<\/style\s*>/, ''));
257257+ }
258258+ // else: skip line entirely
259259+ }
260260+ }
261261+ return out.join('\n');
262262+}
263263+264264+/**
265265+ * Find the inner content of `<TAG ...attrMatch...>…</TAG>` inside `text`,
266266+ * handling nested same-tag elements via depth counting. `attrMatch` is a
267267+ * regex source fragment that must appear inside the opener tag.
268268+ * Returns the inner string (may be empty), or null if not found.
269269+ */
270270+function extractInnerByAttr(text, attrMatch) {
271271+ const openerRe = new RegExp('<([A-Za-z][A-Za-z0-9]*)\\b[^>]*' + attrMatch + '[^>]*>');
272272+ const openMatch = text.match(openerRe);
273273+ if (!openMatch) return null;
274274+275275+ const tagName = openMatch[1];
276276+ const innerStart = openMatch.index + openMatch[0].length;
277277+278278+ // Match any opener or closer of this tag name after innerStart.
279279+ // (Does not match self-closing <TAG … />, which doesn't contribute to depth.)
280280+ const tagRe = new RegExp('<(?:/)?' + tagName + '\\b[^>]*>', 'g');
281281+ tagRe.lastIndex = innerStart;
282282+283283+ let depth = 1;
284284+ let m;
285285+ while ((m = tagRe.exec(text))) {
286286+ const isClose = m[0].startsWith('</');
287287+ const isSelfClose = !isClose && /\/\s*>$/.test(m[0]);
288288+ if (isClose) {
289289+ depth--;
290290+ if (depth === 0) return text.slice(innerStart, m.index);
291291+ } else if (!isSelfClose) {
292292+ depth++;
293293+ }
294294+ }
295295+ return null;
296296+}
297297+298298+/**
299299+ * Extract the original element content from within the variant wrapper.
300300+ * Returns an array of lines.
301301+ */
302302+function extractOriginal(lines, block) {
303303+ const text = stripStyleAndJoin(lines, block);
304304+ const inner = extractInnerByAttr(text, 'data-impeccable-variant="original"');
305305+ if (inner === null) return [];
306306+ return inner.split('\n');
307307+}
308308+309309+/**
310310+ * Extract a specific variant's inner content (stripping the wrapper div).
311311+ * Returns an array of lines, or null if not found.
312312+ */
313313+function extractVariant(lines, block, variantNum) {
314314+ const text = stripStyleAndJoin(lines, block);
315315+ const inner = extractInnerByAttr(text, 'data-impeccable-variant="' + variantNum + '"');
316316+ if (inner === null) return null;
317317+ const result = inner.split('\n');
318318+ // Collapse a lone empty leading/trailing line (common after string splice).
319319+ while (result.length > 1 && result[0].trim() === '') result.shift();
320320+ while (result.length > 1 && result[result.length - 1].trim() === '') result.pop();
321321+ return result.length > 0 ? result : null;
322322+}
323323+324324+/**
325325+ * Extract the colocated <style> block content (between the style tags).
326326+ * Returns an array of CSS lines, or null if no style block found.
327327+ *
328328+ * Handles three shapes of `<style data-impeccable-css="ID" ...>`:
329329+ * 1. Self-closing: `<style ... />` — no body; return null (nothing to carbonize).
330330+ * 2. Same-line open+close: `<style>...</style>` — return the inner content.
331331+ * 3. Multi-line: `<style>` on one line, `</style>` on a later line — return
332332+ * the lines between them.
333333+ */
334334+function extractCss(lines, block, id) {
335335+ const styleAttr = 'data-impeccable-css="' + id + '"';
336336+ let inStyle = false;
337337+ const content = [];
338338+339339+ for (let i = block.start; i <= block.end; i++) {
340340+ const line = lines[i];
341341+342342+ if (!inStyle && line.includes(styleAttr)) {
343343+ // Self-closing: nothing to carbonize.
344344+ if (/<style\b[^>]*\/\s*>/.test(line)) return null;
345345+ // Same-line open + close: extract inner text.
346346+ const sameLine = line.match(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/);
347347+ if (sameLine) {
348348+ const inner = sameLine[1];
349349+ return inner.length > 0 ? inner.split('\n') : null;
350350+ }
351351+ inStyle = true;
352352+ continue; // skip the <style> opening tag
353353+ }
354354+355355+ if (inStyle) {
356356+ // Detect </style> anywhere on the line — JSX template-literal closes
357357+ // (`}</style>`) put the close mid-line, and we don't want to absorb the
358358+ // template-literal punctuation as CSS content.
359359+ const closeIdx = line.indexOf('</style>');
360360+ if (closeIdx !== -1) break;
361361+ content.push(line);
362362+ }
363363+ }
364364+365365+ return content.length > 0 ? content : null;
366366+}
367367+368368+/**
369369+ * De-indent content that was indented by live-wrap.mjs.
370370+ * The wrap script adds `indent + ' '` (4 extra spaces) to each line.
371371+ * We restore to just `indent` level.
372372+ */
373373+function deindentContent(contentLines, baseIndent) {
374374+ // Find the minimum indentation in the content to determine how much was added
375375+ let minIndent = Infinity;
376376+ for (const line of contentLines) {
377377+ if (line.trim() === '') continue;
378378+ const leadingSpaces = line.match(/^(\s*)/)[1].length;
379379+ minIndent = Math.min(minIndent, leadingSpaces);
380380+ }
381381+ if (minIndent === Infinity) minIndent = 0;
382382+383383+ // Strip the extra indentation and re-add base indent
384384+ return contentLines.map(line => {
385385+ if (line.trim() === '') return '';
386386+ return baseIndent + line.slice(minIndent);
387387+ });
388388+}
389389+390390+function detectCommentSyntax(filePath) {
391391+ const ext = path.extname(filePath).toLowerCase();
392392+ if (ext === '.jsx' || ext === '.tsx') {
393393+ return { open: '{/*', close: '*/}' };
394394+ }
395395+ return { open: '<!--', close: '-->' };
396396+}
397397+398398+// ---------------------------------------------------------------------------
399399+// File search (find the file containing session markers)
400400+// ---------------------------------------------------------------------------
401401+402402+function findSessionFile(id, cwd) {
403403+ const marker = 'impeccable-variants-start ' + id;
404404+ const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
405405+ const seen = new Set();
406406+407407+ for (const dir of searchDirs) {
408408+ const absDir = path.join(cwd, dir);
409409+ if (!fs.existsSync(absDir)) continue;
410410+ const result = searchDir(absDir, marker, seen, 0);
411411+ if (result) {
412412+ const content = fs.readFileSync(result, 'utf-8');
413413+ return { file: result, content, lines: content.split('\n') };
414414+ }
415415+ }
416416+ return null;
417417+}
418418+419419+function searchDir(dir, query, seen, depth) {
420420+ if (depth > 5) return null;
421421+ let realDir;
422422+ try { realDir = fs.realpathSync(dir); } catch { return null; }
423423+ if (seen.has(realDir)) return null;
424424+ seen.add(realDir);
425425+426426+ let entries;
427427+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
428428+ catch { return null; }
429429+430430+ for (const entry of entries) {
431431+ if (!entry.isFile()) continue;
432432+ if (!EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue;
433433+ const filePath = path.join(dir, entry.name);
434434+ try {
435435+ const content = fs.readFileSync(filePath, 'utf-8');
436436+ if (content.includes(query)) return filePath;
437437+ } catch { /* skip */ }
438438+ }
439439+440440+ for (const entry of entries) {
441441+ if (!entry.isDirectory()) continue;
442442+ if (['node_modules', '.git', 'dist', 'build'].includes(entry.name)) continue;
443443+ const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
444444+ if (result) return result;
445445+ }
446446+447447+ return null;
448448+}
449449+450450+// ---------------------------------------------------------------------------
451451+// Utilities
452452+// ---------------------------------------------------------------------------
453453+454454+function argVal(args, flag) {
455455+ const idx = args.indexOf(flag);
456456+ return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
457457+}
458458+459459+// Auto-execute when run directly
460460+const _running = process.argv[1];
461461+if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs/')) {
462462+ acceptCli();
463463+}
464464+465465+export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax };
+4777
.agents/skills/impeccable/scripts/live-browser.js
···11+/**
22+ * Impeccable Live Variant Mode — Browser Script
33+ *
44+ * Injected into the user's page via <script src="http://localhost:PORT/live.js">.
55+ * The server prepends window.__IMPECCABLE_TOKEN__ and window.__IMPECCABLE_PORT__
66+ * before this code.
77+ *
88+ * UI: a single floating bar that morphs between three states —
99+ * configure (pick action + go), generating (progressive dots), and cycling
1010+ * (prev/next + accept/discard). Feels like Spotlight, not a modal.
1111+ */
1212+(function () {
1313+ 'use strict';
1414+ if (typeof window === 'undefined') return;
1515+1616+ // Guard against double-init. Bun's HTML loader may process the <script> tag
1717+ // and create a bundled copy alongside the external load, or HMR may re-execute.
1818+ // Check BEFORE reading token/port to catch all cases.
1919+ if (window.__IMPECCABLE_LIVE_INIT__) return;
2020+ window.__IMPECCABLE_LIVE_INIT__ = true;
2121+2222+ const TOKEN = window.__IMPECCABLE_TOKEN__;
2323+ const PORT = window.__IMPECCABLE_PORT__;
2424+ if (!TOKEN || !PORT) {
2525+ window.__IMPECCABLE_LIVE_INIT__ = false; // reset so the real load can init
2626+ return;
2727+ }
2828+2929+ // ---------------------------------------------------------------------------
3030+ // Design tokens
3131+ // ---------------------------------------------------------------------------
3232+3333+ // Brand magenta is pinned to the site token (--color-accent in main.css)
3434+ // so Accept / knobs / cycle-dots match the site's accent, not a washed
3535+ // theme-adjusted one.
3636+ const C = {
3737+ brand: 'oklch(60% 0.25 350)',
3838+ brandHov: 'oklch(52% 0.25 350)',
3939+ brandSoft: 'oklch(60% 0.25 350 / 0.15)',
4040+ ink: 'oklch(15% 0.01 350)',
4141+ ash: 'oklch(55% 0 0)',
4242+ paper: 'oklch(98% 0.005 350 / 0.92)',
4343+ paperSolid:'oklch(98% 0.005 350)',
4444+ mist: 'oklch(90% 0.01 350 / 0.6)',
4545+ white: 'oklch(99% 0 0)',
4646+ };
4747+ const FONT = 'system-ui, -apple-system, sans-serif';
4848+ const MONO = 'ui-monospace, SFMono-Regular, Menlo, monospace';
4949+ // z-index: detect overlays use 99999, so our UI must be above them
5050+ const Z = { highlight: 100001, bar: 100005, picker: 100007, toast: 100010 };
5151+ const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; // ease-out-quint
5252+ const PREFIX = 'impeccable-live';
5353+ const HIGHLIGHT_TRANSITION =
5454+ 'top 140ms ' + EASE +
5555+ ', left 140ms ' + EASE +
5656+ ', width 140ms ' + EASE +
5757+ ', height 140ms ' + EASE +
5858+ ', opacity 150ms ease';
5959+ const TOOLTIP_TRANSITION =
6060+ 'top 140ms ' + EASE + ', left 140ms ' + EASE + ', opacity 150ms ease';
6161+6262+ const SKIP_TAGS = new Set([
6363+ 'html', 'head', 'body', 'script', 'style', 'link', 'meta', 'noscript', 'br', 'wbr',
6464+ ]);
6565+6666+ // SVG icons stack above each chip label. All strokes use currentColor so the
6767+ // icon recolors to C.brand when its chip is selected. 20x20 render, 24-viewBox,
6868+ // 1.5 stroke — visually consistent with the Foundation grid on the homepage.
6969+ 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"';
7070+ const ICONS = {
7171+ impeccable: `<svg ${ICON_ATTRS}><path d="M4 20l4-1L18 9l-3-3L5 16z"/><path d="M14 7l3 3"/></svg>`,
7272+ 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>`,
7373+ 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>`,
7474+ distill: `<svg ${ICON_ATTRS}><path d="M4 5h16l-6 8v7l-4-2v-5z"/></svg>`,
7575+ 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>`,
7676+ 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>`,
7777+ 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>`,
7878+ 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>`,
7979+ 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>`,
8080+ 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>`,
8181+ delight: `<svg ${ICON_ATTRS}><path d="M12 3l2 6 6 2-6 2-2 6-2-6-6-2 6-2z"/></svg>`,
8282+ overdrive: `<svg ${ICON_ATTRS}><path d="M13 3L5 13h5l-1 8 9-12h-6z"/></svg>`,
8383+ };
8484+8585+ const ACTIONS = [
8686+ { value: 'impeccable', label: 'Freeform' },
8787+ { value: 'bolder', label: 'Bolder' },
8888+ { value: 'quieter', label: 'Quieter' },
8989+ { value: 'distill', label: 'Distill' },
9090+ { value: 'polish', label: 'Polish' },
9191+ { value: 'typeset', label: 'Typeset' },
9292+ { value: 'colorize', label: 'Colorize' },
9393+ { value: 'layout', label: 'Layout' },
9494+ { value: 'adapt', label: 'Adapt' },
9595+ { value: 'animate', label: 'Animate' },
9696+ { value: 'delight', label: 'Delight' },
9797+ { value: 'overdrive', label: 'Overdrive' },
9898+ ];
9999+100100+ // ---------------------------------------------------------------------------
101101+ // State
102102+ // ---------------------------------------------------------------------------
103103+104104+ let state = 'IDLE';
105105+ let hoveredElement = null;
106106+ let selectedElement = null;
107107+ let currentSessionId = null;
108108+ let expectedVariants = 0;
109109+ let arrivedVariants = 0;
110110+ let visibleVariant = 0;
111111+ let variantObserver = null;
112112+ let hasProjectContext = false;
113113+ let selectedAction = 'impeccable';
114114+ let selectedCount = 3;
115115+116116+ // Scroll lock — holds window.scrollY at a fixed value while the session is
117117+ // active, so HMR DOM patches and variant swaps can't drift the page. See
118118+ // startScrollLock / stopScrollLock below.
119119+ let scrollLockObserver = null;
120120+ let scrollLockTargetY = null;
121121+ let scrollLockRaf = null;
122122+ let scrollLockAbort = null;
123123+124124+ // Dedicated key for scroll position — SEPARATE from LS_KEY so that
125125+ // saveSession's state updates don't clobber a carefully-captured scrollY.
126126+ // (Previously: saveSession wrote scrollY alongside state, so every call
127127+ // during resume overwrote the pre-reload value with whatever the browser
128128+ // had landed on, typically 0.)
129129+ const SCROLL_KEY_SUFFIX = '-scroll';
130130+ function writeScrollY(y) {
131131+ try { localStorage.setItem(LS_KEY + SCROLL_KEY_SUFFIX, String(y)); } catch {}
132132+ }
133133+ function readScrollY() {
134134+ try {
135135+ const raw = localStorage.getItem(LS_KEY + SCROLL_KEY_SUFFIX);
136136+ if (raw == null) return null;
137137+ const n = parseFloat(raw);
138138+ return isFinite(n) ? n : null;
139139+ } catch { return null; }
140140+ }
141141+ function clearScrollY() {
142142+ try { localStorage.removeItem(LS_KEY + SCROLL_KEY_SUFFIX); } catch {}
143143+ }
144144+145145+ // Pre-empt the browser: apply manual scroll restoration and jump to the
146146+ // saved scrollY at script-parse time. Retries on fonts.ready and load
147147+ // are essential: scrollTo(y) clamps to the current document.scrollHeight,
148148+ // which is often hundreds of pixels short of the final value until
149149+ // async-loaded fonts swap in and reflow.
150150+ try {
151151+ history.scrollRestoration = 'manual';
152152+ const savedY = readScrollY();
153153+ if (savedY != null) {
154154+ const apply = () => {
155155+ if (Math.abs(window.scrollY - savedY) > 0.5) {
156156+ console.log('[impeccable.scroll] early restore', { from: window.scrollY, to: savedY });
157157+ window.scrollTo(0, savedY);
158158+ }
159159+ };
160160+ apply();
161161+ if (document.fonts?.ready) document.fonts.ready.then(apply).catch(() => {});
162162+ window.addEventListener('load', apply, { once: true });
163163+ }
164164+ } catch {}
165165+166166+ // UI refs
167167+ let highlightEl = null;
168168+ let tooltipEl = null;
169169+ let barEl = null;
170170+ let pickerEl = null;
171171+ let toastEl = null;
172172+ let scrollRaf = null;
173173+174174+ // ---------------------------------------------------------------------------
175175+ // Helpers
176176+ // ---------------------------------------------------------------------------
177177+178178+ function own(el) {
179179+ return el && (el.id?.startsWith(PREFIX) || el.closest?.('[id^="' + PREFIX + '"]'));
180180+ }
181181+182182+ function pickable(el) {
183183+ if (!el || el.nodeType !== 1) return false;
184184+ if (SKIP_TAGS.has(el.tagName.toLowerCase())) return false;
185185+ if (own(el)) return false;
186186+ const r = el.getBoundingClientRect();
187187+ return r.width >= 20 && r.height >= 20;
188188+ }
189189+190190+ function desc(el) {
191191+ if (!el) return '';
192192+ let s = el.tagName.toLowerCase();
193193+ if (el.id) s += '#' + el.id;
194194+ else if (el.classList.length) s += '.' + [...el.classList].slice(0, 2).join('.');
195195+ return s;
196196+ }
197197+198198+ function id8() { return crypto.randomUUID().replace(/-/g, '').slice(0, 8); }
199199+200200+ // Modal-aware chrome: keep our floating UI clickable inside Radix /
201201+ // Headless UI / vaul portals.
202202+ //
203203+ // Two host-page behaviors break us when the picked element lives inside a
204204+ // modal dialog:
205205+ //
206206+ // 1. Modal scroll-lock disables outside pointer events. Radix's
207207+ // `DismissableLayer` sets `document.body.style.pointerEvents = 'none'`
208208+ // while a modal is open and only restores `auto` on the layer. Our
209209+ // chrome inherits `none` from <body> and becomes unclickable.
210210+ // 2. The dialog's outside-interaction handler (Radix's
211211+ // `usePointerDownOutside`) listens at document level and dismisses
212212+ // the dialog whenever a `pointerdown` lands outside the layer node.
213213+ // Our chrome is a sibling of <body>, so Radix classifies our clicks
214214+ // as outside and tears the dialog down mid-task.
215215+ //
216216+ // We can't reliably re-parent our chrome into the dialog subtree (z-index
217217+ // stacking, scroll containers, theming all become host-page concerns), so
218218+ // we defang both behaviors at our root:
219219+ //
220220+ // - `pointer-events: auto !important` overrides the inherited `none`.
221221+ // - Stop `pointerdown` / `mousedown` propagation so the document-level
222222+ // dismiss listener never fires for our clicks.
223223+ // - Stop `focusin` propagation so any focus shifts inside our chrome
224224+ // don't read as "focus moved outside the dialog" to focus traps.
225225+ //
226226+ // Click events still bubble normally — only the early pointer/focus
227227+ // signals that drive outside-interaction detection are silenced.
228228+ function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) {
229229+ if (!rootEl) return;
230230+ if (setPointerEvents) {
231231+ rootEl.style.setProperty('pointer-events', 'auto', 'important');
232232+ }
233233+ const stop = (e) => e.stopPropagation();
234234+ rootEl.addEventListener('pointerdown', stop);
235235+ rootEl.addEventListener('mousedown', stop);
236236+ rootEl.addEventListener('focusin', stop);
237237+ }
238238+239239+ // ---------------------------------------------------------------------------
240240+ // Highlight overlay
241241+ // ---------------------------------------------------------------------------
242242+243243+ function initHighlight() {
244244+ highlightEl = document.createElement('div');
245245+ highlightEl.id = PREFIX + '-highlight';
246246+ Object.assign(highlightEl.style, {
247247+ position: 'fixed', top: '0', left: '0', width: '0', height: '0',
248248+ border: '2px solid ' + C.brand, borderRadius: '3px',
249249+ pointerEvents: 'none', zIndex: Z.highlight, boxSizing: 'border-box',
250250+ transition: HIGHLIGHT_TRANSITION,
251251+ display: 'none', opacity: '0',
252252+ });
253253+ document.body.appendChild(highlightEl);
254254+255255+ tooltipEl = document.createElement('div');
256256+ tooltipEl.id = PREFIX + '-tooltip';
257257+ Object.assign(tooltipEl.style, {
258258+ position: 'fixed',
259259+ background: C.ink, color: C.white,
260260+ fontFamily: MONO, fontSize: '10px', fontWeight: '500',
261261+ padding: '2px 6px', borderRadius: '3px',
262262+ zIndex: Z.highlight + 1, pointerEvents: 'none',
263263+ whiteSpace: 'nowrap', display: 'none',
264264+ letterSpacing: '0.02em',
265265+ transition: TOOLTIP_TRANSITION,
266266+ });
267267+ document.body.appendChild(tooltipEl);
268268+ }
269269+270270+ function showHighlight(el) {
271271+ if (!el || !highlightEl) return;
272272+ const r = el.getBoundingClientRect();
273273+ const top = (r.top - 2) + 'px', left = (r.left - 2) + 'px';
274274+ const width = (r.width + 4) + 'px', height = (r.height + 4) + 'px';
275275+ const tipTop = r.top - 20;
276276+ const tipY = (tipTop < 4 ? r.bottom + 4 : tipTop) + 'px';
277277+ const tipX = Math.max(4, r.left) + 'px';
278278+ tooltipEl.textContent = desc(el);
279279+280280+ const hiWasHidden = highlightEl.style.display === 'none' || highlightEl.style.opacity === '0';
281281+ if (hiWasHidden) {
282282+ // Snap to first target without animating from (0,0), then fade in.
283283+ highlightEl.style.transition = 'none';
284284+ Object.assign(highlightEl.style, { top, left, width, height, display: 'block' });
285285+ tooltipEl.style.transition = 'none';
286286+ Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block' });
287287+ void highlightEl.offsetWidth;
288288+ highlightEl.style.transition = HIGHLIGHT_TRANSITION;
289289+ highlightEl.style.opacity = '1';
290290+ tooltipEl.style.transition = TOOLTIP_TRANSITION;
291291+ tooltipEl.style.opacity = '1';
292292+ } else {
293293+ Object.assign(highlightEl.style, { top, left, width, height, display: 'block', opacity: '1' });
294294+ Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block', opacity: '1' });
295295+ }
296296+ }
297297+298298+ function hideHighlight() {
299299+ if (highlightEl) { highlightEl.style.opacity = '0'; highlightEl.style.display = 'none'; }
300300+ if (tooltipEl) { tooltipEl.style.opacity = '0'; tooltipEl.style.display = 'none'; }
301301+ }
302302+303303+ // ---------------------------------------------------------------------------
304304+ // Annotation overlay (comment pins + magenta strokes)
305305+ //
306306+ // Active while state === 'CONFIGURING'. The overlay is a fixed-positioned
307307+ // sibling of <body> mirroring selectedElement's bounding rect. Click (no
308308+ // drag) drops a comment pin; drag paints a magenta SVG stroke. All coords
309309+ // are stored in element-local CSS px so they survive scroll / resize and
310310+ // correlate directly with the captured PNG.
311311+ // ---------------------------------------------------------------------------
312312+313313+ const DRAG_THRESHOLD = 5; // px — below this, treat pointerup as a click
314314+ const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it
315315+ let annotOverlayEl = null;
316316+ let annotSvgEl = null;
317317+ let annotPinsEl = null;
318318+ let annotClearChipEl = null;
319319+ let annotState = { comments: [], strokes: [] };
320320+ let annotActive = false;
321321+ // `annotPointer` is either:
322322+ // { kind: 'new', x0, y0, moved, strokeEl, strokePoints } creating a stroke/pin
323323+ // { kind: 'pin', idx, startPointer, startPin, moved } dragging an existing pin
324324+ let annotPointer = null;
325325+ let annotEditing = null; // { idx, input, wrapEl }
326326+ let annotLastPinClick = { idx: -1, time: 0 }; // for click-click-to-delete
327327+328328+ function initAnnotOverlay() {
329329+ annotOverlayEl = document.createElement('div');
330330+ annotOverlayEl.id = PREFIX + '-annot';
331331+ Object.assign(annotOverlayEl.style, {
332332+ position: 'fixed', top: '0', left: '0', width: '0', height: '0',
333333+ pointerEvents: 'auto', zIndex: Z.highlight + 2,
334334+ display: 'none', overflow: 'visible',
335335+ cursor: 'crosshair', touchAction: 'none',
336336+ });
337337+338338+ annotSvgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
339339+ annotSvgEl.id = PREFIX + '-annot-svg';
340340+ Object.assign(annotSvgEl.style, {
341341+ position: 'absolute', top: '0', left: '0',
342342+ width: '100%', height: '100%',
343343+ // The SVG itself doesn't absorb clicks; individual hit-paths opt-in via
344344+ // pointer-events=stroke so gaps still fall through to the overlay.
345345+ pointerEvents: 'none', overflow: 'visible',
346346+ });
347347+ annotOverlayEl.appendChild(annotSvgEl);
348348+349349+ annotPinsEl = document.createElement('div');
350350+ annotPinsEl.id = PREFIX + '-annot-pins';
351351+ Object.assign(annotPinsEl.style, {
352352+ position: 'absolute', inset: '0',
353353+ pointerEvents: 'none',
354354+ });
355355+ annotOverlayEl.appendChild(annotPinsEl);
356356+357357+ annotClearChipEl = document.createElement('div');
358358+ annotClearChipEl.id = PREFIX + '-annot-clear';
359359+ annotClearChipEl.dataset.annotClear = 'true';
360360+ annotClearChipEl.textContent = 'Clear';
361361+ Object.assign(annotClearChipEl.style, {
362362+ position: 'absolute', top: '8px', right: '8px',
363363+ background: C.ink, color: C.white,
364364+ fontFamily: FONT, fontSize: '10px', fontWeight: '500',
365365+ letterSpacing: '0.08em', textTransform: 'uppercase',
366366+ padding: '5px 12px', borderRadius: '999px',
367367+ cursor: 'pointer', pointerEvents: 'auto',
368368+ display: 'none', userSelect: 'none',
369369+ boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
370370+ });
371371+ annotOverlayEl.appendChild(annotClearChipEl);
372372+373373+ annotOverlayEl.addEventListener('pointerdown', onAnnotDown);
374374+ annotOverlayEl.addEventListener('pointermove', onAnnotMove);
375375+ annotOverlayEl.addEventListener('pointerup', onAnnotUp);
376376+ annotOverlayEl.addEventListener('pointercancel', onAnnotUp);
377377+ document.body.appendChild(annotOverlayEl);
378378+ // Modal-host friendliness: pointer-events is already 'auto' on this
379379+ // overlay; we only need to silence the host's outside-interaction
380380+ // listeners. Don't override pointer-events here (the overlay toggles
381381+ // visibility via display:none, which is fine).
382382+ defangOutsideHandlers(annotOverlayEl, { setPointerEvents: false });
383383+ }
384384+385385+ function updateClearChip() {
386386+ if (!annotClearChipEl) return;
387387+ const hasAny = annotState.comments.length > 0 || annotState.strokes.length > 0;
388388+ annotClearChipEl.style.display = hasAny ? 'block' : 'none';
389389+ }
390390+391391+ function showAnnotOverlay(el) {
392392+ if (!annotOverlayEl || !el) return;
393393+ annotActive = true;
394394+ positionAnnotOverlay(el);
395395+ annotOverlayEl.style.display = 'block';
396396+ }
397397+398398+ function hideAnnotOverlay() {
399399+ annotActive = false;
400400+ if (annotOverlayEl) annotOverlayEl.style.display = 'none';
401401+ // Drop any in-progress edit without touching annotState — clearAnnotations
402402+ // (if the caller is exiting configure mode) handles state reset.
403403+ annotEditing = null;
404404+ }
405405+406406+ function positionAnnotOverlay(el) {
407407+ if (!annotOverlayEl || !el) return;
408408+ const r = el.getBoundingClientRect();
409409+ Object.assign(annotOverlayEl.style, {
410410+ top: r.top + 'px', left: r.left + 'px',
411411+ width: r.width + 'px', height: r.height + 'px',
412412+ });
413413+ annotSvgEl.setAttribute('viewBox', '0 0 ' + r.width + ' ' + r.height);
414414+ }
415415+416416+ function clearAnnotations() {
417417+ annotState.comments = [];
418418+ annotState.strokes = [];
419419+ if (annotSvgEl) while (annotSvgEl.firstChild) annotSvgEl.removeChild(annotSvgEl.firstChild);
420420+ if (annotPinsEl) annotPinsEl.innerHTML = '';
421421+ annotPointer = null;
422422+ annotEditing = null;
423423+ annotLastPinClick = { idx: -1, time: 0 };
424424+ updateClearChip();
425425+ }
426426+427427+ // Rebuild the SVG layer. Each stroke gets a wider invisible hit path
428428+ // beneath the visible magenta path so clicks register on thin lines.
429429+ function redrawStrokes() {
430430+ while (annotSvgEl.firstChild) annotSvgEl.removeChild(annotSvgEl.firstChild);
431431+ annotState.strokes.forEach((s, idx) => {
432432+ const d = pointsToPath(s.points);
433433+ const hit = document.createElementNS('http://www.w3.org/2000/svg', 'path');
434434+ hit.setAttribute('d', d);
435435+ hit.setAttribute('stroke', 'transparent');
436436+ hit.setAttribute('stroke-width', '16');
437437+ hit.setAttribute('stroke-linecap', 'round');
438438+ hit.setAttribute('stroke-linejoin', 'round');
439439+ hit.setAttribute('fill', 'none');
440440+ hit.setAttribute('pointer-events', 'stroke');
441441+ hit.style.cursor = 'pointer';
442442+ hit.dataset.annotStroke = String(idx);
443443+ annotSvgEl.appendChild(hit);
444444+ const visible = document.createElementNS('http://www.w3.org/2000/svg', 'path');
445445+ visible.setAttribute('d', d);
446446+ visible.setAttribute('stroke', C.brand);
447447+ visible.setAttribute('stroke-width', '3');
448448+ visible.setAttribute('stroke-linecap', 'round');
449449+ visible.setAttribute('stroke-linejoin', 'round');
450450+ visible.setAttribute('fill', 'none');
451451+ visible.setAttribute('pointer-events', 'none');
452452+ annotSvgEl.appendChild(visible);
453453+ });
454454+ updateClearChip();
455455+ }
456456+457457+ function localCoords(e) {
458458+ const rect = annotOverlayEl.getBoundingClientRect();
459459+ return { x: e.clientX - rect.left, y: e.clientY - rect.top };
460460+ }
461461+462462+ function onAnnotDown(e) {
463463+ if (!annotActive) return;
464464+465465+ // 1) Clear chip → wipe all annotations
466466+ if (e.target.closest?.('[data-annot-clear]')) {
467467+ if (annotEditing) annotEditing = null;
468468+ clearAnnotations();
469469+ renderAllPins();
470470+ redrawStrokes();
471471+ e.stopPropagation(); e.preventDefault();
472472+ return;
473473+ }
474474+475475+ // 2) Stroke hit path → delete that stroke
476476+ const strokeHit = e.target.closest?.('[data-annot-stroke]');
477477+ if (strokeHit) {
478478+ const idx = parseInt(strokeHit.dataset.annotStroke, 10);
479479+ if (Number.isInteger(idx)) {
480480+ annotState.strokes.splice(idx, 1);
481481+ redrawStrokes();
482482+ }
483483+ e.stopPropagation(); e.preventDefault();
484484+ return;
485485+ }
486486+487487+ // 3) Pin → drag, edit, or delete-on-double-click
488488+ const pinWrap = e.target.closest?.('[data-annot-pin]');
489489+ if (pinWrap) {
490490+ const idx = parseInt(pinWrap.dataset.annotPin, 10);
491491+ if (!Number.isInteger(idx)) return;
492492+ // Double-click (two pointerdowns on the same pin within window) → delete.
493493+ const now = Date.now();
494494+ if (annotLastPinClick.idx === idx && now - annotLastPinClick.time < PIN_DBL_CLICK_MS) {
495495+ if (annotEditing && annotEditing.idx === idx) annotEditing = null;
496496+ annotState.comments.splice(idx, 1);
497497+ annotLastPinClick = { idx: -1, time: 0 };
498498+ renderAllPins();
499499+ e.stopPropagation(); e.preventDefault();
500500+ return;
501501+ }
502502+ annotLastPinClick = { idx, time: now };
503503+ // If editing a different pin, commit that edit before starting here.
504504+ if (annotEditing && annotEditing.idx !== idx) finalizeEditingPin();
505505+ // If already editing THIS pin and the user clicked the dot, let the
506506+ // input keep focus (don't start a drag — the click wasn't meant as one).
507507+ if (annotEditing && annotEditing.idx === idx) return;
508508+ const p = localCoords(e);
509509+ const pin = annotState.comments[idx];
510510+ annotPointer = {
511511+ kind: 'pin', idx,
512512+ startPointer: p,
513513+ startPin: { x: pin.x, y: pin.y },
514514+ moved: false,
515515+ };
516516+ try { annotOverlayEl.setPointerCapture(e.pointerId); } catch {}
517517+ e.stopPropagation(); e.preventDefault();
518518+ return;
519519+ }
520520+521521+ // 4) Empty area → commit any open edit, then start new annotation
522522+ if (annotEditing) {
523523+ finalizeEditingPin();
524524+ e.stopPropagation(); e.preventDefault();
525525+ return;
526526+ }
527527+ const p = localCoords(e);
528528+ annotPointer = { kind: 'new', x0: p.x, y0: p.y, moved: false, strokeEl: null, strokePoints: null };
529529+ try { annotOverlayEl.setPointerCapture(e.pointerId); } catch {}
530530+ e.stopPropagation(); e.preventDefault();
531531+ }
532532+533533+ function onAnnotMove(e) {
534534+ if (!annotActive || !annotPointer) return;
535535+ const p = localCoords(e);
536536+537537+ if (annotPointer.kind === 'pin') {
538538+ const dx = p.x - annotPointer.startPointer.x;
539539+ const dy = p.y - annotPointer.startPointer.y;
540540+ if (!annotPointer.moved) {
541541+ if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
542542+ annotPointer.moved = true;
543543+ }
544544+ const pin = annotState.comments[annotPointer.idx];
545545+ if (!pin) { annotPointer = null; return; }
546546+ pin.x = annotPointer.startPin.x + dx;
547547+ pin.y = annotPointer.startPin.y + dy;
548548+ renderAllPins();
549549+ e.stopPropagation();
550550+ return;
551551+ }
552552+553553+ // kind === 'new'
554554+ const dx = p.x - annotPointer.x0, dy = p.y - annotPointer.y0;
555555+ if (!annotPointer.moved) {
556556+ if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
557557+ annotPointer.moved = true;
558558+ const strokeEl = document.createElementNS('http://www.w3.org/2000/svg', 'path');
559559+ strokeEl.setAttribute('stroke', C.brand);
560560+ strokeEl.setAttribute('stroke-width', '3');
561561+ strokeEl.setAttribute('stroke-linecap', 'round');
562562+ strokeEl.setAttribute('stroke-linejoin', 'round');
563563+ strokeEl.setAttribute('fill', 'none');
564564+ strokeEl.setAttribute('pointer-events', 'none');
565565+ annotSvgEl.appendChild(strokeEl);
566566+ annotPointer.strokeEl = strokeEl;
567567+ annotPointer.strokePoints = [[annotPointer.x0, annotPointer.y0]];
568568+ }
569569+ annotPointer.strokePoints.push([p.x, p.y]);
570570+ annotPointer.strokeEl.setAttribute('d', pointsToPath(annotPointer.strokePoints));
571571+ e.stopPropagation();
572572+ }
573573+574574+ function onAnnotUp(e) {
575575+ if (!annotActive || !annotPointer) return;
576576+577577+ if (annotPointer.kind === 'pin') {
578578+ const wasDrag = annotPointer.moved;
579579+ const idx = annotPointer.idx;
580580+ try { annotOverlayEl.releasePointerCapture(e.pointerId); } catch {}
581581+ annotPointer = null;
582582+ if (wasDrag) {
583583+ // A drag is an intentional reposition; a follow-up click shouldn't be
584584+ // interpreted as a double-click-to-delete.
585585+ annotLastPinClick = { idx: -1, time: 0 };
586586+ } else {
587587+ beginEditPin(idx);
588588+ }
589589+ e.stopPropagation();
590590+ return;
591591+ }
592592+593593+ // kind === 'new'
594594+ const wasDrag = annotPointer.moved;
595595+ if (wasDrag) {
596596+ annotState.strokes.push({ points: annotPointer.strokePoints });
597597+ // Swap the temporary preview SVG path for the full render with hit paths.
598598+ redrawStrokes();
599599+ } else {
600600+ const idx = annotState.comments.length;
601601+ annotState.comments.push({ x: annotPointer.x0, y: annotPointer.y0, text: '' });
602602+ renderAllPins();
603603+ beginEditPin(idx);
604604+ }
605605+ try { annotOverlayEl.releasePointerCapture(e.pointerId); } catch {}
606606+ annotPointer = null;
607607+ e.stopPropagation();
608608+ }
609609+610610+ function pointsToPath(points) {
611611+ if (!points || points.length === 0) return '';
612612+ let d = 'M' + points[0][0].toFixed(1) + ' ' + points[0][1].toFixed(1);
613613+ for (let i = 1; i < points.length; i++) {
614614+ d += ' L' + points[i][0].toFixed(1) + ' ' + points[i][1].toFixed(1);
615615+ }
616616+ return d;
617617+ }
618618+619619+ function renderAllPins() {
620620+ annotPinsEl.innerHTML = '';
621621+ annotState.comments.forEach((c, idx) => {
622622+ annotPinsEl.appendChild(buildPinElement(c, idx));
623623+ });
624624+ updateClearChip();
625625+ }
626626+627627+ function buildPinElement(comment, idx) {
628628+ const interactive = idx >= 0;
629629+ const wrap = document.createElement('div');
630630+ if (interactive) wrap.dataset.annotPin = String(idx);
631631+ Object.assign(wrap.style, {
632632+ position: 'absolute',
633633+ left: (comment.x - 7) + 'px', top: (comment.y - 7) + 'px',
634634+ pointerEvents: interactive ? 'auto' : 'none',
635635+ display: 'flex', alignItems: 'flex-start', gap: '6px',
636636+ cursor: interactive ? 'grab' : 'default',
637637+ touchAction: 'none',
638638+ });
639639+ const dot = document.createElement('div');
640640+ Object.assign(dot.style, {
641641+ width: '14px', height: '14px', borderRadius: '50%',
642642+ background: C.brand, border: '2px solid ' + C.white,
643643+ boxShadow: '0 1px 3px rgba(0,0,0,0.25)',
644644+ flexShrink: '0',
645645+ });
646646+ wrap.appendChild(dot);
647647+648648+ if (comment.text) {
649649+ const bubble = document.createElement('div');
650650+ bubble.textContent = comment.text;
651651+ Object.assign(bubble.style, {
652652+ background: C.ink, color: C.white,
653653+ fontFamily: FONT, fontSize: '12px', lineHeight: '1.4',
654654+ padding: '4px 8px', borderRadius: '3px',
655655+ marginTop: '-2px', maxWidth: '220px',
656656+ pointerEvents: 'none', whiteSpace: 'pre-wrap',
657657+ wordBreak: 'break-word',
658658+ });
659659+ wrap.appendChild(bubble);
660660+ }
661661+ return wrap;
662662+ }
663663+664664+ function beginEditPin(idx) {
665665+ const wrapEl = annotPinsEl.querySelector('[data-annot-pin="' + idx + '"]');
666666+ if (!wrapEl) return;
667667+ // Strip any existing bubble (but keep the dot)
668668+ wrapEl.querySelectorAll('div:not(:first-child)').forEach(n => n.remove());
669669+ const input = document.createElement('input');
670670+ input.type = 'text';
671671+ input.placeholder = 'Note…';
672672+ Object.assign(input.style, {
673673+ background: C.ink, color: C.white,
674674+ fontFamily: FONT, fontSize: '12px', lineHeight: '1.4',
675675+ padding: '4px 8px', borderRadius: '3px',
676676+ border: '1px solid ' + C.brand,
677677+ outline: 'none', marginTop: '-2px',
678678+ width: '220px', pointerEvents: 'auto',
679679+ });
680680+ const originalText = annotState.comments[idx].text || '';
681681+ input.value = originalText;
682682+ wrapEl.appendChild(input);
683683+ annotEditing = { idx, input, wrapEl, originalText };
684684+ input.addEventListener('keydown', onAnnotInputKey, true);
685685+ input.addEventListener('blur', () => {
686686+ // Fires on both focus-loss and programmatic blur; commit unless we
687687+ // already handled it.
688688+ if (annotEditing && annotEditing.input === input) finalizeEditingPin();
689689+ });
690690+ // Stop clicks/pointerdowns inside the input from bubbling to the overlay
691691+ ['pointerdown', 'click'].forEach(ev => {
692692+ input.addEventListener(ev, e => e.stopPropagation());
693693+ });
694694+ setTimeout(() => input.focus(), 0);
695695+ }
696696+697697+ function onAnnotInputKey(e) {
698698+ if (e.key === 'Enter') {
699699+ e.preventDefault(); e.stopPropagation();
700700+ finalizeEditingPin();
701701+ } else if (e.key === 'Escape') {
702702+ e.preventDefault(); e.stopPropagation();
703703+ cancelEditingPin();
704704+ } else {
705705+ // Keep arrows / backspace from hitting global handlers
706706+ e.stopPropagation();
707707+ }
708708+ }
709709+710710+ function finalizeEditingPin() {
711711+ if (!annotEditing) return;
712712+ const { idx, input } = annotEditing;
713713+ const text = input.value.trim();
714714+ annotEditing = null;
715715+ if (text) annotState.comments[idx].text = text;
716716+ else annotState.comments.splice(idx, 1);
717717+ renderAllPins();
718718+ }
719719+720720+ function cancelEditingPin() {
721721+ if (!annotEditing) return;
722722+ const { idx, originalText } = annotEditing;
723723+ annotEditing = null;
724724+ // If the pin had text before this edit, revert to it. If it was a
725725+ // just-created empty pin, Escape removes it.
726726+ if (originalText) {
727727+ annotState.comments[idx].text = originalText;
728728+ } else {
729729+ annotState.comments.splice(idx, 1);
730730+ }
731731+ renderAllPins();
732732+ }
733733+734734+ // Build a detached annotation subtree suitable for injection into the clone
735735+ // modern-screenshot creates. Coordinates are element-local so this slots
736736+ // straight into an element that's been made position:relative. Takes an
737737+ // explicit snapshot so it works after annotState has been cleared.
738738+ function buildAnnotationsForCapture(rect, snapshot) {
739739+ const comments = snapshot ? snapshot.comments : annotState.comments;
740740+ const strokes = snapshot ? snapshot.strokes : annotState.strokes;
741741+ if (comments.length === 0 && strokes.length === 0) return null;
742742+ const wrap = document.createElement('div');
743743+ Object.assign(wrap.style, {
744744+ position: 'absolute', top: '0', left: '0',
745745+ width: rect.width + 'px', height: rect.height + 'px',
746746+ pointerEvents: 'none', overflow: 'visible',
747747+ });
748748+ if (strokes.length > 0) {
749749+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
750750+ svg.setAttribute('viewBox', '0 0 ' + rect.width + ' ' + rect.height);
751751+ Object.assign(svg.style, {
752752+ position: 'absolute', top: '0', left: '0',
753753+ width: '100%', height: '100%', overflow: 'visible',
754754+ });
755755+ for (const s of strokes) {
756756+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
757757+ path.setAttribute('stroke', C.brand);
758758+ path.setAttribute('stroke-width', '3');
759759+ path.setAttribute('stroke-linecap', 'round');
760760+ path.setAttribute('stroke-linejoin', 'round');
761761+ path.setAttribute('fill', 'none');
762762+ path.setAttribute('d', pointsToPath(s.points));
763763+ svg.appendChild(path);
764764+ }
765765+ wrap.appendChild(svg);
766766+ }
767767+ for (const c of comments) {
768768+ // idx=-1 means non-interactive; pointerEvents stay off in the clone
769769+ wrap.appendChild(buildPinElement(c, -1));
770770+ }
771771+ return wrap;
772772+ }
773773+774774+ // ---------------------------------------------------------------------------
775775+ // Element context extraction
776776+ // ---------------------------------------------------------------------------
777777+778778+ function extractContext(el) {
779779+ const cs = getComputedStyle(el);
780780+ const r = el.getBoundingClientRect();
781781+ const props = {};
782782+ for (const sheet of document.styleSheets) {
783783+ try {
784784+ for (const rule of sheet.cssRules) {
785785+ if (rule.style) for (let i = 0; i < rule.style.length; i++) {
786786+ const p = rule.style[i];
787787+ if (p.startsWith('--') && !props[p]) {
788788+ const v = cs.getPropertyValue(p).trim();
789789+ if (v) props[p] = v;
790790+ }
791791+ }
792792+ }
793793+ } catch { /* cross-origin */ }
794794+ }
795795+ return {
796796+ tagName: el.tagName.toLowerCase(), id: el.id || null,
797797+ classes: [...el.classList],
798798+ textContent: (el.textContent || '').slice(0, 500),
799799+ outerHTML: el.outerHTML.slice(0, 10000),
800800+ computedStyles: {
801801+ 'font-family': cs.fontFamily, 'font-size': cs.fontSize,
802802+ 'font-weight': cs.fontWeight, 'line-height': cs.lineHeight,
803803+ 'color': cs.color, 'background': cs.background,
804804+ 'background-color': cs.backgroundColor,
805805+ 'padding': cs.padding, 'margin': cs.margin,
806806+ 'display': cs.display, 'position': cs.position,
807807+ 'gap': cs.gap, 'border-radius': cs.borderRadius,
808808+ 'box-shadow': cs.boxShadow,
809809+ },
810810+ cssCustomProperties: props,
811811+ parentContext: el.parentElement
812812+ ? '<' + el.parentElement.tagName.toLowerCase()
813813+ + (el.parentElement.id ? ' id="' + el.parentElement.id + '"' : '')
814814+ + (el.parentElement.className ? ' class="' + el.parentElement.className + '"' : '')
815815+ + '>'
816816+ : null,
817817+ boundingRect: { width: Math.round(r.width), height: Math.round(r.height) },
818818+ };
819819+ }
820820+821821+ // ---------------------------------------------------------------------------
822822+ // The Bar — one floating element, three modes
823823+ // ---------------------------------------------------------------------------
824824+825825+ // Contextual-bar palette. Cached at init so every build*Row reads a
826826+ // consistent set of colors; detectPageTheme runs once rather than on every
827827+ // phase transition.
828828+ let BP = null;
829829+830830+ // Bar shadow variants. The default projects down + subtle around. When
831831+ // the Tune popover opens below the bar, a downward shadow lands on the
832832+ // dark popover and reads as a bright ghost line. We swap to UP-only while
833833+ // tune is open below so the popover's top edge is clean.
834834+ const BAR_SHADOW_DEFAULT = '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)';
835835+ const BAR_SHADOW_UP = '0 -4px 20px oklch(0% 0 0 / 0.08), 0 -1px 3px oklch(0% 0 0 / 0.06)';
836836+ const BAR_SHADOW_DOWN = BAR_SHADOW_DEFAULT;
837837+838838+ function initBar() {
839839+ BP = barPaletteForTheme(detectPageTheme());
840840+ barEl = document.createElement('div');
841841+ barEl.id = PREFIX + '-bar';
842842+ Object.assign(barEl.style, {
843843+ position: 'fixed', zIndex: Z.bar,
844844+ display: 'none', opacity: '0',
845845+ transform: 'translateY(6px)',
846846+ transition: 'opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
847847+ background: BP.surface,
848848+ backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
849849+ border: '1px solid ' + BP.hairline,
850850+ borderRadius: '10px',
851851+ boxShadow: BAR_SHADOW_DEFAULT,
852852+ transition: 'box-shadow 0.2s ease, opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
853853+ fontFamily: FONT, fontSize: '13px', color: BP.text,
854854+ padding: '6px',
855855+ maxWidth: '520px', minWidth: '320px',
856856+ });
857857+ document.body.appendChild(barEl);
858858+ defangOutsideHandlers(barEl);
859859+ }
860860+861861+ function positionBar() {
862862+ if (!barEl || !selectedElement) return;
863863+ const r = selectedElement.getBoundingClientRect();
864864+ const barH = barEl.offsetHeight || 44;
865865+ const barW = barEl.offsetWidth || 380;
866866+ const GLOBAL_BAR_RESERVE = 64; // global bar height + bottom margin + breathing room
867867+ const GAP = 8;
868868+869869+ // Prefer below the element; fall back to above; if neither fits (element
870870+ // taller than viewport), pin to a stable viewport anchor so the bar
871871+ // doesn't teleport between top and bottom as the user scrolls.
872872+ let top;
873873+ const belowTop = r.bottom + GAP;
874874+ const aboveTop = r.top - barH - GAP;
875875+ if (belowTop + barH + GAP <= window.innerHeight - GLOBAL_BAR_RESERVE) {
876876+ top = belowTop;
877877+ } else if (aboveTop >= GAP) {
878878+ top = aboveTop;
879879+ } else {
880880+ top = window.innerHeight - barH - GLOBAL_BAR_RESERVE;
881881+ }
882882+883883+ let left = r.left + (r.width - barW) / 2;
884884+ if (left < GAP) left = GAP;
885885+ if (left + barW > window.innerWidth - GAP) left = window.innerWidth - barW - GAP;
886886+ Object.assign(barEl.style, { top: top + 'px', left: left + 'px' });
887887+ }
888888+889889+ function showBar(mode) {
890890+ barEl.innerHTML = '';
891891+ if (mode === 'configure') barEl.appendChild(buildConfigureRow());
892892+ else if (mode === 'generating') barEl.appendChild(buildGeneratingRow());
893893+ else if (mode === 'cycling') barEl.appendChild(buildCyclingRow());
894894+ barEl.style.display = 'block';
895895+ positionBar();
896896+ requestAnimationFrame(() => {
897897+ barEl.style.opacity = '1';
898898+ barEl.style.transform = 'translateY(0)';
899899+ });
900900+ }
901901+902902+ function hideBar() {
903903+ if (!barEl) return;
904904+ barEl.style.opacity = '0';
905905+ barEl.style.transform = 'translateY(6px)';
906906+ setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250);
907907+ hideActionPicker();
908908+ closeTunePopover();
909909+ }
910910+911911+ function updateBarContent(mode) {
912912+ if (!barEl || barEl.style.display === 'none') return;
913913+ barEl.innerHTML = '';
914914+ // Reset bar styling to the theme-aware palette
915915+ barEl.style.background = BP.surface;
916916+ barEl.style.border = '1px solid ' + BP.hairline;
917917+ if (mode === 'configure') barEl.appendChild(buildConfigureRow());
918918+ else if (mode === 'generating') barEl.appendChild(buildGeneratingRow());
919919+ else if (mode === 'cycling') barEl.appendChild(buildCyclingRow());
920920+ else if (mode === 'saving') barEl.appendChild(buildSavingRow());
921921+ else if (mode === 'confirmed') {
922922+ barEl.appendChild(buildConfirmedRow());
923923+ barEl.style.background = 'oklch(95% 0.05 145)';
924924+ barEl.style.border = '1px solid oklch(75% 0.12 145 / 0.4)';
925925+ }
926926+ }
927927+928928+ // --- Configure row ---
929929+930930+ function buildConfigureRow() {
931931+ const row = el('div', {
932932+ display: 'flex', alignItems: 'center', gap: '4px',
933933+ });
934934+935935+ // Action pill
936936+ const pill = el('button', {
937937+ display: 'inline-flex', alignItems: 'center', gap: '4px',
938938+ padding: '5px 10px', borderRadius: '6px',
939939+ background: BP.mark, color: BP.markText,
940940+ fontFamily: FONT, fontSize: '12px', fontWeight: '500',
941941+ border: 'none', cursor: 'pointer',
942942+ transition: 'background 0.12s ease, transform 0.1s ease',
943943+ whiteSpace: 'nowrap', flexShrink: '0',
944944+ });
945945+ pill.textContent = actionLabel() + ' \u25BE';
946946+ pill.addEventListener('mouseenter', () => pill.style.background = BP.accent);
947947+ pill.addEventListener('mouseleave', () => pill.style.background = BP.mark);
948948+ pill.addEventListener('mousedown', () => pill.style.transform = 'scale(0.97)');
949949+ pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)');
950950+ pill.addEventListener('click', (e) => { e.stopPropagation(); toggleActionPicker(); });
951951+ row.appendChild(pill);
952952+953953+ // Freeform input. Focus state shows an accent-colored border only —
954954+ // an earlier version tinted the background with `BP.accentSoft`, which
955955+ // composited against the dark bar surface to a murky purple where the
956956+ // browser's default placeholder gray was unreadable. Placeholder color
957957+ // is set explicitly via a one-shot stylesheet keyed off this input's id
958958+ // so it picks up the bar's `textDim` token in both themes.
959959+ const input = document.createElement('input');
960960+ input.id = PREFIX + '-input';
961961+ input.type = 'text';
962962+ input.placeholder = selectedAction === 'impeccable' ? 'describe what you want...' : 'refine further (optional)...';
963963+ Object.assign(input.style, {
964964+ flex: '1', minWidth: '0',
965965+ padding: '5px 8px', borderRadius: '6px',
966966+ border: '1px solid transparent', background: 'transparent',
967967+ fontFamily: FONT, fontSize: '12px', color: BP.text,
968968+ outline: 'none',
969969+ transition: 'border-color 0.15s ease',
970970+ });
971971+ if (!document.getElementById(PREFIX + '-input-style')) {
972972+ const s = document.createElement('style');
973973+ s.id = PREFIX + '-input-style';
974974+ s.textContent =
975975+ '#' + PREFIX + '-input::placeholder { color: ' + BP.textDim + '; opacity: 1; }';
976976+ document.head.appendChild(s);
977977+ }
978978+ input.addEventListener('focus', () => {
979979+ input.style.borderColor = BP.accent;
980980+ });
981981+ input.addEventListener('blur', () => {
982982+ input.style.borderColor = 'transparent';
983983+ });
984984+ input.addEventListener('keydown', (e) => {
985985+ if (e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); handleGo(); return; }
986986+ if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); input.blur(); hideBar(); state = 'PICKING'; return; }
987987+ // Let arrow keys pass through to the element picker when the input is empty
988988+ if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && !input.value) return;
989989+ e.stopPropagation();
990990+ });
991991+ row.appendChild(input);
992992+993993+ // Variant count toggle
994994+ const count = el('button', {
995995+ padding: '4px 6px', borderRadius: '5px',
996996+ border: '1px solid ' + BP.hairline, background: 'transparent',
997997+ fontFamily: MONO, fontSize: '11px', fontWeight: '600',
998998+ color: BP.textDim, cursor: 'pointer',
999999+ transition: 'color 0.12s ease, border-color 0.12s ease',
10001000+ flexShrink: '0', whiteSpace: 'nowrap',
10011001+ });
10021002+ count.textContent = '\u00D7' + selectedCount;
10031003+ count.title = 'Variants: click to change';
10041004+ count.addEventListener('mouseenter', () => { count.style.color = BP.text; count.style.borderColor = BP.text; });
10051005+ count.addEventListener('mouseleave', () => { count.style.color = BP.textDim; count.style.borderColor = BP.hairline; });
10061006+ count.addEventListener('click', (e) => {
10071007+ e.stopPropagation();
10081008+ selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1;
10091009+ count.textContent = '\u00D7' + selectedCount;
10101010+ });
10111011+ row.appendChild(count);
10121012+10131013+ // Go button
10141014+ const go = el('button', {
10151015+ padding: '5px 12px', borderRadius: '6px',
10161016+ border: 'none', background: BP.accent, color: BP.mark,
10171017+ fontFamily: FONT, fontSize: '12px', fontWeight: '600',
10181018+ cursor: 'pointer',
10191019+ transition: 'filter 0.12s ease, transform 0.1s ease',
10201020+ flexShrink: '0', whiteSpace: 'nowrap',
10211021+ });
10221022+ go.textContent = 'Go \u2192';
10231023+ go.addEventListener('mouseenter', () => go.style.filter = 'brightness(1.1)');
10241024+ go.addEventListener('mouseleave', () => go.style.filter = 'none');
10251025+ go.addEventListener('mousedown', () => go.style.transform = 'scale(0.97)');
10261026+ go.addEventListener('mouseup', () => go.style.transform = 'scale(1)');
10271027+ go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); });
10281028+ row.appendChild(go);
10291029+10301030+ // Auto-focus input after a beat
10311031+ setTimeout(() => input.focus(), 60);
10321032+ return row;
10331033+ }
10341034+10351035+ // --- Generating row ---
10361036+10371037+ function buildGeneratingRow() {
10381038+ const row = el('div', {
10391039+ display: 'flex', alignItems: 'center', gap: '8px',
10401040+ padding: '2px 4px',
10411041+ });
10421042+10431043+ // Action label
10441044+ const label = el('span', {
10451045+ fontWeight: '600', fontSize: '12px', color: BP.text,
10461046+ flexShrink: '0', whiteSpace: 'nowrap',
10471047+ });
10481048+ label.textContent = actionLabel();
10491049+ row.appendChild(label);
10501050+10511051+ // Dots
10521052+ row.appendChild(buildDots(false));
10531053+10541054+ // Status
10551055+ const status = el('span', {
10561056+ fontSize: '11px', color: BP.textDim, whiteSpace: 'nowrap',
10571057+ marginLeft: 'auto',
10581058+ });
10591059+ // Variants currently arrive atomically in a single file edit, so a
10601060+ // per-variant counter would lie. Say what's true.
10611061+ status.textContent = arrivedVariants < expectedVariants
10621062+ ? 'Generating ' + expectedVariants + ' variants...'
10631063+ : 'Done';
10641064+ row.appendChild(status);
10651065+10661066+ return row;
10671067+ }
10681068+10691069+ // --- Cycling row ---
10701070+10711071+ 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>';
10721072+10731073+ function buildCyclingRow() {
10741074+ const row = el('div', {
10751075+ display: 'flex', alignItems: 'center', gap: '6px',
10761076+ padding: '1px 2px',
10771077+ });
10781078+10791079+ // Prev
10801080+ const prev = navBtn('\u2190');
10811081+ prev.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(-1); });
10821082+ if (visibleVariant <= 1) prev.style.opacity = '0.3';
10831083+ row.appendChild(prev);
10841084+10851085+ // Dots (clickable)
10861086+ row.appendChild(buildDots(true));
10871087+10881088+ // Counter
10891089+ const counter = el('span', {
10901090+ fontFamily: MONO, fontSize: '11px', fontWeight: '500',
10911091+ color: BP.textDim, minWidth: '24px', textAlign: 'center',
10921092+ });
10931093+ counter.textContent = visibleVariant + '/' + arrivedVariants;
10941094+ row.appendChild(counter);
10951095+10961096+ // Next
10971097+ const next = navBtn('\u2192');
10981098+ next.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(1); });
10991099+ if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
11001100+ row.appendChild(next);
11011101+11021102+ // Tune chip — only when the visible variant exposes params
11031103+ const visParams = parseVariantParams(getVisibleVariantEl());
11041104+ const hasParams = visParams.length > 0;
11051105+ if (hasParams) {
11061106+ const tune = el('button', {
11071107+ display: 'inline-flex', alignItems: 'center', gap: '6px',
11081108+ padding: '4px 10px', borderRadius: '5px',
11091109+ border: '1px solid transparent',
11101110+ background: tuneOpen ? BP.accentSoft : 'transparent',
11111111+ color: tuneOpen ? BP.accent : BP.text,
11121112+ fontFamily: FONT, fontSize: '11px', fontWeight: '500',
11131113+ cursor: 'pointer',
11141114+ transition: 'color 0.12s ease, background 0.12s ease',
11151115+ whiteSpace: 'nowrap',
11161116+ });
11171117+ tune.innerHTML = TUNE_ICON_SVG;
11181118+ const tuneLabel = document.createElement('span');
11191119+ tuneLabel.textContent = 'Tune';
11201120+ tune.appendChild(tuneLabel);
11211121+ const tuneBadge = document.createElement('span');
11221122+ Object.assign(tuneBadge.style, {
11231123+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
11241124+ minWidth: '16px', height: '16px', padding: '0 4px',
11251125+ borderRadius: '999px',
11261126+ background: tuneOpen ? C.brand : BP.hairline,
11271127+ color: tuneOpen ? 'oklch(98% 0 0)' : 'inherit',
11281128+ fontFamily: MONO, fontSize: '9.5px', fontWeight: '600',
11291129+ lineHeight: '1',
11301130+ boxSizing: 'border-box',
11311131+ });
11321132+ tuneBadge.textContent = String(visParams.length);
11331133+ tune.appendChild(tuneBadge);
11341134+ tune.title = 'Tune this variant (' + visParams.length + ' knob' + (visParams.length === 1 ? '' : 's') + ')';
11351135+ tune.addEventListener('mouseenter', () => {
11361136+ if (!tuneOpen) tune.style.background = BP.accentSoft;
11371137+ });
11381138+ tune.addEventListener('mouseleave', () => {
11391139+ if (!tuneOpen) tune.style.background = 'transparent';
11401140+ });
11411141+ tune.addEventListener('click', (e) => { e.stopPropagation(); toggleTunePopover(); });
11421142+ tune.dataset.iceqTune = '1';
11431143+ row.appendChild(tune);
11441144+ }
11451145+11461146+ // Spacer
11471147+ row.appendChild(el('div', { flex: '1' }));
11481148+11491149+ // Accept — primary action, uses the site's saturated brand magenta
11501150+ // with paper-white text, not the theme-muted BP.accent.
11511151+ const accept = el('button', {
11521152+ padding: '5px 14px', borderRadius: '5px',
11531153+ border: 'none', background: C.brand, color: 'oklch(98% 0 0)',
11541154+ fontFamily: FONT, fontSize: '11px', fontWeight: '600',
11551155+ cursor: 'pointer', transition: 'filter 0.12s ease, transform 0.1s ease',
11561156+ whiteSpace: 'nowrap',
11571157+ });
11581158+ accept.textContent = '\u2713 Accept';
11591159+ accept.addEventListener('mouseenter', () => accept.style.filter = 'brightness(1.08)');
11601160+ accept.addEventListener('mouseleave', () => accept.style.filter = 'none');
11611161+ accept.addEventListener('mousedown', () => accept.style.transform = 'scale(0.97)');
11621162+ accept.addEventListener('mouseup', () => accept.style.transform = 'scale(1)');
11631163+ accept.addEventListener('click', (e) => { e.stopPropagation(); handleAccept(); });
11641164+ if (arrivedVariants === 0) { accept.style.opacity = '0.3'; accept.style.pointerEvents = 'none'; }
11651165+ row.appendChild(accept);
11661166+11671167+ // Discard
11681168+ const discard = el('button', {
11691169+ padding: '4px 6px', borderRadius: '5px',
11701170+ border: '1px solid ' + BP.hairline, background: 'transparent',
11711171+ fontFamily: FONT, fontSize: '11px', color: BP.textDim,
11721172+ cursor: 'pointer', transition: 'color 0.12s ease, border-color 0.12s ease',
11731173+ });
11741174+ discard.textContent = '\u2715';
11751175+ discard.title = 'Discard all variants';
11761176+ discard.addEventListener('mouseenter', () => { discard.style.color = BP.text; discard.style.borderColor = BP.text; });
11771177+ discard.addEventListener('mouseleave', () => { discard.style.color = BP.textDim; discard.style.borderColor = BP.hairline; });
11781178+ discard.addEventListener('click', (e) => { e.stopPropagation(); handleDiscard(); });
11791179+ row.appendChild(discard);
11801180+11811181+ return row;
11821182+ }
11831183+11841184+ // --- Shared UI builders ---
11851185+11861186+ // --- Saving row (waiting for agent to process accept/discard) ---
11871187+11881188+ function buildSavingRow() {
11891189+ const row = el('div', {
11901190+ display: 'flex', alignItems: 'center', gap: '8px',
11911191+ padding: '2px 8px',
11921192+ });
11931193+ const spinner = el('div', {
11941194+ width: '14px', height: '14px', borderRadius: '50%',
11951195+ border: '2px solid ' + BP.hairline,
11961196+ borderTopColor: BP.accent,
11971197+ animation: 'impeccable-spin 0.6s linear infinite',
11981198+ flexShrink: '0',
11991199+ });
12001200+ row.appendChild(spinner);
12011201+ const label = el('span', {
12021202+ fontSize: '12px', color: BP.textDim, fontWeight: '500',
12031203+ });
12041204+ label.textContent = 'Applying variant...';
12051205+ row.appendChild(label);
12061206+12071207+ // Inject the keyframes if not already present
12081208+ if (!document.getElementById(PREFIX + '-keyframes')) {
12091209+ const style = document.createElement('style');
12101210+ style.id = PREFIX + '-keyframes';
12111211+ style.textContent = '@keyframes impeccable-spin { to { transform: rotate(360deg); } }';
12121212+ document.head.appendChild(style);
12131213+ }
12141214+ return row;
12151215+ }
12161216+12171217+ // --- Confirmed row (green success, auto-dismisses) ---
12181218+12191219+ function buildConfirmedRow() {
12201220+ const row = el('div', {
12211221+ display: 'flex', alignItems: 'center', gap: '8px',
12221222+ padding: '2px 8px',
12231223+ });
12241224+ const check = el('span', {
12251225+ fontSize: '15px', lineHeight: '1', flexShrink: '0',
12261226+ color: 'oklch(45% 0.15 145)',
12271227+ });
12281228+ check.textContent = '\u2713';
12291229+ row.appendChild(check);
12301230+ const label = el('span', {
12311231+ fontSize: '12px', color: 'oklch(35% 0.1 145)', fontWeight: '600',
12321232+ });
12331233+ label.textContent = 'Variant applied';
12341234+ row.appendChild(label);
12351235+ return row;
12361236+ }
12371237+12381238+ // --- Shared UI builders ---
12391239+12401240+ function buildDots(clickable) {
12411241+ const container = el('div', {
12421242+ display: 'flex', alignItems: 'center', gap: '4px',
12431243+ });
12441244+ for (let i = 1; i <= expectedVariants; i++) {
12451245+ const arrived = i <= arrivedVariants;
12461246+ const active = i === visibleVariant;
12471247+ // active: solid site-brand magenta dot. arrived+inactive: muted neutral.
12481248+ // pending (not yet arrived): faint outline ring. No borders on arrived
12491249+ // dots — the previous "accent ring + ash fill" combo read as noisy
12501250+ // magenta chips, especially when all variants had arrived and every
12511251+ // dot wore an accent ring.
12521252+ const dotBg = active ? C.brand
12531253+ : arrived ? BP.textDim
12541254+ : 'transparent';
12551255+ const dotBorder = arrived ? 'none' : '1.5px solid ' + BP.hairline;
12561256+ const dot = el('div', {
12571257+ width: active ? '8px' : '6px',
12581258+ height: active ? '8px' : '6px',
12591259+ borderRadius: '50%',
12601260+ background: dotBg,
12611261+ border: dotBorder,
12621262+ boxSizing: 'border-box',
12631263+ transition: 'all 0.2s ' + EASE,
12641264+ cursor: (clickable && arrived) ? 'pointer' : 'default',
12651265+ transform: arrived ? 'scale(1)' : 'scale(0.85)',
12661266+ opacity: arrived ? (active ? '1' : '0.6') : '0.4',
12671267+ });
12681268+ if (clickable && arrived) {
12691269+ const idx = i;
12701270+ dot.addEventListener('click', (e) => {
12711271+ e.stopPropagation();
12721272+ visibleVariant = idx;
12731273+ showVariantInDOM(currentSessionId, idx);
12741274+ updateSelectedElement();
12751275+ updateBarContent('cycling');
12761276+ });
12771277+ }
12781278+ container.appendChild(dot);
12791279+ }
12801280+ return container;
12811281+ }
12821282+12831283+ function navBtn(text) {
12841284+ const b = el('button', {
12851285+ width: '26px', height: '26px', borderRadius: '5px',
12861286+ border: '1px solid ' + BP.hairline, background: 'transparent',
12871287+ color: BP.text, fontFamily: FONT, fontSize: '13px',
12881288+ cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
12891289+ transition: 'border-color 0.12s ease, background 0.12s ease',
12901290+ padding: '0', lineHeight: '1',
12911291+ });
12921292+ b.textContent = text;
12931293+ b.addEventListener('mouseenter', () => { b.style.borderColor = BP.text; });
12941294+ b.addEventListener('mouseleave', () => { b.style.borderColor = BP.hairline; });
12951295+ return b;
12961296+ }
12971297+12981298+ function actionLabel() {
12991299+ const a = ACTIONS.find(a => a.value === selectedAction);
13001300+ return a ? a.label : 'Freeform';
13011301+ }
13021302+13031303+ function el(tag, styles) {
13041304+ const e = document.createElement(tag);
13051305+ if (styles) Object.assign(e.style, styles);
13061306+ return e;
13071307+ }
13081308+13091309+ // ---------------------------------------------------------------------------
13101310+ // Action picker popover
13111311+ // ---------------------------------------------------------------------------
13121312+13131313+ function initActionPicker() {
13141314+ const P = barPaletteForTheme(detectPageTheme());
13151315+ pickerEl = document.createElement('div');
13161316+ pickerEl.id = PREFIX + '-picker';
13171317+ Object.assign(pickerEl.style, {
13181318+ position: 'fixed', zIndex: Z.picker,
13191319+ display: 'none', opacity: '0',
13201320+ transform: 'scale(0.96) translateY(4px)',
13211321+ transformOrigin: 'bottom left',
13221322+ transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE,
13231323+ background: P.surface,
13241324+ border: '1px solid ' + P.hairline,
13251325+ borderRadius: '10px',
13261326+ boxShadow: '0 8px 30px oklch(0% 0 0 / 0.10), 0 2px 6px oklch(0% 0 0 / 0.06)',
13271327+ padding: '6px',
13281328+ fontFamily: FONT,
13291329+ backdropFilter: 'blur(10px)',
13301330+ WebkitBackdropFilter: 'blur(10px)',
13311331+ });
13321332+13331333+ // Build the chip grid
13341334+ const grid = el('div', {
13351335+ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '3px',
13361336+ });
13371337+13381338+ ACTIONS.forEach(action => {
13391339+ const chip = el('button', {
13401340+ display: 'flex', flexDirection: 'column', alignItems: 'center',
13411341+ gap: '4px',
13421342+ padding: '8px 6px', borderRadius: '6px',
13431343+ border: 'none',
13441344+ background: action.value === selectedAction ? P.accentSoft : 'transparent',
13451345+ color: action.value === selectedAction ? P.accent : P.text,
13461346+ fontFamily: FONT, fontSize: '11px', fontWeight: '500',
13471347+ cursor: 'pointer',
13481348+ transition: 'background 0.1s ease, color 0.1s ease',
13491349+ textAlign: 'center', whiteSpace: 'nowrap',
13501350+ });
13511351+ const iconWrap = el('span', {
13521352+ display: 'flex', alignItems: 'center', justifyContent: 'center',
13531353+ height: '20px', opacity: '0.9',
13541354+ });
13551355+ iconWrap.innerHTML = ICONS[action.value] || '';
13561356+ const labelEl = el('span', { lineHeight: '1' });
13571357+ labelEl.textContent = action.label;
13581358+ chip.appendChild(iconWrap);
13591359+ chip.appendChild(labelEl);
13601360+ chip.dataset.action = action.value;
13611361+ chip.addEventListener('mouseenter', () => {
13621362+ if (action.value !== selectedAction) chip.style.background = P.accentSoft;
13631363+ });
13641364+ chip.addEventListener('mouseleave', () => {
13651365+ chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent';
13661366+ });
13671367+ chip.addEventListener('click', (e) => {
13681368+ e.stopPropagation();
13691369+ selectedAction = action.value;
13701370+ hideActionPicker();
13711371+ updateBarContent('configure');
13721372+ });
13731373+ grid.appendChild(chip);
13741374+ });
13751375+13761376+ pickerEl.appendChild(grid);
13771377+ document.body.appendChild(pickerEl);
13781378+ defangOutsideHandlers(pickerEl);
13791379+13801380+ // Cache the palette on the picker so toggleActionPicker's state refresh
13811381+ // uses the same theme-aware colors when it repaints chips.
13821382+ pickerEl.__iceq_palette = P;
13831383+ }
13841384+13851385+ function toggleActionPicker() {
13861386+ if (pickerEl.style.display !== 'none') { hideActionPicker(); return; }
13871387+ // Rebuild chips to reflect current selection
13881388+ const P = pickerEl.__iceq_palette || barPaletteForTheme(detectPageTheme());
13891389+ pickerEl.querySelectorAll('button').forEach(chip => {
13901390+ const isActive = chip.dataset.action === selectedAction;
13911391+ chip.style.background = isActive ? P.accentSoft : 'transparent';
13921392+ chip.style.color = isActive ? P.accent : P.text;
13931393+ });
13941394+ // Position above the bar
13951395+ const barRect = barEl.getBoundingClientRect();
13961396+ const pickerH = 170; // approximate; grows with icon + label rows
13971397+ let top = barRect.top - pickerH - 6;
13981398+ if (top < 8) top = barRect.bottom + 6;
13991399+ Object.assign(pickerEl.style, {
14001400+ top: top + 'px', left: barRect.left + 'px',
14011401+ display: 'block',
14021402+ });
14031403+ requestAnimationFrame(() => {
14041404+ pickerEl.style.opacity = '1';
14051405+ pickerEl.style.transform = 'scale(1) translateY(0)';
14061406+ });
14071407+ }
14081408+14091409+ function hideActionPicker() {
14101410+ if (!pickerEl) return;
14111411+ pickerEl.style.opacity = '0';
14121412+ pickerEl.style.transform = 'scale(0.96) translateY(4px)';
14131413+ setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180);
14141414+ }
14151415+14161416+ // ---------------------------------------------------------------------------
14171417+ // Params panel (per-variant coarse controls)
14181418+ //
14191419+ // Variants may declare a parameter manifest via a JSON attribute on the
14201420+ // variant wrapper:
14211421+ //
14221422+ // <div data-impeccable-variant="1"
14231423+ // data-impeccable-params='[{"id":"density","kind":"steps",...}]'>
14241424+ //
14251425+ // The panel docks to the right edge of the outline during CYCLING and
14261426+ // exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped
14271427+ // CSS can respond instantly without regeneration:
14281428+ //
14291429+ // range / numeric toggle → CSS var (`--p-<id>`) used via var(--p-foo, N)
14301430+ // steps / boolean toggle → data-p-<id> attribute used via :scope[data-p-foo="..."]
14311431+ //
14321432+ // On variant switch, values reset to that variant's declared defaults.
14331433+ // On accept, current values are sent in the event payload so the agent
14341434+ // can bake them into the source-file write.
14351435+ // ---------------------------------------------------------------------------
14361436+14371437+ let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide)
14381438+ let paramsPanelInner = null; // translating content (carries bg, padding, knobs)
14391439+ let paramsPanelBody = null; // grid holding the knob cells
14401440+ let paramsCurrentValues = {}; // {paramId: value} — mirror of the visible variant's live values
14411441+ let tuneOpen = false; // whether the Tune popover is open right now
14421442+14431443+ // Theme-aware Tune popover. Appears as a drawer that slides out from the
14441444+ // contextual bar's bar-facing edge (below if the bar sits below the
14451445+ // element, above otherwise). Same width as the bar. Auto-wraps to extra
14461446+ // rows when the knobs exceed one row. The bar's border-radius on the
14471447+ // popover side goes flat while open so the two shapes read as one.
14481448+ let paramsPanelPalette = null;
14491449+14501450+ function initParamsPanel() {
14511451+ paramsPanelPalette = barPaletteForTheme(detectPageTheme());
14521452+ const P = paramsPanelPalette;
14531453+14541454+ // Single element, always in the DOM. The slide animation is a CSS mask
14551455+ // with mask-size growing from 0% to 100% along the bar-facing axis — no
14561456+ // display toggle, no opacity toggle, no transform trickery. The mask
14571457+ // hides everything initially; as it grows, content is revealed from
14581458+ // the bar edge outward.
14591459+ paramsPanelEl = document.createElement('div');
14601460+ paramsPanelEl.id = PREFIX + '-params-panel';
14611461+ Object.assign(paramsPanelEl.style, {
14621462+ position: 'fixed', zIndex: String(Z.bar - 1),
14631463+ background: P.surfaceDeep,
14641464+ color: P.text,
14651465+ fontFamily: FONT,
14661466+ padding: '14px 18px',
14671467+ boxSizing: 'border-box',
14681468+ borderRadius: '0 0 10px 10px',
14691469+ pointerEvents: 'none',
14701470+ backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
14711471+14721472+ // clip-path is the same conceptual reveal as mask but with rock-solid
14731473+ // transition support across engines. Closed state clips from the far
14741474+ // edge; open = inset(0) shows everything.
14751475+ clipPath: 'inset(0 0 100% 0)',
14761476+ transition: 'clip-path 0.44s ' + EASE,
14771477+14781478+ // Park off-screen until positionParamsPanel places it. These are NOT
14791479+ // in the transition list, so they snap instantly — no fly-in from the
14801480+ // top-left when first shown.
14811481+ top: '-9999px', left: '-9999px', width: '0',
14821482+ });
14831483+14841484+ paramsPanelBody = el('div', {
14851485+ display: 'grid',
14861486+ gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
14871487+ gap: '12px 16px',
14881488+ });
14891489+14901490+ paramsPanelEl.appendChild(paramsPanelBody);
14911491+ document.body.appendChild(paramsPanelEl);
14921492+ // Don't override pointer-events: the panel toggles between 'none' (closed,
14931493+ // click-through) and 'auto' (open) on its own. Just silence the host's
14941494+ // outside-interaction listeners while the panel is open.
14951495+ defangOutsideHandlers(paramsPanelEl, { setPointerEvents: false });
14961496+ paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
14971497+ }
14981498+14991499+ function getVisibleVariantEl() {
15001500+ if (!currentSessionId) return null;
15011501+ const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
15021502+ if (!wrapper) return null;
15031503+ return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
15041504+ }
15051505+15061506+ function parseVariantParams(variantEl) {
15071507+ if (!variantEl) return [];
15081508+ const raw = variantEl.getAttribute('data-impeccable-params');
15091509+ if (!raw) return [];
15101510+ try {
15111511+ const parsed = JSON.parse(raw);
15121512+ return Array.isArray(parsed) ? parsed : [];
15131513+ } catch (err) {
15141514+ console.warn('[impeccable] Invalid data-impeccable-params JSON:', err.message);
15151515+ return [];
15161516+ }
15171517+ }
15181518+15191519+ function applyParamValue(variantEl, param, value) {
15201520+ if (!variantEl) return;
15211521+ const attr = 'data-p-' + param.id;
15221522+ if (param.kind === 'range') {
15231523+ variantEl.style.setProperty('--p-' + param.id, String(value));
15241524+ } else if (param.kind === 'toggle') {
15251525+ const on = !!value;
15261526+ variantEl.style.setProperty('--p-' + param.id, on ? '1' : '0');
15271527+ if (on) variantEl.setAttribute(attr, 'on');
15281528+ else variantEl.removeAttribute(attr);
15291529+ } else if (param.kind === 'steps') {
15301530+ variantEl.setAttribute(attr, String(value));
15311531+ }
15321532+ }
15331533+15341534+ function applyParamDefaults(variantEl, params) {
15351535+ paramsCurrentValues = {};
15361536+ for (const p of params) {
15371537+ paramsCurrentValues[p.id] = p.default;
15381538+ applyParamValue(variantEl, p, p.default);
15391539+ }
15401540+ }
15411541+15421542+ function formatRangeValue(input) {
15431543+ const max = parseFloat(input.max), min = parseFloat(input.min);
15441544+ const v = parseFloat(input.value);
15451545+ if (!isFinite(v)) return input.value;
15461546+ return (max - min) <= 2 ? v.toFixed(2) : String(Math.round(v));
15471547+ }
15481548+15491549+ function buildParamsPanel(variantEl, params) {
15501550+ const P = paramsPanelPalette || barPaletteForTheme(detectPageTheme());
15511551+ paramsPanelBody.innerHTML = '';
15521552+ for (const p of params) {
15531553+ const row = el('div', { display: 'flex', flexDirection: 'column', gap: '6px' });
15541554+ const labelRow = el('div', {
15551555+ display: 'flex', justifyContent: 'space-between',
15561556+ alignItems: 'baseline', gap: '8px',
15571557+ });
15581558+ const lbl = el('span', {
15591559+ fontSize: '10.5px', fontWeight: '600', color: P.text,
15601560+ letterSpacing: '0.03em',
15611561+ });
15621562+ lbl.textContent = p.label || p.id;
15631563+ labelRow.appendChild(lbl);
15641564+ const readout = el('span', {
15651565+ fontSize: '10.5px', color: P.textDim,
15661566+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
15671567+ });
15681568+ labelRow.appendChild(readout);
15691569+ row.appendChild(labelRow);
15701570+15711571+ if (p.kind === 'range') {
15721572+ const input = document.createElement('input');
15731573+ input.type = 'range';
15741574+ input.min = String(p.min != null ? p.min : 0);
15751575+ input.max = String(p.max != null ? p.max : 1);
15761576+ input.step = String(p.step != null ? p.step : 0.05);
15771577+ input.value = String(p.default);
15781578+ Object.assign(input.style, {
15791579+ width: '100%', accentColor: C.brand, cursor: 'pointer',
15801580+ });
15811581+ readout.textContent = formatRangeValue(input);
15821582+ input.addEventListener('input', (e) => {
15831583+ e.stopPropagation();
15841584+ const v = parseFloat(input.value);
15851585+ paramsCurrentValues[p.id] = v;
15861586+ readout.textContent = formatRangeValue(input);
15871587+ applyParamValue(variantEl, p, v);
15881588+ });
15891589+ row.appendChild(input);
15901590+ } else if (p.kind === 'toggle') {
15911591+ const initial = !!p.default;
15921592+ readout.textContent = initial ? 'On' : 'Off';
15931593+ const track = el('button', {
15941594+ position: 'relative', width: '36px', height: '20px',
15951595+ borderRadius: '10px', border: 'none', padding: '0',
15961596+ cursor: 'pointer',
15971597+ background: initial ? C.brand : P.hairline,
15981598+ transition: 'background 0.15s ease',
15991599+ alignSelf: 'flex-start',
16001600+ });
16011601+ const knob = el('span', {
16021602+ position: 'absolute', top: '2px',
16031603+ left: initial ? '18px' : '2px',
16041604+ width: '16px', height: '16px', borderRadius: '50%',
16051605+ background: 'oklch(98% 0 0)',
16061606+ transition: 'left 0.18s ' + EASE,
16071607+ boxShadow: '0 1px 2px oklch(0% 0 0 / 0.2)',
16081608+ });
16091609+ track.appendChild(knob);
16101610+ track.addEventListener('click', (e) => {
16111611+ e.stopPropagation();
16121612+ const next = !paramsCurrentValues[p.id];
16131613+ paramsCurrentValues[p.id] = next;
16141614+ track.style.background = next ? C.brand : P.hairline;
16151615+ knob.style.left = next ? '18px' : '2px';
16161616+ readout.textContent = next ? 'On' : 'Off';
16171617+ applyParamValue(variantEl, p, next);
16181618+ });
16191619+ row.appendChild(track);
16201620+ } else if (p.kind === 'steps') {
16211621+ const opts = (p.options || []).map(o =>
16221622+ typeof o === 'string' ? { value: o, label: o } : o
16231623+ );
16241624+ const activeOpt = opts.find(o => o.value === p.default) || opts[0];
16251625+ readout.textContent = activeOpt ? activeOpt.label : String(p.default);
16261626+ const segRow = el('div', {
16271627+ display: 'grid',
16281628+ gridTemplateColumns: 'repeat(' + opts.length + ', 1fr)',
16291629+ gap: '1px', padding: '2px',
16301630+ background: P.hairline, borderRadius: '5px',
16311631+ });
16321632+ const segBtns = [];
16331633+ opts.forEach(o => {
16341634+ const active = o.value === p.default;
16351635+ const b = el('button', {
16361636+ padding: '5px 4px', border: 'none', borderRadius: '3px',
16371637+ background: active ? C.brand : 'transparent',
16381638+ color: active ? 'oklch(98% 0 0)' : P.text,
16391639+ fontFamily: FONT, fontSize: '10.5px', fontWeight: '500',
16401640+ cursor: 'pointer', whiteSpace: 'nowrap',
16411641+ transition: 'background 0.1s ease, color 0.1s ease',
16421642+ });
16431643+ b.textContent = o.label;
16441644+ b.addEventListener('click', (e) => {
16451645+ e.stopPropagation();
16461646+ paramsCurrentValues[p.id] = o.value;
16471647+ readout.textContent = o.label;
16481648+ segBtns.forEach(({ btn, val }) => {
16491649+ const on = val === o.value;
16501650+ btn.style.background = on ? C.brand : 'transparent';
16511651+ btn.style.color = on ? 'oklch(98% 0 0)' : P.text;
16521652+ });
16531653+ applyParamValue(variantEl, p, o.value);
16541654+ });
16551655+ segRow.appendChild(b);
16561656+ segBtns.push({ btn: b, val: o.value });
16571657+ });
16581658+ row.appendChild(segRow);
16591659+ }
16601660+16611661+ paramsPanelBody.appendChild(row);
16621662+ }
16631663+ }
16641664+16651665+ // Decide which way the popover opens: away from the picked element. If the
16661666+ // bar landed below the element, popover slides DOWN from the bar's bottom.
16671667+ // If the bar landed above, popover slides UP from the bar's top.
16681668+ function popoverDirection() {
16691669+ if (!barEl || !selectedElement) return 'below';
16701670+ const br = barEl.getBoundingClientRect();
16711671+ const er = selectedElement.getBoundingClientRect();
16721672+ return br.top >= er.bottom - 4 ? 'below' : 'above';
16731673+ }
16741674+16751675+ // The popover overlaps the bar by OVERLAP px on the bar-facing side. With
16761676+ // popover z-index below bar, that overlap sits behind bar (invisible) and
16771677+ // reinforces the "tucked behind" feel. Padding compensates so the real
16781678+ // content starts flush with bar's outer edge.
16791679+ const TUNE_OVERLAP = 6;
16801680+16811681+ // Closed clip-path depends on direction: for 'below' clip from the far
16821682+ // (bottom) edge so the reveal grows downward from the bar; for 'above'
16831683+ // clip from the top edge so the reveal grows upward from the bar.
16841684+ function closedClipPath(direction) {
16851685+ return direction === 'below' ? 'inset(0 0 100% 0)' : 'inset(100% 0 0 0)';
16861686+ }
16871687+16881688+ function setClipPath(value, withTransition) {
16891689+ const saved = paramsPanelEl.style.transition;
16901690+ if (!withTransition) paramsPanelEl.style.transition = 'none';
16911691+ paramsPanelEl.style.clipPath = value;
16921692+ if (!withTransition) {
16931693+ void paramsPanelEl.offsetHeight;
16941694+ paramsPanelEl.style.transition = saved;
16951695+ }
16961696+ }
16971697+16981698+ function positionParamsPanel() {
16991699+ if (!paramsPanelEl || !barEl || barEl.style.display === 'none') return;
17001700+ const br = barEl.getBoundingClientRect();
17011701+ const direction = popoverDirection();
17021702+ const prevDirection = paramsPanelEl.dataset.tuneDirection;
17031703+17041704+ // top/left/width are NOT in the transition list, so they snap instantly.
17051705+ paramsPanelEl.style.left = br.left + 'px';
17061706+ paramsPanelEl.style.width = br.width + 'px';
17071707+17081708+ if (direction === 'below') {
17091709+ paramsPanelEl.style.top = (br.bottom - TUNE_OVERLAP) + 'px';
17101710+ paramsPanelEl.style.borderRadius = '0 0 10px 10px';
17111711+ paramsPanelEl.style.paddingTop = (14 + TUNE_OVERLAP) + 'px';
17121712+ paramsPanelEl.style.paddingBottom = '14px';
17131713+ } else {
17141714+ const ih = paramsPanelEl.offsetHeight || 80;
17151715+ paramsPanelEl.style.top = (br.top - ih + TUNE_OVERLAP) + 'px';
17161716+ paramsPanelEl.style.borderRadius = '10px 10px 0 0';
17171717+ paramsPanelEl.style.paddingTop = '14px';
17181718+ paramsPanelEl.style.paddingBottom = (14 + TUNE_OVERLAP) + 'px';
17191719+ }
17201720+ paramsPanelEl.dataset.tuneDirection = direction;
17211721+17221722+ // If currently closed and direction flipped (or first-time setup),
17231723+ // snap the clip-path to the new direction's closed pose without
17241724+ // transitioning (so the clip doesn't slide across the element).
17251725+ if (!tuneOpen && (!prevDirection || prevDirection !== direction)) {
17261726+ setClipPath(closedClipPath(direction), false);
17271727+ }
17281728+ }
17291729+17301730+ function showParamsPanel() {
17311731+ if (!paramsPanelEl) return;
17321732+ positionParamsPanel();
17331733+ paramsPanelEl.style.pointerEvents = 'auto';
17341734+ // rAF so the positioning paint commits before the transition fires.
17351735+ requestAnimationFrame(() => {
17361736+ setClipPath('inset(0 0 0 0)', true);
17371737+ });
17381738+ }
17391739+17401740+ function hideParamsPanel() {
17411741+ if (!paramsPanelEl) return;
17421742+ paramsPanelEl.style.pointerEvents = 'none';
17431743+ const direction = paramsPanelEl.dataset.tuneDirection || 'below';
17441744+ setClipPath(closedClipPath(direction), true);
17451745+ }
17461746+17471747+ // Build/rebuild the panel's contents for the current variant AND apply
17481748+ // its defaults to the variant wrapper (so scoped CSS responds even before
17491749+ // the user opens the popover). Visibility is governed by tuneOpen.
17501750+ function refreshParamsPanel() {
17511751+ if (state !== 'CYCLING') {
17521752+ paramsCurrentValues = {};
17531753+ tuneOpen = false;
17541754+ hideParamsPanel();
17551755+ return;
17561756+ }
17571757+ const variantEl = getVisibleVariantEl();
17581758+ const params = parseVariantParams(variantEl);
17591759+ if (!variantEl || params.length === 0) {
17601760+ paramsCurrentValues = {};
17611761+ tuneOpen = false;
17621762+ hideParamsPanel();
17631763+ return;
17641764+ }
17651765+ applyParamDefaults(variantEl, params);
17661766+ buildParamsPanel(variantEl, params);
17671767+ if (tuneOpen) {
17681768+ // If already visible (variant cycled while open), refresh in place
17691769+ // instead of re-running the clip-path animation.
17701770+ const alreadyVisible = paramsPanelEl.style.display === 'block'
17711771+ && paramsPanelEl.style.opacity === '1';
17721772+ if (alreadyVisible) positionParamsPanel();
17731773+ else showParamsPanel();
17741774+ } else {
17751775+ hideParamsPanel();
17761776+ }
17771777+ }
17781778+17791779+ function toggleTunePopover() {
17801780+ if (tuneOpen) { closeTunePopover(); return; }
17811781+ openTunePopover();
17821782+ }
17831783+17841784+ function openTunePopover() {
17851785+ if (state !== 'CYCLING') return;
17861786+ const variantEl = getVisibleVariantEl();
17871787+ const params = parseVariantParams(variantEl);
17881788+ if (!variantEl || params.length === 0) return;
17891789+ // Build fresh to ensure the current variant's controls are shown.
17901790+ applyParamDefaults(variantEl, params);
17911791+ buildParamsPanel(variantEl, params);
17921792+ tuneOpen = true;
17931793+ showParamsPanel();
17941794+ // Kill the bar's shadow on the popover-facing side so the dark popover
17951795+ // doesn't pick up a bright glow line.
17961796+ if (barEl) {
17971797+ const direction = paramsPanelEl?.dataset.tuneDirection || 'below';
17981798+ barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN;
17991799+ }
18001800+ // Re-render the bar so the Tune chip picks up the active styling.
18011801+ updateBarContent('cycling');
18021802+ }
18031803+18041804+ function closeTunePopover() {
18051805+ tuneOpen = false;
18061806+ hideParamsPanel();
18071807+ if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT;
18081808+ if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') {
18091809+ updateBarContent('cycling');
18101810+ }
18111811+ }
18121812+18131813+ // ---------------------------------------------------------------------------
18141814+ // Variant cycling in DOM
18151815+ // ---------------------------------------------------------------------------
18161816+18171817+ function showVariantInDOM(sessionId, num) {
18181818+ const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
18191819+ if (!wrapper) return;
18201820+ for (const child of wrapper.children) {
18211821+ const v = child.dataset ? child.dataset.impeccableVariant : null;
18221822+ if (!v) continue;
18231823+ child.style.display = (v === String(num)) ? '' : 'none';
18241824+ }
18251825+ // Unconditional refresh — covers first-reveal (no-op if state isn't
18261826+ // CYCLING yet, the subsequent CYCLING transition triggers its own
18271827+ // refresh) and every cycle step.
18281828+ refreshParamsPanel();
18291829+ }
18301830+18311831+ /**
18321832+ * No-HMR fallback: fetch the raw source file from the live server,
18331833+ * parse it, extract the variant wrapper, and inject it into the live DOM.
18341834+ * This works even when the dev server caches HTML (Bun, static servers).
18351835+ */
18361836+ function injectVariantsFromSource(filePath, sessionId) {
18371837+ const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
18381838+ fetch(url)
18391839+ .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); })
18401840+ .then(html => {
18411841+ // Parse the raw source HTML
18421842+ const parser = new DOMParser();
18431843+ const doc = parser.parseFromString(html, 'text/html');
18441844+ const srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]');
18451845+ if (!srcWrapper) {
18461846+ console.error('[impeccable] Variant wrapper not found in source file.');
18471847+ return;
18481848+ }
18491849+18501850+ // Find the original element in the live DOM.
18511851+ // The original is inside the wrapper in the source. We find the
18521852+ // corresponding element in the live DOM by matching the first child's
18531853+ // tag + classes from the original snapshot.
18541854+ const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child');
18551855+ if (!origContent) return;
18561856+18571857+ const tag = origContent.tagName.toLowerCase();
18581858+ const cls = origContent.className;
18591859+ let liveEl = null;
18601860+ if (origContent.id) {
18611861+ liveEl = document.getElementById(origContent.id);
18621862+ } else if (cls) {
18631863+ // Find by tag + exact class match
18641864+ const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]);
18651865+ for (const c of candidates) {
18661866+ if (c.className === cls && !own(c)) { liveEl = c; break; }
18671867+ }
18681868+ }
18691869+18701870+ if (!liveEl) {
18711871+ console.error('[impeccable] Could not find original element in live DOM.');
18721872+ return;
18731873+ }
18741874+18751875+ // Replace the live element with the full wrapper from source
18761876+ const wrapper = srcWrapper.cloneNode(true);
18771877+ liveEl.parentElement.replaceChild(wrapper, liveEl);
18781878+18791879+ // Update state: count variants, show the first one
18801880+ const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
18811881+ arrivedVariants = variants.length;
18821882+ expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants);
18831883+ visibleVariant = 1;
18841884+ showVariantInDOM(sessionId, 1);
18851885+18861886+ // Update selectedElement to the visible variant's content
18871887+ selectedElement = pickVariantContent(wrapper, 1) || wrapper.parentElement;
18881888+18891889+ state = 'CYCLING';
18901890+ hideShaderOverlay();
18911891+ updateBarContent('cycling');
18921892+ refreshParamsPanel();
18931893+ saveSession();
18941894+ console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
18951895+ })
18961896+ .catch(err => {
18971897+ console.error('[impeccable] Failed to fetch source:', err);
18981898+ showToast('Could not load variants. Try refreshing the page.', 5000);
18991899+ });
19001900+ }
19011901+19021902+ function cycleVariant(dir) {
19031903+ const next = visibleVariant + dir;
19041904+ if (next < 1 || next > arrivedVariants) return;
19051905+ visibleVariant = next;
19061906+ showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself
19071907+ updateSelectedElement();
19081908+ updateBarContent('cycling');
19091909+ saveSession();
19101910+ }
19111911+19121912+ function updateSelectedElement() {
19131913+ if (!currentSessionId) return;
19141914+ const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
19151915+ if (!wrapper) return;
19161916+ const visEl = pickVariantContent(wrapper, visibleVariant);
19171917+ if (visEl) selectedElement = visEl;
19181918+ }
19191919+19201920+ // Resolve the element that represents the variant's visible content.
19211921+ // Contract: each variant div should contain exactly one top-level element
19221922+ // (the full replacement). In practice a model may ship loose siblings or
19231923+ // lead with <style>/<script>. Be defensive: skip non-visual elements, and
19241924+ // if the variant has multiple element children, use the variant div itself
19251925+ // (it wraps all of them and gets correct bounds).
19261926+ function pickVariantContent(wrapper, index) {
19271927+ if (!wrapper) return null;
19281928+ const variantDiv = wrapper.querySelector('[data-impeccable-variant="' + index + '"]');
19291929+ if (!variantDiv) return null;
19301930+ const NON_VISUAL = new Set(['STYLE', 'SCRIPT', 'LINK', 'META', 'TEMPLATE']);
19311931+ const visual = [];
19321932+ for (const child of variantDiv.children) {
19331933+ if (!NON_VISUAL.has(child.tagName)) visual.push(child);
19341934+ }
19351935+ if (visual.length === 1) return visual[0];
19361936+ return variantDiv;
19371937+ }
19381938+19391939+ // Hold window.scrollY at a fixed value across DOM mutations inside the
19401940+ // session's wrapper (HMR patches, variant inserts, cycle swaps).
19411941+ function startScrollLock(sessionId, initialTargetY) {
19421942+ stopScrollLock();
19431943+ scrollLockTargetY = typeof initialTargetY === 'number' && isFinite(initialTargetY)
19441944+ ? initialTargetY
19451945+ : window.scrollY;
19461946+ console.log('[impeccable.scroll] startScrollLock', { sessionId, scrollY: window.scrollY, targetY: scrollLockTargetY, initialOverride: initialTargetY });
19471947+19481948+ try { history.scrollRestoration = 'manual'; } catch {}
19491949+19501950+ const prevHtmlAnchor = document.documentElement.style.overflowAnchor;
19511951+ const prevBodyAnchor = document.body.style.overflowAnchor;
19521952+ document.documentElement.style.overflowAnchor = 'none';
19531953+ document.body.style.overflowAnchor = 'none';
19541954+19551955+ const correct = (why) => {
19561956+ scrollLockRaf = null;
19571957+ if (scrollLockTargetY == null) return;
19581958+ const before = window.scrollY;
19591959+ const delta = before - scrollLockTargetY;
19601960+ if (Math.abs(delta) < 0.5) {
19611961+ console.log('[impeccable.scroll] correct noop', { why, scrollY: before, targetY: scrollLockTargetY });
19621962+ return;
19631963+ }
19641964+ window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
19651965+ console.log('[impeccable.scroll] corrected', { why, from: before, to: scrollLockTargetY, delta, nowAt: window.scrollY });
19661966+ };
19671967+ const schedule = (why) => {
19681968+ if (scrollLockRaf != null) return;
19691969+ scrollLockRaf = requestAnimationFrame(() => correct(why));
19701970+ };
19711971+19721972+ scrollLockObserver = new MutationObserver((mutations) => {
19731973+ for (const m of mutations) {
19741974+ if (m.target?.closest?.('[data-impeccable-variants="' + sessionId + '"]')) {
19751975+ const childAdds = Array.from(m.addedNodes).map(n => n.nodeType === 1 ? (n.tagName + (n.dataset?.impeccableVariant ? ('[variant=' + n.dataset.impeccableVariant + ']') : '')) : n.nodeType).join(',');
19761976+ console.log('[impeccable.scroll] mutation inside wrapper', { type: m.type, target: m.target?.tagName, adds: childAdds, scrollYBefore: window.scrollY, targetY: scrollLockTargetY });
19771977+ schedule('mutation-in-wrapper');
19781978+ return;
19791979+ }
19801980+ for (const n of m.addedNodes) {
19811981+ if (n.nodeType === 1 && (n.matches?.('[data-impeccable-variants="' + sessionId + '"]') || n.querySelector?.('[data-impeccable-variants="' + sessionId + '"]'))) {
19821982+ console.log('[impeccable.scroll] wrapper node added', { tag: n.tagName, scrollYBefore: window.scrollY, targetY: scrollLockTargetY });
19831983+ schedule('wrapper-added');
19841984+ return;
19851985+ }
19861986+ }
19871987+ }
19881988+ });
19891989+ scrollLockObserver.observe(document.body, { childList: true, subtree: true });
19901990+19911991+ scrollLockAbort = new AbortController();
19921992+ scrollLockAbort.signal.addEventListener('abort', () => {
19931993+ document.documentElement.style.overflowAnchor = prevHtmlAnchor;
19941994+ document.body.style.overflowAnchor = prevBodyAnchor;
19951995+ }, { once: true });
19961996+ const sig = { signal: scrollLockAbort.signal };
19971997+ // Track whether the most recent scroll came from a user gesture. We
19981998+ // gate user-scroll re-anchoring on this flag so programmatic smooth
19991999+ // scrolls (browser reload-restore, scrollIntoView from other scripts)
20002000+ // don't accidentally update our target.
20012001+ let userGestureAt = 0;
20022002+ const USER_GESTURE_WINDOW_MS = 250;
20032003+20042004+ const reanchor = (why) => {
20052005+ if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
20062006+ const prevTarget = scrollLockTargetY;
20072007+ scrollLockTargetY = window.scrollY;
20082008+ writeScrollY(scrollLockTargetY);
20092009+ console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY });
20102010+ };
20112011+ const markGesture = (why) => {
20122012+ userGestureAt = performance.now();
20132013+ reanchor(why);
20142014+ };
20152015+ window.addEventListener('wheel', () => markGesture('wheel'), { passive: true, ...sig });
20162016+ window.addEventListener('touchstart', () => markGesture('touchstart'), { passive: true, ...sig });
20172017+ window.addEventListener('touchmove', () => markGesture('touchmove'), { passive: true, ...sig });
20182018+ window.addEventListener('keydown', (e) => {
20192019+ if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) markGesture('key:' + e.key);
20202020+ }, sig);
20212021+20222022+ // Correct on EVERY scroll event: whether it's the browser's
20232023+ // post-reload animated restore or some other script calling
20242024+ // scrollIntoView, we want to snap back immediately. Only skip if a
20252025+ // user gesture fired in the last 250ms.
20262026+ let lastLoggedScrollY = window.scrollY;
20272027+ window.addEventListener('scroll', () => {
20282028+ const now = window.scrollY;
20292029+ if (Math.abs(now - lastLoggedScrollY) > 5) {
20302030+ console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY });
20312031+ lastLoggedScrollY = now;
20322032+ }
20332033+ if (scrollLockTargetY == null) return;
20342034+ if (performance.now() - userGestureAt < USER_GESTURE_WINDOW_MS) return;
20352035+ if (Math.abs(now - scrollLockTargetY) < 0.5) return;
20362036+ console.log('[impeccable.scroll] scroll-event snap', { from: now, to: scrollLockTargetY });
20372037+ window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
20382038+ }, { passive: true, ...sig });
20392039+20402040+ // Apply target synchronously, not via rAF — racing the browser's
20412041+ // restore or a smooth-scroll animation means we want to win now.
20422042+ if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) {
20432043+ window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
20442044+ console.log('[impeccable.scroll] startScrollLock initial apply', { to: scrollLockTargetY });
20452045+ }
20462046+ }
20472047+20482048+ function stopScrollLock() {
20492049+ if (scrollLockObserver) { scrollLockObserver.disconnect(); scrollLockObserver = null; }
20502050+ if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
20512051+ if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; }
20522052+ scrollLockTargetY = null;
20532053+ // NOTE: do NOT clear the persistent scroll key here. startScrollLock
20542054+ // calls us as a reset, and clearing the key would nuke the Go-time
20552055+ // scrollY that the next resume needs to read.
20562056+ }
20572057+20582058+ // ---------------------------------------------------------------------------
20592059+ // MutationObserver for progressive variant reveal
20602060+ // ---------------------------------------------------------------------------
20612061+20622062+ function startVariantObserver(sessionId) {
20632063+ let updating = false; // re-entrancy guard
20642064+20652065+ const obs = new MutationObserver((mutations) => {
20662066+ if (updating) return;
20672067+20682068+ // Only react to mutations that add nodes with data-impeccable-variant,
20692069+ // or mutations inside the variant wrapper. Ignore our own bar/UI changes.
20702070+ let dominated = false;
20712071+ for (const m of mutations) {
20722072+ if (m.target.closest?.('[data-impeccable-variants]')) { dominated = true; break; }
20732073+ for (const n of m.addedNodes) {
20742074+ if (n.nodeType !== 1) continue;
20752075+ // Direct hit: the added node itself is the wrapper or a variant.
20762076+ if (n.dataset?.impeccableVariants || n.dataset?.impeccableVariant) {
20772077+ dominated = true; break;
20782078+ }
20792079+ // Subtree hit: framework HMR (notably SvelteKit) sometimes replaces
20802080+ // a whole subtree where the wrapper is a descendant of the added
20812081+ // node. Without this check, the observer ignores those mutations
20822082+ // and the session stays in GENERATING forever.
20832083+ if (n.querySelector?.('[data-impeccable-variants],[data-impeccable-variant]')) {
20842084+ dominated = true; break;
20852085+ }
20862086+ }
20872087+ if (dominated) break;
20882088+ }
20892089+ if (!dominated) return;
20902090+20912091+ const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
20922092+ if (!wrapper) return;
20932093+20942094+ // Re-anchor selectedElement if it was detached by live-wrap's HMR swap.
20952095+ // Without this, the shader / highlight / bar track a zero-rect phantom
20962096+ // and the overlay appears frozen.
20972097+ if (selectedElement && !document.body.contains(selectedElement)) {
20982098+ selectedElement = pickVariantContent(wrapper, 'original') || wrapper;
20992099+ }
21002100+21012101+ const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
21022102+ const count = variants.length;
21032103+21042104+ // Nothing new
21052105+ if (count <= arrivedVariants) return;
21062106+21072107+ updating = true;
21082108+ arrivedVariants = count;
21092109+ if (visibleVariant === 0 && arrivedVariants > 0) {
21102110+ visibleVariant = 1;
21112111+ showVariantInDOM(sessionId, 1);
21122112+ // showVariantInDOM hid the original (display:none); if we were still
21132113+ // anchored to the original's content, its boundingRect is now zero
21142114+ // and the bar snaps to (0,0). Re-point at the visible variant instead.
21152115+ const visEl = pickVariantContent(wrapper, visibleVariant);
21162116+ if (visEl) selectedElement = visEl;
21172117+ }
21182118+21192119+ const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
21202120+ if (expected > 0) expectedVariants = expected;
21212121+21222122+ if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
21232123+ state = 'CYCLING';
21242124+ hideShaderOverlay();
21252125+ updateBarContent('cycling');
21262126+ refreshParamsPanel();
21272127+ } else if (state === 'GENERATING') {
21282128+ updateBarContent('generating');
21292129+ }
21302130+ saveSession();
21312131+ updating = false;
21322132+ });
21332133+21342134+ obs.observe(document.body, { childList: true, subtree: true });
21352135+ return obs;
21362136+ }
21372137+21382138+ // ---------------------------------------------------------------------------
21392139+ // Bar scroll tracking
21402140+ // ---------------------------------------------------------------------------
21412141+21422142+ function startScrollTracking() {
21432143+ function tick() {
21442144+ if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') {
21452145+ positionBar();
21462146+ showHighlight(selectedElement);
21472147+ if (tuneOpen) positionParamsPanel();
21482148+ }
21492149+ if (annotActive) positionAnnotOverlay(selectedElement);
21502150+ // Shader overlay (via debug P toggle or generation) is repositioned
21512151+ // by its own branch below; debug no longer has a separate overlay.
21522152+ if (shaderState) positionShaderOverlay();
21532153+ scrollRaf = requestAnimationFrame(tick);
21542154+ }
21552155+ scrollRaf = requestAnimationFrame(tick);
21562156+ }
21572157+21582158+ function stopScrollTracking() {
21592159+ if (scrollRaf) { cancelAnimationFrame(scrollRaf); scrollRaf = null; }
21602160+ }
21612161+21622162+ // ---------------------------------------------------------------------------
21632163+ // SSE (server→browser) + fetch POST (browser→server)
21642164+ // Zero-dependency replacement for WebSocket.
21652165+ // ---------------------------------------------------------------------------
21662166+21672167+ let evtSource = null;
21682168+ let sseRetries = 0;
21692169+ const SSE_MAX_RETRIES = 20; // generous: heartbeats keep the connection alive, so retries mean real trouble
21702170+21712171+ function connectSSE() {
21722172+ evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN);
21732173+21742174+ evtSource.onopen = () => {
21752175+ sseRetries = 0; // reset on successful (re)connect
21762176+ };
21772177+21782178+ evtSource.onmessage = (e) => {
21792179+ sseRetries = 0; // reset on any successful message
21802180+ let msg; try { msg = JSON.parse(e.data); } catch { return; }
21812181+ switch (msg.type) {
21822182+ case 'connected':
21832183+ hasProjectContext = !!msg.hasProjectContext;
21842184+ if (!hasProjectContext) showToast('No PRODUCT.md found. Variants will be brand-agnostic. Run /impeccable teach to generate one.', 7000);
21852185+ console.log('[impeccable] Live mode connected.');
21862186+ if (state === 'IDLE') state = 'PICKING';
21872187+ break;
21882188+ case 'done':
21892189+ // Variants already arrived via HMR → normal transition.
21902190+ if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
21912191+ if (state === 'GENERATING') {
21922192+ state = 'CYCLING';
21932193+ updateBarContent('cycling');
21942194+ refreshParamsPanel();
21952195+ }
21962196+ break;
21972197+ }
21982198+ // Variants are in source but not in the DOM yet. Common when the
21992199+ // picked element lived inside conditional render (closed modal,
22002200+ // hidden tab, a route the user navigated away from). The variant
22012201+ // MutationObserver stays armed and auto-transitions to CYCLING
22022202+ // the moment the wrapper actually mounts. Nudge the user toward
22032203+ // that path with a toast — better than the prior force-reload
22042204+ // which reset framework state and left the session stuck.
22052205+ setTimeout(() => {
22062206+ if (arrivedVariants >= expectedVariants && expectedVariants > 0) return;
22072207+ if (state !== 'GENERATING') return;
22082208+ showToast(
22092209+ "Variants ready. If the picked element isn't visible, retrace the path that revealed it — they'll appear automatically.",
22102210+ 15000,
22112211+ );
22122212+ }, 2000);
22132213+ break;
22142214+ case 'error':
22152215+ console.error('[impeccable] Error:', msg.message);
22162216+ showToast('Error: ' + msg.message, 5000);
22172217+ hideBar();
22182218+ state = 'PICKING';
22192219+ break;
22202220+ }
22212221+ };
22222222+22232223+ evtSource.onerror = () => {
22242224+ sseRetries++;
22252225+ if (sseRetries <= SSE_MAX_RETRIES) {
22262226+ console.log('[impeccable] SSE connection lost. Retry ' + sseRetries + '/' + SSE_MAX_RETRIES + '...');
22272227+ return; // EventSource auto-reconnects
22282228+ }
22292229+ // Server is gone. Clean up gracefully.
22302230+ console.log('[impeccable] Live server unreachable. Cleaning up UI.');
22312231+ evtSource.close();
22322232+ evtSource = null;
22332233+ handleServerLost();
22342234+ };
22352235+ }
22362236+22372237+ /** Server died or became unreachable. Reset UI to a clean state. */
22382238+ function handleServerLost() {
22392239+ if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
22402240+ showToast('Live server disconnected. Session ended.', 5000);
22412241+ }
22422242+ hideBar();
22432243+ hideHighlight();
22442244+ hideShaderOverlay();
22452245+ hideAnnotOverlay();
22462246+ stopScrollTracking();
22472247+ if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
22482248+ stopScrollLock();
22492249+ clearScrollY();
22502250+ clearSession();
22512251+ selectedElement = null;
22522252+ currentSessionId = null;
22532253+ selectedAction = 'impeccable';
22542254+ state = 'IDLE';
22552255+ }
22562256+22572257+ function sendEvent(msg) {
22582258+ msg.token = TOKEN;
22592259+ fetch('http://localhost:' + PORT + '/events', {
22602260+ method: 'POST',
22612261+ headers: { 'Content-Type': 'application/json' },
22622262+ body: JSON.stringify(msg),
22632263+ }).catch(err => console.error('[impeccable] Failed to send event:', err));
22642264+ }
22652265+22662266+ // ---------------------------------------------------------------------------
22672267+ // Event handlers
22682268+ // ---------------------------------------------------------------------------
22692269+22702270+ function handleMouseMove(e) {
22712271+ if (state !== 'PICKING' || !pickActive) return;
22722272+ const target = document.elementFromPoint(e.clientX, e.clientY);
22732273+ if (!target || !pickable(target) || target === hoveredElement) return;
22742274+ hoveredElement = target;
22752275+ showHighlight(target);
22762276+ }
22772277+22782278+ function handleClick(e) {
22792279+ // Close action picker on any outside click
22802280+ if (pickerEl?.style.display !== 'none' && !own(e.target)) {
22812281+ hideActionPicker();
22822282+ }
22832283+ // Close Tune popover on outside click (anything outside panel + bar)
22842284+ if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) {
22852285+ closeTunePopover();
22862286+ }
22872287+ // In CONFIGURING: click outside the bar and selected element returns to PICKING
22882288+ if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) {
22892289+ hideBar();
22902290+ stopScrollTracking();
22912291+ hideAnnotOverlay();
22922292+ clearAnnotations();
22932293+ state = 'PICKING';
22942294+ hoveredElement = null;
22952295+ hideHighlight();
22962296+ return;
22972297+ }
22982298+ if (state !== 'PICKING' || !pickActive) return;
22992299+ if (own(e.target)) return;
23002300+ if (!hoveredElement || !pickable(hoveredElement)) return;
23012301+ e.preventDefault();
23022302+ e.stopPropagation();
23032303+ selectedElement = hoveredElement;
23042304+ state = 'CONFIGURING';
23052305+ showHighlight(selectedElement);
23062306+ clearAnnotations();
23072307+ showAnnotOverlay(selectedElement);
23082308+ showBar('configure');
23092309+ startScrollTracking();
23102310+ maybePrefetchPage();
23112311+ maybeWarnConditionalAncestor(selectedElement);
23122312+ }
23132313+23142314+ /**
23152315+ * Surface a brief, non-blocking heads-up when the picked element lives
23162316+ * inside a container whose visibility is gated by ephemeral state — modals,
23172317+ * collapsible panels, popovers, off-screen tab panels. If HMR remounts the
23182318+ * parent during generation (Vite Fast Refresh, SvelteKit page reload), the
23192319+ * variants land in source but stay invisible until the user re-opens the
23202320+ * container. Telling the user upfront is much friendlier than the silent
23212321+ * timeout-then-toast that they'd otherwise hit.
23222322+ *
23232323+ * Heuristic, intentionally narrow — only fires for unambiguous cases so
23242324+ * we don't cry wolf on every nested element.
23252325+ */
23262326+ function maybeWarnConditionalAncestor(el) {
23272327+ let node = el?.parentElement;
23282328+ let depth = 0;
23292329+ while (node && depth < 12) {
23302330+ // 1. Active dialog / modal
23312331+ if (node.getAttribute && node.getAttribute('role') === 'dialog'
23322332+ && node.getAttribute('aria-modal') === 'true') {
23332333+ showToast('Heads up: this element lives inside a dialog. If state resets during generation, you may need to re-open it.', 6000);
23342334+ return;
23352335+ }
23362336+ // 2. Common Radix / shadcn / headless-ui open-state attribute
23372337+ if (node.dataset && node.dataset.state === 'open') {
23382338+ showToast('Heads up: this element lives inside an open panel. If state resets during generation, you may need to re-open it.', 6000);
23392339+ return;
23402340+ }
23412341+ // 3. Tab panel — only meaningful when the page also shows ANOTHER
23422342+ // tab as selected. A single tabpanel with no tablist is just a static
23432343+ // section in disguise and isn't conditional.
23442344+ if (node.getAttribute && node.getAttribute('role') === 'tabpanel') {
23452345+ const list = document.querySelector('[role="tablist"]');
23462346+ if (list) {
23472347+ const tabs = list.querySelectorAll('[role="tab"]');
23482348+ if (tabs.length > 1) {
23492349+ showToast('Heads up: this element lives in a tab panel. If state resets during generation, switch back to this tab.', 6000);
23502350+ return;
23512351+ }
23522352+ }
23532353+ }
23542354+ // 4. Collapsible: aria-expanded sibling. Look for the trigger button.
23552355+ if (node.id) {
23562356+ const trigger = document.querySelector(`[aria-controls="${CSS.escape(node.id)}"][aria-expanded="true"]`);
23572357+ if (trigger) {
23582358+ showToast('Heads up: this element lives inside an expandable section. If state resets during generation, re-expand it.', 6000);
23592359+ return;
23602360+ }
23612361+ }
23622362+ node = node.parentElement;
23632363+ depth++;
23642364+ }
23652365+ }
23662366+23672367+ // Fire a lightweight prefetch event the first time the user selects an
23682368+ // element on a given route. The agent uses this to Read the underlying file
23692369+ // into context before Go is hit, shaving the read off the critical path.
23702370+ // Dedupe per session by pathname — clicking around on the same page doesn't
23712371+ // re-fire.
23722372+ //
23732373+ // DISABLED: quick-Go workflows pay an extra harness round trip because
23742374+ // prefetch + generate arrive as two events instead of one. Re-enable with
23752375+ // a browser-side debounce (~800–1000ms, cancelled on Go) if we want to
23762376+ // resurrect this. Server validator and skill dispatch remain in place so
23772377+ // flipping this flag is the only change needed.
23782378+ const PREFETCH_ENABLED = false;
23792379+ const prefetchedPaths = new Set();
23802380+ function maybePrefetchPage() {
23812381+ if (!PREFETCH_ENABLED) return;
23822382+ const path = location.pathname;
23832383+ if (prefetchedPaths.has(path)) return;
23842384+ prefetchedPaths.add(path);
23852385+ sendEvent({ type: 'prefetch', pageUrl: path });
23862386+ }
23872387+23882388+ function handleKeyDown(e) {
23892389+ // When the annotation input is focused, let it handle its own keys.
23902390+ if (annotEditing && annotEditing.input && e.target === annotEditing.input) return;
23912391+ if (e.key === 'Escape') {
23922392+ e.preventDefault();
23932393+ if (pickerEl?.style.display !== 'none') { hideActionPicker(); return; }
23942394+ if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); state = 'PICKING'; return; }
23952395+ if (state === 'CYCLING') { handleDiscard(); return; }
23962396+ if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt
23972397+ if (state === 'PICKING') {
23982398+ // Use togglePick so the "Pick" button in the global bar also flips
23992399+ // off, otherwise the bar stays lit while nothing else is active.
24002400+ if (pickActive) togglePick();
24012401+ else { hideHighlight(); state = 'IDLE'; }
24022402+ return;
24032403+ }
24042404+ }
24052405+24062406+ // Arrow/Enter nav works in PICKING (hover) and CONFIGURING (selected, input empty)
24072407+ var navEl = (state === 'PICKING') ? hoveredElement : (state === 'CONFIGURING') ? selectedElement : null;
24082408+ if (navEl && (e.key === 'ArrowUp' || e.key === 'ArrowDown' || (e.key === 'Enter' && state === 'PICKING'))) {
24092409+ let next = null;
24102410+ if (e.key === 'ArrowDown' && !e.shiftKey) {
24112411+ next = navEl.nextElementSibling;
24122412+ while (next && !pickable(next)) next = next.nextElementSibling;
24132413+ } else if (e.key === 'ArrowUp' && !e.shiftKey) {
24142414+ next = navEl.previousElementSibling;
24152415+ while (next && !pickable(next)) next = next.previousElementSibling;
24162416+ } else if (e.key === 'ArrowUp' && e.shiftKey) {
24172417+ next = navEl.parentElement;
24182418+ if (next && !pickable(next)) next = null;
24192419+ } else if (e.key === 'ArrowDown' && e.shiftKey) {
24202420+ next = navEl.firstElementChild;
24212421+ while (next && !pickable(next)) next = next.nextElementSibling;
24222422+ } else if (e.key === 'Enter') {
24232423+ e.preventDefault();
24242424+ selectedElement = hoveredElement;
24252425+ state = 'CONFIGURING';
24262426+ showHighlight(selectedElement);
24272427+ clearAnnotations();
24282428+ showAnnotOverlay(selectedElement);
24292429+ showBar('configure');
24302430+ startScrollTracking();
24312431+ return;
24322432+ }
24332433+ if (next) {
24342434+ e.preventDefault();
24352435+ if (state === 'PICKING') {
24362436+ hoveredElement = next;
24372437+ } else {
24382438+ // CONFIGURING: re-select the new element and refresh the bar
24392439+ selectedElement = next;
24402440+ clearAnnotations();
24412441+ showAnnotOverlay(next);
24422442+ showBar('configure');
24432443+ startScrollTracking();
24442444+ }
24452445+ showHighlight(next);
24462446+ next.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
24472447+ }
24482448+ return;
24492449+ }
24502450+24512451+ if (state === 'CYCLING') {
24522452+ if (e.key === 'ArrowLeft') { e.preventDefault(); cycleVariant(-1); }
24532453+ if (e.key === 'ArrowRight') { e.preventDefault(); cycleVariant(1); }
24542454+ if (e.key === 'Enter') { e.preventDefault(); handleAccept(); }
24552455+ }
24562456+ }
24572457+24582458+ function handleGo() {
24592459+ if (!selectedElement || state !== 'CONFIGURING') return;
24602460+ const input = document.getElementById(PREFIX + '-input');
24612461+ const prompt = input ? input.value.trim() : '';
24622462+24632463+ // Commit any pending pin edit BEFORE we snapshot annotations.
24642464+ if (annotEditing) finalizeEditingPin();
24652465+24662466+ currentSessionId = id8();
24672467+ expectedVariants = selectedCount;
24682468+ arrivedVariants = 0;
24692469+ visibleVariant = 0;
24702470+24712471+ // Flip to GENERATING immediately so the bar morphs without waiting on
24722472+ // capture + upload. The event is emitted from captureAndEmit() once the
24732473+ // screenshot is uploaded (or capture fails — we still emit, just without
24742474+ // screenshotPath).
24752475+ const elForCapture = selectedElement;
24762476+ const captureRect = elForCapture.getBoundingClientRect();
24772477+ const snapshot = {
24782478+ comments: annotState.comments.map(c => ({ x: c.x, y: c.y, text: c.text })),
24792479+ strokes: annotState.strokes.map(s => ({ points: s.points.map(p => [p[0], p[1]]) })),
24802480+ };
24812481+ const basePayload = {
24822482+ type: 'generate', id: currentSessionId,
24832483+ action: selectedAction,
24842484+ freeformPrompt: prompt || undefined,
24852485+ count: selectedCount,
24862486+ pageUrl: location.pathname,
24872487+ element: extractContext(elForCapture),
24882488+ };
24892489+ if (snapshot.comments.length > 0) basePayload.comments = snapshot.comments;
24902490+ if (snapshot.strokes.length > 0) basePayload.strokes = snapshot.strokes;
24912491+24922492+ // Hide the interactive overlay so it doesn't linger during generation.
24932493+ hideAnnotOverlay();
24942494+ clearAnnotations();
24952495+24962496+ state = 'GENERATING';
24972497+ showBar('generating');
24982498+ saveSession();
24992499+ writeScrollY(window.scrollY);
25002500+ if (variantObserver) variantObserver.disconnect();
25012501+ variantObserver = startVariantObserver(currentSessionId);
25022502+ console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId });
25032503+ startScrollLock(currentSessionId);
25042504+25052505+ captureAndEmit(elForCapture, basePayload, snapshot, captureRect);
25062506+ }
25072507+25082508+ // ---------------------------------------------------------------------------
25092509+ // Screenshot capture + upload
25102510+ // ---------------------------------------------------------------------------
25112511+25122512+ let msLoadPromise = null;
25132513+ function loadModernScreenshot() {
25142514+ if (window.modernScreenshot) return Promise.resolve(window.modernScreenshot);
25152515+ if (msLoadPromise) return msLoadPromise;
25162516+ msLoadPromise = new Promise((resolve, reject) => {
25172517+ const s = document.createElement('script');
25182518+ s.src = 'http://localhost:' + PORT + '/modern-screenshot.js';
25192519+ s.onload = () => resolve(window.modernScreenshot);
25202520+ s.onerror = () => { msLoadPromise = null; reject(new Error('modern-screenshot failed to load')); };
25212521+ document.head.appendChild(s);
25222522+ });
25232523+ return msLoadPromise;
25242524+ }
25252525+25262526+ // Collect @font-face rules from every stylesheet on the page. Cross-origin
25272527+ // sheets (Google Fonts, Typekit, etc.) throw SecurityError on .cssRules
25282528+ // access, so modern-screenshot can't embed them on its own — the resulting
25292529+ // SVG falls back to system fonts and text re-wraps + renders with different
25302530+ // weight. We fetch the raw CSS text (CORS-permitted for these providers),
25312531+ // extract @font-face blocks, inline the referenced font files as base64
25322532+ // data URIs (SVGs rasterized via canvas can't fetch external resources,
25332533+ // so URLs inside the SVG silently fail without this), and pass the result
25342534+ // to modern-screenshot as font.cssText.
25352535+ const FONT_EXT_RE = /\.(woff2?|ttf|otf|eot)(\?.*)?$/i;
25362536+ const FONT_MIME = {
25372537+ woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf', otf: 'font/otf', eot: 'application/vnd.ms-fontobject',
25382538+ };
25392539+ function bufferToBase64(buf) {
25402540+ const bytes = new Uint8Array(buf);
25412541+ let binary = '';
25422542+ const CHUNK = 0x8000;
25432543+ for (let i = 0; i < bytes.length; i += CHUNK) {
25442544+ binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK));
25452545+ }
25462546+ return btoa(binary);
25472547+ }
25482548+ async function inlineFontUrls(cssText) {
25492549+ const urlRe = /url\((['"]?)(https?:\/\/[^'")\s]+)\1\)/g;
25502550+ const urls = new Set();
25512551+ let m;
25522552+ while ((m = urlRe.exec(cssText))) {
25532553+ if (FONT_EXT_RE.test(m[2])) urls.add(m[2]);
25542554+ }
25552555+ const map = new Map();
25562556+ await Promise.all([...urls].map(async (url) => {
25572557+ try {
25582558+ const res = await fetch(url);
25592559+ if (!res.ok) return;
25602560+ const buf = await res.arrayBuffer();
25612561+ const ext = url.toLowerCase().match(FONT_EXT_RE)?.[1] || 'woff2';
25622562+ const mime = FONT_MIME[ext] || 'application/octet-stream';
25632563+ map.set(url, 'data:' + mime + ';base64,' + bufferToBase64(buf));
25642564+ } catch { /* skip; fall through to URL */ }
25652565+ }));
25662566+ return cssText.replace(urlRe, (orig, q, url) => {
25672567+ const data = map.get(url);
25682568+ return data ? 'url(' + q + data + q + ')' : orig;
25692569+ });
25702570+ }
25712571+ async function collectFontCssText() {
25722572+ const chunks = [];
25732573+ const fontFaceRe = /@font-face\s*\{[^}]*\}/g;
25742574+ for (const sheet of document.styleSheets) {
25752575+ try {
25762576+ const rules = sheet.cssRules;
25772577+ for (const rule of rules) {
25782578+ if (rule.constructor.name === 'CSSFontFaceRule' || rule.cssText?.startsWith('@font-face')) {
25792579+ chunks.push(rule.cssText);
25802580+ }
25812581+ }
25822582+ } catch {
25832583+ if (!sheet.href) continue;
25842584+ try {
25852585+ const res = await fetch(sheet.href);
25862586+ if (!res.ok) continue;
25872587+ const text = await res.text();
25882588+ let m2;
25892589+ while ((m2 = fontFaceRe.exec(text))) chunks.push(m2[0]);
25902590+ } catch { /* ignore; capture is best-effort */ }
25912591+ }
25922592+ }
25932593+ if (chunks.length === 0) return '';
25942594+ return inlineFontUrls(chunks.join('\n'));
25952595+ }
25962596+25972597+ // True if `s` is a computed color string that renders as nothing
25982598+ // (explicit `transparent`, or `rgba(...)` with alpha 0).
25992599+ function isTransparentColor(s) {
26002600+ if (!s) return true;
26012601+ if (s === 'transparent') return true;
26022602+ const m = /rgba?\(([^)]+)\)/.exec(s);
26032603+ if (!m) return false;
26042604+ const parts = m[1].split(',').map((p) => p.trim());
26052605+ if (parts.length === 4) return parseFloat(parts[3]) === 0;
26062606+ return false;
26072607+ }
26082608+26092609+ // modern-screenshot force-sets `background-color: X !important` on the
26102610+ // cloned root whenever `backgroundColor` is passed, clobbering the
26112611+ // element's own background. So we only pass it when the element is
26122612+ // genuinely transparent (no own color, no own image) — in that case
26132613+ // we resolve up the DOM to the nearest opaque ancestor so the capture
26142614+ // sits on the page's real background instead of rendering black.
26152615+ function resolveCanvasBackground(el) {
26162616+ const own = getComputedStyle(el);
26172617+ if (!isTransparentColor(own.backgroundColor)) return null;
26182618+ if (own.backgroundImage && own.backgroundImage !== 'none') return null;
26192619+ let node = el.parentElement;
26202620+ while (node) {
26212621+ const cs = getComputedStyle(node);
26222622+ if (!isTransparentColor(cs.backgroundColor)) return cs.backgroundColor;
26232623+ node = node.parentElement;
26242624+ }
26252625+ return (
26262626+ getComputedStyle(document.body).backgroundColor ||
26272627+ getComputedStyle(document.documentElement).backgroundColor ||
26282628+ '#ffffff'
26292629+ );
26302630+ }
26312631+26322632+ // Capture the element (with current annotations baked in) and return a PNG
26332633+ // Blob. Shared between the Go flow (uploads it to the server) and the
26342634+ // debug toggle (displays it as an overlay for side-by-side comparison).
26352635+ async function captureElementToBlob(el, snapshot, rect) {
26362636+ try { if (document.fonts?.ready) await document.fonts.ready; } catch {}
26372637+ const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0);
26382638+ let annotNode = null;
26392639+ let savedPosition = null;
26402640+ if (hasAnnotations) {
26412641+ const pos = getComputedStyle(el).position;
26422642+ if (pos === 'static') {
26432643+ savedPosition = el.style.position;
26442644+ el.style.position = 'relative';
26452645+ }
26462646+ annotNode = buildAnnotationsForCapture(rect, snapshot);
26472647+ el.appendChild(annotNode);
26482648+ }
26492649+ try {
26502650+ const ms = await loadModernScreenshot();
26512651+ const fontCssText = await collectFontCssText();
26522652+ const backgroundColor = resolveCanvasBackground(el);
26532653+ return await ms.domToBlob(el, {
26542654+ scale: Math.min(window.devicePixelRatio || 1, 2),
26552655+ font: fontCssText ? { cssText: fontCssText } : undefined,
26562656+ ...(backgroundColor ? { backgroundColor } : {}),
26572657+ });
26582658+ } finally {
26592659+ if (annotNode) annotNode.remove();
26602660+ if (savedPosition !== null) el.style.position = savedPosition;
26612661+ }
26622662+ }
26632663+26642664+ async function captureAndEmit(el, basePayload, snapshot, rect) {
26652665+ let screenshotPath;
26662666+ let blob;
26672667+ try {
26682668+ blob = await captureElementToBlob(el, snapshot, rect);
26692669+ } catch (err) {
26702670+ console.warn('[impeccable] capture failed, proceeding without screenshot:', err);
26712671+ }
26722672+ // Light up the shader overlay the moment capture is ready — no reason to
26732673+ // wait for the upload to complete before the user sees something alive.
26742674+ if (blob && state === 'GENERATING') {
26752675+ showShaderOverlay(el, blob, rect);
26762676+ }
26772677+ // Only upload + forward the screenshot when annotations (comments/strokes)
26782678+ // are present. Without annotations the image is pure visual anchoring —
26792679+ // it biases the model toward the current rendering and works against the
26802680+ // three-distinct-directions brief.
26812681+ const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0);
26822682+ if (blob && hasAnnotations) {
26832683+ try {
26842684+ const uploadRes = await fetch(
26852685+ 'http://localhost:' + PORT + '/annotation?token=' + encodeURIComponent(TOKEN) +
26862686+ '&eventId=' + encodeURIComponent(basePayload.id),
26872687+ { method: 'POST', headers: { 'Content-Type': 'image/png' }, body: blob },
26882688+ );
26892689+ if (uploadRes.ok) {
26902690+ const { path: p } = await uploadRes.json();
26912691+ screenshotPath = p;
26922692+ } else {
26932693+ console.warn('[impeccable] annotation upload failed:', uploadRes.status);
26942694+ }
26952695+ } catch (err) {
26962696+ console.warn('[impeccable] annotation upload failed:', err);
26972697+ }
26982698+ }
26992699+ sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload);
27002700+ }
27012701+27022702+ // ---------------------------------------------------------------------------
27032703+ // Shader overlay — renders the captured screenshot as a WebGL texture and
27042704+ // runs an editorial "ink-wash" fragment shader over it during generation.
27052705+ // A single rolling band sweeps top-to-bottom, desaturating + tinting magenta
27062706+ // and leaving a soft trail. Makes the wait feel like a letterpress scan
27072707+ // instead of a dead spinner.
27082708+ // ---------------------------------------------------------------------------
27092709+27102710+ const SHADER_VS = `attribute vec2 a_position;
27112711+attribute vec2 a_uv;
27122712+varying vec2 v_uv;
27132713+void main() {
27142714+ v_uv = a_uv;
27152715+ gl_Position = vec4(a_position, 0.0, 1.0);
27162716+}`;
27172717+27182718+ const SHADER_FS = `precision highp float;
27192719+uniform sampler2D u_texture;
27202720+uniform float u_time;
27212721+uniform vec2 u_resolution;
27222722+uniform vec3 u_accent;
27232723+varying vec2 v_uv;
27242724+27252725+// Asymmetric roller band. Product of two one-sided smoothsteps — peaks at
27262726+// d=0 with a short sharp leading ramp and a longer soft trailing tail. Clean
27272727+// outside the [-leadW, trailW] range (no rogue "trail=1 everywhere below"
27282728+// failure that reversed-edge smoothstep would give).
27292729+float bandAt(float d, float leadW, float trailW) {
27302730+ float above = smoothstep(-leadW, 0.0, d);
27312731+ float below = 1.0 - smoothstep(0.0, trailW, d);
27322732+ return above * below;
27332733+}
27342734+27352735+void main() {
27362736+ vec2 uv = v_uv;
27372737+ // Roller sweeps top-to-bottom with small overshoot so each cycle enters
27382738+ // and exits the element cleanly.
27392739+ float phase = fract(u_time / 3.4);
27402740+ float y = phase * 1.25 - 0.12;
27412741+ float band = bandAt(uv.y - y, 0.05, 0.32);
27422742+27432743+ // Halftone cell grid (fixed ~10 px pitch).
27442744+ float cellPx = 10.0;
27452745+ vec2 gridUv = uv * u_resolution / cellPx;
27462746+ vec2 cellId = floor(gridUv);
27472747+ vec2 cellUv = fract(gridUv) - 0.5;
27482748+ vec2 sampleCenter = (cellId + 0.5) * cellPx / u_resolution;
27492749+ vec3 cellImg = texture2D(u_texture, sampleCenter).rgb;
27502750+ float luma = dot(cellImg, vec3(0.299, 0.587, 0.114));
27512751+ // Darker cells → bigger magenta dots (classic risograph halftone curve).
27522752+ float radius = sqrt(clamp(1.0 - luma, 0.0, 1.0)) * 0.56;
27532753+ float dotMask = smoothstep(radius + 0.06, radius, length(cellUv));
27542754+ vec3 paper = vec3(0.975, 0.965, 0.955);
27552755+ vec3 dotLayer = mix(paper, u_accent, dotMask);
27562756+27572757+ // Blend the halftone layer in where the roller is passing; leave the
27582758+ // element pristine elsewhere.
27592759+ vec3 base = texture2D(u_texture, uv).rgb;
27602760+ gl_FragColor = vec4(mix(base, dotLayer, band), 1.0);
27612761+}`;
27622762+27632763+ // Editorial Magenta converted to approximate sRGB 0-1 (matches oklch(60% 0.25 350))
27642764+ const SHADER_ACCENT = [0.82, 0.16, 0.47];
27652765+ let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
27662766+27672767+ function compileShader(gl, type, source) {
27682768+ const sh = gl.createShader(type);
27692769+ gl.shaderSource(sh, source);
27702770+ gl.compileShader(sh);
27712771+ if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
27722772+ const info = gl.getShaderInfoLog(sh);
27732773+ gl.deleteShader(sh);
27742774+ throw new Error('shader compile failed: ' + info);
27752775+ }
27762776+ return sh;
27772777+ }
27782778+27792779+ function positionShaderOverlay() {
27802780+ if (!shaderState || !selectedElement) return;
27812781+ const r = selectedElement.getBoundingClientRect();
27822782+ Object.assign(shaderState.canvas.style, {
27832783+ top: r.top + 'px', left: r.left + 'px',
27842784+ width: r.width + 'px', height: r.height + 'px',
27852785+ });
27862786+ }
27872787+27882788+ function hideShaderOverlay() {
27892789+ if (!shaderState) return;
27902790+ if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
27912791+ if (shaderState.canvas) shaderState.canvas.remove();
27922792+ const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
27932793+ try { lose?.loseContext(); } catch {}
27942794+ shaderState = null;
27952795+ }
27962796+27972797+ async function showShaderOverlay(el, blob, rect) {
27982798+ hideShaderOverlay();
27992799+ if (!blob || !el) return;
28002800+ const canvas = document.createElement('canvas');
28012801+ canvas.id = PREFIX + '-shader';
28022802+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
28032803+ canvas.width = Math.max(1, Math.floor(rect.width * dpr));
28042804+ canvas.height = Math.max(1, Math.floor(rect.height * dpr));
28052805+ Object.assign(canvas.style, {
28062806+ position: 'fixed',
28072807+ top: rect.top + 'px', left: rect.left + 'px',
28082808+ width: rect.width + 'px', height: rect.height + 'px',
28092809+ pointerEvents: 'none',
28102810+ zIndex: Z.bar - 1,
28112811+ });
28122812+ document.body.appendChild(canvas);
28132813+28142814+ const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false })
28152815+ || canvas.getContext('experimental-webgl');
28162816+ if (!gl) {
28172817+ // WebGL unavailable — fall back to a plain <img> overlay so the user
28182818+ // still sees something meaningful during generation.
28192819+ canvas.remove();
28202820+ const img = document.createElement('img');
28212821+ img.src = URL.createObjectURL(blob);
28222822+ img.id = PREFIX + '-shader';
28232823+ // Copy positioning via cssText. Object.assign across CSSStyleDeclaration
28242824+ // throws in modern Chromium because the source's indexed properties
28252825+ // (style[0], [1], ...) are read-only and the engine forbids writing
28262826+ // them on the destination.
28272827+ img.style.cssText = canvas.style.cssText;
28282828+ img.style.outline = '2px dashed ' + C.brand;
28292829+ img.style.outlineOffset = '-2px';
28302830+ document.body.appendChild(img);
28312831+ shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 };
28322832+ return;
28332833+ }
28342834+28352835+ let program, texture;
28362836+ try {
28372837+ const vs = compileShader(gl, gl.VERTEX_SHADER, SHADER_VS);
28382838+ const fs = compileShader(gl, gl.FRAGMENT_SHADER, SHADER_FS);
28392839+ program = gl.createProgram();
28402840+ gl.attachShader(program, vs);
28412841+ gl.attachShader(program, fs);
28422842+ gl.linkProgram(program);
28432843+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
28442844+ throw new Error('program link failed: ' + gl.getProgramInfoLog(program));
28452845+ }
28462846+ // Full-screen quad
28472847+ const buf = gl.createBuffer();
28482848+ gl.bindBuffer(gl.ARRAY_BUFFER, buf);
28492849+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
28502850+ -1, -1, 0, 1,
28512851+ 1, -1, 1, 1,
28522852+ -1, 1, 0, 0,
28532853+ -1, 1, 0, 0,
28542854+ 1, -1, 1, 1,
28552855+ 1, 1, 1, 0,
28562856+ ]), gl.STATIC_DRAW);
28572857+ const posLoc = gl.getAttribLocation(program, 'a_position');
28582858+ const uvLoc = gl.getAttribLocation(program, 'a_uv');
28592859+ gl.enableVertexAttribArray(posLoc);
28602860+ gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 16, 0);
28612861+ gl.enableVertexAttribArray(uvLoc);
28622862+ gl.vertexAttribPointer(uvLoc, 2, gl.FLOAT, false, 16, 8);
28632863+ } catch (err) {
28642864+ console.warn('[impeccable] shader setup failed:', err);
28652865+ canvas.remove();
28662866+ return;
28672867+ }
28682868+28692869+ // Upload the screenshot as a texture
28702870+ let bitmap;
28712871+ try {
28722872+ bitmap = await createImageBitmap(blob);
28732873+ } catch {
28742874+ // Safari fallback: go via a regular Image
28752875+ const imgUrl = URL.createObjectURL(blob);
28762876+ const img = new Image();
28772877+ img.src = imgUrl;
28782878+ await new Promise((r, rej) => { img.onload = r; img.onerror = rej; });
28792879+ bitmap = img;
28802880+ URL.revokeObjectURL(imgUrl);
28812881+ }
28822882+ texture = gl.createTexture();
28832883+ gl.bindTexture(gl.TEXTURE_2D, texture);
28842884+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
28852885+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
28862886+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
28872887+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
28882888+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
28892889+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bitmap);
28902890+ if (bitmap.close) bitmap.close();
28912891+28922892+ const uTime = gl.getUniformLocation(program, 'u_time');
28932893+ const uRes = gl.getUniformLocation(program, 'u_resolution');
28942894+ const uAccent = gl.getUniformLocation(program, 'u_accent');
28952895+ const uTex = gl.getUniformLocation(program, 'u_texture');
28962896+ const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
28972897+28982898+ shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
28992899+ function frame() {
29002900+ if (!shaderState) return;
29012901+ const elapsed = (performance.now() - shaderState.startTime) / 1000;
29022902+ const t = shaderState.reduced ? 0.0 : elapsed;
29032903+ gl.viewport(0, 0, canvas.width, canvas.height);
29042904+ gl.useProgram(program);
29052905+ gl.activeTexture(gl.TEXTURE0);
29062906+ gl.bindTexture(gl.TEXTURE_2D, texture);
29072907+ gl.uniform1i(uTex, 0);
29082908+ gl.uniform1f(uTime, t);
29092909+ gl.uniform2f(uRes, canvas.width, canvas.height);
29102910+ gl.uniform3f(uAccent, SHADER_ACCENT[0], SHADER_ACCENT[1], SHADER_ACCENT[2]);
29112911+ gl.drawArrays(gl.TRIANGLES, 0, 6);
29122912+ shaderState.rafId = requestAnimationFrame(frame);
29132913+ }
29142914+ frame();
29152915+ }
29162916+29172917+ function handleAccept() {
29182918+ if (!currentSessionId || arrivedVariants === 0) return;
29192919+ const acceptPayload = { type: 'accept', id: currentSessionId, variantId: String(visibleVariant) };
29202920+ if (Object.keys(paramsCurrentValues).length > 0) {
29212921+ acceptPayload.paramValues = { ...paramsCurrentValues };
29222922+ }
29232923+ sendEvent(acceptPayload);
29242924+ markSessionHandled();
29252925+29262926+ // The accepted variant is already the only visible child of the wrapper
29272927+ // (all other variants are display:none). HMR from the source rewrite will
29282928+ // replace the wrapper imminently. Don't eagerly replaceChild here — React
29292929+ // reconciliation races with our mutation and throws NotFoundError in Next
29302930+ // 16 / Turbopack. Schedule a fallback that runs the manual swap only if
29312931+ // HMR hasn't cleaned up by then (keeps static-server flows working).
29322932+ const acceptedSessionId = currentSessionId;
29332933+ const acceptedVariant = visibleVariant;
29342934+29352935+ state = 'CONFIRMED';
29362936+ updateBarContent('confirmed');
29372937+ setTimeout(function() {
29382938+ hideBar();
29392939+ hideHighlight();
29402940+ stopScrollTracking();
29412941+ if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
29422942+ stopScrollLock();
29432943+ clearScrollY();
29442944+ clearSession();
29452945+ selectedElement = null;
29462946+ currentSessionId = null;
29472947+ selectedAction = 'impeccable';
29482948+ state = 'PICKING';
29492949+ }, 1800);
29502950+29512951+ // Static-server / no-HMR fallback: if the wrapper is still around 2s after
29522952+ // the cleanup above, swap it out manually. By now React has either moved
29532953+ // on or the app isn't React at all. Preserve the `data-impeccable-variant="N"`
29542954+ // div (with display:contents) so @scope rules anchored to the variant
29552955+ // attribute keep matching until reload replaces it with the carbonize block.
29562956+ setTimeout(function() {
29572957+ const wrapper = document.querySelector('[data-impeccable-variants="' + acceptedSessionId + '"]');
29582958+ if (!wrapper) return;
29592959+ const accepted = wrapper.querySelector('[data-impeccable-variant="' + acceptedVariant + '"]');
29602960+ if (accepted && accepted.firstElementChild) {
29612961+ const parent = wrapper.parentElement;
29622962+ if (!parent) return;
29632963+ accepted.style.display = 'contents';
29642964+ parent.replaceChild(accepted, wrapper);
29652965+ }
29662966+ }, 2000);
29672967+ }
29682968+29692969+ function handleDiscard() {
29702970+ if (!currentSessionId) return;
29712971+ sendEvent({ type: 'discard', id: currentSessionId });
29722972+ markSessionHandled();
29732973+ // Instant DOM restore + fire-and-forget (script handles file cleanup)
29742974+ cleanup();
29752975+ }
29762976+29772977+ // ---------------------------------------------------------------------------
29782978+ // Session persistence via localStorage
29792979+ // ---------------------------------------------------------------------------
29802980+ // Survives page reloads, browser close/reopen, HMR, and accidental refreshes.
29812981+29822982+ const LS_KEY = PREFIX + '-session';
29832983+29842984+ function saveSession() {
29852985+ if (!currentSessionId) return;
29862986+ // NOTE: scrollY is stored under a separate key (writeScrollY). Storing
29872987+ // it here would overwrite the Go-time value every time state changes.
29882988+ try {
29892989+ localStorage.setItem(LS_KEY, JSON.stringify({
29902990+ id: currentSessionId,
29912991+ state: state,
29922992+ action: selectedAction,
29932993+ count: selectedCount,
29942994+ expected: expectedVariants,
29952995+ arrived: arrivedVariants,
29962996+ visible: visibleVariant,
29972997+ }));
29982998+ } catch { /* quota exceeded or private mode */ }
29992999+ }
30003000+30013001+ function loadSession() {
30023002+ try {
30033003+ const raw = localStorage.getItem(LS_KEY);
30043004+ return raw ? JSON.parse(raw) : null;
30053005+ } catch { return null; }
30063006+ }
30073007+30083008+ function clearSession() {
30093009+ try { localStorage.removeItem(LS_KEY); } catch {}
30103010+ }
30113011+30123012+ /** Mark session as handled (accepted/discarded). The agent will clean up
30133013+ * the source, but until it does the wrapper is still in the HTML. This
30143014+ * prevents resumeSession from picking it up again after reload. */
30153015+ function markSessionHandled() {
30163016+ if (!currentSessionId) return;
30173017+ try {
30183018+ localStorage.setItem(LS_KEY + '-handled', currentSessionId);
30193019+ } catch {}
30203020+ }
30213021+30223022+ function isSessionHandled(id) {
30233023+ try {
30243024+ return localStorage.getItem(LS_KEY + '-handled') === id;
30253025+ } catch { return false; }
30263026+ }
30273027+30283028+ function clearHandled() {
30293029+ try { localStorage.removeItem(LS_KEY + '-handled'); } catch {}
30303030+ }
30313031+30323032+ function cleanup() {
30333033+ // Hide the wrapper immediately so variants disappear. DON'T structurally
30343034+ // mutate the DOM yet — HMR from the agent's source rewrite is on its way,
30353035+ // and a manual replaceChild under React causes NotFoundError when the
30363036+ // reconciler later tries to remove a wrapper we already removed.
30373037+ // Schedule a 2s fallback that does the manual swap only if HMR hasn't
30383038+ // replaced the wrapper by then (keeps static-server / no-HMR flows alive).
30393039+ const cleanupSessionId = currentSessionId;
30403040+ if (cleanupSessionId) {
30413041+ const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
30423042+ if (wrapper) wrapper.style.display = 'none';
30433043+ }
30443044+ setTimeout(function() {
30453045+ if (!cleanupSessionId) return;
30463046+ const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
30473047+ if (!wrapper) return;
30483048+ const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
30493049+ if (orig) {
30503050+ const content = orig.firstElementChild;
30513051+ if (content) {
30523052+ wrapper.parentElement.replaceChild(content, wrapper);
30533053+ return;
30543054+ }
30553055+ }
30563056+ wrapper.remove();
30573057+ }, 2000);
30583058+ hideBar();
30593059+ hideHighlight();
30603060+ stopScrollTracking();
30613061+ if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
30623062+ stopScrollLock();
30633063+ clearScrollY();
30643064+ clearSession();
30653065+ selectedElement = null;
30663066+ currentSessionId = null;
30673067+ selectedAction = 'impeccable';
30683068+ state = 'PICKING';
30693069+ }
30703070+30713071+ // ---------------------------------------------------------------------------
30723072+ // Toast
30733073+ // ---------------------------------------------------------------------------
30743074+30753075+ function showToast(message, duration) {
30763076+ if (toastEl) toastEl.remove();
30773077+ // Stack the toast above the global bar (which sits at bottom:14px) so
30783078+ // the two never overlap. Read the bar's actual rect — its height varies
30793079+ // with hover-expanded labels — and fall back to a sensible default
30803080+ // when the bar isn't mounted yet.
30813081+ const barRect = globalBarEl?.getBoundingClientRect();
30823082+ const barTopFromBottom = barRect && barRect.height > 0
30833083+ ? Math.max(16, window.innerHeight - barRect.top + 12)
30843084+ : 16;
30853085+ toastEl = el('div', {
30863086+ position: 'fixed', bottom: barTopFromBottom + 'px', left: '50%',
30873087+ transform: 'translateX(-50%) translateY(8px)',
30883088+ background: C.ink, color: C.white,
30893089+ fontFamily: FONT, fontSize: '12px',
30903090+ padding: '8px 16px', borderRadius: '8px',
30913091+ zIndex: Z.toast, opacity: '0',
30923092+ transition: 'opacity 0.25s ' + EASE + ', transform 0.25s ' + EASE,
30933093+ pointerEvents: 'none', maxWidth: '420px', textAlign: 'center',
30943094+ });
30953095+ toastEl.id = PREFIX + '-toast';
30963096+ toastEl.textContent = message;
30973097+ document.body.appendChild(toastEl);
30983098+ requestAnimationFrame(() => {
30993099+ toastEl.style.opacity = '1';
31003100+ toastEl.style.transform = 'translateX(-50%) translateY(0)';
31013101+ });
31023102+ setTimeout(() => {
31033103+ if (toastEl) {
31043104+ toastEl.style.opacity = '0';
31053105+ toastEl.style.transform = 'translateX(-50%) translateY(8px)';
31063106+ setTimeout(() => { if (toastEl) { toastEl.remove(); toastEl = null; } }, 250);
31073107+ }
31083108+ }, duration);
31093109+ }
31103110+31113111+ // ---------------------------------------------------------------------------
31123112+ // Init
31133113+ // ---------------------------------------------------------------------------
31143114+31153115+ // Resume an active variant session after HMR/page reload.
31163116+ // If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote
31173117+ // variants before HMR fired. Pick up where we left off.
31183118+ function resumeSession() {
31193119+ const wrapper = document.querySelector('[data-impeccable-variants]');
31203120+ if (!wrapper) { clearSession(); clearHandled(); return false; }
31213121+31223122+ const sessionId = wrapper.dataset.impeccableVariants;
31233123+31243124+ // Don't resume if this session was already accepted/discarded
31253125+ if (isSessionHandled(sessionId)) return false;
31263126+31273127+ currentSessionId = sessionId;
31283128+ expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || '0');
31293129+ const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
31303130+ arrivedVariants = variants.length;
31313131+31323132+ // Restore state from localStorage if available
31333133+ const saved = loadSession();
31343134+ if (saved && saved.id === sessionId) {
31353135+ visibleVariant = (saved.visible > 0 && saved.visible <= arrivedVariants) ? saved.visible : (arrivedVariants > 0 ? 1 : 0);
31363136+ if (saved.action) selectedAction = saved.action;
31373137+ if (saved.count) selectedCount = saved.count;
31383138+ } else {
31393139+ visibleVariant = arrivedVariants > 0 ? 1 : 0;
31403140+ }
31413141+31423142+ // Find the visible variant's content element for highlight positioning.
31433143+ // Try the visible variant first, fall back to the original's content.
31443144+ const visEl = visibleVariant > 0 ? pickVariantContent(wrapper, visibleVariant) : null;
31453145+ const origEl = pickVariantContent(wrapper, 'original');
31463146+ selectedElement = visEl || origEl || wrapper.parentElement;
31473147+31483148+ // Set display state BEFORE starting observer (avoid triggering it)
31493149+ if (visibleVariant > 0) showVariantInDOM(currentSessionId, visibleVariant);
31503150+31513151+ state = arrivedVariants >= expectedVariants ? 'CYCLING' : 'GENERATING';
31523152+ showBar(state === 'CYCLING' ? 'cycling' : 'generating');
31533153+ startScrollTracking();
31543154+ // Build the params panel for the restored visible variant. Previously
31553155+ // this was missed on page-reload resume: showVariantInDOM above fires
31563156+ // refreshParamsPanel, but state was still IDLE at that moment so it
31573157+ // hid. Now that state is CYCLING, re-fire.
31583158+ if (state === 'CYCLING') refreshParamsPanel();
31593159+ saveSession();
31603160+31613161+ // Start observing for more variants AFTER initial setup
31623162+ if (variantObserver) variantObserver.disconnect();
31633163+ variantObserver = startVariantObserver(currentSessionId);
31643164+31653165+ // Hold the target at its saved viewport top through any subsequent
31663166+ // HMR patches, variant inserts, or cycle swaps.
31673167+ startScrollLock(currentSessionId, readScrollY());
31683168+31693169+ // If we reloaded mid-generation (Bun's HTML HMR destroys the shader
31703170+ // canvas), re-capture the original's content and restart the shader so
31713171+ // the wait doesn't go dead.
31723172+ if (state === 'GENERATING' && origEl) {
31733173+ (async () => {
31743174+ try {
31753175+ const rect = origEl.getBoundingClientRect();
31763176+ if (rect.width === 0 || rect.height === 0) return;
31773177+ const blob = await captureElementToBlob(origEl, null, rect);
31783178+ if (blob && state === 'GENERATING') {
31793179+ showShaderOverlay(origEl, blob, rect);
31803180+ }
31813181+ } catch (err) {
31823182+ console.warn('[impeccable] shader resume failed:', err);
31833183+ }
31843184+ })();
31853185+ }
31863186+ return true;
31873187+ }
31883188+31893189+ // ---------------------------------------------------------------------------
31903190+ // Global bar (always visible at bottom)
31913191+ // ---------------------------------------------------------------------------
31923192+31933193+ let globalBarEl = null;
31943194+ let detectActive = false;
31953195+ let pickActive = true;
31963196+ let detectCount = 0;
31973197+ let detectScriptLoaded = false;
31983198+31993199+ // Theme-aware color palette for the global bar. We detect the page's
32003200+ // ambient background and invert — dark bar on light pages, light bar on
32013201+ // dark pages. This keeps the bar from fighting with the host design.
32023202+ function detectPageTheme() {
32033203+ try {
32043204+ // Dev override: set localStorage 'impeccable-dev-theme' to 'light' or
32053205+ // 'dark' to preview the opposite palette without actually changing the
32063206+ // page bg. Used for screenshots and theme QA.
32073207+ const override = localStorage.getItem('impeccable-dev-theme');
32083208+ if (override === 'light' || override === 'dark') return override;
32093209+32103210+ // Walk body → html, taking the first opaque background. The browser's
32113211+ // default body / html background is `rgba(0, 0, 0, 0)`, which a naive
32123212+ // regex would read as black and mislabel a perfectly white page as
32133213+ // dark. Honoring alpha avoids that — and falling through to <html>
32143214+ // catches the common pattern of a bg only on <html> (or only on body).
32153215+ function readOpaque(el) {
32163216+ if (!el) return null;
32173217+ const bg = getComputedStyle(el).backgroundColor;
32183218+ const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)/);
32193219+ if (!m) return null;
32203220+ const alpha = m[4] == null ? 1 : parseFloat(m[4]);
32213221+ if (alpha < 0.5) return null; // transparent / nearly transparent → skip
32223222+ return [+m[1], +m[2], +m[3]];
32233223+ }
32243224+32253225+ const rgb = readOpaque(document.body) || readOpaque(document.documentElement);
32263226+ // Both transparent → fall back to the browser's effective canvas color.
32273227+ // White is the universal default; only one in a thousand sites swaps it
32283228+ // via `color-scheme: dark` on <html>, and `prefers-color-scheme` lets
32293229+ // us catch that case.
32303230+ if (!rgb) {
32313231+ return matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
32323232+ }
32333233+ const [r, g, b] = rgb;
32343234+ // Perceptual luminance (Rec. 709)
32353235+ const L = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
32363236+ return L > 0.55 ? 'light' : 'dark';
32373237+ } catch { return 'light'; }
32383238+ }
32393239+32403240+ function barPaletteForTheme(theme) {
32413241+ if (theme === 'dark') {
32423242+ // Light bar on dark page
32433243+ return {
32443244+ surface: 'oklch(98% 0 0 / 0.92)',
32453245+ surfaceDeep: 'oklch(92% 0.005 60 / 0.96)', // slightly deeper, faint warm
32463246+ hairline: 'oklch(70% 0 0 / 0.35)',
32473247+ text: 'oklch(15% 0 0)',
32483248+ textDim: 'oklch(45% 0 0)',
32493249+ accent: 'oklch(60% 0.25 350)',
32503250+ accentSoft: 'oklch(60% 0.25 350 / 0.18)',
32513251+ mark: 'oklch(98% 0 0)', // logo mark fill
32523252+ markText: 'oklch(15% 0 0)', // logo "/" color
32533253+ exitHover: 'oklch(85% 0 0 / 0.5)',
32543254+ };
32553255+ }
32563256+ // Dark bar on light page. Bar is a warm charcoal, logo slab is much
32573257+ // deeper so the rounded-right shape reads as a clear sculpted mark.
32583258+ return {
32593259+ surface: 'oklch(26% 0 0 / 0.94)',
32603260+ surfaceDeep: 'oklch(18% 0 0 / 0.96)', // darker sand for Tune popover
32613261+ hairline: 'oklch(42% 0 0 / 0.5)',
32623262+ text: 'oklch(96% 0 0)',
32633263+ textDim: 'oklch(72% 0 0)',
32643264+ accent: 'oklch(72% 0.22 350)',
32653265+ accentSoft: 'oklch(72% 0.22 350 / 0.22)',
32663266+ mark: 'oklch(8% 0 0)',
32673267+ markText: 'oklch(96% 0 0)',
32683268+ exitHover: 'oklch(36% 0 0 / 0.6)',
32693269+ };
32703270+ }
32713271+32723272+ // Impeccable logo mark — matches the site-header SVG (rounded square + "/").
32733273+ function brandMarkSvg(fill, ink, size = 18) {
32743274+ return `<svg width="${size}" height="${size}" viewBox="0 0 32 32" aria-hidden="true">
32753275+ <rect width="32" height="32" rx="7" fill="${fill}"/>
32763276+ <text x="16" y="24" font-family="system-ui, -apple-system, sans-serif" font-size="22" font-weight="500" fill="${ink}" text-anchor="middle">/</text>
32773277+ </svg>`;
32783278+ }
32793279+32803280+ function initGlobalBar() {
32813281+ const theme = detectPageTheme();
32823282+ const P = barPaletteForTheme(theme);
32833283+32843284+ // Custom focus-visible for bar buttons. Browser default is a heavy
32853285+ // blue ring that looks jarring on the dark capsule. Replace with a
32863286+ // soft accent-tinted inner ring that respects the bar's palette.
32873287+ if (!document.getElementById(PREFIX + '-bar-focus-style')) {
32883288+ const s = document.createElement('style');
32893289+ s.id = PREFIX + '-bar-focus-style';
32903290+ s.textContent =
32913291+ '#' + PREFIX + '-global-bar button:focus { outline: none; }' +
32923292+ '#' + PREFIX + '-global-bar button:focus-visible {' +
32933293+ ' outline: none;' +
32943294+ ' box-shadow: 0 0 0 2px ' + P.accentSoft + ', 0 0 0 3px ' + P.accent + ';' +
32953295+ '}';
32963296+ document.head.appendChild(s);
32973297+ }
32983298+32993299+ globalBarEl = el('div', {
33003300+ position: 'fixed', bottom: '14px', left: '50%',
33013301+ transform: 'translateX(-50%) translateY(20px)',
33023302+ zIndex: Z.bar + 5,
33033303+ display: 'flex', alignItems: 'stretch',
33043304+ gap: '2px',
33053305+ background: P.surface,
33063306+ backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
33073307+ border: '1px solid ' + P.hairline,
33083308+ borderRadius: '10px',
33093309+ boxShadow: '0 4px 20px oklch(0% 0 0 / 0.12), 0 1px 3px oklch(0% 0 0 / 0.08)',
33103310+ fontFamily: FONT, fontSize: '12px', lineHeight: '1',
33113311+ opacity: '0',
33123312+ overflow: 'hidden', // clip the full-bleed brand mark to the bar radius
33133313+ transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE,
33143314+ });
33153315+ globalBarEl.id = PREFIX + '-global-bar';
33163316+ globalBarEl.dataset.theme = theme;
33173317+33183318+ // Brand mark — fills bar height on the left. Left side inherits the bar's
33193319+ // rounded corner via overflow:hidden; right side is a clean hard edge since
33203320+ // the near-black/charcoal contrast does the shape-defining work.
33213321+ const brand = el('span', {
33223322+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
33233323+ alignSelf: 'stretch',
33243324+ padding: '0 12px 0 14px',
33253325+ background: P.mark,
33263326+ color: P.markText,
33273327+ fontFamily: 'system-ui, -apple-system, sans-serif',
33283328+ fontWeight: '500',
33293329+ fontSize: '18px', lineHeight: '1',
33303330+ });
33313331+ brand.textContent = '/';
33323332+ brand.title = 'Impeccable';
33333333+ globalBarEl.appendChild(brand);
33343334+33353335+ // Inner wrapper: holds the toggles with normal bar padding.
33363336+ const inner = el('div', {
33373337+ display: 'flex', alignItems: 'center',
33383338+ padding: '4px 5px', gap: '2px',
33393339+ });
33403340+ inner.id = PREFIX + '-global-bar-inner';
33413341+ globalBarEl.appendChild(inner);
33423342+33433343+ // --- button factory: icon-only at rest, label slides in on hover/active ---
33443344+ function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) {
33453345+ const b = el('button', {
33463346+ position: 'relative',
33473347+ display: 'inline-flex', alignItems: 'center',
33483348+ padding: '6px 8px', borderRadius: '7px',
33493349+ border: 'none', background: 'transparent',
33503350+ color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500',
33513351+ cursor: 'pointer',
33523352+ transition: 'background 0.15s ease, color 0.15s ease',
33533353+ whiteSpace: 'nowrap', overflow: 'hidden',
33543354+ });
33553355+ b.id = id;
33563356+ b.title = ariaLabel || label || '';
33573357+ b.setAttribute('aria-label', ariaLabel || label || '');
33583358+ b.innerHTML = svg + (label
33593359+ ? `<span class="icon-btn-label" style="display:inline-block;max-width:0;opacity:0;margin-left:0;overflow:hidden;font-family:${labelFont || FONT};transition:max-width 0.25s ${EASE}, opacity 0.2s ease, margin-left 0.25s ${EASE};">${label}</span>`
33603360+ : '');
33613361+ const labelEl = b.querySelector('.icon-btn-label');
33623362+ const expand = () => {
33633363+ if (!labelEl) return;
33643364+ labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px';
33653365+ };
33663366+ const collapse = () => {
33673367+ if (!labelEl || b.dataset.active === 'true') return;
33683368+ labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0';
33693369+ };
33703370+ // Per-button hover only changes color (no layout). The label expand/
33713371+ // collapse is driven by the bar-level mouseenter/mouseleave so moving
33723372+ // the mouse between adjacent buttons doesn't trigger per-button width
33733373+ // thrashing — the whole bar grows once and shrinks once.
33743374+ b.addEventListener('mouseenter', () => { if (b.dataset.active !== 'true') b.style.color = P.text; });
33753375+ b.addEventListener('mouseleave', () => { if (b.dataset.active !== 'true') b.style.color = P.textDim; });
33763376+ b.addEventListener('click', onClick);
33773377+ b._expandLabel = expand;
33783378+ b._collapseLabel = collapse;
33793379+ return b;
33803380+ }
33813381+33823382+ // Pick toggle — starts active (primary intent when entering live mode).
33833383+ const pickBtn = makeIconBtn({
33843384+ id: PREFIX + '-pick-toggle',
33853385+ svg: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><circle cx="12" cy="12" r="10"/><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><line x1="12" y1="6" x2="12" y2="2"/><line x1="12" y1="22" x2="12" y2="18"/></svg>',
33863386+ label: 'Pick',
33873387+ ariaLabel: 'Pick element',
33883388+ onClick: () => togglePick(),
33893389+ });
33903390+ pickBtn.style.background = P.accentSoft;
33913391+ pickBtn.style.color = P.accent;
33923392+ pickBtn.dataset.active = 'true';
33933393+ pickBtn._expandLabel();
33943394+ inner.appendChild(pickBtn);
33953395+33963396+ // Detect toggle
33973397+ const detectBtn = makeIconBtn({
33983398+ id: PREFIX + '-detect-toggle',
33993399+ svg: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>',
34003400+ label: 'Detect',
34013401+ ariaLabel: 'Detect anti-patterns',
34023402+ onClick: () => toggleDetect(),
34033403+ });
34043404+ const detectBadge = el('span', {
34053405+ fontSize: '10px', fontWeight: '600',
34063406+ padding: '0px 5px', borderRadius: '7px', lineHeight: '16px',
34073407+ background: P.accent, color: P.surface.includes('18%') ? 'oklch(18% 0 0)' : 'oklch(98% 0 0)',
34083408+ display: 'none', fontFamily: MONO, marginLeft: '4px',
34093409+ });
34103410+ detectBadge.id = PREFIX + '-detect-badge';
34113411+ detectBtn.appendChild(detectBadge);
34123412+ inner.appendChild(detectBtn);
34133413+34143414+ // DESIGN.md panel toggle — quartet of color squares as the mark.
34153415+ const designBtn = makeIconBtn({
34163416+ id: PREFIX + '-design-toggle',
34173417+ svg: `<span style="display:inline-grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;width:14px;height:14px;border-radius:3px;overflow:hidden;box-shadow:inset 0 0 0 1px ${P.hairline};flex-shrink:0">
34183418+ <span style="background:oklch(60% 0.25 350)"></span>
34193419+ <span style="background:oklch(60% 0.15 45)"></span>
34203420+ <span style="background:oklch(55% 0.12 250)"></span>
34213421+ <span style="background:oklch(30% 0 0)"></span>
34223422+ </span>`,
34233423+ label: 'DESIGN.md',
34243424+ ariaLabel: 'Toggle DESIGN.md panel',
34253425+ labelFont: MONO,
34263426+ onClick: () => toggleDesignPanel(),
34273427+ });
34283428+ inner.appendChild(designBtn);
34293429+34303430+ // Thin divider before the exit button
34313431+ const divider = el('span', {
34323432+ width: '1px', height: '18px',
34333433+ background: P.hairline,
34343434+ margin: '0 4px 0 2px',
34353435+ });
34363436+ inner.appendChild(divider);
34373437+34383438+ // Exit × on the right — intentionally subtle (textDim at rest, text on
34393439+ // hover) so it sits behind the active toggles in visual hierarchy.
34403440+ //
34413441+ // Explicit padding + box-sizing here is load-bearing: a host page like
34423442+ // `button { padding: 0.5rem 1rem; }` (very common in resets) would
34433443+ // otherwise inflate this 24x24 button into 56x40 and push the SVG out
34443444+ // of the visible bar — the X stays invisible even though the styles in
34453445+ // DevTools look fine. Every other chrome button sets padding inline;
34463446+ // this one needed it too.
34473447+ const exitBtn = el('button', {
34483448+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
34493449+ padding: '0', boxSizing: 'border-box',
34503450+ width: '24px', height: '24px', borderRadius: '6px',
34513451+ border: 'none', background: 'transparent',
34523452+ color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
34533453+ cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
34543454+ });
34553455+ exitBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="3" y1="3" x2="11" y2="11"/><line x1="11" y1="3" x2="3" y2="11"/></svg>';
34563456+ exitBtn.title = 'Exit live mode';
34573457+ exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = P.text; exitBtn.style.background = P.exitHover; });
34583458+ exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = P.textDim; exitBtn.style.background = 'transparent'; });
34593459+ exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); });
34603460+ inner.appendChild(exitBtn);
34613461+34623462+ // Bar-level hover: expand every toggle's label at once; collapse on leave.
34633463+ // Buttons with dataset.active="true" ignore collapse (their label stays).
34643464+ const toggles = [pickBtn, detectBtn, designBtn];
34653465+ globalBarEl.addEventListener('mouseenter', () => {
34663466+ toggles.forEach((t) => t._expandLabel && t._expandLabel());
34673467+ });
34683468+ globalBarEl.addEventListener('mouseleave', () => {
34693469+ toggles.forEach((t) => t._collapseLabel && t._collapseLabel());
34703470+ });
34713471+34723472+ document.body.appendChild(globalBarEl);
34733473+ defangOutsideHandlers(globalBarEl);
34743474+34753475+ requestAnimationFrame(() => {
34763476+ globalBarEl.style.opacity = '1';
34773477+ globalBarEl.style.transform = 'translateX(-50%) translateY(0)';
34783478+ });
34793479+34803480+ // Listen for detection results AND ready signal
34813481+ window.addEventListener('message', onDetectMessage);
34823482+ }
34833483+34843484+ function updateGlobalBarState() {
34853485+ const detectToggle = document.getElementById(PREFIX + '-detect-toggle');
34863486+ const detectBadge = document.getElementById(PREFIX + '-detect-badge');
34873487+ const pickToggle = document.getElementById(PREFIX + '-pick-toggle');
34883488+ const designToggle = document.getElementById(PREFIX + '-design-toggle');
34893489+ const theme = globalBarEl?.dataset.theme || 'light';
34903490+ const P = barPaletteForTheme(theme);
34913491+34923492+ // Sync one toggle's active state, colors, and slide-label visibility.
34933493+ function sync(btn, active) {
34943494+ if (!btn) return;
34953495+ btn.style.background = active ? P.accentSoft : 'transparent';
34963496+ btn.style.color = active ? P.accent : P.textDim;
34973497+ btn.dataset.active = active ? 'true' : 'false';
34983498+ if (active && btn._expandLabel) btn._expandLabel();
34993499+ else if (!active && btn._collapseLabel) btn._collapseLabel();
35003500+ }
35013501+ sync(pickToggle, pickActive);
35023502+ sync(detectToggle, detectActive);
35033503+ sync(designToggle, designState.open);
35043504+35053505+ // If the bar is currently under the cursor, keep all labels expanded —
35063506+ // otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
35073507+ // would collapse its label while the user's mouse is still on the bar.
35083508+ if (globalBarEl && globalBarEl.matches(':hover')) {
35093509+ [pickToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.());
35103510+ }
35113511+35123512+ if (detectBadge) {
35133513+ detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none';
35143514+ detectBadge.textContent = detectCount;
35153515+ }
35163516+35173517+ // When pick is active, make detect overlays click-through so the picker works
35183518+ document.querySelectorAll('.impeccable-overlay').forEach(o => {
35193519+ o.style.pointerEvents = pickActive ? 'none' : '';
35203520+ });
35213521+ }
35223522+35233523+ let detectReady = false; // true once detect script posts 'impeccable-ready'
35243524+ let detectPendingScan = false; // scan requested before script was ready
35253525+35263526+ function toggleDetect() {
35273527+ detectActive = !detectActive;
35283528+ updateGlobalBarState();
35293529+35303530+ if (detectActive) {
35313531+ if (!detectScriptLoaded) {
35323532+ detectPendingScan = true;
35333533+ loadDetectScript();
35343534+ } else if (detectReady) {
35353535+ window.postMessage({ source: 'impeccable-command', action: 'scan' }, '*');
35363536+ } else {
35373537+ detectPendingScan = true;
35383538+ }
35393539+ } else {
35403540+ window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
35413541+ detectCount = 0;
35423542+ updateGlobalBarState();
35433543+ }
35443544+ }
35453545+35463546+ function togglePick() {
35473547+ pickActive = !pickActive;
35483548+ updateGlobalBarState();
35493549+35503550+ if (!pickActive) {
35513551+ // Disabling pick clears any in-flight selection and UI: highlight,
35523552+ // contextual bar, selectedElement. Otherwise a stale selection sits
35533553+ // on screen with no obvious way to dismiss.
35543554+ hideHighlight();
35553555+ hideBar();
35563556+ hideActionPicker();
35573557+ selectedElement = null;
35583558+ if (state === 'PICKING' || state === 'CONFIGURING') state = 'IDLE';
35593559+ } else {
35603560+ if (state === 'IDLE') state = 'PICKING';
35613561+ }
35623562+ }
35633563+35643564+ function loadDetectScript() {
35653565+ if (detectScriptLoaded) return;
35663566+ detectScriptLoaded = true;
35673567+ const s = document.createElement('script');
35683568+ s.src = 'http://localhost:' + PORT + '/detect.js';
35693569+ s.dataset.impeccableExtension = 'true';
35703570+ document.head.appendChild(s);
35713571+ }
35723572+35733573+ function onDetectMessage(e) {
35743574+ if (!e.data || typeof e.data.source !== 'string') return;
35753575+ // Detection script is loaded and ready
35763576+ if (e.data.source === 'impeccable-ready') {
35773577+ detectReady = true;
35783578+ if (detectPendingScan && detectActive) {
35793579+ detectPendingScan = false;
35803580+ window.postMessage({ source: 'impeccable-command', action: 'scan' }, '*');
35813581+ }
35823582+ }
35833583+ // Scan results arrived
35843584+ if (e.data.source === 'impeccable-results') {
35853585+ detectCount = e.data.count || 0;
35863586+ updateGlobalBarState();
35873587+ }
35883588+ }
35893589+35903590+ /** Full teardown: remove all UI, disconnect SSE, clean up. */
35913591+ function teardown() {
35923592+ cleanup();
35933593+ hideBar();
35943594+ if (globalBarEl) {
35953595+ globalBarEl.style.transform = 'translateY(100%)';
35963596+ setTimeout(() => { if (globalBarEl) globalBarEl.remove(); globalBarEl = null; }, 300);
35973597+ }
35983598+ if (highlightEl) { highlightEl.remove(); highlightEl = null; }
35993599+ if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; }
36003600+ if (barEl) { barEl.remove(); barEl = null; }
36013601+ if (pickerEl) { pickerEl.remove(); pickerEl = null; }
36023602+ if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; }
36033603+ if (evtSource) { evtSource.close(); evtSource = null; }
36043604+ document.removeEventListener('mousemove', handleMouseMove, true);
36053605+ document.removeEventListener('click', handleClick, true);
36063606+ document.removeEventListener('keydown', handleKeyDown, true);
36073607+ window.removeEventListener('message', onDetectMessage);
36083608+ // Remove detection overlays
36093609+ window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
36103610+ state = 'IDLE';
36113611+ window.__IMPECCABLE_LIVE_INIT__ = false;
36123612+ console.log('[impeccable] Live mode exited.');
36133613+ }
36143614+36153615+ // ---------------------------------------------------------------------------
36163616+ // Design System Panel — visualizes the project's DESIGN.json sidecar
36173617+ // ---------------------------------------------------------------------------
36183618+36193619+ const DESIGN_PREFS_KEY = 'impeccable-live-design-panel';
36203620+ const DESIGN_PANEL_WIDTH = 440;
36213621+36223622+ let designHost = null;
36233623+ let designShadow = null;
36243624+ let designState = {
36253625+ open: false,
36263626+ tab: 'visual', // 'visual' | 'raw'
36273627+ parsed: null, // parseDesignMd output (frontmatter + body sections)
36283628+ sidecar: null, // DESIGN.json v2 payload (extensions + components + narrative)
36293629+ hasMd: false,
36303630+ hasSidecar: false,
36313631+ present: null, // true/false once fetch resolves
36323632+ raw: null, // raw DESIGN.md for the raw tab
36333633+ mdNewerThanJson: false, // stale-hint flag
36343634+ loading: false,
36353635+ error: null,
36363636+ collapsed: { // narrative-section accordion state
36373637+ rules: true, dosdonts: true, overview: true,
36383638+ },
36393639+ };
36403640+36413641+ function loadDesignPrefs() {
36423642+ // `open` is intentionally NOT persisted — the panel always starts closed
36433643+ // so live mode doesn't auto-slide a big panel over the page on startup.
36443644+ try {
36453645+ const raw = localStorage.getItem(DESIGN_PREFS_KEY);
36463646+ if (!raw) return;
36473647+ const prefs = JSON.parse(raw);
36483648+ if (prefs.tab === 'visual' || prefs.tab === 'raw') designState.tab = prefs.tab;
36493649+ if (prefs.collapsed && typeof prefs.collapsed === 'object') {
36503650+ Object.assign(designState.collapsed, prefs.collapsed);
36513651+ }
36523652+ } catch { /* ignore */ }
36533653+ }
36543654+36553655+ function saveDesignPrefs() {
36563656+ try {
36573657+ localStorage.setItem(DESIGN_PREFS_KEY, JSON.stringify({
36583658+ tab: designState.tab,
36593659+ collapsed: designState.collapsed,
36603660+ }));
36613661+ } catch { /* ignore */ }
36623662+ }
36633663+36643664+ function initDesignPanel() {
36653665+ designHost = document.createElement('div');
36663666+ designHost.id = PREFIX + '-design-host';
36673667+ Object.assign(designHost.style, {
36683668+ position: 'fixed', top: '0', left: '0',
36693669+ width: '0', height: '0',
36703670+ zIndex: String(Z.bar + 10),
36713671+ pointerEvents: 'none',
36723672+ });
36733673+ designShadow = designHost.attachShadow({ mode: 'open' });
36743674+36753675+ const style = document.createElement('style');
36763676+ // Theme-match the bar: dark chrome on light pages, light chrome on dark pages.
36773677+ const theme = detectPageTheme();
36783678+ style.textContent = designPanelCss(barPaletteForTheme(theme));
36793679+ designShadow.appendChild(style);
36803680+36813681+ const root = document.createElement('div');
36823682+ root.className = 'root';
36833683+ designShadow.appendChild(root);
36843684+36853685+ document.body.appendChild(designHost);
36863686+ // The host is pointer-events: none; the panel inside the shadow DOM
36873687+ // manages its own auto/none. Events bubble through the shadow boundary,
36883688+ // so attaching here silences host-page outside-interaction handlers
36893689+ // without touching the host's click-through behavior.
36903690+ defangOutsideHandlers(designHost, { setPointerEvents: false });
36913691+36923692+ loadDesignPrefs();
36933693+ renderDesignChrome();
36943694+ if (designState.open) {
36953695+ fetchDesignSystem();
36963696+ }
36973697+ }
36983698+36993699+ // Neutral panel palette — deliberately NOT Impeccable-branded. The panel is
37003700+ // a viewer of the project's design system, not an Impeccable surface.
37013701+ const DP = {
37023702+ canvas: 'oklch(94% 0 0)', // panel background
37033703+ tile: 'oklch(98.5% 0 0)', // card-on-canvas
37043704+ tileAlt: 'oklch(96% 0 0)', // subtler tile for inner surfaces
37053705+ ink: 'oklch(15% 0 0)',
37063706+ ink2: 'oklch(35% 0 0)',
37073707+ meta: 'oklch(55% 0 0)',
37083708+ hairline: 'oklch(88% 0 0)',
37093709+ hairlineSoft: 'oklch(92% 0 0)',
37103710+ amber: 'oklch(70% 0.13 65)', // stale-hint accent
37113711+ amberBg: 'oklch(95% 0.05 80)',
37123712+ };
37133713+37143714+ function designPanelCss(BP) {
37153715+ // BP = bar palette (theme-aware, matches the global bar).
37163716+ // DP = internal content palette (neutral, so tiles render colors true).
37173717+ return `
37183718+ :host, .root { all: initial; }
37193719+ .root {
37203720+ font-family: ${FONT};
37213721+ color: ${DP.ink};
37223722+ pointer-events: none;
37233723+ }
37243724+ .root * { box-sizing: border-box; }
37253725+ button { font: inherit; color: inherit; }
37263726+37273727+ /* --- Panel shell: chrome matches the bar; body canvas stays neutral --- */
37283728+ .panel {
37293729+ position: fixed; top: 12px; bottom: 72px; right: 12px;
37303730+ width: ${DESIGN_PANEL_WIDTH}px; max-width: calc(100vw - 24px);
37313731+ background: ${BP.surface};
37323732+ border: 1px solid ${BP.hairline};
37333733+ border-radius: 14px;
37343734+ backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
37353735+ box-shadow: 0 20px 60px oklch(0% 0 0 / 0.18), 0 4px 12px oklch(0% 0 0 / 0.08);
37363736+ display: flex; flex-direction: column;
37373737+ transform: translateX(calc(100% + 24px));
37383738+ opacity: 0;
37393739+ transition: transform 0.35s ${EASE}, opacity 0.25s ${EASE};
37403740+ pointer-events: none;
37413741+ overflow: hidden;
37423742+ }
37433743+ .panel[data-open="true"] { transform: translateX(0); opacity: 1; pointer-events: auto; }
37443744+37453745+ .panel-header {
37463746+ display: flex; align-items: center; gap: 10px;
37473747+ padding: 10px 10px 10px 14px;
37483748+ background: transparent;
37493749+ border-bottom: 1px solid ${BP.hairline};
37503750+ }
37513751+ .panel-title {
37523752+ flex: 1; min-width: 0;
37533753+ font-family: ${MONO};
37543754+ font-size: 11.5px; font-weight: 600;
37553755+ letter-spacing: 0.02em;
37563756+ color: ${BP.text};
37573757+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
37583758+ }
37593759+ .panel-close {
37603760+ border: none; background: transparent; color: ${BP.textDim};
37613761+ width: 26px; height: 26px; border-radius: 7px;
37623762+ display: inline-flex; align-items: center; justify-content: center;
37633763+ cursor: pointer; transition: background 0.15s ease, color 0.15s ease;
37643764+ }
37653765+ .panel-close:hover { background: ${BP.hairline}; color: ${BP.text}; }
37663766+37673767+ .tabs {
37683768+ display: inline-flex; padding: 2px;
37693769+ background: ${BP.hairline};
37703770+ border-radius: 7px;
37713771+ gap: 2px;
37723772+ }
37733773+ .tab {
37743774+ border: none; background: transparent;
37753775+ padding: 4px 10px; border-radius: 5px;
37763776+ font-family: ${MONO};
37773777+ font-size: 10px; font-weight: 600; letter-spacing: 0.08em;
37783778+ text-transform: uppercase;
37793779+ color: ${BP.textDim}; cursor: pointer;
37803780+ transition: background 0.15s ease, color 0.15s ease;
37813781+ }
37823782+ .tab[data-active="true"] { background: ${BP.surface}; color: ${BP.text}; }
37833783+37843784+ .panel-body {
37853785+ flex: 1; overflow-y: auto;
37863786+ padding: 12px 12px 20px;
37873787+ background: ${DP.canvas};
37883788+ scrollbar-width: thin;
37893789+ scrollbar-color: ${DP.hairline} transparent;
37903790+ }
37913791+ .panel-body::-webkit-scrollbar { width: 8px; }
37923792+ .panel-body::-webkit-scrollbar-thumb { background: ${DP.hairline}; border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; }
37933793+37943794+ /* --- States --- */
37953795+ .empty, .loading, .error {
37963796+ margin: 16px 4px;
37973797+ padding: 28px 20px; text-align: center;
37983798+ background: ${DP.tile}; border-radius: 14px;
37993799+ color: ${DP.ink2}; font-size: 13px; line-height: 1.55;
38003800+ }
38013801+ .empty strong { color: ${DP.ink}; display: block; margin-bottom: 6px; font-size: 14px; }
38023802+ .empty code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 6px; border-radius: 4px; font-size: 12px; color: ${DP.ink}; }
38033803+ .error { color: oklch(45% 0.15 25); }
38043804+38053805+ /* --- Stale hint --- */
38063806+ .stale {
38073807+ display: flex; align-items: center; gap: 8px;
38083808+ margin: 8px 4px 12px;
38093809+ padding: 8px 12px;
38103810+ background: ${DP.amberBg};
38113811+ border-radius: 10px;
38123812+ font-size: 11.5px; color: ${DP.ink2};
38133813+ }
38143814+ .stale-dot { width: 8px; height: 8px; border-radius: 50%; background: ${DP.amber}; flex-shrink: 0; }
38153815+ .stale-text { flex: 1; min-width: 0; }
38163816+ .stale-text strong { color: ${DP.ink}; font-weight: 600; }
38173817+38183818+ /* --- Parsed-md fallback banner --- */
38193819+ .parsed-md-cta {
38203820+ margin: 8px 4px 14px;
38213821+ padding: 14px 16px;
38223822+ background: ${DP.tile};
38233823+ border: 1px dashed ${DP.hairline};
38243824+ border-radius: 12px;
38253825+ font-size: 12px; color: ${DP.ink2}; line-height: 1.55;
38263826+ }
38273827+ .parsed-md-cta strong { color: ${DP.ink}; display: block; margin-bottom: 4px; font-size: 13px; font-weight: 600; }
38283828+ .parsed-md-cta code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 5px; border-radius: 4px; font-size: 11.5px; color: ${DP.ink}; }
38293829+38303830+ /* --- Tile primitives --- */
38313831+ .tile {
38323832+ position: relative;
38333833+ background: ${DP.tile};
38343834+ border-radius: 16px;
38353835+ padding: 16px;
38363836+ margin: 0 4px 10px;
38373837+ }
38383838+ .tile-row { margin: 0 4px 10px; display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
38393839+ .tile-row .tile { margin: 0; }
38403840+ .tile-meta {
38413841+ display: flex; align-items: baseline; justify-content: space-between;
38423842+ gap: 10px;
38433843+ font-family: ${MONO};
38443844+ font-size: 10px; font-weight: 500; letter-spacing: 0.1em; text-transform: uppercase;
38453845+ color: ${DP.meta};
38463846+ }
38473847+ .tile-meta .name { color: ${DP.ink}; font-weight: 600; letter-spacing: 0.05em; text-transform: none; font-family: ${FONT}; font-size: 12.5px; }
38483848+38493849+ /* --- Color tile --- */
38503850+ .c-tile { cursor: pointer; transition: transform 0.2s ${EASE}; }
38513851+ .c-tile:hover { transform: translateY(-1px); }
38523852+ .c-hero {
38533853+ height: 72px; border-radius: 10px; margin-top: 10px;
38543854+ box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.05);
38553855+ }
38563856+ .c-ramp {
38573857+ display: flex; gap: 0; height: 14px; border-radius: 4px; overflow: hidden;
38583858+ margin-top: 8px;
38593859+ box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.04);
38603860+ }
38613861+ .c-ramp > span { flex: 1; }
38623862+ .c-desc { margin-top: 8px; font-size: 11.5px; line-height: 1.45; color: ${DP.ink2}; }
38633863+38643864+ /* --- Type tile --- */
38653865+ .t-tile { }
38663866+ .t-specimen {
38673867+ margin: 4px 0 6px;
38683868+ color: ${DP.ink};
38693869+ line-height: 0.9;
38703870+ }
38713871+ .t-family { margin-top: 4px; font-size: 12px; font-weight: 600; color: ${DP.ink}; }
38723872+ .t-purpose { margin-top: 4px; font-size: 11px; line-height: 1.45; color: ${DP.ink2}; }
38733873+38743874+ /* --- Shadow tile --- */
38753875+ .s-tile { }
38763876+ .s-surface {
38773877+ height: 60px; margin: 8px 2px 10px;
38783878+ background: ${DP.tile};
38793879+ border-radius: 10px;
38803880+ }
38813881+ .s-value { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; word-break: break-all; line-height: 1.4; }
38823882+ .s-purpose { margin-top: 4px; font-size: 11px; color: ${DP.ink2}; line-height: 1.45; }
38833883+38843884+ /* --- Radii strip --- */
38853885+ .r-strip { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 10px; }
38863886+ .r-item { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; min-width: 60px; }
38873887+ .r-sample { width: 44px; height: 44px; background: ${DP.canvas}; box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.08); }
38883888+ .r-label { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.05em; text-transform: uppercase; }
38893889+ .r-val { font-family: ${MONO}; font-size: 10px; color: ${DP.ink}; }
38903890+38913891+ /* --- Component tile (hosts live primitives) --- */
38923892+ .cmp-tile { }
38933893+ .cmp-stage {
38943894+ margin: 12px -4px 0;
38953895+ padding: 18px 16px 10px;
38963896+ border-top: 1px solid ${DP.hairlineSoft};
38973897+ display: flex; flex-direction: column; align-items: center; justify-content: center;
38983898+ gap: 14px;
38993899+ min-height: 68px;
39003900+ }
39013901+ .cmp-stage + .cmp-stage { border-top: 1px dashed ${DP.hairlineSoft}; }
39023902+ .cmp-sublabel { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.06em; }
39033903+ .cmp-kind { font-family: ${MONO}; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: ${DP.meta}; }
39043904+39053905+ /* --- Collapsible --- */
39063906+ .coll {
39073907+ margin: 0 4px 8px;
39083908+ background: ${DP.tile};
39093909+ border-radius: 12px;
39103910+ overflow: hidden;
39113911+ }
39123912+ .coll-head {
39133913+ display: flex; align-items: center; gap: 10px;
39143914+ width: 100%;
39153915+ padding: 12px 14px;
39163916+ background: transparent; border: none;
39173917+ cursor: pointer; text-align: left;
39183918+ font-family: ${FONT}; font-size: 12.5px; font-weight: 600; color: ${DP.ink};
39193919+ transition: background 0.12s ease;
39203920+ }
39213921+ .coll-head:hover { background: ${DP.tileAlt}; }
39223922+ .coll-chev {
39233923+ width: 12px; height: 12px; flex-shrink: 0;
39243924+ color: ${DP.meta};
39253925+ transition: transform 0.2s ${EASE};
39263926+ }
39273927+ .coll[data-open="true"] .coll-chev { transform: rotate(90deg); }
39283928+ .coll-count { margin-left: auto; font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.05em; }
39293929+ .coll-body { padding: 0 14px 14px; display: none; }
39303930+ .coll[data-open="true"] .coll-body { display: block; }
39313931+39323932+ .rule-card {
39333933+ padding: 10px 0;
39343934+ border-top: 1px solid ${DP.hairlineSoft};
39353935+ }
39363936+ .rule-card:first-child { border-top: none; padding-top: 2px; }
39373937+ .rule-card .name { font-size: 11.5px; font-weight: 700; color: ${DP.ink}; margin-bottom: 3px; }
39383938+ .rule-card .name .section { font-family: ${MONO}; font-size: 9px; font-weight: 500; letter-spacing: 0.1em; text-transform: uppercase; color: ${DP.meta}; margin-left: 8px; }
39393939+ .rule-card .body { font-size: 11.5px; color: ${DP.ink2}; line-height: 1.5; }
39403940+39413941+ .coll .dos { display: grid; gap: 0; margin-top: 2px; }
39423942+ .coll .do, .coll .dont {
39433943+ position: relative;
39443944+ padding: 8px 0 8px 22px;
39453945+ font-size: 11.5px; line-height: 1.5; color: ${DP.ink2};
39463946+ border-top: 1px solid ${DP.hairlineSoft};
39473947+ }
39483948+ .coll .do:first-child, .coll .dont:first-child,
39493949+ .coll .do:first-of-type { border-top: none; }
39503950+ .coll .do + .dont { border-top: 1px solid ${DP.hairlineSoft}; }
39513951+ .coll .do::before, .coll .dont::before {
39523952+ content: ''; position: absolute; left: 4px; top: 13px;
39533953+ width: 8px; height: 8px; border-radius: 50%;
39543954+ }
39553955+ .coll .do::before { background: oklch(62% 0.16 145); }
39563956+ .coll .dont::before { background: oklch(58% 0.22 25); }
39573957+39583958+ .coll .overview-body {
39593959+ font-size: 12px; line-height: 1.55; color: ${DP.ink2};
39603960+ }
39613961+ .coll .overview-body .north-star {
39623962+ display: block; font-family: ${FONT}; font-style: italic;
39633963+ font-size: 15px; line-height: 1.3; color: ${DP.ink};
39643964+ margin-bottom: 8px;
39653965+ }
39663966+ .coll .overview-body p { margin: 0 0 8px; }
39673967+ .coll .overview-body ul { margin: 6px 0 0; padding-left: 16px; font-size: 11.5px; }
39683968+ .coll .overview-body li { margin-bottom: 3px; }
39693969+39703970+ /* --- raw tab markdown (unchanged layout, neutralized palette) --- */
39713971+ .md { padding: 4px 10px 20px; font-size: 13px; line-height: 1.6; color: ${DP.ink}; }
39723972+ .md h1, .md h2, .md h3, .md h4 { margin: 20px 0 8px; color: ${DP.ink}; font-weight: 600; }
39733973+ .md h1 { font-size: 18px; }
39743974+ .md h2 { font-size: 15px; padding-bottom: 4px; border-bottom: 1px solid ${DP.hairlineSoft}; }
39753975+ .md h3 { font-size: 13px; }
39763976+ .md h4 { font-size: 12px; color: ${DP.meta}; }
39773977+ .md p { margin: 0 0 10px; }
39783978+ .md ul, .md ol { margin: 0 0 10px; padding-left: 20px; }
39793979+ .md li { margin-bottom: 4px; }
39803980+ .md code { font-family: ${MONO}; font-size: 12px; background: ${DP.canvas}; padding: 1px 5px; border-radius: 4px; }
39813981+ .md pre { font-family: ${MONO}; font-size: 12px; background: ${DP.canvas}; padding: 10px 12px; border-radius: 8px; overflow-x: auto; margin: 0 0 10px; }
39823982+ .md pre code { background: none; padding: 0; }
39833983+ .md strong { font-weight: 700; }
39843984+ .md em { font-style: italic; }
39853985+ .md a { color: ${DP.ink}; text-decoration: underline; }
39863986+ .md hr { border: none; border-top: 1px solid ${DP.hairlineSoft}; margin: 16px 0; }
39873987+ `;
39883988+ }
39893989+39903990+ function renderDesignChrome() {
39913991+ const root = designShadow.querySelector('.root');
39923992+ root.innerHTML = '';
39933993+39943994+ // (Panel toggle lives in the global bar — no floating FAB.)
39953995+ // Panel
39963996+ const panel = document.createElement('aside');
39973997+ panel.className = 'panel';
39983998+ panel.setAttribute('data-open', designState.open ? 'true' : 'false');
39993999+ panel.appendChild(buildDesignHeader());
40004000+ const body = document.createElement('div');
40014001+ body.className = 'panel-body';
40024002+ body.id = 'panel-body';
40034003+ panel.appendChild(body);
40044004+ root.appendChild(panel);
40054005+40064006+ renderDesignBody();
40074007+ }
40084008+40094009+ function buildDesignHeader() {
40104010+ const header = document.createElement('div');
40114011+ header.className = 'panel-header';
40124012+40134013+ const title = document.createElement('div');
40144014+ title.className = 'panel-title';
40154015+ title.textContent = 'DESIGN.md';
40164016+ header.appendChild(title);
40174017+40184018+ const tabs = document.createElement('div');
40194019+ tabs.className = 'tabs';
40204020+ for (const t of [['visual', 'Visual'], ['raw', 'Raw']]) {
40214021+ const btn = document.createElement('button');
40224022+ btn.className = 'tab';
40234023+ btn.textContent = t[1];
40244024+ btn.setAttribute('data-active', designState.tab === t[0] ? 'true' : 'false');
40254025+ btn.addEventListener('click', () => {
40264026+ if (designState.tab === t[0]) return;
40274027+ designState.tab = t[0];
40284028+ saveDesignPrefs();
40294029+ renderDesignChrome();
40304030+ if (t[0] === 'raw' && designState.raw === null && !designState.loading) {
40314031+ fetchDesignSystem(); // raw is part of the same fetch pair
40324032+ }
40334033+ });
40344034+ tabs.appendChild(btn);
40354035+ }
40364036+ header.appendChild(tabs);
40374037+40384038+ const close = document.createElement('button');
40394039+ close.className = 'panel-close';
40404040+ close.innerHTML = '✕';
40414041+ close.setAttribute('aria-label', 'Close panel');
40424042+ close.addEventListener('click', toggleDesignPanel);
40434043+ header.appendChild(close);
40444044+40454045+ return header;
40464046+ }
40474047+40484048+ function toggleDesignPanel() {
40494049+ designState.open = !designState.open;
40504050+ renderDesignChrome();
40514051+ updateGlobalBarState();
40524052+ if (designState.open && designState.present === null && !designState.loading) {
40534053+ fetchDesignSystem();
40544054+ }
40554055+ }
40564056+40574057+ async function fetchDesignSystem() {
40584058+ designState.loading = true;
40594059+ designState.error = null;
40604060+ renderDesignBody();
40614061+ try {
40624062+ const [jsonRes, rawRes] = await Promise.all([
40634063+ fetch(`http://localhost:${PORT}/design-system.json?token=${TOKEN}`, { cache: 'no-store' }),
40644064+ fetch(`http://localhost:${PORT}/design-system/raw?token=${TOKEN}`, { cache: 'no-store' }),
40654065+ ]);
40664066+ const jsonData = await jsonRes.json();
40674067+ designState.present = jsonData.present === true;
40684068+ designState.parsed = jsonData.parsed || null;
40694069+ designState.sidecar = jsonData.sidecar || null;
40704070+ designState.hasMd = !!jsonData.hasMd;
40714071+ designState.hasSidecar = !!jsonData.hasSidecar;
40724072+ designState.mdNewerThanJson = !!jsonData.mdNewerThanJson;
40734073+ designState.raw = designState.present && rawRes.ok ? await rawRes.text() : null;
40744074+ designState.error = jsonData.parseError || jsonData.sidecarError || null;
40754075+ } catch (err) {
40764076+ designState.error = err?.message || 'Failed to load design system.';
40774077+ } finally {
40784078+ designState.loading = false;
40794079+ renderDesignChrome(); // refresh title from data
40804080+ }
40814081+ }
40824082+40834083+ function renderDesignBody() {
40844084+ const body = designShadow.querySelector('#panel-body');
40854085+ if (!body) return;
40864086+ body.innerHTML = '';
40874087+40884088+ if (designState.loading) {
40894089+ body.appendChild(msgDiv('loading', 'Loading design system…'));
40904090+ return;
40914091+ }
40924092+ if (designState.error) {
40934093+ body.appendChild(msgDiv('error', designState.error));
40944094+ return;
40954095+ }
40964096+ if (designState.present === false) {
40974097+ const empty = document.createElement('div');
40984098+ empty.className = 'empty';
40994099+ empty.innerHTML = `<strong>No DESIGN.md yet</strong>Create one by running <code>/impeccable document</code> in your terminal, then re-open this panel.`;
41004100+ body.appendChild(empty);
41014101+ return;
41024102+ }
41034103+41044104+ if (designState.tab === 'raw') {
41054105+ renderRawTab(body, designState.raw || '');
41064106+ return;
41074107+ }
41084108+41094109+ // Visual tab — single unified render path.
41104110+ if (designState.mdNewerThanJson) body.appendChild(renderStaleHint());
41114111+ if (designState.hasMd && !designState.hasSidecar) {
41124112+ body.appendChild(renderParsedMdCta());
41134113+ }
41144114+ renderDesignVisual(body, designState.parsed, designState.sidecar);
41154115+ }
41164116+41174117+ function msgDiv(cls, text) {
41184118+ const d = document.createElement('div');
41194119+ d.className = cls;
41204120+ d.textContent = text;
41214121+ return d;
41224122+ }
41234123+41244124+ function renderStaleHint() {
41254125+ const box = document.createElement('div');
41264126+ box.className = 'stale';
41274127+ box.innerHTML = `
41284128+ <span class="stale-dot"></span>
41294129+ <span class="stale-text"><strong>DESIGN.md is newer than DESIGN.json.</strong> Run <code>/impeccable document</code> to refresh the sidecar.</span>
41304130+ `;
41314131+ return box;
41324132+ }
41334133+41344134+ function renderParsedMdCta() {
41354135+ const box = document.createElement('div');
41364136+ box.className = 'parsed-md-cta';
41374137+ box.innerHTML = `<strong>Basic view</strong>This panel reads the tokens in your <code>DESIGN.md</code> frontmatter. Running <code>/impeccable document</code> also generates a <code>DESIGN.json</code> sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`;
41384138+ return box;
41394139+ }
41404140+41414141+ // --- Unified render: merge parsed DESIGN.md frontmatter with sidecar v2 ---
41424142+41434143+ function renderDesignVisual(body, parsed, sidecar) {
41444144+ const frontmatter = parsed?.frontmatter || {};
41454145+ const extensions = sidecar?.extensions || {};
41464146+ const proseColors = parsed?.colors || null;
41474147+41484148+ const colors = buildColorModels(frontmatter.colors, extensions.colorMeta, proseColors);
41494149+ if (colors.length) renderColorTiles(body, colors);
41504150+41514151+ const types = buildTypographyModels(frontmatter.typography, extensions.typographyMeta);
41524152+ if (types.length) renderTypeTiles(body, types);
41534153+41544154+ const radii = buildRadiiModels(frontmatter.rounded);
41554155+ if (radii.length) renderRadiiTile(body, radii);
41564156+41574157+ if (extensions.shadows?.length) renderShadowTiles(body, extensions.shadows);
41584158+41594159+ const components = sidecar?.components || [];
41604160+ if (components.length) renderComponentTiles(body, components);
41614161+41624162+ // Narrative: sidecar wins if present (richer, agent-curated). Otherwise
41634163+ // synthesize from prose sections.
41644164+ const narrative = sidecar?.narrative || synthesizeNarrative(parsed);
41654165+ if (narrative.rules?.length) body.appendChild(renderRulesCollapsible(narrative.rules));
41664166+ if ((narrative.dos?.length || narrative.donts?.length)) body.appendChild(renderDosDontsCollapsible(narrative));
41674167+ if (narrative.overview || narrative.northStar || narrative.keyCharacteristics?.length) {
41684168+ body.appendChild(renderOverviewCollapsible(narrative));
41694169+ }
41704170+41714171+ if (body.childElementCount === 0) {
41724172+ body.appendChild(msgDiv('empty', 'No design system data available.'));
41734173+ }
41744174+ }
41754175+41764176+ // Frontmatter primitives + sidecar colorMeta → tile-ready color models.
41774177+ // A matching prose bullet (when the slug sits in the bullet text) supplies
41784178+ // description as a last-resort fallback.
41794179+ function buildColorModels(fmColors, colorMeta, proseColors) {
41804180+ if (!fmColors) return [];
41814181+ const meta = colorMeta || {};
41824182+ return Object.entries(fmColors).map(([key, value]) => {
41834183+ const m = meta[key] || {};
41844184+ return {
41854185+ role: m.role || humanizeKey(key),
41864186+ name: m.displayName || humanizeKey(key),
41874187+ value: value,
41884188+ canonical: m.canonical || null,
41894189+ description: m.description || findProseDescription(proseColors, key, m.displayName),
41904190+ tonalRamp: m.tonalRamp || null,
41914191+ };
41924192+ });
41934193+ }
41944194+41954195+ function buildTypographyModels(fmTypography, typographyMeta) {
41964196+ if (!fmTypography) return [];
41974197+ const meta = typographyMeta || {};
41984198+ return Object.entries(fmTypography).map(([key, spec]) => {
41994199+ const m = meta[key] || {};
42004200+ const { family, fallback } = splitFontFamily(spec?.fontFamily);
42014201+ return {
42024202+ role: key,
42034203+ name: m.displayName || humanizeKey(key),
42044204+ family,
42054205+ fallback,
42064206+ weight: spec?.fontWeight ?? 400,
42074207+ // fontStyle isn't in Stitch's frontmatter schema; the sidecar carries
42084208+ // it when a role is rendered in italic (e.g. display italic).
42094209+ style: m.style || 'normal',
42104210+ sampleSize: spec?.fontSize || '1rem',
42114211+ lineHeight: spec?.lineHeight != null ? String(spec.lineHeight) : '',
42124212+ letterSpacing: spec?.letterSpacing,
42134213+ purpose: m.purpose,
42144214+ };
42154215+ });
42164216+ }
42174217+42184218+ function buildRadiiModels(fmRounded) {
42194219+ if (!fmRounded) return [];
42204220+ return Object.entries(fmRounded).map(([name, value]) => ({ name, value }));
42214221+ }
42224222+42234223+ function splitFontFamily(stack) {
42244224+ if (!stack || typeof stack !== 'string') return { family: '', fallback: '' };
42254225+ const parts = stack.split(',').map((s) => s.trim().replace(/^['"]|['"]$/g, ''));
42264226+ return { family: parts[0] || '', fallback: parts.slice(1).join(', ') };
42274227+ }
42284228+42294229+ function humanizeKey(k) {
42304230+ return String(k || '').replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
42314231+ }
42324232+42334233+ function findProseDescription(proseColors, key, displayName) {
42344234+ if (!proseColors || !proseColors.groups) return null;
42354235+ const needles = [key, displayName].filter(Boolean).map((s) => s.toLowerCase());
42364236+ for (const g of proseColors.groups) {
42374237+ for (const c of g.colors || []) {
42384238+ const hay = String(c.name || '').toLowerCase();
42394239+ if (hay && needles.some((n) => hay.includes(n) || n.includes(hay))) {
42404240+ return c.description || null;
42414241+ }
42424242+ }
42434243+ }
42444244+ return null;
42454245+ }
42464246+42474247+ function synthesizeNarrative(parsed) {
42484248+ if (!parsed) return {};
42494249+ const md = parsed;
42504250+ return {
42514251+ northStar: md.overview?.creativeNorthStar,
42524252+ overview: (md.overview?.philosophy || []).join(' '),
42534253+ keyCharacteristics: md.overview?.keyCharacteristics || [],
42544254+ rules: [
42554255+ ...(md.colors?.rules || []).map((r) => ({ ...r, section: 'colors' })),
42564256+ ...(md.typography?.rules || []).map((r) => ({ ...r, section: 'typography' })),
42574257+ ...(md.elevation?.rules || []).map((r) => ({ ...r, section: 'elevation' })),
42584258+ ],
42594259+ dos: md.dosDonts?.dos || [],
42604260+ donts: md.dosDonts?.donts || [],
42614261+ };
42624262+ }
42634263+42644264+ function renderColorTiles(body, colors) {
42654265+ for (const c of colors) {
42664266+ const tile = document.createElement('div');
42674267+ tile.className = 'tile c-tile';
42684268+ tile.title = 'Click to copy';
42694269+ tile.addEventListener('click', () => copyToClipboard(c.value));
42704270+42714271+ const meta = document.createElement('div');
42724272+ meta.className = 'tile-meta';
42734273+ meta.innerHTML = `<span class="name">${escapeHtml(c.name || c.role || 'Color')}</span><span>${escapeHtml(c.value || '')}</span>`;
42744274+ tile.appendChild(meta);
42754275+42764276+ const hero = document.createElement('div');
42774277+ hero.className = 'c-hero';
42784278+ hero.style.background = c.value;
42794279+ tile.appendChild(hero);
42804280+42814281+ const ramp = synthesizeRamp(c);
42824282+ if (ramp.length) {
42834283+ const r = document.createElement('div');
42844284+ r.className = 'c-ramp';
42854285+ r.innerHTML = ramp.map((v) => `<span style="background:${cssSafe(v)}"></span>`).join('');
42864286+ tile.appendChild(r);
42874287+ }
42884288+42894289+ if (c.description) {
42904290+ const d = document.createElement('div');
42914291+ d.className = 'c-desc';
42924292+ d.textContent = c.description;
42934293+ tile.appendChild(d);
42944294+ }
42954295+ body.appendChild(tile);
42964296+ }
42974297+ }
42984298+42994299+ function synthesizeRamp(c) {
43004300+ if (c.tonalRamp?.length) return c.tonalRamp;
43014301+ // If base value is OKLCH, synthesize an 8-step ramp across lightness.
43024302+ const m = typeof c.value === 'string' && c.value.match(/^oklch\(\s*([\d.]+)%\s+([\d.]+)\s+([\d.]+)\s*(?:\/\s*([\d.]+))?\s*\)$/i);
43034303+ if (!m) return [];
43044304+ const [, , chroma, hue] = m;
43054305+ const steps = [20, 32, 44, 56, 68, 80, 90, 96];
43064306+ return steps.map((l) => `oklch(${l}% ${chroma} ${hue})`);
43074307+ }
43084308+43094309+ function renderTypeTiles(body, types) {
43104310+ for (const t of types) {
43114311+ const tile = document.createElement('div');
43124312+ tile.className = 'tile t-tile';
43134313+43144314+ const meta = document.createElement('div');
43154315+ meta.className = 'tile-meta';
43164316+ meta.innerHTML = `<span>${escapeHtml(t.role || '')}</span><span>${escapeHtml(t.weight || '')} ${escapeHtml(t.style === 'italic' ? 'italic' : '')}</span>`;
43174317+ tile.appendChild(meta);
43184318+43194319+ const specimen = document.createElement('div');
43204320+ specimen.className = 't-specimen';
43214321+ specimen.textContent = 'Aa';
43224322+ specimen.style.fontFamily = fontStack(t);
43234323+ specimen.style.fontWeight = String(t.weight || 400);
43244324+ specimen.style.fontStyle = t.style || 'normal';
43254325+ specimen.style.fontSize = '56px'; // Fixed specimen size — compare faces, not scales.
43264326+ specimen.style.letterSpacing = 'normal';
43274327+ specimen.style.textTransform = 'none';
43284328+ tile.appendChild(specimen);
43294329+43304330+ // The system's actual sample size for this role, shown as small mono meta below.
43314331+ if (t.sampleSize) {
43324332+ const scale = document.createElement('div');
43334333+ scale.style.cssText = 'font-family:' + MONO + '; font-size: 10px; color:' + DP.meta + '; margin-top: 2px;';
43344334+ scale.textContent = t.sampleSize;
43354335+ tile.appendChild(scale);
43364336+ }
43374337+43384338+ const family = document.createElement('div');
43394339+ family.className = 't-family';
43404340+ family.textContent = t.family || t.name || '';
43414341+ tile.appendChild(family);
43424342+43434343+ if (t.purpose) {
43444344+ const p = document.createElement('div');
43454345+ p.className = 't-purpose';
43464346+ p.textContent = t.purpose;
43474347+ tile.appendChild(p);
43484348+ }
43494349+ body.appendChild(tile);
43504350+ }
43514351+ }
43524352+43534353+ function fontStack(t) {
43544354+ const fam = t.family || '';
43554355+ const fb = t.fallback || '';
43564356+ if (fam && /[,\s]/.test(fam) && !fam.includes("'") && !fam.includes('"')) {
43574357+ return `"${fam}", ${fb}`;
43584358+ }
43594359+ return fam && fb ? `"${fam}", ${fb}` : (fam || fb);
43604360+ }
43614361+43624362+ function renderRadiiTile(body, radii) {
43634363+ const tile = document.createElement('div');
43644364+ tile.className = 'tile';
43654365+ const meta = document.createElement('div');
43664366+ meta.className = 'tile-meta';
43674367+ meta.innerHTML = `<span class="name">Corner Radii</span><span>${radii.length}</span>`;
43684368+ tile.appendChild(meta);
43694369+43704370+ const strip = document.createElement('div');
43714371+ strip.className = 'r-strip';
43724372+ for (const r of radii) {
43734373+ const item = document.createElement('div');
43744374+ item.className = 'r-item';
43754375+ const s = document.createElement('div');
43764376+ s.className = 'r-sample';
43774377+ s.style.borderRadius = r.value || '0';
43784378+ item.appendChild(s);
43794379+ const lbl = document.createElement('div');
43804380+ lbl.className = 'r-label';
43814381+ lbl.textContent = r.name || '';
43824382+ item.appendChild(lbl);
43834383+ const val = document.createElement('div');
43844384+ val.className = 'r-val';
43854385+ val.textContent = r.value || '';
43864386+ item.appendChild(val);
43874387+ strip.appendChild(item);
43884388+ }
43894389+ tile.appendChild(strip);
43904390+ body.appendChild(tile);
43914391+ }
43924392+43934393+ function renderShadowTiles(body, shadows) {
43944394+ for (const sh of shadows) {
43954395+ const tile = document.createElement('div');
43964396+ tile.className = 'tile s-tile';
43974397+43984398+ const meta = document.createElement('div');
43994399+ meta.className = 'tile-meta';
44004400+ meta.innerHTML = `<span class="name">${escapeHtml(sh.name || 'Shadow')}</span><span>Elevation</span>`;
44014401+ tile.appendChild(meta);
44024402+44034403+ const surface = document.createElement('div');
44044404+ surface.className = 's-surface';
44054405+ surface.style.boxShadow = sh.value || 'none';
44064406+ tile.appendChild(surface);
44074407+44084408+ const val = document.createElement('div');
44094409+ val.className = 's-value';
44104410+ val.textContent = sh.value || '';
44114411+ tile.appendChild(val);
44124412+44134413+ if (sh.purpose) {
44144414+ const p = document.createElement('div');
44154415+ p.className = 's-purpose';
44164416+ p.textContent = sh.purpose;
44174417+ tile.appendChild(p);
44184418+ }
44194419+ body.appendChild(tile);
44204420+ }
44214421+ }
44224422+44234423+ function renderComponentTiles(body, components) {
44244424+ // Group consecutive components that share a kind into one tile. This avoids
44254425+ // a pile of one-component tiles (e.g., three button variants = three tiles)
44264426+ // and reads more like a proper category.
44274427+ const groups = groupByKind(components);
44284428+44294429+ for (const group of groups) {
44304430+ const tile = document.createElement('div');
44314431+ tile.className = 'tile cmp-tile';
44324432+44334433+ const meta = document.createElement('div');
44344434+ meta.className = 'tile-meta';
44354435+ const groupTitle = group.length === 1
44364436+ ? (group[0].name || group[0].kind || 'Component')
44374437+ : titleForKind(group[0].kind, group.length);
44384438+ meta.innerHTML = `<span class="name">${escapeHtml(groupTitle)}</span><span class="cmp-kind">${escapeHtml(group[0].kind || '')}</span>`;
44394439+ tile.appendChild(meta);
44404440+44414441+ for (const c of group) {
44424442+ const stage = document.createElement('div');
44434443+ stage.className = 'cmp-stage';
44444444+44454445+ // Render the component in its own shadow root so its CSS can't bleed.
44464446+ const host = document.createElement('div');
44474447+ const sub = host.attachShadow({ mode: 'open' });
44484448+ const style = document.createElement('style');
44494449+ style.textContent = c.css || '';
44504450+ sub.appendChild(style);
44514451+ const container = document.createElement('div');
44524452+ container.innerHTML = c.html || '';
44534453+ sub.appendChild(container);
44544454+ stage.appendChild(host);
44554455+44564456+ // Show component name as a sublabel only when the tile groups >1 item,
44574457+ // or when the component's display name differs from its kind.
44584458+ const showSublabel = group.length > 1;
44594459+ if (showSublabel) {
44604460+ const lbl = document.createElement('div');
44614461+ lbl.className = 'cmp-sublabel';
44624462+ lbl.textContent = c.name || '';
44634463+ stage.appendChild(lbl);
44644464+ }
44654465+ tile.appendChild(stage);
44664466+ }
44674467+44684468+ // Single shared description if all items carry the same one; otherwise
44694469+ // skip — per-item descriptions clutter a grouped tile.
44704470+ if (group.length === 1 && group[0].description) {
44714471+ const d = document.createElement('div');
44724472+ d.className = 'c-desc';
44734473+ d.textContent = group[0].description;
44744474+ tile.appendChild(d);
44754475+ }
44764476+ body.appendChild(tile);
44774477+ }
44784478+ }
44794479+44804480+ function groupByKind(components) {
44814481+ const groups = [];
44824482+ for (const c of components) {
44834483+ const last = groups[groups.length - 1];
44844484+ if (last && last[0].kind && c.kind === last[0].kind) {
44854485+ last.push(c);
44864486+ } else {
44874487+ groups.push([c]);
44884488+ }
44894489+ }
44904490+ return groups;
44914491+ }
44924492+44934493+ function titleForKind(kind, count) {
44944494+ const labels = {
44954495+ button: 'Buttons',
44964496+ input: 'Inputs',
44974497+ nav: 'Navigation',
44984498+ chip: 'Chips',
44994499+ card: 'Cards',
45004500+ custom: 'Components',
45014501+ };
45024502+ return labels[kind] || (kind ? kind.charAt(0).toUpperCase() + kind.slice(1) + 's' : 'Components');
45034503+ }
45044504+45054505+ // --- Collapsibles ---------------------------------------------------------
45064506+45074507+ function buildCollapsible(key, label, count) {
45084508+ const wrap = document.createElement('div');
45094509+ wrap.className = 'coll';
45104510+ wrap.setAttribute('data-open', designState.collapsed[key] ? 'false' : 'true');
45114511+45124512+ const head = document.createElement('button');
45134513+ head.className = 'coll-head';
45144514+ head.innerHTML = `
45154515+ <svg class="coll-chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2.5L8 6 4 9.5"/></svg>
45164516+ <span>${escapeHtml(label)}</span>
45174517+ ${count != null ? `<span class="coll-count">${escapeHtml(String(count))}</span>` : ''}
45184518+ `;
45194519+ head.addEventListener('click', () => {
45204520+ designState.collapsed[key] = !designState.collapsed[key];
45214521+ saveDesignPrefs();
45224522+ renderDesignBody();
45234523+ });
45244524+ wrap.appendChild(head);
45254525+45264526+ const body = document.createElement('div');
45274527+ body.className = 'coll-body';
45284528+ wrap.appendChild(body);
45294529+ return { wrap, body };
45304530+ }
45314531+45324532+ function renderRulesCollapsible(rules) {
45334533+ const { wrap, body } = buildCollapsible('rules', 'Named Rules', rules.length);
45344534+ for (const r of rules) {
45354535+ const card = document.createElement('div');
45364536+ card.className = 'rule-card';
45374537+ const name = document.createElement('div');
45384538+ name.className = 'name';
45394539+ name.innerHTML = `${escapeHtml(r.name)}${r.section ? `<span class="section">${escapeHtml(r.section)}</span>` : ''}`;
45404540+ card.appendChild(name);
45414541+ const b = document.createElement('div');
45424542+ b.className = 'body';
45434543+ b.textContent = r.body || '';
45444544+ card.appendChild(b);
45454545+ body.appendChild(card);
45464546+ }
45474547+ return wrap;
45484548+ }
45494549+45504550+ function renderDosDontsCollapsible(n) {
45514551+ const total = (n.dos?.length || 0) + (n.donts?.length || 0);
45524552+ const { wrap, body } = buildCollapsible('dosdonts', "Do's and Don'ts", total);
45534553+ const grid = document.createElement('div');
45544554+ grid.className = 'dos';
45554555+ for (const d of n.dos || []) {
45564556+ const el = document.createElement('div');
45574557+ el.className = 'do';
45584558+ el.innerHTML = inlineMd(d);
45594559+ grid.appendChild(el);
45604560+ }
45614561+ for (const d of n.donts || []) {
45624562+ const el = document.createElement('div');
45634563+ el.className = 'dont';
45644564+ el.innerHTML = inlineMd(d);
45654565+ grid.appendChild(el);
45664566+ }
45674567+ body.appendChild(grid);
45684568+ return wrap;
45694569+ }
45704570+45714571+ function renderOverviewCollapsible(n) {
45724572+ const { wrap, body } = buildCollapsible('overview', 'Overview', null);
45734573+ const ov = document.createElement('div');
45744574+ ov.className = 'overview-body';
45754575+ if (n.northStar) {
45764576+ const star = document.createElement('span');
45774577+ star.className = 'north-star';
45784578+ star.textContent = '“' + n.northStar + '”';
45794579+ ov.appendChild(star);
45804580+ }
45814581+ if (n.overview) {
45824582+ const p = document.createElement('p');
45834583+ p.innerHTML = inlineMd(n.overview);
45844584+ ov.appendChild(p);
45854585+ }
45864586+ if (n.keyCharacteristics?.length) {
45874587+ const ul = document.createElement('ul');
45884588+ ul.innerHTML = n.keyCharacteristics.map((k) => `<li>${inlineMd(k)}</li>`).join('');
45894589+ ov.appendChild(ul);
45904590+ }
45914591+ body.appendChild(ov);
45924592+ return wrap;
45934593+ }
45944594+45954595+ function cssSafe(v) {
45964596+ // Strip anything outside valid CSS value chars to prevent injection via
45974597+ // DESIGN.json values rendered into inline style strings.
45984598+ return String(v).replace(/[<>"'`\n]/g, '');
45994599+ }
46004600+46014601+ // --- Raw tab: minimal markdown renderer (subset) --------------------------
46024602+46034603+ function renderRawTab(body, md) {
46044604+ const wrap = document.createElement('div');
46054605+ wrap.className = 'md';
46064606+ wrap.innerHTML = renderMarkdown(md);
46074607+ body.appendChild(wrap);
46084608+ }
46094609+46104610+ function renderMarkdown(md) {
46114611+ const lines = md.split(/\r?\n/);
46124612+ const out = [];
46134613+ let i = 0;
46144614+ let inCode = false;
46154615+ let codeBuf = [];
46164616+ let paraBuf = [];
46174617+ let listBuf = []; // array of { indent, html }
46184618+ let listType = null; // 'ul' | 'ol'
46194619+46204620+ const flushPara = () => {
46214621+ if (paraBuf.length) {
46224622+ out.push(`<p>${inlineMd(paraBuf.join(' '))}</p>`);
46234623+ paraBuf = [];
46244624+ }
46254625+ };
46264626+ const flushList = () => {
46274627+ if (listBuf.length) {
46284628+ out.push(buildListHtml(listBuf, listType));
46294629+ listBuf = [];
46304630+ listType = null;
46314631+ }
46324632+ };
46334633+ const flushAll = () => { flushPara(); flushList(); };
46344634+46354635+ for (; i < lines.length; i++) {
46364636+ const line = lines[i];
46374637+46384638+ // Code fence
46394639+ const fence = line.match(/^```(\w*)\s*$/);
46404640+ if (fence) {
46414641+ if (!inCode) { flushAll(); inCode = true; codeBuf = []; }
46424642+ else {
46434643+ out.push(`<pre><code>${escapeHtml(codeBuf.join('\n'))}</code></pre>`);
46444644+ inCode = false;
46454645+ }
46464646+ continue;
46474647+ }
46484648+ if (inCode) { codeBuf.push(line); continue; }
46494649+46504650+ if (line.trim() === '') { flushAll(); continue; }
46514651+46524652+ const hr = line.match(/^\s*(?:---+|\*\*\*+)\s*$/);
46534653+ if (hr) { flushAll(); out.push('<hr />'); continue; }
46544654+46554655+ const heading = line.match(/^(#{1,4})\s+(.+)$/);
46564656+ if (heading) {
46574657+ flushAll();
46584658+ const lvl = heading[1].length;
46594659+ out.push(`<h${lvl}>${inlineMd(heading[2])}</h${lvl}>`);
46604660+ continue;
46614661+ }
46624662+46634663+ const bullet = line.match(/^(\s*)([-*])\s+(.+)$/);
46644664+ const ordered = line.match(/^(\s*)(\d+)\.\s+(.+)$/);
46654665+ if (bullet || ordered) {
46664666+ flushPara();
46674667+ const m = bullet || ordered;
46684668+ const indent = Math.floor(m[1].length / 2);
46694669+ const t = bullet ? 'ul' : 'ol';
46704670+ if (listType && listType !== t) flushList();
46714671+ listType = t;
46724672+ listBuf.push({ indent, html: inlineMd(m[3]) });
46734673+ continue;
46744674+ }
46754675+46764676+ paraBuf.push(line);
46774677+ }
46784678+ flushAll();
46794679+ if (inCode && codeBuf.length) {
46804680+ out.push(`<pre><code>${escapeHtml(codeBuf.join('\n'))}</code></pre>`);
46814681+ }
46824682+ return out.join('\n');
46834683+ }
46844684+46854685+ function buildListHtml(items, type) {
46864686+ // Nest by indent (one level deep is plenty for DESIGN.md).
46874687+ let html = `<${type}>`;
46884688+ let lastIndent = 0;
46894689+ for (const it of items) {
46904690+ if (it.indent > lastIndent) html += `<${type}>`;
46914691+ else if (it.indent < lastIndent) html += `</${type}>`.repeat(lastIndent - it.indent);
46924692+ html += `<li>${it.html}</li>`;
46934693+ lastIndent = it.indent;
46944694+ }
46954695+ html += `</${type}>`.repeat(lastIndent + 1);
46964696+ return html;
46974697+ }
46984698+46994699+ function inlineMd(text) {
47004700+ // Order matters: escape first, then re-inject tags.
47014701+ let s = escapeHtml(text);
47024702+ // Code spans
47034703+ s = s.replace(/`([^`]+)`/g, (_, code) => `<code>${code}</code>`);
47044704+ // Links [text](url)
47054705+ s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, t, u) => `<a href="${u}" target="_blank" rel="noopener noreferrer">${t}</a>`);
47064706+ // Bold
47074707+ s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
47084708+ // Italic (only single *…*, skip if inside bold already handled)
47094709+ s = s.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1<em>$2</em>');
47104710+ return s;
47114711+ }
47124712+47134713+ function highlightBold(text) {
47144714+ return inlineMd(text);
47154715+ }
47164716+47174717+ function escapeHtml(s) {
47184718+ return String(s)
47194719+ .replace(/&/g, '&')
47204720+ .replace(/</g, '<')
47214721+ .replace(/>/g, '>')
47224722+ .replace(/"/g, '"')
47234723+ .replace(/'/g, ''');
47244724+ }
47254725+47264726+ function copyToClipboard(text) {
47274727+ if (!text) return;
47284728+ try {
47294729+ navigator.clipboard.writeText(text);
47304730+ showToast('Copied: ' + text);
47314731+ } catch { /* ignore */ }
47324732+ }
47334733+47344734+ // ---------------------------------------------------------------------------
47354735+ // Init
47364736+ // ---------------------------------------------------------------------------
47374737+47384738+ function init() {
47394739+ try { history.scrollRestoration = 'manual'; } catch {}
47404740+ initHighlight();
47414741+ initAnnotOverlay();
47424742+ initBar();
47434743+ initActionPicker();
47444744+ initParamsPanel();
47454745+ initGlobalBar();
47464746+ initDesignPanel();
47474747+ document.addEventListener('mousemove', handleMouseMove, true);
47484748+ document.addEventListener('click', handleClick, true);
47494749+ document.addEventListener('keydown', handleKeyDown, true);
47504750+ connectSSE();
47514751+47524752+ // Check for an active session to resume (variant wrapper already in DOM after HMR)
47534753+ if (!resumeSession()) {
47544754+ console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.');
47554755+ // SvelteKit (and any framework that hydrates after HTML parse) may add
47564756+ // the variant wrapper AFTER init runs. Watch for it and retry resume
47574757+ // once it appears. Disconnect on first hit.
47584758+ const scout = new MutationObserver(() => {
47594759+ const wrapper = document.querySelector('[data-impeccable-variants]');
47604760+ if (!wrapper) return;
47614761+ scout.disconnect();
47624762+ if (resumeSession()) {
47634763+ console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
47644764+ }
47654765+ });
47664766+ scout.observe(document.body, { childList: true, subtree: true });
47674767+ } else {
47684768+ console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).');
47694769+ }
47704770+ }
47714771+47724772+ if (document.readyState === 'loading') {
47734773+ document.addEventListener('DOMContentLoaded', init);
47744774+ } else {
47754775+ init();
47764776+ }
47774777+})();
+436
.agents/skills/impeccable/scripts/live-inject.mjs
···11+/**
22+ * CLI helper: insert/remove the live variant mode script tag in the project's
33+ * main HTML entry point.
44+ *
55+ * On first live run, the agent generates `config.json` in this script's
66+ * directory with the project's insertion target (framework-specific). On
77+ * every subsequent run, this script handles insert/remove deterministically
88+ * with zero LLM involvement.
99+ *
1010+ * Usage:
1111+ * node live-inject.mjs --port PORT # Insert the live script tag
1212+ * node live-inject.mjs --remove # Remove the live script tag
1313+ * node live-inject.mjs --check # Check whether config.json exists
1414+ */
1515+1616+import fs from 'node:fs';
1717+import path from 'node:path';
1818+import { fileURLToPath } from 'node:url';
1919+2020+const __dirname = path.dirname(fileURLToPath(import.meta.url));
2121+const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, 'config.json');
2222+const MARKER_OPEN_TEXT = 'impeccable-live-start';
2323+const MARKER_CLOSE_TEXT = 'impeccable-live-end';
2424+2525+/**
2626+ * Hard-excluded directory patterns. These are NEVER user-facing pages and
2727+ * matching them would silently inject tracking scripts into third-party
2828+ * code. The user cannot turn these off via config — they are the floor.
2929+ */
3030+const HARD_EXCLUDES = [
3131+ '**/node_modules/**',
3232+ '**/.git/**',
3333+];
3434+3535+export async function injectCli() {
3636+ const args = process.argv.slice(2);
3737+3838+ if (args.includes('--help') || args.includes('-h')) {
3939+ console.log(`Usage: node live-inject.mjs [options]
4040+4141+Insert or remove the live mode script tag in the project's HTML entry point.
4242+Reads configuration from config.json (in this same directory).
4343+4444+Modes:
4545+ --port PORT Insert script tag pointing at http://localhost:PORT/live.js
4646+ --remove Remove the script tag (if present)
4747+ --check Print whether config.json exists and its content
4848+4949+Output (JSON):
5050+ { ok, file, inserted|removed, config? }`);
5151+ process.exit(0);
5252+ }
5353+5454+ if (args.includes('--check')) {
5555+ if (!fs.existsSync(CONFIG_PATH)) {
5656+ console.log(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH }));
5757+ process.exit(0);
5858+ }
5959+ let cfg;
6060+ try {
6161+ cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
6262+ } catch (err) {
6363+ console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH }));
6464+ return;
6565+ }
6666+ try {
6767+ validateConfig(cfg);
6868+ } catch (err) {
6969+ console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH }));
7070+ return;
7171+ }
7272+ console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH }));
7373+ return;
7474+ }
7575+7676+ // Load config
7777+ if (!fs.existsSync(CONFIG_PATH)) {
7878+ console.error(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH }));
7979+ process.exit(1);
8080+ }
8181+ const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
8282+ validateConfig(config);
8383+8484+ const resolvedFiles = resolveFiles(process.cwd(), config);
8585+8686+ if (args.includes('--remove')) {
8787+ const results = resolvedFiles.map((relFile) => {
8888+ const absFile = path.resolve(process.cwd(), relFile);
8989+ if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
9090+ const content = fs.readFileSync(absFile, 'utf-8');
9191+ const detagged = removeTag(content, config.commentSyntax);
9292+ const updated = revertCspMeta(detagged);
9393+ if (updated === content) return { file: relFile, removed: false, note: 'no tag present' };
9494+ fs.writeFileSync(absFile, updated, 'utf-8');
9595+ return {
9696+ file: relFile,
9797+ removed: detagged !== content,
9898+ cspReverted: updated !== detagged,
9999+ };
100100+ });
101101+ console.log(JSON.stringify({ ok: true, results }));
102102+ return;
103103+ }
104104+105105+ // Insert mode — need --port
106106+ const portIdx = args.indexOf('--port');
107107+ const port = portIdx !== -1 ? parseInt(args[portIdx + 1], 10) : NaN;
108108+ if (!Number.isFinite(port)) {
109109+ console.error(JSON.stringify({ ok: false, error: 'missing_port' }));
110110+ process.exit(1);
111111+ }
112112+113113+ const results = resolvedFiles.map((relFile) => {
114114+ const absFile = path.resolve(process.cwd(), relFile);
115115+ if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
116116+ const content = fs.readFileSync(absFile, 'utf-8');
117117+ const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax));
118118+ const withTag = insertTag(withoutOld, config, port);
119119+ if (withTag === withoutOld) {
120120+ return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
121121+ }
122122+ const updated = patchCspMeta(withTag, port);
123123+ fs.writeFileSync(absFile, updated, 'utf-8');
124124+ return {
125125+ file: relFile,
126126+ inserted: true,
127127+ cspPatched: updated !== withTag,
128128+ };
129129+ });
130130+ const anyInserted = results.some((r) => r.inserted);
131131+ console.log(JSON.stringify({ ok: anyInserted, port, results }));
132132+ if (!anyInserted) process.exit(1);
133133+}
134134+135135+/**
136136+ * Expand config.files (which may contain glob patterns) into a literal list
137137+ * of existing file paths relative to rootDir. Literal entries pass through;
138138+ * glob patterns are expanded via fs.globSync. HARD_EXCLUDES and config.exclude
139139+ * are applied as filters. Duplicates are removed. Order is preserved by
140140+ * first appearance.
141141+ */
142142+export function resolveFiles(rootDir, config) {
143143+ const patterns = config.files;
144144+ const userExcludes = Array.isArray(config.exclude) ? config.exclude : [];
145145+ const allExcludes = [...HARD_EXCLUDES, ...userExcludes];
146146+ const excludeRegexes = allExcludes.map(globToRegex);
147147+148148+ const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath));
149149+ const isGlob = (s) => /[*?[]/.test(s);
150150+151151+ const seen = new Set();
152152+ const out = [];
153153+ for (const pat of patterns) {
154154+ if (!isGlob(pat)) {
155155+ // Literal path — include even if it doesn't exist yet; the caller
156156+ // reports file_not_found per-entry. Exclude list doesn't apply to
157157+ // explicit literal entries (user named it on purpose).
158158+ if (!seen.has(pat)) {
159159+ seen.add(pat);
160160+ out.push(pat);
161161+ }
162162+ continue;
163163+ }
164164+ let matches;
165165+ try {
166166+ matches = fs.globSync(pat, { cwd: rootDir, withFileTypes: true });
167167+ } catch {
168168+ continue;
169169+ }
170170+ for (const ent of matches) {
171171+ if (!ent.isFile || !ent.isFile()) continue;
172172+ const abs = path.join(ent.parentPath || ent.path || rootDir, ent.name);
173173+ const rel = path.relative(rootDir, abs).split(path.sep).join('/');
174174+ if (isExcluded(rel)) continue;
175175+ if (seen.has(rel)) continue;
176176+ seen.add(rel);
177177+ out.push(rel);
178178+ }
179179+ }
180180+ return out;
181181+}
182182+183183+/**
184184+ * Convert a glob pattern to a RegExp. Supports:
185185+ * ** → any number of path segments (including zero)
186186+ * * → any chars except `/`
187187+ * ? → any single char except `/`
188188+ * Paths are normalized to forward slashes before matching.
189189+ */
190190+function globToRegex(pattern) {
191191+ let re = '';
192192+ let i = 0;
193193+ while (i < pattern.length) {
194194+ const c = pattern[i];
195195+ if (c === '*') {
196196+ if (pattern[i + 1] === '*') {
197197+ // ** — any number of segments, including zero. Handle the common
198198+ // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`.
199199+ if (pattern[i + 2] === '/') {
200200+ re += '(?:.*/)?';
201201+ i += 3;
202202+ } else {
203203+ re += '.*';
204204+ i += 2;
205205+ }
206206+ } else {
207207+ re += '[^/]*';
208208+ i += 1;
209209+ }
210210+ } else if (c === '?') {
211211+ re += '[^/]';
212212+ i += 1;
213213+ } else if (/[.+^${}()|[\]\\]/.test(c)) {
214214+ re += '\\' + c;
215215+ i += 1;
216216+ } else {
217217+ re += c;
218218+ i += 1;
219219+ }
220220+ }
221221+ return new RegExp('^' + re + '$');
222222+}
223223+224224+// ---------------------------------------------------------------------------
225225+// Core operations
226226+// ---------------------------------------------------------------------------
227227+228228+function validateConfig(cfg) {
229229+ if (!cfg || typeof cfg !== 'object') throw new Error('config.json must be an object');
230230+ if (!Array.isArray(cfg.files) || cfg.files.length === 0) {
231231+ throw new Error('config.files (non-empty string array) required');
232232+ }
233233+ if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) {
234234+ throw new Error('config.files must contain only non-empty strings');
235235+ }
236236+ if (cfg.exclude !== undefined) {
237237+ if (!Array.isArray(cfg.exclude)) {
238238+ throw new Error('config.exclude, if present, must be a string array');
239239+ }
240240+ if (!cfg.exclude.every((f) => typeof f === 'string' && f.length > 0)) {
241241+ throw new Error('config.exclude must contain only non-empty strings');
242242+ }
243243+ }
244244+ if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') {
245245+ throw new Error('config.insertBefore or config.insertAfter (string) required');
246246+ }
247247+ if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') {
248248+ throw new Error("config.commentSyntax must be 'html' or 'jsx'");
249249+ }
250250+ if (cfg.cspChecked !== undefined && typeof cfg.cspChecked !== 'boolean') {
251251+ throw new Error("config.cspChecked, if present, must be a boolean");
252252+ }
253253+}
254254+255255+function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; }
256256+function commentClose(syntax) { return syntax === 'jsx' ? '*/}' : '-->'; }
257257+258258+function buildTagBlock(syntax, port) {
259259+ const open = commentOpen(syntax);
260260+ const close = commentClose(syntax);
261261+ return (
262262+ open + ' ' + MARKER_OPEN_TEXT + ' ' + close + '\n' +
263263+ '<script src="http://localhost:' + port + '/live.js"></script>\n' +
264264+ open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n'
265265+ );
266266+}
267267+268268+function insertTag(content, config, port) {
269269+ const block = buildTagBlock(config.commentSyntax, port);
270270+ // insertBefore: match the LAST occurrence. Anchors like `</body>` naturally
271271+ // belong at the end, and the same literal can appear earlier in code blocks
272272+ // within rendered documentation pages.
273273+ if (config.insertBefore) {
274274+ const idx = content.lastIndexOf(config.insertBefore);
275275+ if (idx === -1) return content;
276276+ return content.slice(0, idx) + block + content.slice(idx);
277277+ }
278278+ // insertAfter: match the FIRST occurrence — typical anchors like `<head>` or
279279+ // `<body>` open near the top of the document.
280280+ const idx = content.indexOf(config.insertAfter);
281281+ if (idx === -1) return content;
282282+ const after = idx + config.insertAfter.length;
283283+ // Preserve a single trailing newline if the anchor didn't end with one
284284+ const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n';
285285+ return prefix + block + content.slice(prefix.length);
286286+}
287287+288288+/**
289289+ * Remove the live script block. Matches either HTML or JSX comment markers
290290+ * regardless of config (so stale tags from a wrong config can still be cleaned).
291291+ *
292292+ * Indent-preserving: captures any whitespace immediately preceding the opener
293293+ * marker and re-emits it in place of the removed block. `insertTag` inserted
294294+ * the block *after* the original line's indent and *before* the anchor (e.g.
295295+ * `</body>`), which moved the indent onto the opener line and left the anchor
296296+ * unindented. Replacing the whole block (plus its trailing newline) with just
297297+ * the captured indent hands the indent back to the anchor that follows.
298298+ */
299299+function removeTag(content, _syntax) {
300300+ const patterns = [
301301+ /([ \t]*)<!--\s*impeccable-live-start\s*-->[\s\S]*?<!--\s*impeccable-live-end\s*-->[ \t]*\n/,
302302+ /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}[ \t]*\n/,
303303+ ];
304304+ for (const pat of patterns) {
305305+ const next = content.replace(pat, '$1');
306306+ if (next !== content) return next;
307307+ }
308308+ return content;
309309+}
310310+311311+// ---------------------------------------------------------------------------
312312+// Content-Security-Policy meta-tag patcher
313313+//
314314+// When the user's HTML carries `<meta http-equiv="Content-Security-Policy">`,
315315+// the cross-origin load of /live.js (and the SSE/POST connection back to
316316+// localhost:PORT) is blocked unless the CSP explicitly allows that origin.
317317+//
318318+// On insert: append `http://localhost:PORT` to `script-src` and `connect-src`,
319319+// and stash the original `content` value in a `data-impeccable-csp-original`
320320+// attribute (base64) so revert is exact.
321321+//
322322+// On remove: detect the marker attribute, decode it, restore the original
323323+// content value verbatim, drop the marker.
324324+//
325325+// Header-based CSP (Next.js headers, Nuxt routeRules, SvelteKit kit.csp,
326326+// shared helpers) is NOT patched here — those need framework-specific config
327327+// edits and are handled via the existing detect-csp.mjs reference output.
328328+// Only the in-source meta-tag form gets the auto-patch.
329329+// ---------------------------------------------------------------------------
330330+331331+const CSP_MARKER_ATTR = 'data-impeccable-csp-original';
332332+333333+function findCspMetaTags(content) {
334334+ const out = [];
335335+ const tagRe = /<meta\s+([^>]*?)\/?>/gis;
336336+ let m;
337337+ while ((m = tagRe.exec(content)) !== null) {
338338+ const attrs = m[1];
339339+ if (!/(http-equiv|httpEquiv)\s*=\s*(['"])Content-Security-Policy\2/i.test(attrs)) continue;
340340+ out.push({ start: m.index, end: m.index + m[0].length, full: m[0], attrs });
341341+ }
342342+ return out;
343343+}
344344+345345+function getAttr(attrs, name) {
346346+ const re = new RegExp(`\\b${name}\\s*=\\s*(['"])([\\s\\S]*?)\\1`, 'i');
347347+ const m = attrs.match(re);
348348+ return m ? { quote: m[1], value: m[2], full: m[0] } : null;
349349+}
350350+351351+function appendOriginToDirective(csp, directive, origin) {
352352+ const re = new RegExp(`(^|;)(\\s*)(${directive})\\s+([^;]*)`, 'i');
353353+ const m = csp.match(re);
354354+ if (m) {
355355+ const tokens = m[4].trim().split(/\s+/);
356356+ if (tokens.includes(origin)) return csp;
357357+ return csp.replace(re, `${m[1]}${m[2]}${m[3]} ${[...tokens, origin].join(' ')}`);
358358+ }
359359+ // Directive missing — add it. Use 'self' + origin so we don't inadvertently
360360+ // narrow the policy compared to the default-src fallback (most users with
361361+ // an explicit CSP have 'self' there).
362362+ return csp.trim().replace(/;?\s*$/, '') + `; ${directive} 'self' ${origin}`;
363363+}
364364+365365+export function patchCspMeta(content, port) {
366366+ const tags = findCspMetaTags(content);
367367+ if (tags.length === 0) return content;
368368+ const origin = `http://localhost:${port}`;
369369+370370+ // Walk last-to-first so prior splices don't invalidate later indices.
371371+ let result = content;
372372+ for (let i = tags.length - 1; i >= 0; i--) {
373373+ const tag = tags[i];
374374+ const attrs = tag.attrs;
375375+ if (getAttr(attrs, CSP_MARKER_ATTR)) continue; // already patched
376376+ const contentAttr = getAttr(attrs, 'content');
377377+ if (!contentAttr) continue;
378378+379379+ const original = contentAttr.value;
380380+ let patched = original;
381381+ patched = appendOriginToDirective(patched, 'script-src', origin);
382382+ patched = appendOriginToDirective(patched, 'connect-src', origin);
383383+ // The shader overlay during 'generating' creates a screenshot via
384384+ // URL.createObjectURL, producing a `blob:` URL — img-src 'self' rejects
385385+ // those. Add `blob:` so the overlay doesn't throw a CSP violation.
386386+ patched = appendOriginToDirective(patched, 'img-src', 'blob:');
387387+ if (patched === original) continue;
388388+389389+ const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
390390+ const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
391391+ const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
392392+ const newTag = tag.full.replace(attrs, newAttrs);
393393+394394+ result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
395395+ }
396396+ return result;
397397+}
398398+399399+export function revertCspMeta(content) {
400400+ const tags = findCspMetaTags(content);
401401+ if (tags.length === 0) return content;
402402+403403+ let result = content;
404404+ for (let i = tags.length - 1; i >= 0; i--) {
405405+ const tag = tags[i];
406406+ const origAttr = getAttr(tag.attrs, CSP_MARKER_ATTR);
407407+ if (!origAttr) continue;
408408+ const contentAttr = getAttr(tag.attrs, 'content');
409409+ if (!contentAttr) continue;
410410+411411+ let originalValue;
412412+ try { originalValue = Buffer.from(origAttr.value, 'base64').toString('utf-8'); }
413413+ catch { continue; }
414414+415415+ const newContentAttr = `content=${contentAttr.quote}${originalValue}${contentAttr.quote}`;
416416+ let newAttrs = tag.attrs.replace(contentAttr.full, newContentAttr);
417417+ // Drop the marker attribute and any single space immediately preceding it.
418418+ newAttrs = newAttrs.replace(new RegExp(`\\s*${origAttr.full}`), '');
419419+ const newTag = tag.full.replace(tag.attrs, newAttrs);
420420+421421+ result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
422422+ }
423423+ return result;
424424+}
425425+426426+// ---------------------------------------------------------------------------
427427+// Auto-execute
428428+// ---------------------------------------------------------------------------
429429+430430+const _running = process.argv[1];
431431+if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) {
432432+ injectCli();
433433+}
434434+435435+export { insertTag, removeTag, validateConfig, buildTagBlock };
436436+// patchCspMeta + revertCspMeta are exported above where they're defined.
+187
.agents/skills/impeccable/scripts/live-poll.mjs
···11+/**
22+ * CLI client for the live variant mode poll/reply protocol.
33+ *
44+ * Usage:
55+ * npx impeccable poll # Block until browser event, print JSON
66+ * npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly
77+ * npx impeccable poll --reply <id> done # Reply "done" to event <id>
88+ * npx impeccable poll --reply <id> error "msg" # Reply with error
99+ */
1010+1111+import { execSync } from 'node:child_process';
1212+import fs from 'node:fs';
1313+import path from 'node:path';
1414+import os from 'node:os';
1515+import { fileURLToPath } from 'node:url';
1616+1717+// Node's built-in fetch (undici under the hood) enforces a 300s headers
1818+// timeout that can't be lowered per-request. We cap each request below
1919+// that ceiling and loop in `pollOnce` to synthesize a long poll without
2020+// depending on the standalone undici package.
2121+const PER_REQUEST_TIMEOUT_MS = 270_000;
2222+2323+const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json');
2424+2525+function readServerInfo() {
2626+ try {
2727+ return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
2828+ } catch {
2929+ console.error('No running live server found. Start one with: npx impeccable live');
3030+ process.exit(1);
3131+ }
3232+}
3333+3434+export async function pollCli() {
3535+ const args = process.argv.slice(2);
3636+3737+ if (args.includes('--help') || args.includes('-h')) {
3838+ console.log(`Usage: impeccable poll [options]
3939+4040+Wait for a browser event from the live variant server, or reply to one.
4141+4242+Modes:
4343+ poll Block until a browser event arrives, print JSON
4444+ poll --reply <id> done Reply "done" to event <id>
4545+ poll --reply <id> error "msg" Reply with an error message
4646+4747+Options:
4848+ --timeout=MS Long-poll timeout in ms (default: 600000). Use the default unless the user asked to pause live; never use a short timeout to end the chat turn
4949+ --help Show this help message`);
5050+ process.exit(0);
5151+ }
5252+5353+ const info = readServerInfo();
5454+ const base = `http://localhost:${info.port}`;
5555+5656+ // Reply mode: npx impeccable poll --reply <id> <status> [--file path] [message]
5757+ const replyIdx = args.indexOf('--reply');
5858+ if (replyIdx !== -1) {
5959+ const id = args[replyIdx + 1];
6060+ const status = args[replyIdx + 2] || 'done';
6161+ const fileIdx = args.indexOf('--file');
6262+ const filePath = fileIdx !== -1 && fileIdx + 1 < args.length ? args[fileIdx + 1] : undefined;
6363+ // Message is any remaining positional arg that isn't a flag
6464+ const message = args.find((a, i) => i > replyIdx + 2 && !a.startsWith('--') && i !== fileIdx + 1) || undefined;
6565+6666+ if (!id) {
6767+ console.error('Usage: npx impeccable poll --reply <id> <status> [--file path] [message]');
6868+ process.exit(1);
6969+ }
7070+7171+ try {
7272+ const res = await fetch(`${base}/poll`, {
7373+ method: 'POST',
7474+ headers: { 'Content-Type': 'application/json' },
7575+ body: JSON.stringify({
7676+ token: info.token,
7777+ id,
7878+ type: status,
7979+ message,
8080+ file: filePath,
8181+ }),
8282+ });
8383+8484+ if (!res.ok) {
8585+ const body = await res.json().catch(() => ({}));
8686+ console.error(`Reply failed (${res.status}):`, body.error || res.statusText);
8787+ process.exit(1);
8888+ }
8989+9090+ // Success — silent exit (agent doesn't need output for replies)
9191+ } catch (err) {
9292+ if (err.cause?.code === 'ECONNREFUSED') {
9393+ console.error('Live server not running. Start one with: npx impeccable live');
9494+ } else {
9595+ console.error('Reply failed:', err.message);
9696+ }
9797+ process.exit(1);
9898+ }
9999+ return;
100100+ }
101101+102102+ // Poll mode: block until browser event. Default 10 min. Node's built-in
103103+ // fetch enforces a 300s headers timeout, so we loop in slices under that
104104+ // ceiling and keep re-polling until we get a real event or the user's
105105+ // total timeout runs out.
106106+ const timeoutArg = args.find(a => a.startsWith('--timeout='));
107107+ const totalTimeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600000;
108108+109109+ const deadline = Date.now() + totalTimeout;
110110+ let event;
111111+ try {
112112+ while (true) {
113113+ const remaining = deadline - Date.now();
114114+ if (remaining <= 0) {
115115+ event = { type: 'timeout' };
116116+ break;
117117+ }
118118+ const slice = Math.min(remaining, PER_REQUEST_TIMEOUT_MS);
119119+ const res = await fetch(`${base}/poll?token=${info.token}&timeout=${slice}`);
120120+121121+ if (res.status === 401) {
122122+ console.error('Authentication failed. The server token may have changed.');
123123+ console.error('Try restarting: npx impeccable live stop && npx impeccable live');
124124+ process.exit(1);
125125+ }
126126+127127+ if (!res.ok) {
128128+ console.error(`Poll failed: ${res.status} ${res.statusText}`);
129129+ process.exit(1);
130130+ }
131131+132132+ const next = await res.json();
133133+ // Server-side timeout means no browser event arrived in this slice.
134134+ // Loop and re-poll until we get a real event or we hit the user's
135135+ // total deadline.
136136+ if (next?.type === 'timeout' && Date.now() < deadline) continue;
137137+ event = next;
138138+ break;
139139+ }
140140+141141+ // Auto-handle accept/discard via deterministic script
142142+ if (event.type === 'accept' || event.type === 'discard') {
143143+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
144144+ const acceptScript = path.join(__dirname, 'live-accept.mjs');
145145+ const scriptArgs = event.type === 'discard'
146146+ ? ['--id', event.id, '--discard']
147147+ : ['--id', event.id, '--variant', event.variantId];
148148+ if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
149149+ // Pass through a JSON blob; the shell-safe wrap uses single quotes because
150150+ // values are finite {id, number|string|boolean} pairs from a validated payload.
151151+ scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
152152+ }
153153+ try {
154154+ const out = execSync(
155155+ `node "${acceptScript}" ${scriptArgs.join(' ')}`,
156156+ { encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 }
157157+ );
158158+ event._acceptResult = JSON.parse(out.trim());
159159+ } catch (err) {
160160+ event._acceptResult = { handled: false, error: err.message };
161161+ }
162162+ }
163163+164164+ // Second signal path: stderr banner in case the agent parses stdout
165165+ // JSON but skips nested fields. One line is enough — the full checklist
166166+ // is in reference/live.md.
167167+ if (event._acceptResult?.carbonize === true) {
168168+ process.stderr.write('\n⚠ Carbonize cleanup REQUIRED before next poll. See reference/live.md "Required after accept".\n\n');
169169+ }
170170+171171+ // Print the event as JSON — the agent reads this from stdout
172172+ console.log(JSON.stringify(event));
173173+ } catch (err) {
174174+ if (err.cause?.code === 'ECONNREFUSED') {
175175+ console.error('Live server not running. Start one with: npx impeccable live');
176176+ } else {
177177+ console.error('Poll failed:', err.message);
178178+ }
179179+ process.exit(1);
180180+ }
181181+}
182182+183183+// Auto-execute when run directly
184184+const _running = process.argv[1];
185185+if (_running?.endsWith('live-poll.mjs') || _running?.endsWith('live-poll.mjs/')) {
186186+ pollCli();
187187+}
+679
.agents/skills/impeccable/scripts/live-server.mjs
···11+#!/usr/bin/env node
22+/**
33+ * Live variant mode server (self-contained, zero dependencies).
44+ *
55+ * Serves the browser script (/live.js), the detection overlay (/detect.js),
66+ * uses Server-Sent Events (SSE) for server→browser push, and HTTP POST for
77+ * browser→server events. Agent communicates via HTTP long-poll (/poll).
88+ *
99+ * Usage:
1010+ * node <scripts_path>/live-server.mjs # start
1111+ * node <scripts_path>/live-server.mjs stop # stop + remove injected live.js tag
1212+ * node <scripts_path>/live-server.mjs stop --keep-inject # stop only
1313+ * node <scripts_path>/live-server.mjs --help
1414+ */
1515+1616+import http from 'node:http';
1717+import { randomUUID } from 'node:crypto';
1818+import { spawn, execFileSync } from 'node:child_process';
1919+import fs from 'node:fs';
2020+import path from 'node:path';
2121+import net from 'node:net';
2222+import { fileURLToPath } from 'node:url';
2323+import { parseDesignMd } from './design-parser.mjs';
2424+2525+const __dirname = path.dirname(fileURLToPath(import.meta.url));
2626+// PID file in the project root so both the server and agent can find it
2727+// predictably (os.tmpdir() varies across platforms).
2828+const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json');
2929+const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
3030+const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
3131+3232+// ---------------------------------------------------------------------------
3333+// Port detection
3434+// ---------------------------------------------------------------------------
3535+3636+async function findOpenPort(start = 8400) {
3737+ return new Promise((resolve) => {
3838+ const srv = net.createServer();
3939+ srv.listen(start, '127.0.0.1', () => {
4040+ const port = srv.address().port;
4141+ srv.close(() => resolve(port));
4242+ });
4343+ srv.on('error', () => resolve(findOpenPort(start + 1)));
4444+ });
4545+}
4646+4747+// ---------------------------------------------------------------------------
4848+// Session state
4949+// ---------------------------------------------------------------------------
5050+5151+const state = {
5252+ token: null,
5353+ port: null,
5454+ sseClients: new Set(), // SSE response objects (server→browser push)
5555+ pendingEvents: [], // browser events waiting for agent poll
5656+ pendingPolls: [], // agent poll callbacks waiting for browser events
5757+ exitTimer: null,
5858+ sessionDir: null, // per-session tmp dir for annotation screenshots
5959+};
6060+6161+// Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB;
6262+// cap at 10 MB to guard against runaway writes from a misbehaving client.
6363+const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
6464+6565+function enqueueEvent(event) {
6666+ if (state.pendingPolls.length > 0) {
6767+ state.pendingPolls.shift()(event);
6868+ } else {
6969+ state.pendingEvents.push(event);
7070+ }
7171+}
7272+7373+/** Push a message to all connected SSE clients. */
7474+function broadcast(msg) {
7575+ const data = 'data: ' + JSON.stringify(msg) + '\n\n';
7676+ for (const res of state.sseClients) {
7777+ try { res.write(data); } catch { /* client gone */ }
7878+ }
7979+}
8080+8181+// ---------------------------------------------------------------------------
8282+// Load scripts
8383+// ---------------------------------------------------------------------------
8484+8585+function loadBrowserScripts() {
8686+ // Detection script: look relative to the skill scripts dir, then fall back
8787+ // to the npm package location (src/detect-antipatterns-browser.js).
8888+ // This one IS cached — detect.js rarely changes during a session.
8989+ const detectPaths = [
9090+ path.join(__dirname, '..', '..', '..', '..', 'src', 'detect-antipatterns-browser.js'),
9191+ path.join(process.cwd(), 'node_modules', 'impeccable', 'src', 'detect-antipatterns-browser.js'),
9292+ ];
9393+ let detectScript = '';
9494+ for (const p of detectPaths) {
9595+ try { detectScript = fs.readFileSync(p, 'utf-8'); break; } catch { /* try next */ }
9696+ }
9797+9898+ // live-browser.js: DO NOT cache. Return the path so the /live.js handler
9999+ // can re-read on every request. Editing the browser script during iteration
100100+ // should land on the next tab reload, not require a server restart.
101101+ const livePath = path.join(__dirname, 'live-browser.js');
102102+ if (!fs.existsSync(livePath)) {
103103+ process.stderr.write('Error: live-browser.js not found at ' + livePath + '\n');
104104+ process.exit(1);
105105+ }
106106+107107+ return { detectScript, livePath };
108108+}
109109+110110+function hasProjectContext() {
111111+ // PRODUCT.md carries brand voice / anti-references — that's what determines
112112+ // whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
113113+ // concern, surfaced by the design panel's own empty state. Legacy
114114+ // .impeccable.md is auto-migrated to PRODUCT.md by load-context.mjs.
115115+ try {
116116+ fs.accessSync(path.join(process.cwd(), 'PRODUCT.md'), fs.constants.R_OK);
117117+ return true;
118118+ } catch { return false; }
119119+}
120120+121121+function statOrNull(filePath) {
122122+ try { return fs.statSync(filePath); } catch { return null; }
123123+}
124124+125125+// ---------------------------------------------------------------------------
126126+// Validation (inline — no external import needed for self-contained script)
127127+// ---------------------------------------------------------------------------
128128+129129+const VISUAL_ACTIONS = [
130130+ 'impeccable', 'bolder', 'quieter', 'distill', 'polish', 'typeset',
131131+ 'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
132132+];
133133+134134+function validateEvent(msg) {
135135+ if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
136136+ switch (msg.type) {
137137+ case 'generate':
138138+ if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id';
139139+ if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action';
140140+ if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
141141+ if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context';
142142+ // Optional annotation fields (all-or-nothing: if any present, all must be well-formed).
143143+ if (msg.screenshotPath !== undefined && typeof msg.screenshotPath !== 'string') return 'generate: screenshotPath must be string';
144144+ if (msg.comments !== undefined && !Array.isArray(msg.comments)) return 'generate: comments must be array';
145145+ if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array';
146146+ return null;
147147+ case 'accept':
148148+ if (!msg.id) return 'accept: missing id';
149149+ if (!msg.variantId) return 'accept: missing variantId';
150150+ if (msg.paramValues !== undefined) {
151151+ if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
152152+ return 'accept: paramValues must be an object';
153153+ }
154154+ }
155155+ return null;
156156+ case 'discard':
157157+ return msg.id ? null : 'discard: missing id';
158158+ case 'exit':
159159+ return null;
160160+ case 'prefetch':
161161+ if (!msg.pageUrl || typeof msg.pageUrl !== 'string') return 'prefetch: missing pageUrl';
162162+ return null;
163163+ default:
164164+ return 'Unknown event type: ' + msg.type;
165165+ }
166166+}
167167+168168+// ---------------------------------------------------------------------------
169169+// HTTP request handler
170170+// ---------------------------------------------------------------------------
171171+172172+function createRequestHandler({ detectScript, livePath }) {
173173+ return (req, res) => {
174174+ const url = new URL(req.url, `http://localhost:${state.port}`);
175175+ res.setHeader('Access-Control-Allow-Origin', '*');
176176+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
177177+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
178178+ if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
179179+180180+ const p = url.pathname;
181181+182182+ // --- Scripts ---
183183+ if (p === '/live.js') {
184184+ // Re-read from disk each request so edits to live-browser.js land on
185185+ // the next tab reload. No-store headers prevent browser caching across
186186+ // sessions — during iteration, a cached old script silently breaks
187187+ // every subsequent session.
188188+ let liveScript;
189189+ try {
190190+ liveScript = fs.readFileSync(livePath, 'utf-8');
191191+ } catch (err) {
192192+ res.writeHead(500, { 'Content-Type': 'text/plain' });
193193+ res.end('Error reading live-browser.js: ' + err.message);
194194+ return;
195195+ }
196196+ const body =
197197+ `window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` +
198198+ `window.__IMPECCABLE_PORT__ = ${state.port};\n` +
199199+ liveScript;
200200+ res.writeHead(200, {
201201+ 'Content-Type': 'application/javascript',
202202+ 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
203203+ 'Pragma': 'no-cache',
204204+ });
205205+ res.end(body);
206206+ return;
207207+ }
208208+ if (p === '/detect.js' || p === '/') {
209209+ if (!detectScript) { res.writeHead(404); res.end('Not available'); return; }
210210+ res.writeHead(200, { 'Content-Type': 'application/javascript' });
211211+ res.end(detectScript);
212212+ return;
213213+ }
214214+215215+ // --- Vendored modern-screenshot (UMD build) ---
216216+ // Lazy-loaded by live.js when the user clicks Go; exposes
217217+ // window.modernScreenshot.domToBlob(...) for capture.
218218+ if (p === '/modern-screenshot.js') {
219219+ const vendorPath = path.join(__dirname, 'modern-screenshot.umd.js');
220220+ try {
221221+ res.writeHead(200, {
222222+ 'Content-Type': 'application/javascript',
223223+ 'Cache-Control': 'public, max-age=31536000, immutable',
224224+ });
225225+ res.end(fs.readFileSync(vendorPath));
226226+ } catch {
227227+ res.writeHead(404); res.end('Vendor script not found');
228228+ }
229229+ return;
230230+ }
231231+232232+ // --- Annotation upload (browser → server, raw PNG body) ---
233233+ // Client generates the eventId, POSTs the PNG, then POSTs the generate
234234+ // event with screenshotPath already set. Keeps bytes out of the SSE/poll
235235+ // bridge and preserves the "one shot from the user's POV" UX.
236236+ if (p === '/annotation' && req.method === 'POST') {
237237+ const token = url.searchParams.get('token');
238238+ if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
239239+ const eventId = url.searchParams.get('eventId');
240240+ if (!eventId || !/^[A-Za-z0-9_-]{1,64}$/.test(eventId)) {
241241+ res.writeHead(400, { 'Content-Type': 'application/json' });
242242+ res.end(JSON.stringify({ error: 'Invalid eventId' }));
243243+ return;
244244+ }
245245+ if ((req.headers['content-type'] || '').toLowerCase() !== 'image/png') {
246246+ res.writeHead(415, { 'Content-Type': 'application/json' });
247247+ res.end(JSON.stringify({ error: 'Content-Type must be image/png' }));
248248+ return;
249249+ }
250250+ if (!state.sessionDir) {
251251+ res.writeHead(500, { 'Content-Type': 'application/json' });
252252+ res.end(JSON.stringify({ error: 'Session dir unavailable' }));
253253+ return;
254254+ }
255255+ const chunks = [];
256256+ let total = 0;
257257+ let aborted = false;
258258+ req.on('data', (c) => {
259259+ if (aborted) return;
260260+ total += c.length;
261261+ if (total > MAX_ANNOTATION_BYTES) {
262262+ aborted = true;
263263+ res.writeHead(413, { 'Content-Type': 'application/json' });
264264+ res.end(JSON.stringify({ error: 'Payload too large' }));
265265+ req.destroy();
266266+ return;
267267+ }
268268+ chunks.push(c);
269269+ });
270270+ req.on('end', () => {
271271+ if (aborted) return;
272272+ const absPath = path.join(state.sessionDir, eventId + '.png');
273273+ try {
274274+ fs.writeFileSync(absPath, Buffer.concat(chunks));
275275+ } catch (err) {
276276+ res.writeHead(500, { 'Content-Type': 'application/json' });
277277+ res.end(JSON.stringify({ error: 'Write failed: ' + err.message }));
278278+ return;
279279+ }
280280+ res.writeHead(200, { 'Content-Type': 'application/json' });
281281+ res.end(JSON.stringify({ ok: true, path: absPath }));
282282+ });
283283+ req.on('error', () => {
284284+ if (!aborted) {
285285+ res.writeHead(500, { 'Content-Type': 'application/json' });
286286+ res.end(JSON.stringify({ error: 'Upload failed' }));
287287+ }
288288+ });
289289+ return;
290290+ }
291291+292292+ // --- Health ---
293293+ if (p === '/health') {
294294+ res.writeHead(200, { 'Content-Type': 'application/json' });
295295+ res.end(JSON.stringify({
296296+ status: 'ok', port: state.port, mode: 'variant',
297297+ hasProjectContext: hasProjectContext(),
298298+ connectedClients: state.sseClients.size,
299299+ }));
300300+ return;
301301+ }
302302+303303+ // --- Design system (unified v2 response) + raw ---
304304+ // /design-system.json returns both parsed DESIGN.md and DESIGN.json
305305+ // sidecar when present. Panel merges them:
306306+ // { present, parsed, sidecar, hasMd, hasSidecar,
307307+ // mdNewerThanJson, parseError?, sidecarError? }
308308+ // - parsed: output of parseDesignMd (frontmatter
309309+ // + six canonical sections) when DESIGN.md exists.
310310+ // - sidecar: DESIGN.json contents when present.
311311+ // Expected shape: schemaVersion 2, carrying
312312+ // extensions + components + narrative.
313313+ // /design-system/raw returns DESIGN.md markdown verbatim
314314+ if (p === '/design-system.json' || p === '/design-system/raw') {
315315+ const token = url.searchParams.get('token');
316316+ if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
317317+318318+ const mdPath = path.join(process.cwd(), 'DESIGN.md');
319319+ const jsonPath = path.join(process.cwd(), 'DESIGN.json');
320320+ const mdStat = statOrNull(mdPath);
321321+ const jsonStat = statOrNull(jsonPath);
322322+323323+ if (p === '/design-system/raw') {
324324+ if (!mdStat) { res.writeHead(404); res.end('Not found'); return; }
325325+ res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' });
326326+ res.end(fs.readFileSync(mdPath, 'utf-8'));
327327+ return;
328328+ }
329329+330330+ if (!mdStat && !jsonStat) {
331331+ res.writeHead(404, { 'Content-Type': 'application/json' });
332332+ res.end(JSON.stringify({ present: false }));
333333+ return;
334334+ }
335335+336336+ const response = {
337337+ present: true,
338338+ hasMd: !!mdStat,
339339+ hasSidecar: !!jsonStat,
340340+ mdNewerThanJson: !!(mdStat && jsonStat && mdStat.mtimeMs > jsonStat.mtimeMs + 1000),
341341+ };
342342+343343+ if (mdStat) {
344344+ try {
345345+ response.parsed = parseDesignMd(fs.readFileSync(mdPath, 'utf-8'));
346346+ } catch (err) {
347347+ response.parseError = err.message;
348348+ }
349349+ }
350350+351351+ if (jsonStat) {
352352+ try {
353353+ response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
354354+ } catch (err) {
355355+ response.sidecarError = 'Failed to parse DESIGN.json: ' + err.message;
356356+ }
357357+ }
358358+359359+ res.writeHead(200, { 'Content-Type': 'application/json' });
360360+ res.end(JSON.stringify(response));
361361+ return;
362362+ }
363363+364364+ // --- Source file (no-HMR fallback) ---
365365+ if (p === '/source') {
366366+ const token = url.searchParams.get('token');
367367+ if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
368368+ const filePath = url.searchParams.get('path');
369369+ if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
370370+ const absPath = path.resolve(process.cwd(), filePath);
371371+ if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; }
372372+ let content;
373373+ try { content = fs.readFileSync(absPath, 'utf-8'); }
374374+ catch { res.writeHead(404); res.end('File not found'); return; }
375375+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
376376+ res.end(content);
377377+ return;
378378+ }
379379+380380+ // --- SSE: server→browser push (replaces WebSocket) ---
381381+ if (p === '/events' && req.method === 'GET') {
382382+ const token = url.searchParams.get('token');
383383+ if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
384384+ res.writeHead(200, {
385385+ 'Content-Type': 'text/event-stream',
386386+ 'Cache-Control': 'no-cache',
387387+ 'Connection': 'keep-alive',
388388+ });
389389+ res.write('data: ' + JSON.stringify({
390390+ type: 'connected',
391391+ hasProjectContext: hasProjectContext(),
392392+ }) + '\n\n');
393393+394394+ state.sseClients.add(res);
395395+ clearTimeout(state.exitTimer);
396396+397397+ // Keepalive: SSE comment every 30s prevents silent connection drops.
398398+ const heartbeat = setInterval(() => {
399399+ try { res.write(': keepalive\n\n'); } catch { clearInterval(heartbeat); }
400400+ }, SSE_HEARTBEAT_INTERVAL);
401401+402402+ req.on('close', () => {
403403+ clearInterval(heartbeat);
404404+ state.sseClients.delete(res);
405405+ if (state.sseClients.size === 0) {
406406+ clearTimeout(state.exitTimer);
407407+ state.exitTimer = setTimeout(() => {
408408+ if (state.sseClients.size === 0) enqueueEvent({ type: 'exit' });
409409+ }, 8000);
410410+ }
411411+ });
412412+ return;
413413+ }
414414+415415+ // --- Browser→server events (replaces WebSocket messages) ---
416416+ if (p === '/events' && req.method === 'POST') {
417417+ let body = '';
418418+ req.on('data', (c) => { body += c; });
419419+ req.on('end', () => {
420420+ let msg;
421421+ try { msg = JSON.parse(body); } catch {
422422+ res.writeHead(400, { 'Content-Type': 'application/json' });
423423+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
424424+ return;
425425+ }
426426+ if (msg.token !== state.token) {
427427+ res.writeHead(401, { 'Content-Type': 'application/json' });
428428+ res.end(JSON.stringify({ error: 'Unauthorized' }));
429429+ return;
430430+ }
431431+ const error = validateEvent(msg);
432432+ if (error) {
433433+ res.writeHead(400, { 'Content-Type': 'application/json' });
434434+ res.end(JSON.stringify({ error }));
435435+ return;
436436+ }
437437+ enqueueEvent(msg);
438438+ res.writeHead(200, { 'Content-Type': 'application/json' });
439439+ res.end(JSON.stringify({ ok: true }));
440440+ });
441441+ return;
442442+ }
443443+444444+ // --- Stop ---
445445+ if (p === '/stop') {
446446+ const token = url.searchParams.get('token');
447447+ if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
448448+ res.writeHead(200, { 'Content-Type': 'text/plain' });
449449+ res.end('stopping');
450450+ shutdown();
451451+ return;
452452+ }
453453+454454+ // --- Agent poll ---
455455+ if (p === '/poll' && req.method === 'GET') {
456456+ handlePollGet(req, res, url);
457457+ return;
458458+ }
459459+ if (p === '/poll' && req.method === 'POST') {
460460+ handlePollPost(req, res);
461461+ return;
462462+ }
463463+464464+ res.writeHead(404); res.end('Not found');
465465+ };
466466+}
467467+468468+// ---------------------------------------------------------------------------
469469+// Agent poll endpoints (unchanged from WS version)
470470+// ---------------------------------------------------------------------------
471471+472472+function handlePollGet(req, res, url) {
473473+ const token = url.searchParams.get('token');
474474+ if (token !== state.token) {
475475+ res.writeHead(401, { 'Content-Type': 'application/json' });
476476+ res.end(JSON.stringify({ error: 'Unauthorized' }));
477477+ return;
478478+ }
479479+ const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10);
480480+ if (state.pendingEvents.length > 0) {
481481+ res.writeHead(200, { 'Content-Type': 'application/json' });
482482+ res.end(JSON.stringify(state.pendingEvents.shift()));
483483+ return;
484484+ }
485485+ const timer = setTimeout(() => {
486486+ const idx = state.pendingPolls.indexOf(resolve);
487487+ if (idx !== -1) state.pendingPolls.splice(idx, 1);
488488+ res.writeHead(200, { 'Content-Type': 'application/json' });
489489+ res.end(JSON.stringify({ type: 'timeout' }));
490490+ }, timeout);
491491+ function resolve(event) {
492492+ clearTimeout(timer);
493493+ res.writeHead(200, { 'Content-Type': 'application/json' });
494494+ res.end(JSON.stringify(event));
495495+ }
496496+ state.pendingPolls.push(resolve);
497497+ req.on('close', () => {
498498+ clearTimeout(timer);
499499+ const idx = state.pendingPolls.indexOf(resolve);
500500+ if (idx !== -1) state.pendingPolls.splice(idx, 1);
501501+ });
502502+}
503503+504504+function handlePollPost(req, res) {
505505+ let body = '';
506506+ req.on('data', (c) => { body += c; });
507507+ req.on('end', () => {
508508+ let msg;
509509+ try { msg = JSON.parse(body); } catch {
510510+ res.writeHead(400, { 'Content-Type': 'application/json' });
511511+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
512512+ return;
513513+ }
514514+ if (msg.token !== state.token) {
515515+ res.writeHead(401, { 'Content-Type': 'application/json' });
516516+ res.end(JSON.stringify({ error: 'Unauthorized' }));
517517+ return;
518518+ }
519519+ // Forward the reply to the browser via SSE
520520+ broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data });
521521+ res.writeHead(200, { 'Content-Type': 'application/json' });
522522+ res.end(JSON.stringify({ ok: true }));
523523+ });
524524+}
525525+526526+// ---------------------------------------------------------------------------
527527+// Lifecycle
528528+// ---------------------------------------------------------------------------
529529+530530+let httpServer = null;
531531+532532+function shutdown() {
533533+ try { fs.unlinkSync(LIVE_PID_FILE); } catch {}
534534+ if (state.sessionDir) {
535535+ try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {}
536536+ }
537537+ for (const res of state.sseClients) { try { res.end(); } catch {} }
538538+ state.sseClients.clear();
539539+ for (const resolve of state.pendingPolls) resolve({ type: 'exit' });
540540+ state.pendingPolls.length = 0;
541541+ if (httpServer) httpServer.close();
542542+ process.exit(0);
543543+}
544544+545545+// ---------------------------------------------------------------------------
546546+// Main
547547+// ---------------------------------------------------------------------------
548548+549549+const args = process.argv.slice(2);
550550+551551+if (args.includes('--help') || args.includes('-h')) {
552552+ console.log(`Usage: node live-server.mjs [options]
553553+554554+Start the live variant mode server (zero dependencies).
555555+556556+Commands:
557557+ (default) Start the server (foreground)
558558+ stop Stop the server and remove the injected live.js script tag
559559+ stop --keep-inject Stop the server only (leave the script tag in the HTML entry)
560560+561561+Options:
562562+ --background Start detached, print connection JSON to stdout, then exit
563563+ --port=PORT Use a specific port (default: auto-detect starting at 8400)
564564+ --keep-inject Only with stop: skip live-inject.mjs --remove
565565+ --help Show this help
566566+567567+Endpoints:
568568+ /live.js Browser script (element picker + variant cycling)
569569+ /detect.js Detection overlay (backwards compatible)
570570+ /modern-screenshot.js Vendored modern-screenshot UMD build (lazy-loaded by live.js)
571571+ /annotation POST raw image/png to stage a variant screenshot
572572+ /events SSE stream (server→browser) + POST (browser→server)
573573+ /poll Long-poll for agent CLI
574574+ /source Raw source file reader (no-HMR fallback)
575575+ /health Health check`);
576576+ process.exit(0);
577577+}
578578+579579+if (args.includes('stop')) {
580580+ const keepInject = args.includes('--keep-inject');
581581+ try {
582582+ const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
583583+ const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`);
584584+ if (res.ok) console.log(`Stopped live server on port ${info.port}.`);
585585+ } catch {
586586+ console.log('No running live server found.');
587587+ }
588588+ if (!keepInject) {
589589+ const injectPath = path.join(__dirname, 'live-inject.mjs');
590590+ try {
591591+ const out = execFileSync(process.execPath, [injectPath, '--remove'], {
592592+ encoding: 'utf-8',
593593+ cwd: process.cwd(),
594594+ });
595595+ const line = out.trim().split('\n').filter(Boolean).pop();
596596+ if (line) {
597597+ try {
598598+ const j = JSON.parse(line);
599599+ if (j.removed === true) {
600600+ console.log(`Removed live script tag from ${j.file}.`);
601601+ }
602602+ } catch {
603603+ /* ignore non-JSON lines */
604604+ }
605605+ }
606606+ } catch (err) {
607607+ const detail = err.stderr?.toString?.().trim?.()
608608+ || err.stdout?.toString?.().trim?.()
609609+ || err.message
610610+ || String(err);
611611+ console.warn(`Note: could not remove live script tag (${detail.split('\n')[0]})`);
612612+ }
613613+ }
614614+ process.exit(0);
615615+}
616616+617617+// --background: spawn a detached child server, wait for it to be ready,
618618+// print the connection JSON, then exit. This keeps the startup command
619619+// simple (no shell backgrounding or chained commands).
620620+if (args.includes('--background')) {
621621+ const childArgs = args.filter(a => a !== '--background');
622622+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...childArgs], {
623623+ detached: true,
624624+ stdio: 'ignore',
625625+ cwd: process.cwd(),
626626+ });
627627+ child.unref();
628628+629629+ // Poll for the PID file (the child writes it once the HTTP server is listening).
630630+ const deadline = Date.now() + 10_000;
631631+ while (Date.now() < deadline) {
632632+ try {
633633+ const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
634634+ if (info.pid !== process.pid) {
635635+ // Output JSON so the agent can read port + token from stdout.
636636+ console.log(JSON.stringify(info));
637637+ process.exit(0);
638638+ }
639639+ } catch { /* not ready yet */ }
640640+ await new Promise(r => setTimeout(r, 200));
641641+ }
642642+ console.error('Timed out waiting for live server to start.');
643643+ process.exit(1);
644644+}
645645+646646+// Check for existing session
647647+try {
648648+ const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
649649+ try { process.kill(existing.pid, 0);
650650+ console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`);
651651+ console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop');
652652+ process.exit(1);
653653+ } catch { fs.unlinkSync(LIVE_PID_FILE); }
654654+} catch {}
655655+656656+state.token = randomUUID();
657657+const portArg = args.find(a => a.startsWith('--port='));
658658+state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort();
659659+// Annotation screenshots live in the project root so the agent's Read tool
660660+// doesn't trip a per-file permission prompt. Sessioned by token so concurrent
661661+// projects (or quick restarts) don't collide.
662662+const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations');
663663+fs.mkdirSync(annotRoot, { recursive: true });
664664+state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-'));
665665+666666+const { detectScript, livePath } = loadBrowserScripts();
667667+httpServer = http.createServer(createRequestHandler({ detectScript, livePath }));
668668+669669+httpServer.listen(state.port, '127.0.0.1', () => {
670670+ fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token }));
671671+ const url = `http://localhost:${state.port}`;
672672+ console.log(`\nImpeccable live server running on ${url}`);
673673+ console.log(`Token: ${state.token}\n`);
674674+ console.log(`Inject: <script src="${url}/live.js"><\/script>`);
675675+ console.log(`Stop: node ${path.basename(fileURLToPath(import.meta.url))} stop`);
676676+});
677677+678678+process.on('SIGINT', shutdown);
679679+process.on('SIGTERM', shutdown);
+395
.agents/skills/impeccable/scripts/live-wrap.mjs
···11+/**
22+ * CLI helper: find an element in source and wrap it in a variant container.
33+ *
44+ * Usage:
55+ * npx impeccable wrap --id SESSION_ID --count N --query "hero-combined-left" [--file path]
66+ *
77+ * Searches project files for the element matching the query (class name, ID, or
88+ * text snippet), wraps it with the variant scaffolding, and prints the file path
99+ * + line range where the agent should insert variant HTML.
1010+ *
1111+ * This replaces 3-4 agent tool calls (grep + read + edit) with a single CLI call.
1212+ */
1313+1414+import fs from 'node:fs';
1515+import path from 'node:path';
1616+import { isGeneratedFile } from './is-generated.mjs';
1717+1818+const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
1919+2020+export async function wrapCli() {
2121+ const args = process.argv.slice(2);
2222+2323+ if (args.includes('--help') || args.includes('-h')) {
2424+ console.log(`Usage: impeccable wrap [options]
2525+2626+Find an element in source and wrap it in a variant container.
2727+2828+Required:
2929+ --id ID Session ID for the variant wrapper
3030+ --count N Number of expected variants (1-8)
3131+3232+Element identification (at least one required):
3333+ --element-id ID HTML id attribute of the element
3434+ --classes A,B,C Comma-separated CSS class names
3535+ --tag TAG Tag name (div, section, etc.)
3636+ --query TEXT Fallback: raw text to search for
3737+3838+Optional:
3939+ --file PATH Source file to search in (skips auto-detection)
4040+ --help Show this help message
4141+4242+Output (JSON):
4343+ { file, startLine, endLine, insertLine, commentSyntax }
4444+4545+The agent should insert variant HTML at insertLine.`);
4646+ process.exit(0);
4747+ }
4848+4949+ const id = argVal(args, '--id');
5050+ const count = parseInt(argVal(args, '--count') || '3');
5151+ const elementId = argVal(args, '--element-id');
5252+ const classes = argVal(args, '--classes');
5353+ const tag = argVal(args, '--tag');
5454+ const query = argVal(args, '--query');
5555+ const filePath = argVal(args, '--file');
5656+5757+ if (!id) { console.error('Missing --id'); process.exit(1); }
5858+ if (!elementId && !classes && !query) {
5959+ console.error('Need at least one of: --element-id, --classes, --query');
6060+ process.exit(1);
6161+ }
6262+6363+ // Build search queries in priority order (most specific first)
6464+ const queries = buildSearchQueries(elementId, classes, tag, query);
6565+6666+ const genOpts = { cwd: process.cwd() };
6767+6868+ // Find the source file. Generated files are excluded from auto-search so we
6969+ // don't silently write variants into a file the next build will wipe.
7070+ let targetFile = filePath;
7171+ let matchedQuery = null;
7272+ if (!targetFile) {
7373+ for (const q of queries) {
7474+ targetFile = findFileWithQuery(q, process.cwd(), genOpts);
7575+ if (targetFile) { matchedQuery = q; break; }
7676+ }
7777+ if (!targetFile) {
7878+ // Nothing in source. Did the element show up in a generated file? That
7979+ // tells the agent "fall back to the agent-driven flow" vs "element just
8080+ // doesn't exist in this project."
8181+ let generatedHit = null;
8282+ for (const q of queries) {
8383+ generatedHit = findFileWithQuery(q, process.cwd(), { ...genOpts, includeGenerated: true });
8484+ if (generatedHit) break;
8585+ }
8686+ if (generatedHit) {
8787+ console.error(JSON.stringify({
8888+ error: 'element_not_in_source',
8989+ fallback: 'agent-driven',
9090+ generatedMatch: path.relative(process.cwd(), generatedHit),
9191+ hint: 'Element found only in a generated file. See "Handle fallback" in live.md.',
9292+ }));
9393+ } else {
9494+ console.error(JSON.stringify({
9595+ error: 'element_not_found',
9696+ fallback: 'agent-driven',
9797+ hint: 'Element not found in any project file. It may be runtime-injected (JS component, etc.). See "Handle fallback" in live.md.',
9898+ }));
9999+ }
100100+ process.exit(1);
101101+ }
102102+ } else {
103103+ if (isGeneratedFile(targetFile, genOpts)) {
104104+ console.error(JSON.stringify({
105105+ error: 'file_is_generated',
106106+ fallback: 'agent-driven',
107107+ file: path.relative(process.cwd(), path.resolve(process.cwd(), targetFile)),
108108+ hint: 'Explicit --file points at a generated file. Writing here gets wiped by the next build. See "Handle fallback" in live.md.',
109109+ }));
110110+ process.exit(1);
111111+ }
112112+ matchedQuery = queries[0];
113113+ }
114114+115115+ const content = fs.readFileSync(targetFile, 'utf-8');
116116+ const lines = content.split('\n');
117117+118118+ // Find the element, trying each query in priority order.
119119+ // Pass tag hint so findElement can reject matches inside wrong element types
120120+ // and walk backward to the real opener on multi-line JSX tags.
121121+ let match = null;
122122+ for (const q of queries) {
123123+ match = findElement(lines, q, tag);
124124+ if (match) break;
125125+ }
126126+ if (!match) {
127127+ console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
128128+ process.exit(1);
129129+ }
130130+131131+ const { startLine, endLine } = match;
132132+ const commentSyntax = detectCommentSyntax(targetFile);
133133+ const isJsx = commentSyntax.open === '{/*';
134134+ const indent = lines[startLine].match(/^(\s*)/)[1];
135135+136136+ // Extract the original element
137137+ const originalLines = lines.slice(startLine, endLine + 1);
138138+ const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
139139+140140+ // Wrapper attributes differ by syntax. HTML allows plain string attrs;
141141+ // JSX requires object-literal style and parses string attrs as HTML (which
142142+ // either type-errors or renders a literal CSS string).
143143+ const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"';
144144+145145+ // Build the wrapper
146146+ const wrapperLines = [
147147+ indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
148148+ indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
149149+ indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
150150+ indent + ' <div data-impeccable-variant="original">',
151151+ originalIndented,
152152+ indent + ' </div>',
153153+ indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,
154154+ indent + '</div>',
155155+ indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
156156+ ];
157157+158158+ // Replace the original element with the wrapper
159159+ const newLines = [
160160+ ...lines.slice(0, startLine),
161161+ ...wrapperLines,
162162+ ...lines.slice(endLine + 1),
163163+ ];
164164+ fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
165165+166166+ // Calculate insert line (the "insert below this line" comment)
167167+ const insertLine = startLine + 6; // 0-indexed in the new file
168168+169169+ console.log(JSON.stringify({
170170+ file: path.relative(process.cwd(), targetFile),
171171+ startLine: startLine + 1, // 1-indexed for the agent
172172+ endLine: startLine + wrapperLines.length, // 1-indexed
173173+ insertLine: insertLine + 1, // 1-indexed: where variants go
174174+ commentSyntax: commentSyntax,
175175+ originalLineCount: originalLines.length,
176176+ }));
177177+}
178178+179179+// ---------------------------------------------------------------------------
180180+// Helpers
181181+// ---------------------------------------------------------------------------
182182+183183+function argVal(args, flag) {
184184+ const idx = args.indexOf(flag);
185185+ return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
186186+}
187187+188188+/**
189189+ * Build search query strings in priority order (most specific first).
190190+ * ID is most reliable, then specific class combos, then single classes, then raw query.
191191+ */
192192+function buildSearchQueries(elementId, classes, tag, query) {
193193+ const queries = [];
194194+195195+ // 1. ID is the most specific
196196+ if (elementId) {
197197+ queries.push('id="' + elementId + '"');
198198+ }
199199+200200+ // 2. Full class attribute match (for elements with distinctive multi-class combos).
201201+ // Emit both class="..." (HTML) and className="..." (React/JSX) so whichever
202202+ // convention the file uses will match.
203203+ if (classes) {
204204+ const classList = classes.split(',').map(c => c.trim()).filter(Boolean);
205205+ if (classList.length > 1) {
206206+ const joined = classList.join(' ');
207207+ const sorted = [...classList].sort((a, b) => b.length - a.length);
208208+ queries.push('class="' + joined + '"');
209209+ queries.push('className="' + joined + '"');
210210+ queries.push(sorted[0]); // most distinctive single class, fallback
211211+ } else if (classList.length === 1) {
212212+ queries.push(classList[0]);
213213+ }
214214+ }
215215+216216+ // 3. Tag + class combo (e.g., <section class="hero">).
217217+ // Same dual-emit for JSX compatibility.
218218+ if (tag && classes) {
219219+ const firstClass = classes.split(',')[0].trim();
220220+ queries.push('<' + tag + ' class="' + firstClass);
221221+ queries.push('<' + tag + ' className="' + firstClass);
222222+ }
223223+224224+ // 4. Raw fallback query
225225+ if (query) {
226226+ queries.push(query);
227227+ }
228228+229229+ return queries;
230230+}
231231+232232+function detectCommentSyntax(filePath) {
233233+ const ext = path.extname(filePath).toLowerCase();
234234+ if (ext === '.jsx' || ext === '.tsx') {
235235+ return { open: '{/*', close: '*/}' };
236236+ }
237237+ // HTML, Vue, Svelte, Astro all use HTML comments
238238+ return { open: '<!--', close: '-->' };
239239+}
240240+241241+/**
242242+ * Search project files for the query string (class name, ID, etc.)
243243+ * Returns the first matching file path, or null.
244244+ */
245245+function findFileWithQuery(query, cwd, genOpts = {}) {
246246+ const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
247247+ const seen = new Set();
248248+249249+ for (const dir of searchDirs) {
250250+ const absDir = path.join(cwd, dir);
251251+ if (!fs.existsSync(absDir)) continue;
252252+ const result = searchDir(absDir, query, seen, 0, genOpts);
253253+ if (result) return result;
254254+ }
255255+ return null;
256256+}
257257+258258+function searchDir(dir, query, seen, depth, genOpts) {
259259+ if (depth > 5) return null; // don't go too deep
260260+ const realDir = fs.realpathSync(dir);
261261+ if (seen.has(realDir)) return null;
262262+ seen.add(realDir);
263263+264264+ let entries;
265265+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
266266+ catch { return null; }
267267+268268+ // Check files first
269269+ for (const entry of entries) {
270270+ if (!entry.isFile()) continue;
271271+ const ext = path.extname(entry.name).toLowerCase();
272272+ if (!EXTENSIONS.includes(ext)) continue;
273273+274274+ const filePath = path.join(dir, entry.name);
275275+ if (!genOpts.includeGenerated && isGeneratedFile(filePath, genOpts)) continue;
276276+ try {
277277+ const content = fs.readFileSync(filePath, 'utf-8');
278278+ if (content.includes(query)) return filePath;
279279+ } catch { /* skip unreadable files */ }
280280+ }
281281+282282+ // Then recurse into directories. Always skip node_modules and .git (never
283283+ // project content). dist/build/out are left to the isGeneratedFile guard so
284284+ // the includeGenerated second-pass can still find the element there and
285285+ // report `generatedMatch`.
286286+ for (const entry of entries) {
287287+ if (!entry.isDirectory()) continue;
288288+ if (entry.name === 'node_modules' || entry.name === '.git') continue;
289289+ const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts);
290290+ if (result) return result;
291291+ }
292292+293293+ return null;
294294+}
295295+296296+/**
297297+ * Regex that matches a tag opener on a line. Allows the tag name to be
298298+ * followed by whitespace, `>`, `/`, or end-of-line so that multi-line JSX
299299+ * openers (e.g. `<section\n className="..."\n>`) are recognised.
300300+ */
301301+const OPENER_RE = /<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/;
302302+303303+/**
304304+ * Find the element's start and end line in the file.
305305+ *
306306+ * `query` is a class name, attribute fragment (`class="..."`, `className="..."`,
307307+ * `id="..."`), or a raw text snippet. Because a query can appear on a
308308+ * continuation line of a multi-line tag (e.g. the `className="..."` row of a
309309+ * `<section\n className="..."\n>` JSX tag), we walk backward from the match
310310+ * line to find the actual tag opener. When `tag` is provided, opener candidates
311311+ * must match that tag name.
312312+ */
313313+function findElement(lines, query, tag = null) {
314314+ // Iterate all matches — the first substring hit isn't always the right one.
315315+ for (let i = 0; i < lines.length; i++) {
316316+ if (!lines[i].includes(query)) continue;
317317+318318+ const stripped = lines[i].trim();
319319+ if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue;
320320+ // Skip lines already inside a variant wrapper
321321+ if (lines[i].includes('data-impeccable-variant')) continue;
322322+323323+ const openerLine = findOpenerLine(lines, i, tag);
324324+ if (openerLine === -1) continue;
325325+326326+ const endLine = findClosingLine(lines, openerLine);
327327+ return { startLine: openerLine, endLine };
328328+ }
329329+330330+ return null;
331331+}
332332+333333+/**
334334+ * Resolve a match line to the real tag opener. If the match line itself opens
335335+ * a tag, return it. Otherwise walk up to 10 lines backward looking for the
336336+ * first tag opener. If `tag` is specified, the opener must match that tag
337337+ * name; an opener with a different tag name aborts the backward walk for this
338338+ * match (we don't jump across element boundaries).
339339+ *
340340+ * Returns the line index of the opener, or -1 if none can be resolved.
341341+ */
342342+function findOpenerLine(lines, matchLine, tag) {
343343+ const self = lines[matchLine].match(OPENER_RE);
344344+ if (self) {
345345+ if (!tag || self[1] === tag) return matchLine;
346346+ return -1;
347347+ }
348348+ const MAX_BACKWALK = 10;
349349+ for (let i = matchLine - 1; i >= Math.max(0, matchLine - MAX_BACKWALK); i--) {
350350+ const opener = lines[i].match(OPENER_RE);
351351+ if (!opener) continue;
352352+ if (!tag || opener[1] === tag) return i;
353353+ // Different tag name than requested — abort; we're inside a non-target opener.
354354+ return -1;
355355+ }
356356+ return -1;
357357+}
358358+359359+/**
360360+ * Starting from a line with an opening tag, find the line with the matching
361361+ * closing tag by counting tag nesting depth.
362362+ */
363363+function findClosingLine(lines, start) {
364364+ const openMatch = lines[start].match(OPENER_RE);
365365+ if (!openMatch) return start; // caller passed a non-opener; nothing to span
366366+367367+ const tagName = openMatch[1];
368368+ let depth = 0;
369369+ const openRe = new RegExp('<' + tagName + '(?=[\\s/>]|$)', 'g');
370370+ const selfCloseRe = new RegExp('<' + tagName + '[^>]*/>', 'g');
371371+ const closeRe = new RegExp('</' + tagName + '\\s*>', 'g');
372372+373373+ for (let i = start; i < lines.length; i++) {
374374+ const line = lines[i];
375375+ const opens = (line.match(openRe) || []).length;
376376+ const selfCloses = (line.match(selfCloseRe) || []).length;
377377+ const closes = (line.match(closeRe) || []).length;
378378+379379+ depth += opens - selfCloses - closes;
380380+381381+ if (depth <= 0) return i;
382382+ }
383383+384384+ // If we can't find the close, return a reasonable guess
385385+ return Math.min(start + 50, lines.length - 1);
386386+}
387387+388388+// Auto-execute when run directly (node live-wrap.mjs ...)
389389+const _running = process.argv[1];
390390+if (_running?.endsWith('live-wrap.mjs') || _running?.endsWith('live-wrap.mjs/')) {
391391+ wrapCli();
392392+}
393393+394394+// Test exports (used by tests/live-wrap.test.mjs)
395395+export { buildSearchQueries, findElement, findClosingLine, detectCommentSyntax };
+247
.agents/skills/impeccable/scripts/live.mjs
···11+/**
22+ * CLI entry point: prepare everything needed to enter the live variant poll loop.
33+ *
44+ * Does (all in one command):
55+ * 1. Check config.json (returns config_missing if first-ever run)
66+ * 2. Start the live server in the background (or reuse a running one)
77+ * 3. Inject the browser script tag into the project's entry file
88+ * 4. Read .impeccable.md for design context (if present)
99+ * 5. Print a single JSON blob with everything the agent needs
1010+ *
1111+ * After this, the agent's only remaining steps are:
1212+ * - Open the project's live dev/preview URL in the browser (optional, if browser automation exists)—not `serverPort`; that port is the Impeccable helper for /live.js and /poll
1313+ * - Enter the poll loop: `node live-poll.mjs`
1414+ *
1515+ * Usage:
1616+ * node live.mjs # Prepare everything, print JSON, exit
1717+ * node live.mjs --help
1818+ */
1919+2020+import { execSync } from 'node:child_process';
2121+import fs from 'node:fs';
2222+import path from 'node:path';
2323+import { fileURLToPath } from 'node:url';
2424+import { loadContext } from './load-context.mjs';
2525+import { resolveFiles } from './live-inject.mjs';
2626+2727+const __dirname = path.dirname(fileURLToPath(import.meta.url));
2828+const PID_FILE = path.join(process.cwd(), '.impeccable-live.json');
2929+3030+async function liveCli() {
3131+ const args = process.argv.slice(2);
3232+3333+ if (args.includes('--help') || args.includes('-h')) {
3434+ console.log(`Usage: node live.mjs
3535+3636+Prepare everything for live variant mode in a single command:
3737+ - Checks scripts/config.json (required, created once per project)
3838+ - Starts (or reuses) the live server in the background
3939+ - Injects the browser script tag
4040+ - Reads .impeccable.md for design context
4141+4242+On success, prints a JSON blob with:
4343+ { ok, serverPort, serverToken, pageFile, hasContext, context }
4444+4545+On config_missing, prints:
4646+ { ok: false, error: "config_missing", configPath, hint }
4747+4848+The agent should then:
4949+ 1. If config_missing, create the config and re-run this script
5050+ 2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
5151+ 3. Enter the poll loop: node live-poll.mjs`);
5252+ process.exit(0);
5353+ }
5454+5555+ // 1. Check config (fail fast if missing — no point starting anything else)
5656+ const checkOut = runScript('live-inject.mjs', ['--check']);
5757+ const checkResult = safeParse(checkOut);
5858+ if (!checkResult || !checkResult.ok) {
5959+ console.log(JSON.stringify(checkResult || { ok: false, error: 'check_failed', raw: checkOut }));
6060+ process.exit(0);
6161+ }
6262+6363+ // 2. Start server (or reuse existing)
6464+ const serverInfo = ensureServerRunning();
6565+ if (!serverInfo) {
6666+ console.log(JSON.stringify({ ok: false, error: 'server_start_failed' }));
6767+ process.exit(1);
6868+ }
6969+7070+ // 3. Inject the script tag at the current port
7171+ const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)]);
7272+ const injectResult = safeParse(injectOut);
7373+ if (!injectResult || !injectResult.ok) {
7474+ console.log(JSON.stringify({
7575+ ok: false,
7676+ error: 'inject_failed',
7777+ detail: injectResult || injectOut,
7878+ serverPort: serverInfo.port,
7979+ }));
8080+ process.exit(1);
8181+ }
8282+8383+ // 4. Load PRODUCT.md + DESIGN.md context (auto-migrates legacy .impeccable.md)
8484+ const ctx = loadContext(process.cwd());
8585+8686+ // 5. Compute drift-heal: compare resolved inject targets against the
8787+ // project's HTML files. Orphans are HTML files not covered by config.
8888+ // Warning only — the agent decides whether to act.
8989+ const resolvedFiles = resolveFiles(process.cwd(), checkResult.config);
9090+ const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config);
9191+9292+ // 6. Emit everything the agent needs
9393+ console.log(JSON.stringify({
9494+ ok: true,
9595+ serverPort: serverInfo.port,
9696+ serverToken: serverInfo.token,
9797+ pageFiles: resolvedFiles,
9898+ configDrift: drift,
9999+ hasProduct: ctx.hasProduct,
100100+ product: ctx.product,
101101+ productPath: ctx.productPath,
102102+ hasDesign: ctx.hasDesign,
103103+ design: ctx.design,
104104+ designPath: ctx.designPath,
105105+ migrated: ctx.migrated,
106106+ }, null, 2));
107107+}
108108+109109+/**
110110+ * Drift-heal scan. Walks the project for HTML files under common
111111+ * page-source directories (public/, src/, app/, pages/) and reports any
112112+ * that aren't covered by the resolved inject targets. This is purely
113113+ * advisory — the agent can ignore it, or suggest the user add the
114114+ * orphans to config.files.
115115+ *
116116+ * Skipped if config.files already contains at least one glob pattern
117117+ * covering everything in practice (signaled by the orphan count being 0).
118118+ */
119119+function scanForDrift(rootDir, resolvedFiles, config) {
120120+ const SCAN_ROOTS = ['public', 'src', 'app', 'pages'];
121121+ const IGNORE_DIRS = new Set([
122122+ 'node_modules', '.git', '.next', '.nuxt', '.svelte-kit', '.astro',
123123+ '.turbo', '.vercel', '.cache', 'coverage', 'dist', 'build',
124124+ ]);
125125+126126+ const resolvedSet = new Set(resolvedFiles.map((f) => f.split(path.sep).join('/')));
127127+128128+ // Files matching the user's `exclude` globs are intentional omissions,
129129+ // not drift. Compile them to regexes so the orphan list stays signal.
130130+ const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : [])
131131+ .map((p) => globToRegex(p));
132132+ const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel));
133133+134134+ const orphans = [];
135135+136136+ const walk = (dir, relBase) => {
137137+ let entries;
138138+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
139139+ catch { return; }
140140+ for (const e of entries) {
141141+ const rel = relBase ? `${relBase}/${e.name}` : e.name;
142142+ if (e.isDirectory()) {
143143+ if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue;
144144+ walk(path.join(dir, e.name), rel);
145145+ } else if (e.isFile() && e.name.endsWith('.html')) {
146146+ if (resolvedSet.has(rel)) continue;
147147+ if (isUserExcluded(rel)) continue;
148148+ orphans.push(rel);
149149+ }
150150+ }
151151+ };
152152+153153+ for (const root of SCAN_ROOTS) {
154154+ const abs = path.join(rootDir, root);
155155+ if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
156156+ walk(abs, root);
157157+ }
158158+ }
159159+160160+ if (orphans.length === 0) return null;
161161+ const capped = orphans.slice(0, 20);
162162+ return {
163163+ orphans: capped,
164164+ orphanCount: orphans.length,
165165+ hint: `${orphans.length} HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like "public/**/*.html".`,
166166+ };
167167+}
168168+169169+/**
170170+ * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here
171171+ * to avoid a circular import (live-inject.mjs already imports nothing
172172+ * from live.mjs). The two must stay in sync.
173173+ */
174174+function globToRegex(pattern) {
175175+ let re = '';
176176+ let i = 0;
177177+ while (i < pattern.length) {
178178+ const c = pattern[i];
179179+ if (c === '*') {
180180+ if (pattern[i + 1] === '*') {
181181+ if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; }
182182+ else { re += '.*'; i += 2; }
183183+ } else {
184184+ re += '[^/]*';
185185+ i += 1;
186186+ }
187187+ } else if (c === '?') {
188188+ re += '[^/]';
189189+ i += 1;
190190+ } else if (/[.+^${}()|[\]\\]/.test(c)) {
191191+ re += '\\' + c;
192192+ i += 1;
193193+ } else {
194194+ re += c;
195195+ i += 1;
196196+ }
197197+ }
198198+ return new RegExp('^' + re + '$');
199199+}
200200+201201+// ---------------------------------------------------------------------------
202202+// Helpers
203203+// ---------------------------------------------------------------------------
204204+205205+function runScript(name, args) {
206206+ const scriptPath = path.join(__dirname, name);
207207+ const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`;
208208+ try {
209209+ return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 });
210210+ } catch (err) {
211211+ // execSync throws on non-zero exit; return stdout if any
212212+ return err.stdout || err.message || '';
213213+ }
214214+}
215215+216216+function safeParse(out) {
217217+ try { return JSON.parse(String(out).trim()); } catch { return null; }
218218+}
219219+220220+/**
221221+ * Return { pid, port, token } for the running live server, starting one if needed.
222222+ */
223223+function ensureServerRunning() {
224224+ // Try to reuse an existing server
225225+ try {
226226+ const existing = JSON.parse(fs.readFileSync(PID_FILE, 'utf-8'));
227227+ if (existing && existing.pid) {
228228+ try {
229229+ process.kill(existing.pid, 0); // throws if dead
230230+ return existing;
231231+ } catch { /* stale PID file — the server script will clean it up */ }
232232+ }
233233+ } catch { /* no PID file */ }
234234+235235+ // Start a new server
236236+ const out = runScript('live-server.mjs', ['--background']);
237237+ return safeParse(out);
238238+}
239239+240240+// ---------------------------------------------------------------------------
241241+// Auto-execute
242242+// ---------------------------------------------------------------------------
243243+244244+const _running = process.argv[1];
245245+if (_running?.endsWith('live.mjs') || _running?.endsWith('live.mjs/')) {
246246+ liveCli();
247247+}
···11----
22-name: layout
33-description: "Improve layout, spacing, and visual rhythm. Fixes monotonous grids, inconsistent spacing, and weak visual hierarchy. Use when the user mentions layout feeling off, spacing issues, visual hierarchy, crowded UI, alignment problems, or wanting better composition."
44-version: 2.1.1
55-user-invocable: true
66-argument-hint: "[target]"
11+Assess and improve layout and spacing that feels monotonous, crowded, or structurally weak — turning generic arrangements into intentional, rhythmic compositions.
22+73---
8499-Assess and improve layout and spacing that feels monotonous, crowded, or structurally weak — turning generic arrangements into intentional, rhythmic compositions.
55+## Register
1061111-## MANDATORY PREPARATION
77+Brand: asymmetric compositions, fluid spacing with `clamp()`, intentional grid-breaking for emphasis. Rhythm through contrast — tight groupings paired with generous separations.
1281313-Invoke /impeccable — it contains design principles, anti-patterns, and the **Context Gathering Protocol**. Follow the protocol before proceeding — if no design context exists yet, you MUST run /impeccable teach first.
99+Product: predictable grids, consistent densities, familiar navigation patterns. Responsive behavior is structural (collapse sidebar, responsive table), not fluid typography. Consistency IS an affordance.
14101511---
1612···47434844## Plan Layout Improvements
49455050-Consult the [spatial design reference](reference/spatial-design.md) from the impeccable skill for detailed guidance on grids, rhythm, and container queries.
4646+Consult the [spatial design reference](spatial-design.md) for detailed guidance on grids, rhythm, and container queries.
51475248Create a systematic plan:
5349···10399- If an icon looks visually off-center despite being geometrically centered, nudge it — but only if you're confident it actually looks wrong. Don't adjust speculatively.
104100105101**NEVER**:
106106-107102- Use arbitrary spacing values outside your scale
108103- Make all spacing equal — variety creates hierarchy
109104- Wrap everything in cards — not everything needs a container
···124119- **Responsiveness**: Does the layout adapt gracefully across screen sizes?
125120126121Remember: Space is the most underused design tool. A layout with the right rhythm and hierarchy can make even simple content feel polished and intentional.
122122+123123+## Live-mode signature params
124124+125125+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.
126126+127127+```json
128128+{"id":"density","kind":"range","min":0.6,"max":1.4,"step":0.05,"default":1,"label":"Density"}
129129+```
130130+131131+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.
132132+133133+```json
134134+{"id":"structure","kind":"steps","default":"grid","label":"Structure","options":[
135135+ {"value":"stacked","label":"Stacked"},
136136+ {"value":"grid","label":"Grid"},
137137+ {"value":"bento","label":"Bento"}
138138+]}
139139+```
140140+141141+See `reference/live.md` for the full params contract.
···11----
22-name: overdrive
33-description: "Pushes interfaces past conventional limits with technically ambitious implementations — shaders, spring physics, scroll-driven reveals, 60fps animations. Use when the user wants to wow, impress, go all-out, or make something that feels extraordinary."
44-version: 2.1.1
55-user-invocable: true
66-argument-hint: "[target]"
77----
88-91Start your response with:
102113```
···135》》》 Entering overdrive mode...
146```
1571616-Push an interface past conventional limits. This isn't just about visual effects — it's about using the full power of the browser to make any part of an interface feel extraordinary: a table that handles a million rows, a dialog that morphs from its trigger, a form that validates in real-time with streaming feedback, a page transition that feels cinematic.
1717-1818-## MANDATORY PREPARATION
1919-2020-Invoke /impeccable — it contains design principles, anti-patterns, and the **Context Gathering Protocol**. Follow the protocol before proceeding — if no design context exists yet, you MUST run /impeccable teach first.
88+Push an interface past conventional limits. This isn't just about visual effects. It's about using the full power of the browser to make any part of an interface feel extraordinary: a table that handles a million rows, a dialog that morphs from its trigger, a form that validates in real-time with streaming feedback, a page transition that feels cinematic.
2192222-**EXTRA IMPORTANT FOR THIS SKILL**: Context determines what "extraordinary" means. A particle system on a creative portfolio is impressive. The same particle system on a settings page is embarrassing. But a settings page with instant optimistic saves and animated state transitions? That's extraordinary too. Understand the project's personality and goals before deciding what's appropriate.
1010+**EXTRA IMPORTANT FOR THIS COMMAND**: Context determines what "extraordinary" means. A particle system on a creative portfolio is impressive. The same particle system on a settings page is embarrassing. But a settings page with instant optimistic saves and animated state transitions? That's extraordinary too. Understand the project's personality and goals before deciding what's appropriate.
23112412### Propose Before Building
25132626-This skill has the highest potential to misfire. Do NOT jump straight into implementation. You MUST:
1414+This command has the highest potential to misfire. Do NOT jump straight into implementation. You MUST:
27152828-1. **Think through 2-3 different directions** — consider different techniques, levels of ambition, and aesthetic approaches. For each direction, briefly describe what the result would look and feel like.
2929-2. **ask the user directly to clarify what you cannot infer.** to present these directions and get the user's pick before writing any code. Explain trade-offs (browser support, performance cost, complexity).
1616+1. **Think through 2-3 different directions**: consider different techniques, levels of ambition, and aesthetic approaches. For each direction, briefly describe what the result would look and feel like.
1717+2. **STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer.** to present these directions and get the user's pick before writing any code. Explain trade-offs (browser support, performance cost, complexity).
30183. Only proceed with the direction the user confirms.
31193220Skipping this step risks building something embarrassing that needs to be thrown away.
33213422### Iterate with Browser Automation
35233636-Technically ambitious effects almost never work on the first try. You MUST actively use browser automation tools to preview your work, visually verify the result, and iterate. Do not assume the effect looks right — check it. Expect multiple rounds of refinement. The gap between "technically works" and "looks extraordinary" is closed through visual iteration, not code alone.
2424+Technically ambitious effects almost never work on the first try. You MUST actively use browser automation tools to preview your work, visually verify the result, and iterate. Do not assume the effect looks right, check it. Expect multiple rounds of refinement. The gap between "technically works" and "looks extraordinary" is closed through visual iteration, not code alone.
37253826---
3927···4230The right kind of technical ambition depends entirely on what you're working with. Before choosing a technique, ask: **what would make a user of THIS specific interface say "wow, that's nice"?**
43314432### For visual/marketing surfaces
4545-4633Pages, hero sections, landing pages, portfolios — the "wow" is often sensory: a scroll-driven reveal, a shader background, a cinematic page transition, generative art that responds to the cursor.
47344835### For functional UI
4949-5036Tables, forms, dialogs, navigation — the "wow" is in how it FEELS: a dialog that morphs from the button that triggered it via View Transitions, a data table that renders 100k rows at 60fps via virtual scrolling, a form with streaming validation that feels instant, drag-and-drop with spring physics.
51375238### For performance-critical UI
5353-5439The "wow" is invisible but felt: a search that filters 50k items without a flicker, a complex form that never blocks the main thread, an image editor that processes in near-real-time. The interface just never hesitates.
55405641### For data-heavy interfaces
5757-5842Charts and dashboards — the "wow" is in fluidity: GPU-accelerated rendering via Canvas/WebGL for massive datasets, animated transitions between data states, force-directed graph layouts that settle naturally.
59436044**The common thread**: something about the implementation goes beyond what users expect from a web interface. The technique serves the experience, not the other way around.
···6448Organized by what you're trying to achieve, not by technology name.
65496650### Make transitions feel cinematic
6767-6851- **View Transitions API** (same-document: all browsers; cross-document: no Firefox) — shared element morphing between states. A list item expanding into a detail page. A button morphing into a dialog. This is the closest thing to native FLIP animations.
6952- **`@starting-style`** (all browsers) — animate elements from `display: none` to visible with CSS only, including entry keyframes
7053- **Spring physics** — natural motion with mass, tension, and damping instead of cubic-bezier. Libraries: motion (formerly Framer Motion), GSAP, or roll your own spring solver.
71547255### Tie animation to scroll position
7373-7456- **Scroll-driven animations** (`animation-timeline: scroll()`) — CSS-only, no JS. Parallax, progress bars, reveal sequences all driven by scroll position. (Chrome/Edge/Safari; Firefox: flag only — always provide a static fallback)
75577658### Render beyond CSS
7777-7859- **WebGL** (all browsers) — shader effects, post-processing, particle systems. Libraries: Three.js, OGL (lightweight), regl. Use for effects CSS can't express.
7960- **WebGPU** (Chrome/Edge; Safari partial; Firefox: flag only) — next-gen GPU compute. More powerful than WebGL but limited browser support. Always fall back to WebGL2.
8061- **Canvas 2D / OffscreenCanvas** — custom rendering, pixel manipulation, or moving heavy rendering off the main thread entirely via Web Workers + OffscreenCanvas.
8162- **SVG filter chains** — displacement maps, turbulence, morphology for organic distortion effects. CSS-animatable.
82638364### Make data feel alive
8484-8565- **Virtual scrolling** — render only visible rows for tables/lists with tens of thousands of items. No library required for simple cases; TanStack Virtual for complex ones.
8666- **GPU-accelerated charts** — Canvas or WebGL-rendered data visualization for datasets too large for SVG/DOM. Libraries: deck.gl, regl-based custom renderers.
8767- **Animated data transitions** — morph between chart states rather than replacing. D3's `transition()` or View Transitions for DOM-based charts.
88688969### Animate complex properties
9090-9170- **`@property`** (all browsers) — register custom CSS properties with types, enabling animation of gradients, colors, and complex values that CSS can't normally interpolate.
9271- **Web Animations API** (all browsers) — JavaScript-driven animations with the performance of CSS. Composable, cancellable, reversible. The foundation for complex choreography.
93729473### Push performance boundaries
9595-9674- **Web Workers** — move computation off the main thread. Heavy data processing, image manipulation, search indexing — anything that would cause jank.
9775- **OffscreenCanvas** — render in a Worker thread. The main thread stays free while complex visuals render in the background.
9876- **WASM** — near-native performance for computation-heavy features. Image processing, physics simulations, codecs.
997710078### Interact with the device
101101-10279- **Web Audio API** — spatial audio, audio-reactive visualizations, sonic feedback. Requires user gesture to start.
10380- **Device APIs** — orientation, ambient light, geolocation. Use sparingly and always with user permission.
10481105105-**NOTE**: This skill is about enhancing how an interface FEELS, not changing what a product DOES. Adding real-time collaboration, offline support, or new backend capabilities are product decisions, not UI enhancements. Focus on making existing features feel extraordinary.
8282+**NOTE**: This command is about enhancing how an interface FEELS, not changing what a product DOES. Adding real-time collaboration, offline support, or new backend capabilities are product decisions, not UI enhancements. Focus on making existing features feel extraordinary.
1068310784## Implement with Discipline
10885···1128911390```css
11491@supports (animation-timeline: scroll()) {
115115- .hero {
116116- animation-timeline: scroll();
117117- }
9292+ .hero { animation-timeline: scroll(); }
11893}
11994```
1209512196```javascript
122122-if ("gpu" in navigator) {
123123- /* WebGPU */
124124-} else if (canvas.getContext("webgl2")) {
125125- /* WebGL2 fallback */
126126-}
9797+if ('gpu' in navigator) { /* WebGPU */ }
9898+else if (canvas.getContext('webgl2')) { /* WebGL2 fallback */ }
12799/* CSS-only fallback must still look good */
128100```
129101···140112The gap between "cool" and "extraordinary" is in the last 20% of refinement: the easing curve on a spring animation, the timing offset in a staggered reveal, the subtle secondary motion that makes a transition feel physical. Don't ship the first version that works — ship the version that feels inevitable.
141113142114**NEVER**:
143143-144115- Ignore `prefers-reduced-motion` — this is an accessibility requirement, not a suggestion
145116- Ship effects that cause jank on mid-range devices
146117- Use bleeding-edge APIs without a functional fallback
147118- Add sound without explicit user opt-in
148148-- Use technical ambition to mask weak design fundamentals — fix those first with other skills
119119+- Use technical ambition to mask weak design fundamentals; fix those first with other commands
149120- Layer multiple competing extraordinary moments — focus creates impact, excess creates noise
150121151122## Verify the Result
···11----
22-name: polish
33-description: "Performs a final quality pass fixing alignment, spacing, consistency, and micro-detail issues before shipping. Use when the user mentions polish, finishing touches, pre-launch review, something looks off, or wants to go from good to great."
44-version: 2.1.1
55-user-invocable: true
66-argument-hint: "[target]"
77----
88-99-## MANDATORY PREPARATION
1010-1111-Invoke /impeccable — it contains design principles, anti-patterns, and the **Context Gathering Protocol**. Follow the protocol before proceeding — if no design context exists yet, you MUST run /impeccable teach first. Additionally gather: quality bar (MVP vs flagship).
1212-1313----
11+> **Additional context needed**: quality bar (MVP vs flagship).
142153Perform a meticulous final pass to catch all the small details that separate good work from great work. The difference between shipped and polished.
164175## Design System Discovery
1861919-Before polishing, understand the system you are polishing toward:
77+Aligning the feature to the design system is **not optional**. Polish without alignment is decoration on top of drift, and it makes the next person's job harder. Discovery comes before any other polish work.
2082121-1. **Find the design system**: Search for design system documentation, component libraries, style guides, or token definitions. Study the core patterns: color tokens, spacing scale, typography styles, component API.
2222-2. **Note the conventions**: How are shared components imported? What spacing scale is used? Which colors come from tokens vs hard-coded values? What motion and interaction patterns are established?
2323-3. **Identify drift**: Where does the target feature deviate from the system? Hard-coded values that should be tokens, custom components that duplicate shared ones, spacing that doesn't match the scale.
99+1. **Find the design system**: Search for design system documentation, component libraries, style guides, or token definitions. Study the core patterns: design principles, target audience, color tokens, spacing scale, typography styles, component API, motion conventions.
1010+2. **Note the conventions**: How are shared components imported? What spacing scale is used? Which colors come from tokens vs hard-coded values? What motion and interaction patterns are established? What flow shapes are used for comparable actions (modal vs full-page, inline vs route, save-on-blur vs explicit submit)?
1111+3. **Identify drift, then name the root cause**: For every deviation, classify it as a **missing token** (the value should exist in the system but doesn't), a **one-off implementation** (a shared component already exists but wasn't used), or a **conceptual misalignment** (the feature's flow, IA, or hierarchy doesn't match neighboring features). The fix differs by category — patch the value, swap to the shared component, or rework the flow. Fixing the symptom without naming the cause is how drift compounds.
24122525-If a design system exists, polish should align the feature with it. If none exists, polish against the conventions visible in the codebase.
1313+If a design system exists, polish **must** align the feature with it. If none exists, polish against the conventions visible in the codebase. **If anything about the system is ambiguous, ask — never guess at design system principles.**
26142715## Pre-Polish Assessment
28162929-Understand the current state and goals:
1717+Understand the current state and goals before touching anything:
301831191. **Review completeness**:
3220 - Is it functionally complete?
···3422 - What's the quality bar? (MVP vs flagship feature?)
3523 - When does it ship? (How much time for polish?)
36243737-2. **Identify polish areas**:
2525+2. **Think experience-first**: Who actually uses this, and what's the best possible experience for them? Effective design beats decorative polish — a feature that looks beautiful but fights the user's flow is not polished. Walk the path from their perspective before opening DevTools.
2626+2727+3. **Identify polish areas**:
3828 - Visual inconsistencies
3929 - Spacing and alignment issues
4030 - Interaction state gaps
4131 - Copy inconsistencies
4232 - Edge cases and error states
4333 - Loading and transition smoothness
3434+ - Information architecture and flow drift (does this feature reveal complexity the way neighboring features do?)
3535+3636+4. **Triage cosmetic vs functional**: Classify each issue as **cosmetic** (looks off, doesn't impede the user) or **functional** (breaks, blocks, or confuses the experience). When polish time is tight, functional issues ship first; cosmetic ones can land in a follow-up. Quality should be consistent — never perfect one corner while leaving another rough.
44374538**CRITICAL**: Polish is the last step, not the first. Don't polish work that's not functionally complete.
4639···5750- **Grid adherence**: Elements snap to baseline grid
58515952**Check**:
6060-6153- Enable grid overlay and verify alignment
6254- Check spacing with browser inspector
6355- Test at multiple viewport sizes
6456- Look for elements that "feel" off
65575858+### Information Architecture & Flow
5959+6060+Visual polish on a misshapen flow is wasted work. Match the *shape* of the experience to the system, not just the surface.
6161+6262+- **Progressive disclosure**: Match how much is revealed when, compared to neighboring features. A settings page exposing 40 fields when the rest of the app reveals 5 at a time is drift, even if every field is perfectly styled.
6363+- **Established user flows**: Multi-step actions follow the same shape as comparable flows elsewhere — modal vs full-page, inline edit vs separate route, save-on-blur vs explicit submit, optimistic vs pessimistic updates.
6464+- **Hierarchy & complexity**: The same conceptual weight gets the same visual weight throughout. Primary actions don't become tertiary in one corner of the product, and tertiary actions don't shout.
6565+- **Empty, loading, and arrival transitions**: How content arrives, updates, and leaves matches how it does in adjacent features.
6666+- **Naming and mental model**: The feature uses the same nouns and verbs as the rest of the system. A "Workspace" here shouldn't be a "Project" three screens away.
6767+6668### Typography Refinement
67696870- **Hierarchy consistency**: Same elements use same sizes/weights throughout
···102104103105- **Smooth transitions**: All state changes animated appropriately (150-300ms)
104106- **Consistent easing**: Use ease-out-quart/quint/expo for natural deceleration. Never bounce or elastic—they feel dated.
105105-- **No jank**: 60fps animations, only animate transform and opacity
107107+- **No jank**: Smooth animations; use atmospheric blur/filter/mask/shadow effects when they add polish, but bound expensive paint areas and avoid casual layout-property animation
106108- **Appropriate motion**: Motion serves purpose, not decoration
107109- **Reduced motion**: Respects `prefers-reduced-motion`
108110···171173172174Go through systematically:
173175176176+- [ ] Aligned to the design system (drift named and resolved by root cause)
177177+- [ ] Information architecture and flow shape match neighboring features
174178- [ ] Visual alignment perfect at all breakpoints
175179- [ ] Spacing uses design tokens consistently
176180- [ ] Typography hierarchy consistent
···195199**IMPORTANT**: Polish is about details. Zoom in. Squint at it. Use it yourself. The little things add up.
196200197201**NEVER**:
198198-199202- Polish before it's functionally complete
203203+- Polish without aligning to the design system — that's decoration on drift
204204+- Guess at design system principles instead of asking when something is ambiguous
200205- Spend hours on polish if it ships in 30 minutes (triage)
201206- Introduce bugs while polishing (test thoroughly)
202202-- Ignore systematic issues (if spacing is off everywhere, fix the system)
207207+- Ignore systematic issues (if spacing is off everywhere, fix the system, not just one screen)
203208- Perfect one thing while leaving others rough (consistent quality level)
204209- Create new one-off components when design system equivalents exist
205210- Hard-code values that should use design tokens
211211+- Introduce new patterns or flows that diverge from established ones
206212207213## Final Verification
208214
···11----
22-name: quieter
33-description: "Tones down visually aggressive or overstimulating designs, reducing intensity while preserving quality. Use when the user mentions too bold, too loud, overwhelming, aggressive, garish, or wants a calmer, more refined aesthetic."
44-version: 2.1.1
55-user-invocable: true
66-argument-hint: "[target]"
11+Reduce visual intensity in designs that are too bold, aggressive, or overstimulating, creating a more refined and approachable aesthetic without losing effectiveness.
22+73---
8499-Reduce visual intensity in designs that are too bold, aggressive, or overstimulating, creating a more refined and approachable aesthetic without losing effectiveness.
55+## Register
1061111-## MANDATORY PREPARATION
77+Brand: "quieter" means more restrained palette, more whitespace, more typographic air. Drama is reduced, not eliminated — the POV stays intact.
1281313-Invoke /impeccable — it contains design principles, anti-patterns, and the **Context Gathering Protocol**. Follow the protocol before proceeding — if no design context exists yet, you MUST run /impeccable teach first.
99+Product: "quieter" means reducing visual noise — fewer background accents, flatter cards, less color, less motion. The tool should disappear more completely into the task.
14101511---
1612···3228 - What's working? (Don't throw away good ideas)
3329 - What's the core message? (Preserve what matters)
34303535-If any of these are unclear from the codebase, ask the user directly to clarify what you cannot infer.
3131+If any of these are unclear from the codebase, STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer.
36323733**CRITICAL**: "Quieter" doesn't mean boring or generic. It means refined, sophisticated, and easier on the eyes. Think luxury, not laziness.
3834···5248Systematically reduce intensity across these dimensions:
53495450### Color Refinement
5555-5651- **Reduce saturation**: Shift from fully saturated to 70-85% saturation
5752- **Soften palette**: Replace bright colors with muted, sophisticated tones
5853- **Reduce color variety**: Use fewer colors more thoughtfully
···6257- **Never gray on color**: If you have gray text on a colored background, use a darker shade of that color or transparency instead
63586459### Visual Weight Reduction
6565-6660- **Typography**: Reduce font weights (900 → 600, 700 → 500), decrease sizes where appropriate
6761- **Hierarchy through subtlety**: Use weight, size, and space instead of color and boldness
6862- **White space**: Increase breathing room, reduce density
6963- **Borders & lines**: Reduce thickness, decrease opacity, or remove entirely
70647165### Simplification
7272-7366- **Remove decorative elements**: Gradients, shadows, patterns, textures that don't serve purpose
7467- **Simplify shapes**: Reduce border radius extremes, simplify custom shapes
7568- **Reduce layering**: Flatten visual hierarchy where possible
7669- **Clean up effects**: Reduce or remove blur effects, glows, multiple shadows
77707871### Motion Reduction
7979-8072- **Reduce animation intensity**: Shorter distances (10-20px instead of 40px), gentler easing
8173- **Remove decorative animations**: Keep functional motion, remove flourishes
8274- **Subtle micro-interactions**: Replace dramatic effects with gentle feedback
···8476- **Remove animations entirely** if they're not serving a clear purpose
85778678### Composition Refinement
8787-8879- **Reduce scale jumps**: Smaller contrast between sizes creates calmer feeling
8980- **Align to grid**: Bring rogue elements back into systematic alignment
9081- **Even out spacing**: Replace extreme spacing variations with consistent rhythm
91829283**NEVER**:
9393-9484- Make everything the same size/weight (hierarchy still matters)
9585- Remove all color (quiet ≠ grayscale)
9686- Eliminate all personality (maintain character through refinement)
-101
.agents/skills/shape/SKILL.md
···11----
22-name: shape
33-description: "Plan the UX and UI for a feature before writing code. Runs a structured discovery interview, then produces a design brief that guides implementation. Use during the planning phase to establish design direction, constraints, and strategy before any code is written."
44-version: 2.1.1
55-user-invocable: true
66-argument-hint: "[feature to shape]"
77----
88-99-## MANDATORY PREPARATION
1010-1111-Invoke /impeccable, which contains design principles, anti-patterns, and the **Context Gathering Protocol**. Follow the protocol before proceeding. If no design context exists yet, you MUST run /impeccable teach first.
1212-1313----
1414-1515-Shape the UX and UI for a feature before any code is written. This skill produces a **design brief**: a structured artifact that guides implementation through discovery, not guesswork.
1616-1717-**Scope**: Design planning only. This skill does NOT write code. It produces the thinking that makes code good.
1818-1919-**Output**: A design brief that can be handed off to /impeccable craft, /impeccable, or any other implementation skill.
2020-2121-## Philosophy
2222-2323-Most AI-generated UIs fail not because of bad code, but because of skipped thinking. They jump to "here's a card grid" without asking "what is the user trying to accomplish?" This skill inverts that: understand deeply first, so implementation is precise.
2424-2525-## Phase 1: Discovery Interview
2626-2727-**Do NOT write any code or make any design decisions during this phase.** Your only job is to understand the feature deeply enough to make excellent design decisions later.
2828-2929-Ask these questions in conversation, adapting based on answers. Don't dump them all at once; have a natural dialogue. ask the user directly to clarify what you cannot infer.
3030-3131-### Purpose & Context
3232-3333-- What is this feature for? What problem does it solve?
3434-- Who specifically will use it? (Not "users"; be specific: role, context, frequency)
3535-- What does success look like? How will you know this feature is working?
3636-- What's the user's state of mind when they reach this feature? (Rushed? Exploring? Anxious? Focused?)
3737-3838-### Content & Data
3939-4040-- What content or data does this feature display or collect?
4141-- What are the realistic ranges? (Minimum, typical, maximum, e.g., 0 items, 5 items, 500 items)
4242-- What are the edge cases? (Empty state, error state, first-time use, power user)
4343-- Is any content dynamic? What changes and how often?
4444-4545-### Design Goals
4646-4747-- What's the single most important thing a user should do or understand here?
4848-- What should this feel like? (Fast/efficient? Calm/trustworthy? Fun/playful? Premium/refined?)
4949-- Are there existing patterns in the product this should be consistent with?
5050-- Are there specific examples (inside or outside the product) that capture what you're going for?
5151-5252-### Constraints
5353-5454-- Are there technical constraints? (Framework, performance budget, browser support)
5555-- Are there content constraints? (Localization, dynamic text length, user-generated content)
5656-- Mobile/responsive requirements?
5757-- Accessibility requirements beyond WCAG AA?
5858-5959-### Anti-Goals
6060-6161-- What should this NOT be? What would be a wrong direction?
6262-- What's the biggest risk of getting this wrong?
6363-6464-## Phase 2: Design Brief
6565-6666-After the interview, synthesize everything into a structured design brief. Present it to the user for confirmation before considering this skill complete.
6767-6868-### Brief Structure
6969-7070-**1. Feature Summary** (2-3 sentences)
7171-What this is, who it's for, what it needs to accomplish.
7272-7373-**2. Primary User Action**
7474-The single most important thing a user should do or understand here.
7575-7676-**3. Design Direction**
7777-How this should feel. What aesthetic approach fits. Reference the project's design context from `.impeccable.md` and explain how this feature should express it.
7878-7979-**4. Layout Strategy**
8080-High-level spatial approach: what gets emphasis, what's secondary, how information flows. Describe the visual hierarchy and rhythm, not specific CSS.
8181-8282-**5. Key States**
8383-List every state the feature needs: default, empty, loading, error, success, edge cases. For each, note what the user needs to see and feel.
8484-8585-**6. Interaction Model**
8686-How users interact with this feature. What happens on click, hover, scroll? What feedback do they get? What's the flow from entry to completion?
8787-8888-**7. Content Requirements**
8989-What copy, labels, empty state messages, error messages, and microcopy are needed. Note any dynamic content and its realistic ranges.
9090-9191-**8. Recommended References**
9292-Based on the brief, list which impeccable reference files would be most valuable during implementation (e.g., spatial-design.md for complex layouts, motion-design.md for animated features, interaction-design.md for form-heavy features).
9393-9494-**9. Open Questions**
9595-Anything unresolved that the implementer should resolve during build.
9696-9797----
9898-9999-ask the user directly to clarify what you cannot infer. Get explicit confirmation of the brief before finishing. If the user disagrees with any part, revisit the relevant discovery questions.
100100-101101-Once confirmed, the brief is complete. The user can now hand it to /impeccable, or use it to guide any other implementation approach. (If the user wants the full discovery-then-build flow in one step, they should use /impeccable craft instead, which runs this skill internally.)
···11----
22-name: typeset
33-description: "Improves typography by fixing font choices, hierarchy, sizing, weight, and readability so text feels intentional. Use when the user mentions fonts, type, readability, text hierarchy, sizing looks off, or wants more polished, intentional typography."
44-version: 2.1.1
55-user-invocable: true
66-argument-hint: "[target]"
11+Assess and improve typography that feels generic, inconsistent, or poorly structured — turning default-looking text into intentional, well-crafted type.
22+73---
8499-Assess and improve typography that feels generic, inconsistent, or poorly structured — turning default-looking text into intentional, well-crafted type.
55+## Register
1061111-## MANDATORY PREPARATION
77+Brand: run the font selection procedure in [brand.md](brand.md). Pairing follows the brand's lane (display serif + sans body for editorial/luxury, one committed sans for tech, etc.). Fluid `clamp()` scale, ≥1.25 ratio between steps.
1281313-Invoke /impeccable — it contains design principles, anti-patterns, and the **Context Gathering Protocol**. Follow the protocol before proceeding — if no design context exists yet, you MUST run /impeccable teach first.
99+Product: system fonts and familiar sans stacks are legitimate here. One well-tuned family typically carries the whole UI. Fixed `rem` scale, 1.125–1.2 ratio between more closely-spaced steps.
14101511---
1612···47434844## Plan Typography Improvements
49455050-Consult the [typography reference](reference/typography.md) from the impeccable skill for detailed guidance on scales, pairing, and loading strategies.
4646+Consult the [typography reference](typography.md) for detailed guidance on scales, pairing, and loading strategies.
51475248Create a systematic plan:
5349···6157### Font Selection
62586359If fonts need replacing:
6464-6560- Choose fonts that reflect the brand personality
6661- Pair with genuine contrast (serif + sans, geometric + humanist) — or use a single family in multiple weights
6762- Ensure web font loading doesn't cause layout shift (`font-display: swap`, metric-matched fallbacks)
···6964### Establish Hierarchy
70657166Build a clear type scale:
7272-7367- **5 sizes cover most needs**: caption, secondary, body, subheading, heading
7468- **Use a consistent ratio** between levels (1.25, 1.333, or 1.5)
7569- **Combine dimensions**: Size + weight + color + space for strong hierarchy — don't rely on size alone
···9791- Load only the weights you actually use (each weight adds to page load)
98929993**NEVER**:
100100-10194- Use more than 2-3 font families
10295- Pick sizes arbitrarily — commit to a scale
10396- Set body text below 16px
···117110- **Accessibility**: Does text meet WCAG contrast ratios? Is it zoomable to 200%?
118111119112Remember: Typography is the foundation of interface design — it carries the majority of information. Getting it right is the highest-leverage improvement you can make.
113113+114114+## Live-mode signature params
115115+116116+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.
117117+118118+```json
119119+{"id":"scale","kind":"range","min":0.85,"max":1.3,"step":0.05,"default":1,"label":"Scale"}
120120+```
121121+122122+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.
123123+124124+See `reference/live.md` for the full params contract.