this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

update impeccable skill

+11131 -1351
+1 -25
.agents/skills/adapt/SKILL.md .agents/skills/impeccable/reference/adapt.md
··· 1 - --- 2 - name: adapt 3 - 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." 4 - version: 2.1.1 5 - user-invocable: true 6 - argument-hint: "[target] [context (mobile, tablet, print...)]" 7 - --- 1 + > **Additional context needed**: target platforms/devices and usage contexts. 8 2 9 3 Adapt existing designs to work effectively across different contexts - different screen sizes, devices, platforms, or use cases. 10 4 11 - ## MANDATORY PREPARATION 12 - 13 - 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. 14 5 15 6 --- 16 7 ··· 45 36 ### Mobile Adaptation (Desktop → Mobile) 46 37 47 38 **Layout Strategy**: 48 - 49 39 - Single column instead of multi-column 50 40 - Vertical stacking instead of side-by-side 51 41 - Full-width components instead of fixed widths 52 42 - Bottom navigation instead of top/side navigation 53 43 54 44 **Interaction Strategy**: 55 - 56 45 - Touch targets 44x44px minimum (not hover-dependent) 57 46 - Swipe gestures where appropriate (lists, carousels) 58 47 - Bottom sheets instead of dropdowns ··· 60 49 - Larger tap areas with more spacing 61 50 62 51 **Content Strategy**: 63 - 64 52 - Progressive disclosure (don't show everything at once) 65 53 - Prioritize primary content (secondary content in tabs/accordions) 66 54 - Shorter text (more concise) 67 55 - Larger text (16px minimum) 68 56 69 57 **Navigation Strategy**: 70 - 71 58 - Hamburger menu or bottom navigation 72 59 - Reduce navigation complexity 73 60 - Sticky headers for context ··· 76 63 ### Tablet Adaptation (Hybrid Approach) 77 64 78 65 **Layout Strategy**: 79 - 80 66 - Two-column layouts (not single or three-column) 81 67 - Side panels for secondary content 82 68 - Master-detail views (list + detail) 83 69 - Adaptive based on orientation (portrait vs landscape) 84 70 85 71 **Interaction Strategy**: 86 - 87 72 - Support both touch and pointer 88 73 - Touch targets 44x44px but allow denser layouts than phone 89 74 - Side navigation drawers ··· 92 77 ### Desktop Adaptation (Mobile → Desktop) 93 78 94 79 **Layout Strategy**: 95 - 96 80 - Multi-column layouts (use horizontal space) 97 81 - Side navigation always visible 98 82 - Multiple information panels simultaneously 99 83 - Fixed widths with max-width constraints (don't stretch to 4K) 100 84 101 85 **Interaction Strategy**: 102 - 103 86 - Hover states for additional information 104 87 - Keyboard shortcuts 105 88 - Right-click context menus ··· 107 90 - Multi-select with Shift/Cmd 108 91 109 92 **Content Strategy**: 110 - 111 93 - Show more information upfront (less progressive disclosure) 112 94 - Data tables with many columns 113 95 - Richer visualizations ··· 116 98 ### Print Adaptation (Screen → Print) 117 99 118 100 **Layout Strategy**: 119 - 120 101 - Page breaks at logical points 121 102 - Remove navigation, footer, interactive elements 122 103 - Black and white (or limited color) 123 104 - Proper margins for binding 124 105 125 106 **Content Strategy**: 126 - 127 107 - Expand shortened content (show full URLs, hidden sections) 128 108 - Add page numbers, headers, footers 129 109 - Include metadata (print date, page title) ··· 132 112 ### Email Adaptation (Web → Email) 133 113 134 114 **Layout Strategy**: 135 - 136 115 - Narrow width (600px max) 137 116 - Single column only 138 117 - Inline CSS (no external stylesheets) 139 118 - Table-based layouts (for email client compatibility) 140 119 141 120 **Interaction Strategy**: 142 - 143 121 - Large, obvious CTAs (buttons not text links) 144 122 - No hover states (not reliable) 145 123 - Deep links to web app for complex interactions ··· 151 129 ### Responsive Breakpoints 152 130 153 131 Choose appropriate breakpoints: 154 - 155 132 - Mobile: 320px-767px 156 133 - Tablet: 768px-1023px 157 134 - Desktop: 1024px+ ··· 190 167 **IMPORTANT**: Test on real devices, not just browser DevTools. Device emulation is helpful but not perfect. 191 168 192 169 **NEVER**: 193 - 194 170 - Hide core functionality on mobile (if it matters, make it work) 195 171 - Assume desktop = powerful device (consider accessibility, older machines) 196 172 - Use different information architecture across contexts (confusing)
+17 -30
.agents/skills/animate/SKILL.md .agents/skills/impeccable/reference/animate.md
··· 1 - --- 2 - name: animate 3 - 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." 4 - version: 2.1.1 5 - user-invocable: true 6 - argument-hint: "[target]" 7 - --- 1 + > **Additional context needed**: performance constraints. 8 2 9 3 Analyze a feature and strategically add animations and micro-interactions that enhance understanding, provide feedback, and create delight. 10 4 11 - ## MANDATORY PREPARATION 5 + --- 12 6 13 - 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. 7 + ## Register 8 + 9 + Brand: orchestrated page-load sequences, staggered reveals, scroll-driven animation. Motion is part of the voice; one well-rehearsed entrance beats scattered micro-interactions. 10 + 11 + 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. 14 12 15 13 --- 16 14 ··· 31 29 - Who's the audience? (Motion-sensitive users? Power users who want speed?) 32 30 - What matters most? (One hero animation vs many micro-interactions?) 33 31 34 - If any of these are unclear from the codebase, ask the user directly to clarify what you cannot infer. 32 + 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. 35 33 36 34 **CRITICAL**: Respect `prefers-reduced-motion`. Always provide non-animated alternatives for users who need them. 37 35 ··· 51 49 Add motion systematically across these categories: 52 50 53 51 ### Entrance Animations 54 - 55 52 - **Page load choreography**: Stagger element reveals (100-150ms delays), fade + slide combinations 56 53 - **Hero section**: Dramatic entrance for primary content (scale, parallax, or creative effects) 57 54 - **Content reveals**: Scroll-triggered animations using intersection observer 58 55 - **Modal/drawer entry**: Smooth slide + fade, backdrop fade, focus management 59 56 60 57 ### Micro-interactions 61 - 62 58 - **Button feedback**: 63 59 - Hover: Subtle scale (1.02-1.05), color shift, shadow increase 64 60 - Click: Quick scale down then up (0.95 → 1), ripple effect ··· 71 67 - **Like/favorite**: Scale + rotation, particle effects, color transition 72 68 73 69 ### State Transitions 74 - 75 70 - **Show/hide**: Fade + slide (not instant), appropriate timing (200-300ms) 76 71 - **Expand/collapse**: Height transition with overflow handling, icon rotation 77 72 - **Loading states**: Skeleton screen fades, spinner animations, progress bars ··· 79 74 - **Enable/disable**: Opacity transitions, cursor changes 80 75 81 76 ### Navigation & Flow 82 - 83 77 - **Page transitions**: Crossfade between routes, shared element transitions 84 78 - **Tab switching**: Slide indicator, content fade/slide 85 79 - **Carousel/slider**: Smooth transforms, snap points, momentum 86 80 - **Scroll effects**: Parallax layers, sticky headers with state changes, scroll progress indicators 87 81 88 82 ### Feedback & Guidance 89 - 90 83 - **Hover hints**: Tooltip fade-ins, cursor changes, element highlights 91 84 - **Drag & drop**: Lift effect (shadow + scale), drop zone highlights, smooth repositioning 92 85 - **Copy/paste**: Brief highlight flash on paste, "copied" confirmation 93 86 - **Focus flow**: Highlight path through form or workflow 94 87 95 88 ### Delight Moments 96 - 97 89 - **Empty states**: Subtle floating animations on illustrations 98 90 - **Completed actions**: Confetti, check mark flourish, success celebrations 99 91 - **Easter eggs**: Hidden interactions for discovery ··· 106 98 ### Timing & Easing 107 99 108 100 **Durations by purpose:** 109 - 110 101 - **100-150ms**: Instant feedback (button press, toggle) 111 102 - **200-300ms**: State changes (hover, menu open) 112 103 - **300-500ms**: Layout changes (accordion, modal) 113 104 - **500-800ms**: Entrance animations (page load) 114 105 115 106 **Easing curves (use these, not CSS defaults):** 116 - 117 107 ```css 118 108 /* Recommended - natural deceleration */ 119 - --ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1); /* Smooth, refined */ 120 - --ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1); /* Slightly snappier */ 121 - --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); /* Confident, decisive */ 109 + --ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1); /* Smooth, refined */ 110 + --ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1); /* Slightly snappier */ 111 + --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); /* Confident, decisive */ 122 112 123 113 /* AVOID - feel dated and tacky */ 124 114 /* bounce: cubic-bezier(0.34, 1.56, 0.64, 1); */ ··· 128 118 **Exit animations are faster than entrances.** Use ~75% of enter duration. 129 119 130 120 ### CSS Animations 131 - 132 121 ```css 133 122 /* Prefer for simple, declarative animations */ 134 123 - transitions for state changes 135 124 - @keyframes for complex sequences 136 - - transform + opacity only (GPU-accelerated) 125 + - transform and opacity for reliable movement 126 + - blur, filters, masks, clip paths, shadows, and color shifts for premium atmospheric effects when verified smooth 137 127 ``` 138 128 139 129 ### JavaScript Animation 140 - 141 130 ```javascript 142 131 /* Use for complex, interactive animations */ 143 132 - Web Animations API for programmatic control ··· 146 135 ``` 147 136 148 137 ### Performance 149 - 150 - - **GPU acceleration**: Use `transform` and `opacity`, avoid layout properties 138 + - **Motion materials**: Use transform/opacity for reliable movement, but use blur, filters, masks, shadows, and color shifts when they materially improve the effect 139 + - **Layout safety**: Avoid casual animation of layout-driving properties (`width`, `height`, `top`, `left`, margins) 151 140 - **will-change**: Add sparingly for known expensive animations 152 - - **Reduce paint**: Minimize repaints, use `contain` where appropriate 141 + - **Bound expensive effects**: Keep blur/filter/shadow areas small or isolated, use `contain` where appropriate 153 142 - **Monitor FPS**: Ensure 60fps on target devices 154 143 155 144 ### Accessibility 156 - 157 145 ```css 158 146 @media (prefers-reduced-motion: reduce) { 159 147 * { ··· 165 153 ``` 166 154 167 155 **NEVER**: 168 - 169 156 - Use bounce or elastic easing curves—they feel dated and draw attention to the animation itself 170 - - Animate layout properties (width, height, top, left)—use transform instead 157 + - Animate layout properties casually (`width`, `height`, `top`, `left`, margins) when transform, FLIP, or grid-based techniques would work 171 158 - Use durations over 500ms for feedback—it feels laggy 172 159 - Animate without purpose—every animation needs a reason 173 160 - Ignore `prefers-reduced-motion`—this is an accessibility violation
+15 -39
.agents/skills/audit/SKILL.md .agents/skills/impeccable/reference/audit.md
··· 1 - --- 2 - name: audit 3 - 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." 4 - version: 2.1.1 5 - user-invocable: true 6 - argument-hint: "[area (feature, page, component...)]" 7 - --- 8 - 9 - ## MANDATORY PREPARATION 10 - 11 - 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. 12 - 13 - --- 14 - 15 1 Run systematic **technical** quality checks and generate a comprehensive report. Don't fix issues — document them for other commands to address. 16 2 17 3 This is a code-level audit, not a design critique. Check what's measurable and verifiable in the implementation. ··· 23 9 ### 1. Accessibility (A11y) 24 10 25 11 **Check for**: 26 - 27 12 - **Contrast issues**: Text contrast ratios < 4.5:1 (or 7:1 for AAA) 28 13 - **Missing ARIA**: Interactive elements without proper roles, labels, or states 29 14 - **Keyboard navigation**: Missing focus indicators, illogical tab order, keyboard traps ··· 36 21 ### 2. Performance 37 22 38 23 **Check for**: 39 - 40 24 - **Layout thrashing**: Reading/writing layout properties in loops 41 - - **Expensive animations**: Animating layout properties (width, height, top, left) instead of transform/opacity 25 + - **Expensive animations**: Casual layout-property animation, unbounded blur/filter/shadow effects, or effects that visibly drop frames 42 26 - **Missing optimization**: Images without lazy loading, unoptimized assets, missing will-change 43 27 - **Bundle size**: Unnecessary imports, unused dependencies 44 28 - **Render performance**: Unnecessary re-renders, missing memoization ··· 48 32 ### 3. Theming 49 33 50 34 **Check for**: 51 - 52 35 - **Hard-coded colors**: Colors not using design tokens 53 36 - **Broken dark mode**: Missing dark mode variants, poor contrast in dark theme 54 37 - **Inconsistent tokens**: Using wrong tokens, mixing token types ··· 59 42 ### 4. Responsive Design 60 43 61 44 **Check for**: 62 - 63 45 - **Fixed widths**: Hard-coded widths that break on mobile 64 46 - **Touch targets**: Interactive elements < 44x44px 65 47 - **Horizontal scroll**: Content overflow on narrow viewports ··· 70 52 71 53 ### 5. Anti-Patterns (CRITICAL) 72 54 73 - Check against ALL the **DON'T** guidelines in the impeccable skill. Look for AI slop tells (AI color palette, gradient text, glassmorphism, hero metrics, card grids, generic fonts) and general design anti-patterns (gray on color, nested cards, bounce easing, redundant copy). 55 + Check against ALL the **DON'T** guidelines from the parent impeccable skill (already loaded in this context). Look for AI slop tells (AI color palette, gradient text, glassmorphism, hero metrics, card grids, generic fonts) and general design anti-patterns (gray on color, nested cards, bounce easing, redundant copy). 74 56 75 57 **Score 0-4**: 0=AI slop gallery (5+ tells), 1=Heavy AI aesthetic (3-4 tells), 2=Some tells (1-2 noticeable), 3=Mostly clean (subtle issues only), 4=No AI tells (distinctive, intentional design) 76 58 ··· 78 60 79 61 ### Audit Health Score 80 62 81 - | # | Dimension | Score | Key Finding | 82 - | --------- | ----------------- | --------- | ---------------------------------- | 83 - | 1 | Accessibility | ? | [most critical a11y issue or "--"] | 84 - | 2 | Performance | ? | | 85 - | 3 | Responsive Design | ? | | 86 - | 4 | Theming | ? | | 87 - | 5 | Anti-Patterns | ? | | 88 - | **Total** | | **??/20** | **[Rating band]** | 63 + | # | Dimension | Score | Key Finding | 64 + |---|-----------|-------|-------------| 65 + | 1 | Accessibility | ? | [most critical a11y issue or "--"] | 66 + | 2 | Performance | ? | | 67 + | 3 | Responsive Design | ? | | 68 + | 4 | Theming | ? | | 69 + | 5 | Anti-Patterns | ? | | 70 + | **Total** | | **??/20** | **[Rating band]** | 89 71 90 72 **Rating bands**: 18-20 Excellent (minor polish), 14-17 Good (address weak dimensions), 10-13 Acceptable (significant work needed), 6-9 Poor (major overhaul), 0-5 Critical (fundamental issues) 91 73 92 74 ### Anti-Patterns Verdict 93 - 94 75 **Start here.** Pass/fail: Does this look AI-generated? List specific tells. Be brutally honest. 95 76 96 77 ### Executive Summary 97 - 98 78 - Audit Health Score: **??/20** ([rating band]) 99 79 - Total issues found (count by severity: P0/P1/P2/P3) 100 80 - Top 3-5 critical issues ··· 103 83 ### Detailed Findings by Severity 104 84 105 85 Tag every issue with **P0-P3 severity**: 106 - 107 86 - **P0 Blocking**: Prevents task completion — fix immediately 108 87 - **P1 Major**: Significant difficulty or WCAG AA violation — fix before release 109 88 - **P2 Minor**: Annoyance, workaround exists — fix in next pass 110 89 - **P3 Polish**: Nice-to-fix, no real user impact — fix if time permits 111 90 112 91 For each issue, document: 113 - 114 92 - **[P?] Issue name** 115 93 - **Location**: Component, file, line 116 94 - **Category**: Accessibility / Performance / Theming / Responsive / Anti-Pattern 117 95 - **Impact**: How it affects users 118 96 - **WCAG/Standard**: Which standard it violates (if applicable) 119 97 - **Recommendation**: How to fix it 120 - - **Suggested command**: Which command to use (prefer: /polish, /typeset, /colorize, /quieter, /critique, /overdrive, /clarify, /bolder, /audit, /distill, /harden, /layout, /shape, /animate, /optimize, /adapt, /delight) 98 + - **Suggested command**: Which command to use (prefer: $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) 121 99 122 100 ### Patterns & Systemic Issues 123 101 124 102 Identify recurring problems that indicate systemic gaps rather than one-off mistakes: 125 - 126 103 - "Hard-coded colors appear in 15+ components, should use design tokens" 127 104 - "Touch targets consistently too small (<44px) throughout mobile experience" 128 105 ··· 134 111 135 112 List recommended commands in priority order (P0 first, then P1, then P2): 136 113 137 - 1. **[P?] `/command-name`** — Brief description (specific context from audit findings) 138 - 2. **[P?] `/command-name`** — Brief description (specific context) 114 + 1. **[P?] `$command-name`** — Brief description (specific context from audit findings) 115 + 2. **[P?] `$command-name`** — Brief description (specific context) 139 116 140 - **Rules**: Only recommend commands from: /polish, /typeset, /colorize, /quieter, /critique, /overdrive, /clarify, /bolder, /audit, /distill, /harden, /layout, /shape, /animate, /optimize, /adapt, /delight. Map findings to the most appropriate command. End with `/polish` as the final step if any fixes were recommended. 117 + **Rules**: 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. Map findings to the most appropriate command. End with `$impeccable polish` as the final step if any fixes were recommended. 141 118 142 119 After presenting the summary, tell the user: 143 120 144 121 > You can ask me to run these one at a time, all at once, or in any order you prefer. 145 122 > 146 - > Re-run `/audit` after fixes to see your score improve. 123 + > Re-run `$impeccable audit` after fixes to see your score improve. 147 124 148 125 **IMPORTANT**: Be thorough but actionable. Too many P3 issues creates noise. Focus on what actually matters. 149 126 150 127 **NEVER**: 151 - 152 128 - Report issues without explaining impact (why does this matter?) 153 129 - Provide generic recommendations (be specific and actionable) 154 130 - Skip positive findings (celebrate what works)
+8 -19
.agents/skills/bolder/SKILL.md .agents/skills/impeccable/reference/bolder.md
··· 1 - --- 2 - name: bolder 3 - 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." 4 - version: 2.1.1 5 - user-invocable: true 6 - argument-hint: "[target]" 1 + Increase visual impact and personality in designs that are too safe, generic, or visually underwhelming, creating more engaging and memorable experiences. 2 + 7 3 --- 8 4 9 - Increase visual impact and personality in designs that are too safe, generic, or visually underwhelming, creating more engaging and memorable experiences. 5 + ## Register 10 6 11 - ## MANDATORY PREPARATION 7 + Brand: "bolder" means distinctive. Extreme scale, unexpected color, typographic risk, committed POV. 12 8 13 - 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. 9 + 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. 14 10 15 11 --- 16 12 ··· 32 28 - Who's the audience? (What will resonate?) 33 29 - What are the constraints? (Brand guidelines, accessibility, performance) 34 30 35 - If any of these are unclear from the codebase, ask the user directly to clarify what you cannot infer. 31 + 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. 36 32 37 33 **CRITICAL**: "Bolder" doesn't mean chaotic or garish. It means distinctive, memorable, and confident. Think intentional drama, not random chaos. 38 34 39 - **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." 35 + **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." 40 36 41 37 ## Plan Amplification 42 38 ··· 54 50 Systematically increase impact across these dimensions: 55 51 56 52 ### Typography Amplification 57 - 58 - - **Replace generic fonts**: Swap system fonts for distinctive choices (see impeccable skill for inspiration) 53 + - **Replace generic fonts**: Swap system fonts for distinctive choices (see the parent skill's typography guidelines and [typography.md](typography.md) for inspiration) 59 54 - **Extreme scale**: Create dramatic size jumps (3x-5x differences, not 1.5x) 60 55 - **Weight contrast**: Pair 900 weights with 200 weights, not 600 with 400 61 56 - **Unexpected choices**: Variable fonts, display fonts for headlines, condensed/extended widths, monospace as intentional accent (not as lazy "dev tool" default) 62 57 63 58 ### Color Intensification 64 - 65 59 - **Increase saturation**: Shift to more vibrant, energetic colors (but not neon) 66 60 - **Bold palette**: Introduce unexpected color combinations—avoid the purple-blue gradient AI slop 67 61 - **Dominant color strategy**: Let one bold color own 60% of the design ··· 70 64 - **Rich gradients**: Intentional multi-stop gradients (not generic purple-to-blue) 71 65 72 66 ### Spatial Drama 73 - 74 67 - **Extreme scale jumps**: Make important elements 3-5x larger than surroundings 75 68 - **Break the grid**: Let hero elements escape containers and cross boundaries 76 69 - **Asymmetric layouts**: Replace centered, balanced layouts with tension-filled asymmetry ··· 78 71 - **Overlap**: Layer elements intentionally for depth 79 72 80 73 ### Visual Effects 81 - 82 74 - **Dramatic shadows**: Large, soft shadows for elevation (but not generic drop shadows on rounded rectangles) 83 75 - **Background treatments**: Mesh patterns, noise textures, geometric patterns, intentional gradients (not purple-to-blue) 84 76 - **Texture & depth**: Grain, halftone, duotone, layered elements—NOT glassmorphism (it's overused AI slop) ··· 86 78 - **Custom elements**: Illustrative elements, custom icons, decorative details that reinforce brand 87 79 88 80 ### Motion & Animation 89 - 90 81 - **Entrance choreography**: Staggered, dramatic page load animations with 50-100ms delays 91 82 - **Scroll effects**: Parallax, reveal animations, scroll-triggered sequences 92 83 - **Micro-interactions**: Satisfying hover effects, click feedback, state changes 93 84 - **Transitions**: Smooth, noticeable transitions using ease-out-quart/quint/expo (not bounce or elastic—they cheapen the effect) 94 85 95 86 ### Composition Boldness 96 - 97 87 - **Hero moments**: Create clear focal points with dramatic treatment 98 88 - **Diagonal flows**: Escape horizontal/vertical rigidity with diagonal arrangements 99 89 - **Full-bleed elements**: Use full viewport width/height for impact 100 90 - **Unexpected proportions**: Golden ratio? Throw it out. Try 70/30, 80/20 splits 101 91 102 92 **NEVER**: 103 - 104 93 - Add effects randomly without purpose (chaos ≠ bold) 105 94 - Sacrifice readability for aesthetics (body text must be readable) 106 95 - Make everything bold (then nothing is bold - need contrast)
+1 -29
.agents/skills/clarify/SKILL.md .agents/skills/impeccable/reference/clarify.md
··· 1 - --- 2 - name: clarify 3 - 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." 4 - version: 2.1.1 5 - user-invocable: true 6 - argument-hint: "[target]" 7 - --- 1 + > **Additional context needed**: audience technical level and users' mental state in context. 8 2 9 3 Identify and improve unclear, confusing, or poorly written interface text to make the product easier to understand and use. 10 4 11 - ## MANDATORY PREPARATION 12 - 13 - 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. 14 5 15 6 --- 16 7 ··· 51 42 Refine text across these common areas: 52 43 53 44 ### Error Messages 54 - 55 45 **Bad**: "Error 403: Forbidden" 56 46 **Good**: "You don't have permission to view this page. Contact your admin for access." 57 47 ··· 59 49 **Good**: "Email addresses need an @ symbol. Try: name@example.com" 60 50 61 51 **Principles**: 62 - 63 52 - Explain what went wrong in plain language 64 53 - Suggest how to fix it 65 54 - Don't blame the user ··· 67 56 - Link to help/support if applicable 68 57 69 58 ### Form Labels & Instructions 70 - 71 59 **Bad**: "DOB (MM/DD/YYYY)" 72 60 **Good**: "Date of birth" (with placeholder showing format) 73 61 ··· 75 63 **Good**: "Your email address" or "Company name" 76 64 77 65 **Principles**: 78 - 79 66 - Use clear, specific labels (not generic placeholders) 80 67 - Show format expectations with examples 81 68 - Explain why you're asking (when not obvious) ··· 83 70 - Keep required field indicators clear 84 71 85 72 ### Button & CTA Text 86 - 87 73 **Bad**: "Click here" | "Submit" | "OK" 88 74 **Good**: "Create account" | "Save changes" | "Got it, thanks" 89 75 90 76 **Principles**: 91 - 92 77 - Describe the action specifically 93 78 - Use active voice (verb + noun) 94 79 - Match user's mental model 95 80 - Be specific ("Save" is better than "OK") 96 81 97 82 ### Help Text & Tooltips 98 - 99 83 **Bad**: "This is the username field" 100 84 **Good**: "Choose a username. You can change this later in Settings." 101 85 102 86 **Principles**: 103 - 104 87 - Add value (don't just repeat the label) 105 88 - Answer the implicit question ("What is this?" or "Why do you need this?") 106 89 - Keep it brief but complete 107 90 - Link to detailed docs if needed 108 91 109 92 ### Empty States 110 - 111 93 **Bad**: "No items" 112 94 **Good**: "No projects yet. Create your first project to get started." 113 95 114 96 **Principles**: 115 - 116 97 - Explain why it's empty (if not obvious) 117 98 - Show next action clearly 118 99 - Make it welcoming, not dead-end 119 100 120 101 ### Success Messages 121 - 122 102 **Bad**: "Success" 123 103 **Good**: "Settings saved! Your changes will take effect immediately." 124 104 125 105 **Principles**: 126 - 127 106 - Confirm what happened 128 107 - Explain what happens next (if relevant) 129 108 - Be brief but complete 130 109 - Match the user's emotional moment (celebrate big wins) 131 110 132 111 ### Loading States 133 - 134 112 **Bad**: "Loading..." (for 30+ seconds) 135 113 **Good**: "Analyzing your data... this usually takes 30-60 seconds" 136 114 137 115 **Principles**: 138 - 139 116 - Set expectations (how long?) 140 117 - Explain what's happening (when it's not obvious) 141 118 - Show progress when possible 142 119 - Offer escape hatch if appropriate ("Cancel") 143 120 144 121 ### Confirmation Dialogs 145 - 146 122 **Bad**: "Are you sure?" 147 123 **Good**: "Delete 'Project Alpha'? This can't be undone." 148 124 149 125 **Principles**: 150 - 151 126 - State the specific action 152 127 - Explain consequences (especially for destructive actions) 153 128 - Use clear button labels ("Delete project" not "Yes") 154 129 - Don't overuse confirmations (only for risky actions) 155 130 156 131 ### Navigation & Wayfinding 157 - 158 132 **Bad**: Generic labels like "Items" | "Things" | "Stuff" 159 133 **Good**: Specific labels like "Your projects" | "Team members" | "Settings" 160 134 161 135 **Principles**: 162 - 163 136 - Be specific and descriptive 164 137 - Use language users understand (not internal jargon) 165 138 - Make hierarchy clear ··· 177 150 6. **Be consistent**: Use same terms throughout (don't vary for variety) 178 151 179 152 **NEVER**: 180 - 181 153 - Use jargon without explanation 182 154 - Blame users ("You made an error" → "This field is required") 183 155 - Be vague ("Something went wrong" without explanation)
+23 -23
.agents/skills/colorize/SKILL.md .agents/skills/impeccable/reference/colorize.md
··· 1 - --- 2 - name: colorize 3 - 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." 4 - version: 2.1.1 5 - user-invocable: true 6 - argument-hint: "[target]" 7 - --- 1 + > **Additional context needed**: existing brand colors. 8 2 9 3 Strategically introduce color to designs that are too monochromatic, gray, or lacking in visual warmth and personality. 10 4 11 - ## MANDATORY PREPARATION 5 + --- 6 + 7 + ## Register 8 + 9 + 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. 12 10 13 - 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. 11 + 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. 14 12 15 13 --- 16 14 ··· 32 30 - **Wayfinding**: Helping users navigate and understand structure 33 31 - **Delight**: Moments of visual interest and personality 34 32 35 - If any of these are unclear from the codebase, ask the user directly to clarify what you cannot infer. 33 + 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. 36 34 37 35 **CRITICAL**: More color ≠ better. Strategic color beats rainbow vomit every time. Every color should have a purpose. 38 36 ··· 52 50 Add color systematically across these dimensions: 53 51 54 52 ### Semantic Color 55 - 56 53 - **State indicators**: 57 54 - Success: Green tones (emerald, forest, mint) 58 55 - Error: Red/pink tones (rose, crimson, coral) ··· 64 61 - **Progress indicators**: Colored bars, rings, or charts showing completion or health 65 62 66 63 ### Accent Color Application 67 - 68 64 - **Primary actions**: Color the most important buttons/CTAs 69 65 - **Links**: Add color to clickable text (maintain accessibility) 70 66 - **Icons**: Colorize key icons for recognition and personality ··· 72 68 - **Hover states**: Introduce color on interaction 73 69 74 70 ### Background & Surfaces 75 - 76 71 - **Tinted backgrounds**: Replace pure gray (`#f5f5f5`) with warm neutrals (`oklch(97% 0.01 60)`) or cool tints (`oklch(97% 0.01 250)`) 77 72 - **Colored sections**: Use subtle background colors to separate areas 78 73 - **Gradient backgrounds**: Add depth with subtle, intentional gradients (not generic purple-blue) 79 74 - **Cards & surfaces**: Tint cards or surfaces slightly for warmth 80 75 81 - **Use OKLCH for color**: It's perceptually uniform, meaning equal steps in lightness _look_ equal. Great for generating harmonious scales. 76 + **Use OKLCH for color**: It's perceptually uniform, meaning equal steps in lightness *look* equal. Great for generating harmonious scales. 82 77 83 78 ### Data Visualization 84 - 85 79 - **Charts & graphs**: Use color to encode categories or values 86 80 - **Heatmaps**: Color intensity shows density or importance 87 81 - **Comparison**: Color coding for different datasets or timeframes 88 82 89 83 ### Borders & Accents 90 - 91 - - **Accent borders**: Add colored left/top borders to cards or sections 84 + - **Hairline borders**: 1px colored borders on full perimeter (not side-stripes — see the absolute ban on `border-left/right > 1px`) 92 85 - **Underlines**: Color underlines for emphasis or active states 93 86 - **Dividers**: Subtle colored dividers instead of gray lines 94 87 - **Focus rings**: Colored focus indicators matching brand 88 + - **Surface tints**: A 4-8% background wash of the accent color instead of a stripe 95 89 96 - ### Typography Color 90 + **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. 97 91 92 + ### Typography Color 98 93 - **Colored headings**: Use brand colors for section headings (maintain contrast) 99 94 - **Highlight text**: Color for emphasis or categories 100 95 - **Labels & tags**: Small colored labels for metadata or categories 101 96 102 97 ### Decorative Elements 103 - 104 98 - **Illustrations**: Add colored illustrations or icons 105 99 - **Shapes**: Geometric shapes in brand colors as background elements 106 100 - **Gradients**: Colorful gradient overlays or mesh backgrounds ··· 111 105 Ensure color addition improves rather than overwhelms: 112 106 113 107 ### Maintain Hierarchy 114 - 115 108 - **Dominant color** (60%): Primary brand color or most used accent 116 109 - **Secondary color** (30%): Supporting color for variety 117 110 - **Accent color** (10%): High contrast for key moments 118 111 - **Neutrals** (remaining): Gray/black/white for structure 119 112 120 113 ### Accessibility 121 - 122 114 - **Contrast ratios**: Ensure WCAG compliance (4.5:1 for text, 3:1 for UI components) 123 115 - **Don't rely on color alone**: Use icons, labels, or patterns alongside color 124 116 - **Test for color blindness**: Verify red/green combinations work for all users 125 117 126 118 ### Cohesion 127 - 128 119 - **Consistent palette**: Use colors from defined palette, not arbitrary choices 129 120 - **Systematic application**: Same color meanings throughout (green always = success) 130 121 - **Temperature consistency**: Warm palette stays warm, cool stays cool 131 122 132 123 **NEVER**: 133 - 134 124 - Use every color in the rainbow (choose 2-4 colors beyond neutrals) 135 125 - Apply color randomly without semantic meaning 136 126 - Put gray text on colored backgrounds—it looks washed out; use a darker shade of the background color or transparency instead ··· 152 142 - **Not overwhelming**: Is color balanced and purposeful? 153 143 154 144 Remember: Color is emotional and powerful. Use it to create warmth, guide attention, communicate meaning, and express personality. But restraint and strategy matter more than saturation and variety. Be colorful, but be intentional. 145 + 146 + ## Live-mode signature params 147 + 148 + 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. 149 + 150 + ```json 151 + {"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"} 152 + ``` 153 + 154 + 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.
+42 -73
.agents/skills/critique/SKILL.md .agents/skills/impeccable/reference/critique.md
··· 1 - --- 2 - name: critique 3 - 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." 4 - version: 2.1.1 5 - user-invocable: true 6 - argument-hint: "[area (feature, page, component...)]" 7 - --- 1 + > **Additional context needed**: what the interface is trying to accomplish. 8 2 9 - ## STEPS 3 + ### Gather Assessments 10 4 11 - ### Step 1: Preparation 5 + 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. 12 6 13 - 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. 7 + 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. 14 8 15 - ### Step 2: Gather Assessments 16 - 17 - Launch two independent assessments. **Neither must see the other's output** to avoid bias. 18 - 19 - 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. 20 - 21 - If sub-agents are not available in the current environment, complete each assessment sequentially, writing findings to internal notes before proceeding. 9 + Fall back to sequential in-head work only if the environment genuinely cannot spawn sub-agents. 22 10 23 11 **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. 24 12 25 13 #### Assessment A: LLM Design Review 26 14 27 15 Read 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: 28 - 29 16 ```javascript 30 - document.title = "[LLM] " + document.title; 17 + document.title = '[LLM] ' + document.title; 31 18 ``` 32 - 33 19 Think like a design director. Evaluate: 34 20 35 - **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? 21 + **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? 36 22 37 23 **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). 38 24 39 - **Cognitive Load** (consult [cognitive-load](reference/cognitive-load.md)): 40 - 25 + **Cognitive Load** (consult [cognitive-load](cognitive-load.md)): 41 26 - Run the 8-item cognitive load checklist. Report failure count: 0-1 = low (good), 2-3 = moderate, 4+ = critical. 42 27 - Count visible options at each decision point. If >4, flag it. 43 28 - Check for progressive disclosure: is complexity revealed only when needed? 44 29 45 30 **Emotional Journey**: 46 - 47 31 - What emotion does this interface evoke? Is that intentional? 48 32 - **Peak-end rule**: Is the most intense moment positive? Does the experience end well? 49 33 - **Emotional valleys**: Check for anxiety spikes at high-stakes moments (payment, delete, commit). Are there design interventions (progress indicators, reassurance copy, undo options)? 50 34 51 - **Nielsen's Heuristics** (consult [heuristics-scoring](reference/heuristics-scoring.md)): 35 + **Nielsen's Heuristics** (consult [heuristics-scoring](heuristics-scoring.md)): 52 36 Score each of the 10 heuristics 0-4. This scoring will be presented in the report. 53 37 54 38 Return 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. ··· 58 42 Run the bundled deterministic detector, which flags 25 specific patterns (AI slop tells + general design quality). 59 43 60 44 **CLI scan**: 61 - 62 45 ```bash 63 46 npx impeccable --json [--fast] [target] 64 47 ``` ··· 69 52 - For 500+ files, narrow scope or ask the user 70 53 - Exit code 0 = clean, 2 = findings 71 54 72 - **Browser visualization** (when browser automation tools are available AND the target is a viewable page): 55 + **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). 73 56 74 57 The 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. 75 58 ··· 81 64 2. **Create a new tab** and navigate to the page (use dev server URL for local files, or direct URL). Do not reuse existing tabs. 82 65 3. **Label the tab** via `javascript_tool` so the user can distinguish it: 83 66 ```javascript 84 - document.title = "[Human] " + document.title; 67 + document.title = '[Human] ' + document.title; 85 68 ``` 86 69 4. **Scroll to top** to ensure the page is scrolled to the very top before injection 87 70 5. **Inject** via `javascript_tool` (replace PORT with the port from step 1): 88 71 ```javascript 89 - const s = document.createElement("script"); 90 - s.src = "http://localhost:PORT/detect.js"; 91 - document.head.appendChild(s); 72 + const s = document.createElement('script'); s.src = 'http://localhost:PORT/detect.js'; document.head.appendChild(s); 92 73 ``` 93 74 6. Wait 2-3 seconds for the detector to render overlays 94 75 7. **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. ··· 101 82 102 83 Return: CLI findings (JSON), browser console findings (if applicable), and any false positives noted. 103 84 104 - ### Step 3: Generate Combined Critique Report 85 + ### Generate Combined Critique Report 105 86 106 87 Synthesize 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. 107 88 108 89 Structure your feedback as a design director would: 109 90 110 91 #### Design Health Score 111 - 112 - > _Consult [heuristics-scoring](reference/heuristics-scoring.md)_ 92 + > *Consult [heuristics-scoring](heuristics-scoring.md)* 113 93 114 94 Present the Nielsen's 10 heuristics scores as a table: 115 95 116 - | # | Heuristic | Score | Key Issue | 117 - | --------- | ------------------------------- | --------- | ------------------------------------ | 118 - | 1 | Visibility of System Status | ? | [specific finding or "n/a" if solid] | 119 - | 2 | Match System / Real World | ? | | 120 - | 3 | User Control and Freedom | ? | | 121 - | 4 | Consistency and Standards | ? | | 122 - | 5 | Error Prevention | ? | | 123 - | 6 | Recognition Rather Than Recall | ? | | 124 - | 7 | Flexibility and Efficiency | ? | | 125 - | 8 | Aesthetic and Minimalist Design | ? | | 126 - | 9 | Error Recovery | ? | | 127 - | 10 | Help and Documentation | ? | | 128 - | **Total** | | **??/40** | **[Rating band]** | 96 + | # | Heuristic | Score | Key Issue | 97 + |---|-----------|-------|-----------| 98 + | 1 | Visibility of System Status | ? | [specific finding or "n/a" if solid] | 99 + | 2 | Match System / Real World | ? | | 100 + | 3 | User Control and Freedom | ? | | 101 + | 4 | Consistency and Standards | ? | | 102 + | 5 | Error Prevention | ? | | 103 + | 6 | Recognition Rather Than Recall | ? | | 104 + | 7 | Flexibility and Efficiency | ? | | 105 + | 8 | Aesthetic and Minimalist Design | ? | | 106 + | 9 | Error Recovery | ? | | 107 + | 10 | Help and Documentation | ? | | 108 + | **Total** | | **??/40** | **[Rating band]** | 129 109 130 110 Be honest with scores. A 4 means genuinely excellent. Most real interfaces score 20-32. 131 111 ··· 140 120 **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. 141 121 142 122 #### Overall Impression 143 - 144 123 A brief gut reaction: what works, what doesn't, and the single biggest opportunity. 145 124 146 125 #### What's Working 147 - 148 126 Highlight 2-3 things done well. Be specific about why they work. 149 127 150 128 #### Priority Issues 151 - 152 129 The 3-5 most impactful design problems, ordered by importance. 153 130 154 - For each issue, tag with **P0-P3 severity** (consult [heuristics-scoring](reference/heuristics-scoring.md) for severity definitions): 155 - 131 + For each issue, tag with **P0-P3 severity** (consult [heuristics-scoring](heuristics-scoring.md) for severity definitions): 156 132 - **[P?] What**: Name the problem clearly 157 133 - **Why it matters**: How this hurts users or undermines goals 158 134 - **Fix**: What to do about it (be concrete) 159 - - **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) 135 + - **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) 160 136 161 137 #### Persona Red Flags 138 + > *Consult [personas](personas.md)* 162 139 163 - > _Consult [personas](reference/personas.md)_ 164 - 165 - 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. 140 + 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. 166 141 167 142 For each selected persona, walk through the primary user action and list specific red flags found: 168 143 ··· 173 148 Be specific. Name the exact elements and interactions that fail each persona. Don't write generic persona descriptions; write what broke for them. 174 149 175 150 #### Minor Observations 176 - 177 151 Quick notes on smaller issues worth addressing. 178 152 179 153 #### Questions to Consider 180 - 181 154 Provocative questions that might unlock better solutions: 182 - 183 155 - "What if the primary action were more prominent?" 184 156 - "Does this need to feel this complex?" 185 157 - "What would a confident version of this look like?" 186 158 187 159 **Remember**: 188 - 189 160 - Be direct. Vague feedback wastes everyone's time. 190 161 - Be specific. "The submit button," not "some elements." 191 162 - Say what's wrong AND why it matters to users. ··· 193 164 - Prioritize ruthlessly. If everything is important, nothing is. 194 165 - Don't soften criticism. Developers need honest feedback to ship great design. 195 166 196 - ### Step 4: Ask the User 167 + ### Ask the User 197 168 198 - **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. 169 + **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. 199 170 200 171 Ask questions along these lines (adapt to the specific findings; do NOT ask generic questions): 201 172 ··· 208 179 4. **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. 209 180 210 181 **Rules for questions**: 211 - 212 182 - Every question must reference specific findings from the report. Never ask generic "who is your audience?" questions. 213 183 - Keep it to 2-4 questions maximum. Respect the user's time. 214 184 - Offer concrete options, not open-ended prompts. 215 - - If findings are straightforward (e.g., only 1-2 clear issues), skip questions and go directly to Step 5. 185 + - If findings are straightforward (e.g., only 1-2 clear issues), skip questions and go directly to Recommended Actions. 216 186 217 - ### Step 5: Recommended Actions 187 + ### Recommended Actions 218 188 219 - **After receiving the user's answers**, present a prioritized action summary reflecting the user's priorities and scope from Step 4. 189 + **After receiving the user's answers**, present a prioritized action summary reflecting the user's priorities and scope from Ask the User. 220 190 221 191 #### Action Summary 222 192 223 193 List recommended commands in priority order, based on the user's answers: 224 194 225 - 1. **`/command-name`**: Brief description of what to fix (specific context from critique findings) 226 - 2. **`/command-name`**: Brief description (specific context) 227 - ... 195 + 1. **`$command-name`**: Brief description of what to fix (specific context from critique findings) 196 + 2. **`$command-name`**: Brief description (specific context) 197 + ... 228 198 229 199 **Rules for recommendations**: 230 - 231 - - Only recommend commands from: /polish, /typeset, /colorize, /quieter, /critique, /overdrive, /clarify, /bolder, /audit, /distill, /harden, /layout, /shape, /animate, /optimize, /adapt, /delight 200 + - 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 232 201 - Order by the user's stated priorities first, then by impact 233 202 - Each item's description should carry enough context that the command knows what to focus on 234 203 - Map each Priority Issue to the appropriate command 235 204 - Skip commands that would address zero issues 236 205 - If the user chose a limited scope, only include items within that scope 237 206 - If the user marked areas as off-limits, exclude commands that would touch those areas 238 - - End with `/polish` as the final step if any fixes were recommended 207 + - End with `$impeccable polish` as the final step if any fixes were recommended 239 208 240 209 After presenting the summary, tell the user: 241 210 242 211 > You can ask me to run these one at a time, all at once, or in any order you prefer. 243 212 > 244 - > Re-run `/critique` after fixes to see your score improve. 213 + > Re-run `$impeccable critique` after fixes to see your score improve.
+1 -17
.agents/skills/critique/reference/cognitive-load.md .agents/skills/impeccable/reference/cognitive-load.md
··· 7 7 ## Three Types of Cognitive Load 8 8 9 9 ### Intrinsic Load — The Task Itself 10 - 11 10 Complexity inherent to what the user is trying to do. You can't eliminate this, but you can structure it. 12 11 13 12 **Manage it by**: 14 - 15 13 - Breaking complex tasks into discrete steps 16 14 - Providing scaffolding (templates, defaults, examples) 17 15 - Progressive disclosure — show what's needed now, hide the rest 18 16 - Grouping related decisions together 19 17 20 18 ### Extraneous Load — Bad Design 21 - 22 19 Mental effort caused by poor design choices. **Eliminate this ruthlessly** — it's pure waste. 23 20 24 21 **Common sources**: 25 - 26 22 - Confusing navigation that requires mental mapping 27 23 - Unclear labels that force users to guess meaning 28 24 - Visual clutter competing for attention ··· 30 26 - Unnecessary steps between user intent and result 31 27 32 28 ### Germane Load — Learning Effort 33 - 34 - Mental effort spent building understanding. This is _good_ cognitive load — it leads to mastery. 29 + Mental effort spent building understanding. This is *good* cognitive load — it leads to mastery. 35 30 36 31 **Support it by**: 37 - 38 32 - Progressive disclosure that reveals complexity gradually 39 33 - Consistent patterns that reward learning 40 34 - Feedback that confirms correct understanding ··· 64 58 **Humans can hold ≤4 items in working memory at once** (Miller's Law revised by Cowan, 2001). 65 59 66 60 At any decision point, count the number of distinct options, actions, or pieces of information a user must simultaneously consider: 67 - 68 61 - **≤4 items**: Within working memory limits — manageable 69 62 - **5–7 items**: Pushing the boundary — consider grouping or progressive disclosure 70 63 - **8+ items**: Overloaded — users will skip, misclick, or abandon 71 64 72 65 **Practical applications**: 73 - 74 66 - Navigation menus: ≤5 top-level items (group the rest under clear categories) 75 67 - Form sections: ≤4 fields visible per group before a visual break 76 68 - Action buttons: 1 primary, 1–2 secondary, group the rest in a menu ··· 82 74 ## Common Cognitive Load Violations 83 75 84 76 ### 1. The Wall of Options 85 - 86 77 **Problem**: Presenting 10+ choices at once with no hierarchy. 87 78 **Fix**: Group into categories, highlight recommended, use progressive disclosure. 88 79 89 80 ### 2. The Memory Bridge 90 - 91 81 **Problem**: User must remember info from step 1 to complete step 3. 92 82 **Fix**: Keep relevant context visible, or repeat it where it's needed. 93 83 94 84 ### 3. The Hidden Navigation 95 - 96 85 **Problem**: User must build a mental map of where things are. 97 86 **Fix**: Always show current location (breadcrumbs, active states, progress indicators). 98 87 99 88 ### 4. The Jargon Barrier 100 - 101 89 **Problem**: Technical or domain language forces translation effort. 102 90 **Fix**: Use plain language. If domain terms are unavoidable, define them inline. 103 91 104 92 ### 5. The Visual Noise Floor 105 - 106 93 **Problem**: Every element has the same visual weight — nothing stands out. 107 94 **Fix**: Establish clear hierarchy: one primary element, 2–3 secondary, everything else muted. 108 95 109 96 ### 6. The Inconsistent Pattern 110 - 111 97 **Problem**: Similar actions work differently in different places. 112 98 **Fix**: Standardize interaction patterns. Same type of action = same type of UI. 113 99 114 100 ### 7. The Multi-Task Demand 115 - 116 101 **Problem**: Interface requires processing multiple simultaneous inputs (reading + deciding + navigating). 117 102 **Fix**: Sequence the steps. Let the user do one thing at a time. 118 103 119 104 ### 8. The Context Switch 120 - 121 105 **Problem**: User must jump between screens/tabs/modals to gather info for a single decision. 122 106 **Fix**: Co-locate the information needed for each decision. Reduce back-and-forth.
+13 -23
.agents/skills/critique/reference/heuristics-scoring.md .agents/skills/impeccable/reference/heuristics-scoring.md
··· 9 9 Keep users informed about what's happening through timely, appropriate feedback. 10 10 11 11 **Check for**: 12 - 13 12 - Loading indicators during async operations 14 13 - Confirmation of user actions (save, submit, delete) 15 14 - Progress indicators for multi-step processes ··· 30 29 Speak the user's language. Follow real-world conventions. Information appears in natural, logical order. 31 30 32 31 **Check for**: 33 - 34 32 - Familiar terminology (no unexplained jargon) 35 33 - Logical information order matching user expectations 36 34 - Recognizable icons and metaphors ··· 51 49 Users need a clear "emergency exit" from unwanted states without extended dialogue. 52 50 53 51 **Check for**: 54 - 55 52 - Undo/redo functionality 56 53 - Cancel buttons on forms and modals 57 54 - Clear navigation back to safety (home, previous) ··· 72 69 Users shouldn't wonder whether different words, situations, or actions mean the same thing. 73 70 74 71 **Check for**: 75 - 76 72 - Consistent terminology throughout the interface 77 73 - Same actions produce same results everywhere 78 74 - Platform conventions followed (standard UI patterns) ··· 93 89 Better than good error messages is a design that prevents problems in the first place. 94 90 95 91 **Check for**: 96 - 97 92 - Confirmation before destructive actions (delete, overwrite) 98 93 - Constraints preventing invalid input (date pickers, dropdowns) 99 94 - Smart defaults that reduce errors ··· 114 109 Minimize memory load. Make objects, actions, and options visible or easily retrievable. 115 110 116 111 **Check for**: 117 - 118 112 - Visible options (not buried in hidden menus) 119 113 - Contextual help when needed (tooltips, inline hints) 120 114 - Recent items and history ··· 135 129 Accelerators — invisible to novices — speed up expert interaction. 136 130 137 131 **Check for**: 138 - 139 132 - Keyboard shortcuts for common actions 140 133 - Customizable interface elements 141 134 - Recent items and favorites ··· 156 149 Interfaces should not contain irrelevant or rarely needed information. Every element should serve a purpose. 157 150 158 151 **Check for**: 159 - 160 152 - Only necessary information visible at each step 161 153 - Clear visual hierarchy directing attention 162 154 - Purposeful use of color and emphasis ··· 177 169 Error messages should use plain language, precisely indicate the problem, and constructively suggest a solution. 178 170 179 171 **Check for**: 180 - 181 172 - Plain language error messages (no error codes for users) 182 173 - Specific problem identification ("Email is missing @" not "Invalid input") 183 174 - Actionable recovery suggestions ··· 198 189 Even if the system is usable without docs, help should be easy to find, task-focused, and concise. 199 190 200 191 **Check for**: 201 - 202 192 - Searchable help or documentation 203 193 - Contextual help (tooltips, inline hints, guided tours) 204 194 - Task-focused organization (not feature-organized) ··· 220 210 221 211 **Total possible**: 40 points (10 heuristics × 4 max) 222 212 223 - | Score Range | Rating | What It Means | 224 - | ----------- | ---------- | ------------------------------------------------------ | 225 - | 36–40 | Excellent | Minor polish only — ship it | 226 - | 28–35 | Good | Address weak areas, solid foundation | 227 - | 20–27 | Acceptable | Significant improvements needed before users are happy | 228 - | 12–19 | Poor | Major UX overhaul required — core experience broken | 229 - | 0–11 | Critical | Redesign needed — unusable in current state | 213 + | Score Range | Rating | What It Means | 214 + |-------------|--------|---------------| 215 + | 36–40 | Excellent | Minor polish only — ship it | 216 + | 28–35 | Good | Address weak areas, solid foundation | 217 + | 20–27 | Acceptable | Significant improvements needed before users are happy | 218 + | 12–19 | Poor | Major UX overhaul required — core experience broken | 219 + | 0–11 | Critical | Redesign needed — unusable in current state | 230 220 231 221 --- 232 222 ··· 234 224 235 225 Tag each individual issue found during scoring with a priority level: 236 226 237 - | Priority | Name | Description | Action | 238 - | -------- | -------- | ------------------------------------------ | --------------------------------------- | 239 - | **P0** | Blocking | Prevents task completion entirely | Fix immediately — this is a showstopper | 240 - | **P1** | Major | Causes significant difficulty or confusion | Fix before release | 241 - | **P2** | Minor | Annoyance, but workaround exists | Fix in next pass | 242 - | **P3** | Polish | Nice-to-fix, no real user impact | Fix if time permits | 227 + | Priority | Name | Description | Action | 228 + |----------|------|-------------|--------| 229 + | **P0** | Blocking | Prevents task completion entirely | Fix immediately — this is a showstopper | 230 + | **P1** | Major | Causes significant difficulty or confusion | Fix before release | 231 + | **P2** | Minor | Annoyance, but workaround exists | Fix in next pass | 232 + | **P3** | Polish | Nice-to-fix, no real user impact | Fix if time permits | 243 233 244 234 **Tip**: If you're unsure between two levels, ask: "Would a user contact support about this?" If yes, it's at least P1.
+8 -23
.agents/skills/critique/reference/personas.md .agents/skills/impeccable/reference/personas.md
··· 11 11 **Profile**: Expert with similar products. Expects efficiency, hates hand-holding. Will find shortcuts or leave. 12 12 13 13 **Behaviors**: 14 - 15 14 - Skips all onboarding and instructions 16 15 - Looks for keyboard shortcuts immediately 17 16 - Tries to bulk-select, batch-edit, and automate ··· 19 18 - Abandons if anything feels slow or patronizing 20 19 21 20 **Test Questions**: 22 - 23 21 - Can Alex complete the core task in under 60 seconds? 24 22 - Are there keyboard shortcuts for common actions? 25 23 - Can onboarding be skipped entirely? ··· 27 25 - Is there a "power user" path (shortcuts, bulk actions)? 28 26 29 27 **Red Flags** (report these specifically): 30 - 31 28 - Forced tutorials or unskippable onboarding 32 29 - No keyboard navigation for primary actions 33 30 - Slow animations that can't be skipped ··· 41 38 **Profile**: Never used this type of product. Needs guidance at every step. Will abandon rather than figure it out. 42 39 43 40 **Behaviors**: 44 - 45 41 - Reads all instructions carefully 46 42 - Hesitates before clicking anything unfamiliar 47 43 - Looks for help or support constantly ··· 49 45 - Takes the most literal interpretation of any label 50 46 51 47 **Test Questions**: 52 - 53 48 - Is the first action obviously clear within 5 seconds? 54 49 - Are all icons labeled with text? 55 50 - Is there contextual help at decision points? ··· 57 52 - Is there a clear "back" or "undo" at every step? 58 53 59 54 **Red Flags** (report these specifically): 60 - 61 55 - Icon-only navigation with no labels 62 56 - Technical jargon without explanation 63 57 - No visible help option or guidance ··· 71 65 **Profile**: Uses screen reader (VoiceOver/NVDA), keyboard-only navigation. May have low vision, motor impairment, or cognitive differences. 72 66 73 67 **Behaviors**: 74 - 75 68 - Tabs through the interface linearly 76 69 - Relies on ARIA labels and heading structure 77 70 - Cannot see hover states or visual-only indicators ··· 79 72 - May use browser zoom up to 200% 80 73 81 74 **Test Questions**: 82 - 83 75 - Can the entire primary flow be completed keyboard-only? 84 76 - Are all interactive elements focusable with visible focus indicators? 85 77 - Do images have meaningful alt text? ··· 87 79 - Does the screen reader announce state changes (loading, success, errors)? 88 80 89 81 **Red Flags** (report these specifically): 90 - 91 82 - Click-only interactions with no keyboard alternative 92 83 - Missing or invisible focus indicators 93 84 - Meaning conveyed by color alone (red = error, green = success) ··· 102 93 **Profile**: Methodical user who pushes interfaces beyond the happy path. Tests edge cases, tries unexpected inputs, and probes for gaps in the experience. 103 94 104 95 **Behaviors**: 105 - 106 96 - Tests edge cases intentionally (empty states, long strings, special characters) 107 97 - Submits forms with unexpected data (emoji, RTL text, very long values) 108 98 - Tries to break workflows by navigating backwards, refreshing mid-flow, or opening in multiple tabs ··· 110 100 - Documents problems methodically 111 101 112 102 **Test Questions**: 113 - 114 103 - What happens at the edges (0 items, 1000 items, very long text)? 115 104 - Do error states recover gracefully or leave the UI in a broken state? 116 105 - What happens on refresh mid-workflow? Is state preserved? ··· 118 107 - How does the UI handle unexpected input (emoji, special chars, paste from Excel)? 119 108 120 109 **Red Flags** (report these specifically): 121 - 122 110 - Features that appear to work but silently fail or produce wrong results 123 111 - Error handling that exposes technical details or leaves UI in a broken state 124 112 - Empty states that show nothing useful ("No results" with no guidance) ··· 132 120 **Profile**: Using phone one-handed on the go. Frequently interrupted. Possibly on a slow connection. 133 121 134 122 **Behaviors**: 135 - 136 123 - Uses thumb only — prefers bottom-of-screen actions 137 124 - Gets interrupted mid-flow and returns later 138 125 - Switches between apps frequently ··· 140 127 - Types as little as possible, prefers taps and selections 141 128 142 129 **Test Questions**: 143 - 144 130 - Are primary actions in the thumb zone (bottom half of screen)? 145 131 - Is state preserved if the user leaves and returns? 146 132 - Does it work on slow connections (3G)? ··· 148 134 - Are touch targets at least 44×44pt? 149 135 150 136 **Red Flags** (report these specifically): 151 - 152 137 - Important actions positioned at the top of the screen (unreachable by thumb) 153 138 - No state persistence — progress lost on tab switch or interruption 154 139 - Large text inputs required where selection would work ··· 161 146 162 147 Choose personas based on the interface type: 163 148 164 - | Interface Type | Primary Personas | Why | 165 - | ------------------------ | -------------------- | -------------------------------- | 149 + | Interface Type | Primary Personas | Why | 150 + |---------------|-----------------|-----| 166 151 | Landing page / marketing | Jordan, Riley, Casey | First impressions, trust, mobile | 167 - | Dashboard / admin | Alex, Sam | Power users, accessibility | 168 - | E-commerce / checkout | Casey, Riley, Jordan | Mobile, edge cases, clarity | 169 - | Onboarding flow | Jordan, Casey | Confusion, interruption | 170 - | Data-heavy / analytics | Alex, Sam | Efficiency, keyboard nav | 171 - | Form-heavy / wizard | Jordan, Sam, Casey | Clarity, accessibility, mobile | 152 + | Dashboard / admin | Alex, Sam | Power users, accessibility | 153 + | E-commerce / checkout | Casey, Riley, Jordan | Mobile, edge cases, clarity | 154 + | Onboarding flow | Jordan, Casey | Confusion, interruption | 155 + | Data-heavy / analytics | Alex, Sam | Efficiency, keyboard nav | 156 + | Form-heavy / wizard | Jordan, Sam, Casey | Clarity, accessibility, mobile | 172 157 173 158 --- 174 159 175 160 ## Project-Specific Personas 176 161 177 - 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: 162 + If `AGENTS.md` contains a `## Design Context` section (generated by `impeccable teach`), derive 1–2 additional personas from the audience and brand information: 178 163 179 164 1. Read the target audience description 180 165 2. Identify the primary user archetype not covered by the 5 predefined personas
+10 -44
.agents/skills/delight/SKILL.md .agents/skills/impeccable/reference/delight.md
··· 1 - --- 2 - name: delight 3 - 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." 4 - version: 2.1.1 5 - user-invocable: true 6 - argument-hint: "[target]" 7 - --- 1 + > **Additional context needed**: what's appropriate for the domain (playful vs professional vs quirky vs elegant). 8 2 9 3 Identify opportunities to add moments of joy, personality, and unexpected polish that transform functional interfaces into delightful experiences. 10 4 11 - ## MANDATORY PREPARATION 5 + --- 6 + 7 + ## Register 8 + 9 + Brand: delight can be distributed — copy voice, section transitions, discovery rewards, seasonal touches, personality across the whole surface. 12 10 13 - 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). 11 + 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. 14 12 15 13 --- 16 14 ··· 39 37 - **Helpful surprises**: Anticipating needs before users ask (productivity tools) 40 38 - **Sensory richness**: Satisfying sounds, smooth animations (creative tools) 41 39 42 - If any of these are unclear from the codebase, ask the user directly to clarify what you cannot infer. 40 + 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. 43 41 44 42 **CRITICAL**: Delight should enhance usability, never obscure it. If users notice the delight more than accomplishing their goal, you've gone too far. 45 43 ··· 48 46 Follow these guidelines: 49 47 50 48 ### Delight Amplifies, Never Blocks 51 - 52 49 - Delight moments should be quick (< 1 second) 53 50 - Never delay core functionality for delight 54 51 - Make delight skippable or subtle 55 52 - Respect user's time and task focus 56 53 57 54 ### Surprise and Discovery 58 - 59 55 - Hide delightful details for users to discover 60 56 - Reward exploration and curiosity 61 57 - Don't announce every delight moment 62 58 - Let users share discoveries with others 63 59 64 60 ### Appropriate to Context 65 - 66 61 - Match delight to emotional moment (celebrate success, empathize with errors) 67 62 - Respect the user's state (don't be playful during critical errors) 68 63 - Match brand personality and audience expectations 69 64 - Cultural sensitivity (what's delightful varies by culture) 70 65 71 66 ### Compound Over Time 72 - 73 67 - Delight should remain fresh with repeated use 74 68 - Vary responses (not same animation every time) 75 69 - Reveal deeper layers with continued use ··· 82 76 ### Micro-interactions & Animation 83 77 84 78 **Button delight**: 85 - 86 79 ```css 87 80 /* Satisfying button press */ 88 81 .button { 89 - transition: 90 - transform 0.1s, 91 - box-shadow 0.1s; 82 + transition: transform 0.1s, box-shadow 0.1s; 92 83 } 93 84 .button:active { 94 85 transform: translateY(2px); 95 - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); 86 + box-shadow: 0 2px 4px rgba(0,0,0,0.2); 96 87 } 97 88 98 89 /* Ripple effect on click */ ··· 104 95 ``` 105 96 106 97 **Loading delight**: 107 - 108 98 - Playful loading animations (not just spinners) 109 99 - Personality in loading messages (write product-specific ones, not generic AI filler) 110 100 - Progress indication with encouraging messages 111 101 - Skeleton screens with subtle animations 112 102 113 103 **Success animations**: 114 - 115 104 - Checkmark draw animation 116 105 - Confetti burst for major achievements 117 106 - Gentle scale + fade for confirmation 118 107 - Satisfying sound effects (subtle) 119 108 120 109 **Hover surprises**: 121 - 122 110 - Icons that animate on hover 123 111 - Color shifts or glow effects 124 112 - Tooltip reveals with personality ··· 127 115 ### Personality in Copy 128 116 129 117 **Playful error messages**: 130 - 131 118 ``` 132 119 "Error 404" 133 120 "This page is playing hide and seek. (And winning)" ··· 137 124 ``` 138 125 139 126 **Encouraging empty states**: 140 - 141 127 ``` 142 128 "No projects" 143 129 "Your canvas awaits. Create something amazing." ··· 147 133 ``` 148 134 149 135 **Playful labels & tooltips**: 150 - 151 136 ``` 152 137 "Delete" 153 138 "Send to void" (for playful brand) ··· 161 146 ### Illustrations & Visual Personality 162 147 163 148 **Custom illustrations**: 164 - 165 149 - Empty state illustrations (not stock icons) 166 150 - Error state illustrations (friendly monsters, quirky characters) 167 151 - Loading state illustrations (animated characters) 168 152 - Success state illustrations (celebrations) 169 153 170 154 **Icon personality**: 171 - 172 155 - Custom icon set matching brand personality 173 156 - Animated icons (subtle motion on hover/click) 174 157 - Illustrative icons (more detailed than generic) 175 158 - Consistent style across all icons 176 159 177 160 **Background effects**: 178 - 179 161 - Subtle particle effects 180 162 - Gradient mesh backgrounds 181 163 - Geometric patterns ··· 185 167 ### Satisfying Interactions 186 168 187 169 **Drag and drop delight**: 188 - 189 170 - Lift effect on drag (shadow, scale) 190 171 - Snap animation when dropped 191 172 - Satisfying placement sound 192 173 - Undo toast ("Dropped in wrong place? [Undo]") 193 174 194 175 **Toggle switches**: 195 - 196 176 - Smooth slide with spring physics 197 177 - Color transition 198 178 - Haptic feedback on mobile 199 179 - Optional sound effect 200 180 201 181 **Progress & achievements**: 202 - 203 182 - Streak counters with celebratory milestones 204 183 - Progress bars that "celebrate" at 100% 205 184 - Badge unlocks with animation 206 185 - Playful stats ("You're on fire! 5 days in a row") 207 186 208 187 **Form interactions**: 209 - 210 188 - Input fields that animate on focus 211 189 - Checkboxes with a satisfying scale pulse when checked 212 190 - Success state that celebrates valid input ··· 215 193 ### Sound Design 216 194 217 195 **Subtle audio cues** (when appropriate): 218 - 219 196 - Notification sounds (distinctive but not annoying) 220 197 - Success sounds (satisfying "ding") 221 198 - Error sounds (empathetic, not harsh) ··· 223 200 - Ambient background audio (very subtle) 224 201 225 202 **IMPORTANT**: 226 - 227 203 - Respect system sound settings 228 204 - Provide mute option 229 205 - Keep volumes quiet (subtle cues, not alarms) ··· 232 208 ### Easter Eggs & Hidden Delights 233 209 234 210 **Discovery rewards**: 235 - 236 211 - Konami code unlocks special theme 237 212 - Hidden keyboard shortcuts (Cmd+K for special features) 238 213 - Hover reveals on logos or illustrations ··· 240 215 - Console messages for developers ("Like what you see? We're hiring!") 241 216 242 217 **Seasonal touches**: 243 - 244 218 - Holiday themes (subtle, tasteful) 245 219 - Seasonal color shifts 246 220 - Weather-based variations 247 221 - Time-based changes (dark at night, light during day) 248 222 249 223 **Contextual personality**: 250 - 251 224 - Different messages based on time of day 252 225 - Responses to specific user actions 253 226 - Randomized variations (not same every time) ··· 256 229 ### Loading & Waiting States 257 230 258 231 **Make waiting engaging**: 259 - 260 232 - Interesting loading messages that rotate 261 233 - Progress bars with personality 262 234 - Mini-games during long loads ··· 276 248 ### Celebration Moments 277 249 278 250 **Success celebrations**: 279 - 280 251 - Confetti for major milestones 281 252 - Animated checkmarks for completions 282 253 - Progress bar celebrations at 100% ··· 284 255 - Personalized messages ("You published your 10th article!") 285 256 286 257 **Milestone recognition**: 287 - 288 258 - First-time actions get special treatment 289 259 - Streak tracking and celebration 290 260 - Progress toward goals ··· 293 263 ## Implementation Patterns 294 264 295 265 **Animation libraries**: 296 - 297 266 - Framer Motion (React) 298 267 - GSAP (universal) 299 268 - Lottie (After Effects animations) 300 269 - Canvas confetti (party effects) 301 270 302 271 **Sound libraries**: 303 - 304 272 - Howler.js (audio management) 305 273 - Use-sound (React hook) 306 274 307 275 **Physics libraries**: 308 - 309 276 - React Spring (spring physics) 310 277 - Popmotion (animation primitives) 311 278 312 279 **IMPORTANT**: File size matters. Compress images, optimize animations, lazy load delight features. 313 280 314 281 **NEVER**: 315 - 316 282 - Delay core functionality for delight 317 283 - Force users through delightful moments (make skippable) 318 284 - Use delight to hide poor UX
+1 -20
.agents/skills/distill/SKILL.md .agents/skills/impeccable/reference/distill.md
··· 1 - --- 2 - name: distill 3 - 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." 4 - version: 2.1.1 5 - user-invocable: true 6 - argument-hint: "[target]" 7 - --- 8 - 9 1 Remove unnecessary complexity from designs, revealing the essential elements and creating clarity through ruthless simplification. 10 2 11 - ## MANDATORY PREPARATION 12 - 13 - 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. 14 3 15 4 --- 16 5 ··· 32 21 - What can be removed, hidden, or combined? 33 22 - What's the 20% that delivers 80% of value? 34 23 35 - If any of these are unclear from the codebase, ask the user directly to clarify what you cannot infer. 24 + 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. 36 25 37 26 **CRITICAL**: Simplicity is not about removing features - it's about removing obstacles between users and their goals. Every element should justify its existence. 38 27 ··· 52 41 Systematically remove complexity across these dimensions: 53 42 54 43 ### Information Architecture 55 - 56 44 - **Reduce scope**: Remove secondary actions, optional features, redundant information 57 45 - **Progressive disclosure**: Hide complexity behind clear entry points (accordions, modals, step-through flows) 58 46 - **Combine related actions**: Merge similar buttons, consolidate forms, group related content ··· 60 48 - **Remove redundancy**: If it's said elsewhere, don't repeat it here 61 49 62 50 ### Visual Simplification 63 - 64 51 - **Reduce color palette**: Use 1-2 colors plus neutrals, not 5-7 colors 65 52 - **Limit typography**: One font family, 3-4 sizes maximum, 2-3 weights 66 53 - **Remove decorations**: Eliminate borders, shadows, backgrounds that don't serve hierarchy or function ··· 69 56 - **Consistent spacing**: Use one spacing scale, remove arbitrary gaps 70 57 71 58 ### Layout Simplification 72 - 73 59 - **Linear flow**: Replace complex grids with simple vertical flow where possible 74 60 - **Remove sidebars**: Move secondary content inline or hide it 75 61 - **Full-width**: Use available space generously instead of complex multi-column layouts ··· 77 63 - **Generous white space**: Let content breathe, don't pack everything tight 78 64 79 65 ### Interaction Simplification 80 - 81 66 - **Reduce choices**: Fewer buttons, fewer options, clearer path forward (paradox of choice is real) 82 67 - **Smart defaults**: Make common choices automatic, only ask when necessary 83 68 - **Inline actions**: Replace modal flows with inline editing where possible ··· 85 70 - **Clear CTAs**: ONE obvious next step, not five competing actions 86 71 87 72 ### Content Simplification 88 - 89 73 - **Shorter copy**: Cut every sentence in half, then do it again 90 74 - **Active voice**: "Save changes" not "Changes will be saved" 91 75 - **Remove jargon**: Plain language always wins ··· 94 78 - **Remove redundant copy**: No headers restating intros, no repeated explanations, say it once 95 79 96 80 ### Code Simplification 97 - 98 81 - **Remove unused code**: Dead CSS, unused components, orphaned files 99 82 - **Flatten component trees**: Reduce nesting depth 100 83 - **Consolidate styles**: Merge similar styles, use utilities consistently 101 84 - **Reduce variants**: Does that component need 12 variations, or can 3 cover 90% of cases? 102 85 103 86 **NEVER**: 104 - 105 87 - Remove necessary functionality (simplicity ≠ feature-less) 106 88 - Sacrifice accessibility for simplicity (clear labels and ARIA still required) 107 89 - Make things so simple they're unclear (mystery ≠ minimalism) ··· 122 104 ## Document Removed Complexity 123 105 124 106 If you removed features or options: 125 - 126 107 - Document why they were removed 127 108 - Consider if they need alternative access points 128 109 - Note any user feedback to monitor
+18 -98
.agents/skills/harden/SKILL.md .agents/skills/impeccable/reference/harden.md
··· 1 - --- 2 - name: harden 3 - 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." 4 - version: 2.1.1 5 - user-invocable: true 6 - argument-hint: "[target]" 7 - --- 8 - 9 1 Strengthen interfaces against edge cases, errors, internationalization issues, and real-world usage scenarios that break idealized designs. 10 2 11 3 ## Assess Hardening Needs ··· 45 37 ### Text Overflow & Wrapping 46 38 47 39 **Long text handling**: 48 - 49 40 ```css 50 41 /* Single line with ellipsis */ 51 42 .truncate { ··· 71 62 ``` 72 63 73 64 **Flex/Grid overflow**: 74 - 75 65 ```css 76 66 /* Prevent flex items from overflowing */ 77 67 .flex-item { ··· 87 77 ``` 88 78 89 79 **Responsive text sizing**: 90 - 91 80 - Use `clamp()` for fluid typography 92 81 - Set minimum readable sizes (14px on mobile) 93 82 - Test text scaling (zoom to 200%) ··· 96 85 ### Internationalization (i18n) 97 86 98 87 **Text expansion**: 99 - 100 88 - Add 30-40% space budget for translations 101 89 - Use flexbox/grid that adapts to content 102 90 - Test with longest language (usually German) ··· 111 99 ``` 112 100 113 101 **RTL (Right-to-Left) support**: 114 - 115 102 ```css 116 103 /* Use logical properties */ 117 104 margin-inline-start: 1rem; /* Not margin-left */ ··· 119 106 border-inline-end: 1px solid; /* Not border-right */ 120 107 121 108 /* Or use dir attribute */ 122 - [dir="rtl"] .arrow { 123 - transform: scaleX(-1); 124 - } 109 + [dir="rtl"] .arrow { transform: scaleX(-1); } 125 110 ``` 126 111 127 112 **Character set support**: 128 - 129 113 - Use UTF-8 encoding everywhere 130 114 - Test with Chinese/Japanese/Korean (CJK) characters 131 115 - Test with emoji (they can be 2-4 bytes) 132 116 - Handle different scripts (Latin, Cyrillic, Arabic, etc.) 133 117 134 118 **Date/Time formatting**: 135 - 136 119 ```javascript 137 120 // ✅ Use Intl API for proper formatting 138 - new Intl.DateTimeFormat("en-US").format(date); // 1/15/2024 139 - new Intl.DateTimeFormat("de-DE").format(date); // 15.1.2024 121 + new Intl.DateTimeFormat('en-US').format(date); // 1/15/2024 122 + new Intl.DateTimeFormat('de-DE').format(date); // 15.1.2024 140 123 141 - new Intl.NumberFormat("en-US", { 142 - style: "currency", 143 - currency: "USD", 124 + new Intl.NumberFormat('en-US', { 125 + style: 'currency', 126 + currency: 'USD' 144 127 }).format(1234.56); // $1,234.56 145 128 ``` 146 129 147 130 **Pluralization**: 148 - 149 131 ```javascript 150 132 // ❌ Bad: Assumes English pluralization 151 - `${count} item${count !== 1 ? "s" : ""}`; 133 + `${count} item${count !== 1 ? 's' : ''}` 152 134 153 135 // ✅ Good: Use proper i18n library 154 - t("items", { count }); // Handles complex plural rules 136 + t('items', { count }) // Handles complex plural rules 155 137 ``` 156 138 157 139 ### Error Handling 158 140 159 141 **Network errors**: 160 - 161 142 - Show clear error messages 162 143 - Provide retry button 163 144 - Explain what happened ··· 166 147 167 148 ```jsx 168 149 // Error states with recovery 169 - { 170 - error && ( 171 - <ErrorMessage> 172 - <p>Failed to load data. {error.message}</p> 173 - <button onClick={retry}>Try again</button> 174 - </ErrorMessage> 175 - ); 176 - } 150 + {error && ( 151 + <ErrorMessage> 152 + <p>Failed to load data. {error.message}</p> 153 + <button onClick={retry}>Try again</button> 154 + </ErrorMessage> 155 + )} 177 156 ``` 178 157 179 158 **Form validation errors**: 180 - 181 159 - Inline errors near fields 182 160 - Clear, specific messages 183 161 - Suggest corrections ··· 185 163 - Preserve user input on error 186 164 187 165 **API errors**: 188 - 189 166 - Handle each status code appropriately 190 167 - 400: Show validation errors 191 168 - 401: Redirect to login ··· 195 172 - 500: Show generic error, offer support 196 173 197 174 **Graceful degradation**: 198 - 199 175 - Core functionality works without JavaScript 200 176 - Images have alt text 201 177 - Progressive enhancement ··· 204 180 ### Edge Cases & Boundary Conditions 205 181 206 182 **Empty states**: 207 - 208 183 - No items in list 209 184 - No search results 210 185 - No notifications ··· 212 187 - Provide clear next action 213 188 214 189 **Loading states**: 215 - 216 190 - Initial load 217 191 - Pagination load 218 192 - Refresh ··· 220 194 - Time estimates for long operations 221 195 222 196 **Large datasets**: 223 - 224 197 - Pagination or virtual scrolling 225 198 - Search/filter capabilities 226 199 - Performance optimization 227 200 - Don't load all 10,000 items at once 228 201 229 202 **Concurrent operations**: 230 - 231 203 - Prevent double-submission (disable button while loading) 232 204 - Handle race conditions 233 205 - Optimistic updates with rollback 234 206 - Conflict resolution 235 207 236 208 **Permission states**: 237 - 238 209 - No permission to view 239 210 - No permission to edit 240 211 - Read-only mode 241 212 - Clear explanation of why 242 213 243 214 **Browser compatibility**: 244 - 245 215 - Polyfills for modern features 246 216 - Fallbacks for unsupported CSS 247 217 - Feature detection (not browser detection) 248 218 - Test in target browsers 249 219 250 - ### Onboarding & First-Run Experience 251 - 252 - Production-ready features work for first-time users, not just power users. Design the paths that get new users to value: 253 - 254 - **Empty states**: Every zero-data screen needs: 255 - 256 - - What will appear here (description or illustration) 257 - - Why it matters to the user 258 - - Clear CTA to create the first item or start from a template 259 - - Visual interest (not just blank space with "No items yet") 260 - 261 - Empty state types to handle: 262 - 263 - - **First use**: emphasize value, provide templates 264 - - **User cleared**: light touch, easy to recreate 265 - - **No results**: suggest a different query, offer to clear filters 266 - - **No permissions**: explain why, how to get access 267 - 268 - **First-run experience**: Get users to their "aha moment" as quickly as possible. 269 - 270 - - Show, don't tell -- working examples over descriptions 271 - - Progressive disclosure -- teach one thing at a time, not everything upfront 272 - - Make onboarding optional -- let experienced users skip 273 - - Provide smart defaults so required setup is minimal 274 - 275 - **Feature discovery**: Teach features when users need them, not upfront. 276 - 277 - - Contextual tooltips at point of use (brief, dismissable, one-time) 278 - - Badges or indicators on new or unused features 279 - - Celebrate activation events quietly (a toast, not a modal) 280 - 281 - **NEVER**: 282 - 283 - - Force long onboarding before users can touch the product 284 - - Show the same tooltip repeatedly (track and respect dismissals) 285 - - Block the entire UI during a guided tour 286 - - Create separate tutorial modes disconnected from the real product 287 - - Design empty states that just say "No items" with no next action 288 - 289 220 ### Input Validation & Sanitization 290 221 291 222 **Client-side validation**: 292 - 293 223 - Required fields 294 224 - Format validation (email, phone, URL) 295 225 - Length limits ··· 297 227 - Custom validation rules 298 228 299 229 **Server-side validation** (always): 300 - 301 230 - Never trust client-side only 302 231 - Validate and sanitize all inputs 303 232 - Protect against injection attacks 304 233 - Rate limiting 305 234 306 235 **Constraint handling**: 307 - 308 236 ```html 309 237 <!-- Set clear constraints --> 310 - <input 238 + <input 311 239 type="text" 312 240 maxlength="100" 313 241 pattern="[A-Za-z0-9]+" 314 242 required 315 243 aria-describedby="username-hint" 316 244 /> 317 - <small id="username-hint"> Letters and numbers only, up to 100 characters </small> 245 + <small id="username-hint"> 246 + Letters and numbers only, up to 100 characters 247 + </small> 318 248 ``` 319 249 320 250 ### Accessibility Resilience 321 251 322 252 **Keyboard navigation**: 323 - 324 253 - All functionality accessible via keyboard 325 254 - Logical tab order 326 255 - Focus management in modals 327 256 - Skip links for long content 328 257 329 258 **Screen reader support**: 330 - 331 259 - Proper ARIA labels 332 260 - Announce dynamic changes (live regions) 333 261 - Descriptive alt text 334 262 - Semantic HTML 335 263 336 264 **Motion sensitivity**: 337 - 338 265 ```css 339 266 @media (prefers-reduced-motion: reduce) { 340 267 * { ··· 346 273 ``` 347 274 348 275 **High contrast mode**: 349 - 350 276 - Test in Windows high contrast mode 351 277 - Don't rely only on color 352 278 - Provide alternative visual cues ··· 354 280 ### Performance Resilience 355 281 356 282 **Slow connections**: 357 - 358 283 - Progressive image loading 359 284 - Skeleton screens 360 285 - Optimistic UI updates 361 286 - Offline support (service workers) 362 287 363 288 **Memory leaks**: 364 - 365 289 - Clean up event listeners 366 290 - Cancel subscriptions 367 291 - Clear timers/intervals 368 292 - Abort pending requests on unmount 369 293 370 294 **Throttling & Debouncing**: 371 - 372 295 ```javascript 373 296 // Debounce search input 374 297 const debouncedSearch = debounce(handleSearch, 300); ··· 380 303 ## Testing Strategies 381 304 382 305 **Manual testing**: 383 - 384 306 - Test with extreme data (very long, very short, empty) 385 307 - Test in different languages 386 308 - Test offline ··· 390 312 - Test on old browsers 391 313 392 314 **Automated testing**: 393 - 394 315 - Unit tests for edge cases 395 316 - Integration tests for error scenarios 396 317 - E2E tests for critical paths ··· 400 321 **IMPORTANT**: Hardening is about expecting the unexpected. Real users will do things you never imagined. 401 322 402 323 **NEVER**: 403 - 404 324 - Assume perfect input (validate everything) 405 325 - Ignore internationalization (design for global) 406 326 - Leave error messages generic ("Error occurred")
+112 -309
.agents/skills/impeccable/SKILL.md
··· 1 1 --- 2 2 name: impeccable 3 - 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." 4 - version: 2.1.1 5 - user-invocable: true 6 - argument-hint: "[craft|teach|extract]" 7 - license: Apache 2.0. Based on Anthropic's frontend-design skill. See NOTICE.md for attribution. 3 + 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. 8 4 --- 9 5 10 - 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. 6 + Designs and iterates production-grade frontend interfaces. Real working code, committed design choices, exceptional craft. 11 7 12 - ## Context Gathering Protocol 8 + ## Setup (non-optional) 13 9 14 - Design skills produce generic output without project context. You MUST have confirmed design context before doing any design work. 10 + Before any design work or file edits, pass these gates. Skipping them produces generic output that ignores the project. 15 11 16 - **Required context** (every design skill needs at minimum): 12 + | Gate | Required check | If fail | 13 + |---|---|---| 14 + | Context | The PRODUCT.md / DESIGN.md loader result is known from `node .agents/skills/impeccable/scripts/load-context.mjs`. | Run the loader before continuing. | 15 + | 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. | 16 + | Command | The matching command reference is loaded when a sub-command is used. | Load the reference before continuing. | 17 + | 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. | 18 + | 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. | 19 + | Mutation | All active gates above pass. | Do not edit project files yet. | 17 20 18 - - **Target audience**: Who uses this product and in what context? 19 - - **Use cases**: What jobs are they trying to get done? 20 - - **Brand personality/tone**: How should the interface feel? 21 + Codex-style agents must state this before editing files: 21 22 22 - Individual skills may require additional context. Check the skill's preparation section for specifics. 23 + ```text 24 + IMPECCABLE_PREFLIGHT: context=pass product=pass command_reference=pass shape=pass|not_required image_gate=pass|skipped:<reason> mutation=open 25 + ``` 23 26 24 - **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. 27 + 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. 25 28 26 - **Gathering order:** 29 + Other harnesses should follow the same checklist when they can expose this state. 27 30 28 - 1. **Check current instructions (instant)**: If your loaded instructions already contain a **Design Context** section, proceed immediately. 29 - 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. 30 - 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. 31 + ### 1. Context gathering 31 32 32 - --- 33 + Two files at the project root, case-insensitive: 33 34 34 - ## Design Direction 35 + - **PRODUCT.md** — required. Users, brand, tone, anti-references, strategic principles. 36 + - **DESIGN.md** — optional, strongly recommended. Colors, typography, elevation, components. 35 37 36 - Commit to a BOLD aesthetic direction: 38 + Load both in one call: 37 39 38 - - **Purpose**: What problem does this interface solve? Who uses it? 39 - - **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. 40 - - **Constraints**: Technical requirements (framework, performance, accessibility). 41 - - **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember? 40 + ```bash 41 + node .agents/skills/impeccable/scripts/load-context.mjs 42 + ``` 42 43 43 - **CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work. The key is intentionality, not intensity. 44 + Consume the full JSON output. Never pipe through `head`, `tail`, `grep`, or `jq`. 44 45 45 - Then implement working code that is: 46 + 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. 46 47 47 - - Production-grade and functional 48 - - Visually striking and memorable 49 - - Cohesive with a clear aesthetic point-of-view 50 - - Meticulously refined in every detail 48 + `$impeccable live` already warms context via `live.mjs` — if you've run `live.mjs`, don't also run `load-context.mjs` this session. 51 49 52 - ## Frontend Aesthetics Guidelines 50 + 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. 53 51 54 - ### Typography 52 + If DESIGN.md is missing: nudge once per session (*"Run `$impeccable document` for more on-brand output"*), then proceed. 55 53 56 - → _Consult [typography reference](reference/typography.md) for OpenType features, web font loading, and the deeper material on scales._ 54 + ### 2. Register 57 55 58 - Choose fonts that are beautiful, unique, and interesting. Pair a distinctive display font with a refined body font. 56 + 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). 59 57 60 - <typography_principles> 61 - Always apply these — do not consult a reference, just do them: 58 + 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. 62 59 63 - - 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). 64 - - 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. 65 - - 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. 66 - - Cap line length at ~65-75ch. Body text wider than that is fatiguing. 67 - </typography_principles> 60 + 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. 68 61 69 - <font_selection_procedure> 70 - DO THIS BEFORE TYPING ANY FONT NAME. 62 + 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. 71 63 72 - 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: 64 + ## Shared design laws 73 65 74 - 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. 66 + 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. 75 67 76 - Step 2. List the 3 fonts you would normally reach for given those words. Write them down. They are most likely from this list: 68 + ### Color 77 69 78 - <reflex_fonts_to_reject> 79 - Fraunces 80 - Newsreader 81 - Lora 82 - Crimson 83 - Crimson Pro 84 - Crimson Text 85 - Playfair Display 86 - Cormorant 87 - Cormorant Garamond 88 - Syne 89 - IBM Plex Mono 90 - IBM Plex Sans 91 - IBM Plex Serif 92 - Space Mono 93 - Space Grotesk 94 - Inter 95 - DM Sans 96 - DM Serif Display 97 - DM Serif Text 98 - Outfit 99 - Plus Jakarta Sans 100 - Instrument Sans 101 - Instrument Serif 102 - </reflex_fonts_to_reject> 70 + - Use OKLCH. Reduce chroma as lightness approaches 0 or 100 — high chroma at extremes looks garish. 71 + - Never use `#000` or `#fff`. Tint every neutral toward the brand hue (chroma 0.005–0.01 is enough). 72 + - Pick a **color strategy** before picking colors. Four steps on the commitment axis: 73 + - **Restrained** — tinted neutrals + one accent ≤10%. Product default; brand minimalism. 74 + - **Committed** — one saturated color carries 30–60% of the surface. Brand default for identity-driven pages. 75 + - **Full palette** — 3–4 named roles, each used deliberately. Brand campaigns; product data viz. 76 + - **Drenched** — the surface IS the color. Brand heroes, campaign pages. 77 + - 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. 103 78 104 - Reject every font that appears in the reflex_fonts_to_reject list. They are your training-data defaults and they create monoculture across projects. 79 + ### Theme 105 80 106 - 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. 81 + Dark vs. light is never a default. Not dark "because tools look cool dark." Not light "to be safe." 107 82 108 - 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. 109 - </font_selection_procedure> 83 + 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. 110 84 111 - <typography_rules> 112 - DO use a modular type scale with fluid sizing (clamp) on headings. 113 - DO vary font weights and sizes to create clear visual hierarchy. 114 - 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. 85 + "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. 115 86 116 - 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. 117 - DO NOT use monospace typography as lazy shorthand for "technical/developer" vibes. 118 - DO NOT put large icons with rounded corners above every heading. They rarely add value and make sites look templated. 119 - DO NOT use only one font family for the entire page. Pair a distinctive display font with a refined body font. 120 - DO NOT use a flat type hierarchy where sizes are too close together. Aim for at least a 1.25 ratio between steps. 121 - DO NOT set long body passages in uppercase. Reserve all-caps for short labels and headings. 122 - </typography_rules> 87 + ### Typography 123 88 124 - ### Color & Theme 125 - 126 - → _Consult [color reference](reference/color-and-contrast.md) for the deeper material on contrast, accessibility, and palette construction._ 127 - 128 - Commit to a cohesive palette. Dominant colors with sharp accents outperform timid, evenly-distributed palettes. 129 - 130 - <color_principles> 131 - Always apply these — do not consult a reference, just do them: 89 + - Cap body line length at 65–75ch. 90 + - Hierarchy through scale + weight contrast (≥1.25 ratio between steps). Avoid flat scales. 132 91 133 - - 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. 134 - - 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. 135 - - 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. 136 - </color_principles> 92 + ### Layout 137 93 138 - <theme_selection> 139 - 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? 140 - 141 - - A perp DEX consumed during fast trading sessions → dark 142 - - A hospital portal consumed by anxious patients on phones late at night → light 143 - - A children's reading app → light 144 - - A vintage motorcycle forum where users sit in their garage at 9pm → dark 145 - - An observability dashboard for SREs in a dark office → dark 146 - - A wedding planning checklist for couples on a Sunday morning → light 147 - - A music player app for headphone listening at night → dark 148 - - A food magazine homepage browsed during a coffee break → light 149 - 150 - 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. 151 - </theme_selection> 152 - 153 - <color_rules> 154 - DO use modern CSS color functions (oklch, color-mix, light-dark) for perceptually uniform, maintainable palettes. 155 - DO tint your neutrals toward your brand hue. Even a subtle hint creates subconscious cohesion. 156 - 157 - DO NOT use gray text on colored backgrounds; it looks washed out. Use a shade of the background color instead. 158 - DO NOT use pure black (#000) or pure white (#fff). Always tint; pure black/white never appears in nature. 159 - DO NOT use the AI color palette: cyan-on-dark, purple-to-blue gradients, neon accents on dark backgrounds. 160 - DO NOT use gradient text for impact — see <absolute_bans> below for the strict definition. Solid colors only for text. 161 - DO NOT default to dark mode with glowing accents. It looks "cool" without requiring actual design decisions. 162 - DO NOT default to light mode "to be safe" either. The point is to choose, not to retreat to a safe option. 163 - </color_rules> 164 - 165 - ### Layout & Space 166 - 167 - → _Consult [spatial reference](reference/spatial-design.md) for the deeper material on grids, container queries, and optical adjustments._ 168 - 169 - Create visual rhythm through varied spacing, not the same padding everywhere. Embrace asymmetry and unexpected compositions. Break the grid intentionally for emphasis. 170 - 171 - <spatial_principles> 172 - Always apply these — do not consult a reference, just do them: 173 - 174 - - 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. 175 - - Use `gap` instead of margins for sibling spacing. It eliminates margin collapse and the cleanup hacks that come with it. 176 - - 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. 177 - - Self-adjusting grid pattern: `grid-template-columns: repeat(auto-fit, minmax(280px, 1fr))` is the breakpoint-free responsive grid for card-style content. 178 - - 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. 179 - </spatial_principles> 180 - 181 - <spatial_rules> 182 - DO create visual rhythm through varied spacing: tight groupings, generous separations. 183 - DO use fluid spacing with clamp() that breathes on larger screens. 184 - DO use asymmetry and unexpected compositions; break the grid intentionally for emphasis. 185 - 186 - DO NOT wrap everything in cards. Not everything needs a container. 187 - DO NOT nest cards inside cards. Visual noise; flatten the hierarchy. 188 - DO NOT use identical card grids (same-sized cards with icon + heading + text, repeated endlessly). 189 - DO NOT use the hero metric layout template (big number, small label, supporting stats, gradient accent). 190 - DO NOT center everything. Left-aligned text with asymmetric layouts feels more designed. 191 - DO NOT use the same spacing everywhere. Without rhythm, layouts feel monotonous. 192 - DO NOT let body text wrap beyond ~80 characters per line. Add a max-width like 65–75ch so the eye can track easily. 193 - </spatial_rules> 194 - 195 - ### Visual Details 196 - 197 - <absolute_bans> 198 - 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. 199 - 200 - BAN 1: Side-stripe borders on cards/list items/callouts/alerts 201 - 202 - - PATTERN: `border-left:` or `border-right:` with width greater than 1px 203 - - INCLUDES: hard-coded colors AND CSS variables 204 - - FORBIDDEN: `border-left: 3px solid red`, `border-left: 4px solid #ff0000`, `border-left: 4px solid var(--color-warning)`, `border-left: 5px solid oklch(...)`, etc. 205 - - 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." 206 - - 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. 207 - 208 - BAN 2: Gradient text 209 - 210 - - PATTERN: `background-clip: text` (or `-webkit-background-clip: text`) combined with a gradient background 211 - - FORBIDDEN: any combination that makes text fill come from a `linear-gradient`, `radial-gradient`, or `conic-gradient` 212 - - WHY: gradient text is decorative rather than meaningful and is one of the top three AI design tells 213 - - REWRITE: use a single solid color for text. If you want emphasis, use weight or size, not gradient fill. 214 - </absolute_bans> 215 - 216 - DO: Use intentional, purposeful decorative elements that reinforce brand. 217 - 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. 218 - DO NOT: Use glassmorphism everywhere (blur effects, glass cards, glow borders used decoratively rather than purposefully). 219 - DO NOT: Use sparklines as decoration. Tiny charts that look sophisticated but convey nothing meaningful. 220 - DO NOT: Use rounded rectangles with generic drop shadows. Safe, forgettable, could be any AI output. 221 - DO NOT: Use modals unless there's truly no better alternative. Modals are lazy. 94 + - Vary spacing for rhythm. Same padding everywhere is monotony. 95 + - Cards are the lazy answer. Use them only when they're truly the best affordance. Nested cards are always wrong. 96 + - Don't wrap everything in a container. Most things don't need one. 222 97 223 98 ### Motion 224 99 225 - → _Consult [motion reference](reference/motion-design.md) for timing, easing, and reduced motion._ 226 - 227 - Focus on high-impact moments: one well-orchestrated page load with staggered reveals creates more delight than scattered micro-interactions. 228 - 229 - **DO**: Use motion to convey state changes: entrances, exits, feedback 230 - **DO**: Use exponential easing (ease-out-quart/quint/expo) for natural deceleration 231 - **DO**: For height animations, use grid-template-rows transitions instead of animating height directly 232 - **DON'T**: Animate layout properties (width, height, padding, margin). Use transform and opacity only 233 - **DON'T**: Use bounce or elastic easing. They feel dated and tacky; real objects decelerate smoothly 234 - 235 - ### Interaction 236 - 237 - → _Consult [interaction reference](reference/interaction-design.md) for forms, focus, and loading patterns._ 238 - 239 - Make interactions feel fast. Use optimistic UI: update immediately, sync later. 240 - 241 - **DO**: Use progressive disclosure. Start simple, reveal sophistication through interaction (basic options first, advanced behind expandable sections; hover states that reveal secondary actions) 242 - **DO**: Design empty states that teach the interface, not just say "nothing here" 243 - **DO**: Make every interactive surface feel intentional and responsive 244 - **DON'T**: Repeat the same information (redundant headers, intros that restate the heading) 245 - **DON'T**: Make every button primary. Use ghost buttons, text links, secondary styles; hierarchy matters 246 - 247 - ### Responsive 248 - 249 - → _Consult [responsive reference](reference/responsive-design.md) for mobile-first, fluid design, and container queries._ 250 - 251 - **DO**: Use container queries (@container) for component-level responsiveness 252 - **DO**: Adapt the interface for different contexts, not just shrink it 253 - **DON'T**: Hide critical functionality on mobile. Adapt the interface, don't amputate it 254 - 255 - ### UX Writing 256 - 257 - → _Consult [ux-writing reference](reference/ux-writing.md) for labels, errors, and empty states._ 258 - 259 - **DO**: Make every word earn its place 260 - **DON'T**: Repeat information users can already see 261 - 262 - --- 263 - 264 - ## The AI Slop Test 265 - 266 - **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. 267 - 268 - A distinctive interface should make someone ask "how was this made?" not "which AI made this?" 269 - 270 - Review the DON'T guidelines above. They are the fingerprints of AI-generated work from 2024-2025. 271 - 272 - --- 273 - 274 - ## Implementation Principles 275 - 276 - 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. 277 - 278 - 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. 279 - 280 - 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. 281 - 282 - --- 283 - 284 - ## Craft Mode 285 - 286 - 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. 287 - 288 - --- 289 - 290 - ## Teach Mode 291 - 292 - 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. 293 - 294 - ### Step 1: Explore the Codebase 295 - 296 - Before asking questions, thoroughly scan the project to discover what you can: 297 - 298 - - **README and docs**: Project purpose, target audience, any stated goals 299 - - **Package.json / config files**: Tech stack, dependencies, existing design libraries 300 - - **Existing components**: Current design patterns, spacing, typography in use 301 - - **Brand assets**: Logos, favicons, color values already defined 302 - - **Design tokens / CSS variables**: Existing color palettes, font stacks, spacing scales 303 - - **Any style guides or brand documentation** 100 + - Don't animate CSS layout properties. 101 + - Ease out with exponential curves (ease-out-quart / quint / expo). No bounce, no elastic. 304 102 305 - Note what you've learned and what remains unclear. 103 + ### Absolute bans 306 104 307 - ### Step 2: Ask UX-Focused Questions 105 + Match-and-refuse. If you're about to write any of these, rewrite the element with different structure. 308 106 309 - ask the user directly to clarify what you cannot infer. Focus only on what you couldn't infer from the codebase: 107 + - **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. 108 + - **Gradient text.** `background-clip: text` combined with a gradient background. Decorative, never meaningful. Use a single solid color. Emphasis via weight or size. 109 + - **Glassmorphism as default.** Blurs and glass cards used decoratively. Rare and purposeful, or nothing. 110 + - **The hero-metric template.** Big number, small label, supporting stats, gradient accent. SaaS cliché. 111 + - **Identical card grids.** Same-sized cards with icon + heading + text, repeated endlessly. 112 + - **Modal as first thought.** Modals are usually laziness. Exhaust inline / progressive alternatives first. 310 113 311 - #### Users & Purpose 114 + ### Copy 312 115 313 - - Who uses this? What's their context when using it? 314 - - What job are they trying to get done? 315 - - What emotions should the interface evoke? (confidence, delight, calm, urgency, etc.) 116 + - Every word earns its place. No restated headings, no intros that repeat the title. 117 + - **No em dashes.** Use commas, colons, semicolons, periods, or parentheses. Also not `--`. 316 118 317 - #### Brand & Personality 119 + ### The AI slop test 318 120 319 - - How would you describe the brand personality in 3 words? 320 - - Any reference sites or apps that capture the right feel? What specifically about them? 321 - - What should this explicitly NOT look like? Any anti-references? 121 + 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. 322 122 323 - #### Aesthetic Preferences 123 + **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. 324 124 325 - - Any strong preferences for visual direction? (minimal, bold, elegant, playful, technical, organic, etc.) 326 - - Light mode, dark mode, or both? 327 - - Any colors that must be used or avoided? 125 + ## Commands 328 126 329 - #### Accessibility & Inclusion 330 - 331 - - Specific accessibility requirements? (WCAG level, known user needs) 332 - - Considerations for reduced motion, color blindness, or other accommodations? 333 - 334 - Skip questions where the answer is already clear from the codebase exploration. 335 - 336 - ### Step 3: Write Design Context 337 - 338 - Synthesize your findings and the user's answers into a `## Design Context` section: 339 - 340 - ```markdown 341 - ## Design Context 127 + | Command | Category | Description | Reference | 128 + |---|---|---|---| 129 + | `craft [feature]` | Build | Shape, then build a feature end-to-end | [reference/craft.md](reference/craft.md) | 130 + | `shape [feature]` | Build | Plan UX/UI before writing code | [reference/shape.md](reference/shape.md) | 131 + | `teach` | Build | Set up PRODUCT.md and DESIGN.md context | [reference/teach.md](reference/teach.md) | 132 + | `document` | Build | Generate DESIGN.md from existing project code | [reference/document.md](reference/document.md) | 133 + | `extract [target]` | Build | Pull reusable tokens and components into design system | [reference/extract.md](reference/extract.md) | 134 + | `critique [target]` | Evaluate | UX design review with heuristic scoring | [reference/critique.md](reference/critique.md) | 135 + | `audit [target]` | Evaluate | Technical quality checks (a11y, perf, responsive) | [reference/audit.md](reference/audit.md) | 136 + | `polish [target]` | Refine | Final quality pass before shipping | [reference/polish.md](reference/polish.md) | 137 + | `bolder [target]` | Refine | Amplify safe or bland designs | [reference/bolder.md](reference/bolder.md) | 138 + | `quieter [target]` | Refine | Tone down aggressive or overstimulating designs | [reference/quieter.md](reference/quieter.md) | 139 + | `distill [target]` | Refine | Strip to essence, remove complexity | [reference/distill.md](reference/distill.md) | 140 + | `harden [target]` | Refine | Production-ready: errors, i18n, edge cases | [reference/harden.md](reference/harden.md) | 141 + | `onboard [target]` | Refine | Design first-run flows, empty states, activation | [reference/onboard.md](reference/onboard.md) | 142 + | `animate [target]` | Enhance | Add purposeful animations and motion | [reference/animate.md](reference/animate.md) | 143 + | `colorize [target]` | Enhance | Add strategic color to monochromatic UIs | [reference/colorize.md](reference/colorize.md) | 144 + | `typeset [target]` | Enhance | Improve typography hierarchy and fonts | [reference/typeset.md](reference/typeset.md) | 145 + | `layout [target]` | Enhance | Fix spacing, rhythm, and visual hierarchy | [reference/layout.md](reference/layout.md) | 146 + | `delight [target]` | Enhance | Add personality and memorable touches | [reference/delight.md](reference/delight.md) | 147 + | `overdrive [target]` | Enhance | Push past conventional limits | [reference/overdrive.md](reference/overdrive.md) | 148 + | `clarify [target]` | Fix | Improve UX copy, labels, and error messages | [reference/clarify.md](reference/clarify.md) | 149 + | `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) | 150 + | `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) | 151 + | `live` | Iterate | Visual variant mode: pick elements in the browser, generate alternatives | [reference/live.md](reference/live.md) | 342 152 343 - ### Users 153 + Plus two management commands — `pin <command>` and `unpin <command>`, detailed below. 344 154 345 - [Who they are, their context, the job to be done] 155 + ### Routing rules 346 156 347 - ### Brand Personality 157 + 1. **No argument** — render the table above as the user-facing command menu, grouped by category. Ask what they'd like to do. 158 + 2. **First word matches a command** — load its reference file and follow its instructions. Everything after the command name is the target. 159 + 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. 348 160 349 - [Voice, tone, 3-word personality, emotional goals] 161 + Setup (context gathering, register) is already loaded by then; sub-commands don't re-invoke `$impeccable`. 350 162 351 - ### Aesthetic Direction 163 + 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. 352 164 353 - [Visual tone, references, anti-references, theme] 165 + ## Pin / Unpin 354 166 355 - ### Design Principles 167 + **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. 356 168 357 - [3-5 principles derived from the conversation that should guide all design decisions] 169 + ```bash 170 + node .agents/skills/impeccable/scripts/pin.mjs <pin|unpin> <command> 358 171 ``` 359 172 360 - Write this section to `.impeccable.md` in the project root. If the file already exists, update the Design Context section in place. 361 - 362 - 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. 363 - 364 - Confirm completion and summarize the key design principles that will now guide all future work. 365 - 366 - --- 367 - 368 - ## Extract Mode 369 - 370 - 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. 173 + 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
··· 1 + interface: 2 + display_name: Impeccable 3 + short_description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify,... 4 + default_prompt: Use Impeccable to redesign, critique, audit, or polish this frontend.
+104
.agents/skills/impeccable/reference/brand.md
··· 1 + # Brand register 2 + 3 + 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. 4 + 5 + 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. 6 + 7 + ## The brand slop test 8 + 9 + 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?" 10 + 11 + 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. 12 + 13 + **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. 14 + 15 + ## Typography 16 + 17 + ### Font selection procedure 18 + 19 + Every project. Never skip. 20 + 21 + 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. 22 + 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. 23 + 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." 24 + 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. 25 + 26 + ### Reflex-reject list 27 + 28 + Training-data defaults. Ban list — look further: 29 + 30 + 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 31 + 32 + ### Pairing and voice 33 + 34 + Distinctive + refined is the goal — the specific shape depends on the brand: 35 + 36 + - **Editorial / long-form / luxury**: display serif + sans body (a magazine shape). 37 + - **Tech / dev tools / fintech**: one committed sans, usually; custom-tight tracking, strong weight contrast inside a single family. 38 + - **Consumer / food / travel**: warmer pairings, often a humanist sans plus a script or display serif. 39 + - **Creative studios / agencies**: rule-breaking welcome — mono-only, or display-only, or custom-drawn type as voice. 40 + 41 + 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. 42 + 43 + Vary across projects. If the last brief was a serif-display landing page, this one isn't. 44 + 45 + ### Scale 46 + 47 + Modular scale, fluid `clamp()` for headings, ≥1.25 ratio between steps. Flat scales (1.1× apart) read as uncommitted. 48 + 49 + Light text on dark backgrounds: add 0.05–0.1 to line-height. Light type reads as lighter weight and needs more breathing room. 50 + 51 + ## Color 52 + 53 + 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. 54 + 55 + - 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. 56 + - Palette IS voice. A calm brand and a restless brand should not share palette mechanics. 57 + - When the strategy is Committed or Drenched, the color is load-bearing. Don't hedge with neutrals around the edges — commit. 58 + - Don't converge across projects. If the last brand surface was restrained-on-cream, this one is not. 59 + 60 + ## Layout 61 + 62 + - Asymmetric compositions are one option. Break the grid intentionally for emphasis. 63 + - Fluid spacing with `clamp()` that breathes on larger viewports. Vary for rhythm — generous separations, tight groupings. 64 + - 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. 65 + - 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. 66 + - When cards ARE the right affordance, use `grid-template-columns: repeat(auto-fit, minmax(280px, 1fr))` — breakpoint-free responsiveness. 67 + 68 + ## Imagery 69 + 70 + 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. 71 + 72 + **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. 73 + 74 + - **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. 75 + - **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". 76 + - **One decisive photo beats five mediocre ones.** Hero imagery should commit to a mood; padding with more stock doesn't rescue an indecisive one. 77 + - **Alt text is part of the voice.** "Coastal fettuccine, hand-cut, served on the terrace" beats "pasta dish". 78 + 79 + 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. 80 + 81 + ## Motion 82 + 83 + - 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. 84 + - For collapsing/expanding sections, transition `grid-template-rows` rather than `height`. 85 + 86 + ## Brand bans (on top of the shared absolute bans) 87 + 88 + - Monospace as lazy shorthand for "technical / developer." If the brand isn't technical, mono reads as costume. 89 + - Large rounded-corner icons above every heading. Screams template. 90 + - Single-family pages that picked the family by reflex, not voice. (A single family chosen deliberately is fine.) 91 + - All-caps body copy. Reserve caps for short labels and headings. 92 + - Timid palettes and average layouts. Safe = invisible. 93 + - Zero imagery on a brief that implies imagery (restaurant, hotel, food, travel, fashion, photography, hobbyist). Colored blocks where a hero photo belongs. 94 + - 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. 95 + 96 + ## Brand permissions 97 + 98 + Brand can afford things product can't. Take them. 99 + 100 + - Ambitious first-load motion. Reveals, scroll-triggered transitions, typographic choreography. 101 + - Single-purpose viewports. One dominant idea per fold, long scroll, deliberate pacing. 102 + - Typographic risk. Enormous display type, unexpected italic cuts, mixed cases, hand-drawn headlines, a single oversize word as a hero. 103 + - Unexpected color strategies. Palette IS voice — a calm brand and a restless brand should not share palette mechanics. 104 + - Art direction per section. Different sections can have different visual worlds if the narrative demands it. Consistency of voice beats consistency of treatment.
+19 -19
.agents/skills/impeccable/reference/color-and-contrast.md
··· 2 2 3 3 ## Color Spaces: Use OKLCH 4 4 5 - **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. 5 + **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. 6 6 7 7 The 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. 8 8 ··· 22 22 23 23 A complete system needs: 24 24 25 - | Role | Purpose | Example | 26 - | ------------ | ----------------------------- | ------------------------- | 27 - | **Primary** | Brand, CTAs, key actions | 1 color, 3-5 shades | 28 - | **Neutral** | Text, backgrounds, borders | 9-11 shade scale | 25 + | Role | Purpose | Example | 26 + |------|---------|---------| 27 + | **Primary** | Brand, CTAs, key actions | 1 color, 3-5 shades | 28 + | **Neutral** | Text, backgrounds, borders | 9-11 shade scale | 29 29 | **Semantic** | Success, error, warning, info | 4 colors, 2-3 shades each | 30 - | **Surface** | Cards, modals, overlays | 2-3 elevation levels | 30 + | **Surface** | Cards, modals, overlays | 2-3 elevation levels | 31 31 32 32 **Skip secondary/tertiary unless you need them.** Most apps work fine with one accent color. Adding more creates decision fatigue and visual noise. 33 33 ··· 39 39 - **30%**: Secondary colors—text, borders, inactive states 40 40 - **10%**: Accent—CTAs, highlights, focus states 41 41 42 - 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. 42 + 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. 43 43 44 44 ## Contrast & Accessibility 45 45 46 46 ### WCAG Requirements 47 47 48 - | Content Type | AA Minimum | AAA Target | 49 - | ------------------------------- | ---------- | ---------- | 50 - | Body text | 4.5:1 | 7:1 | 51 - | Large text (18px+ or 14px bold) | 3:1 | 4.5:1 | 52 - | UI components, icons | 3:1 | 4.5:1 | 53 - | Non-essential decorations | None | None | 48 + | Content Type | AA Minimum | AAA Target | 49 + |--------------|------------|------------| 50 + | Body text | 4.5:1 | 7:1 | 51 + | Large text (18px+ or 14px bold) | 3:1 | 4.5:1 | 52 + | UI components, icons | 3:1 | 4.5:1 | 53 + | Non-essential decorations | None | None | 54 54 55 55 **The gotcha**: Placeholder text still needs 4.5:1. That light gray placeholder you see everywhere? Usually fails WCAG. 56 56 ··· 83 83 84 84 You can't just swap colors. Dark mode requires different design decisions: 85 85 86 - | Light Mode | Dark Mode | 87 - | ------------------ | --------------------------------------------- | 88 - | Shadows for depth | Lighter surfaces for depth (no shadows) | 89 - | Dark text on light | Light text on dark (reduce font weight) | 90 - | Vibrant accents | Desaturate accents slightly | 91 - | White backgrounds | Never pure black—use dark gray (oklch 12-18%) | 86 + | Light Mode | Dark Mode | 87 + |------------|-----------| 88 + | Shadows for depth | Lighter surfaces for depth (no shadows) | 89 + | Dark text on light | Light text on dark (reduce font weight) | 90 + | Vibrant accents | Desaturate accents slightly | 91 + | White backgrounds | Never pure black—use dark gray (oklch 12-18%) | 92 92 93 93 In 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. 94 94
+151 -31
.agents/skills/impeccable/reference/craft.md
··· 1 1 # Craft Flow 2 2 3 - 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. 3 + 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. 4 + 5 + ## Build Gate 6 + 7 + Craft cannot build until all of these are true: 8 + 9 + 1. PRODUCT context is valid and current. 10 + 2. The shape design brief is explicitly confirmed by the user for this task, unless the user already provided a confirmed brief. 11 + 3. Implementation references from the brief are loaded. 12 + 4. The shape visual probe decision is recorded: generated, skipped with reason, or already resolved. 13 + 5. The north-star mock decision is recorded: generated, skipped with reason, or not applicable. 14 + 15 + 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. 16 + 17 + 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. 18 + 19 + ## Craft Contract 20 + 21 + Craft is not a first pass. It is a loop with these required artifacts: 22 + 23 + 1. Confirmed design brief from `shape`. 24 + 2. Approved visual direction, from generated probes / mocks when image generation is available. 25 + 3. Mock fidelity inventory: the visible ingredients from the approved direction that must survive into code. 26 + 4. Semantic, functional implementation using the project's real stack and conventions. 27 + 5. Browser evidence across relevant viewports. 28 + 6. At least one critique-and-fix pass after the first browser inspection, unless the first pass has no material defects. 29 + 30 + 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." 4 31 5 32 ## Step 1: Shape the Design 6 33 7 - Run /shape, passing along whatever feature description the user provided. 34 + Run $impeccable shape, passing along whatever feature description the user provided. 8 35 9 - 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. 36 + 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. 37 + 38 + 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. 10 39 11 - If the user has already run /shape and has a confirmed design brief, skip this step and use the existing brief. 40 + If the user has already run $impeccable shape and has a confirmed design brief, skip this step and use the existing brief. 12 41 13 42 ## Step 2: Load References 14 43 ··· 18 47 - [typography.md](typography.md) for type hierarchy 19 48 20 49 Then add references based on the brief's needs: 21 - 22 50 - Complex interactions or forms? Consult [interaction-design.md](interaction-design.md) 23 51 - Animation or transitions? Consult [motion-design.md](motion-design.md) 24 52 - Color-heavy or themed? Consult [color-and-contrast.md](color-and-contrast.md) 25 53 - Responsive requirements? Consult [responsive-design.md](responsive-design.md) 26 54 - Heavy on copy, labels, or errors? Consult [ux-writing.md](ux-writing.md) 27 55 28 - ## Step 3: Build 56 + ## Step 3: Land the Visual Direction (Capability-Gated) 57 + 58 + Before implementation, generate high-fidelity visual comps when all of these are true: 59 + 60 + - The work is **net-new** or visually open-ended enough that composition exploration will improve the build. 61 + - The brief's scope is **mid-fi, high-fi, or production-ready**. 62 + - 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. 63 + 64 + 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. 65 + 66 + 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. 67 + 68 + ### Purpose 69 + 70 + 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. 71 + 72 + ### What to generate 73 + 74 + 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. 75 + 76 + - For brand work, push visual identity, composition, and mood aggressively. 77 + - For product work, still push hierarchy, topology, density, and tone, but keep the comps grounded in realistic product structure and states. 78 + - For landing pages and long-form brand surfaces, show enough of the next section or second fold to establish the system beyond the hero. 79 + 80 + The comps must be genuinely different in primary visual direction, not just color variants. 81 + 82 + ### Approval loop 83 + 84 + 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. 85 + 86 + If the user delegates, pick the strongest direction and explain the decision using the brief, not personal taste. 29 87 30 - Implement the feature following the design brief. Work in this order: 88 + Before moving to implementation, summarize: 31 89 32 - 1. **Structure first**: HTML/semantic structure for the primary state. No styling yet. 33 - 2. **Layout and spacing**: Establish the spatial rhythm and visual hierarchy. 34 - 3. **Typography and color**: Apply the type scale and color system. 35 - 4. **Interactive states**: Hover, focus, active, disabled. 36 - 5. **Edge case states**: Empty, loading, error, overflow, first-run. 37 - 6. **Motion**: Purposeful transitions and animations (if appropriate). 38 - 7. **Responsive**: Adapt for different viewports. Don't just shrink; redesign for the context. 90 + - What to carry into code 91 + - What **not** to literalize from the mock 39 92 40 - ### During Build 93 + This summary is required before Step 4. It is the handoff between visual exploration and semantic implementation. 41 94 42 - - Test with real (or realistic) data at every step, not placeholder text 43 - - Check each state as you build it, not all at the end 44 - - If you discover a design question, stop and ask rather than guessing 45 - - Every visual choice should trace back to something in the design brief 95 + ### Mock fidelity inventory 46 96 47 - ## Step 4: Visual Iteration 97 + Before building, inventory the approved mock's major visible ingredients: 98 + 99 + - Hero silhouette and dominant composition. 100 + - Signature motifs: planets, devices, portraits, charts, route lines, insets, badges, or other memorable objects. 101 + - Nav and primary CTA treatment. 102 + - Section sequence visible in the mock, especially the second fold. 103 + - Image-native content the concept depends on. 104 + - Typography, density, color/material treatment, and motion cues. 105 + 106 + 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. 107 + 108 + 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. 109 + 110 + ## Step 4: Asset Extraction (Need-Gated) 111 + 112 + If the chosen direction includes image-native visual ingredients that would materially improve the implementation, generate them as separate assets before building. 113 + 114 + Good candidates: 115 + 116 + - stickers 117 + - badges 118 + - seals 119 + - tickets 120 + - graphic labels 121 + - textures 122 + - abstract objects 123 + - decorative marks 124 + - non-semantic scene elements 125 + 126 + 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. 127 + 128 + Do **not** export assets for core UI text, navigation, body copy, or any structure that should stay semantic and editable in code. 129 + 130 + 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. 131 + 132 + ## Step 5: Build to Production Quality 133 + 134 + 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. 135 + 136 + ### Production bar 137 + 138 + - Use real or realistic content. Remove placeholder copy, placeholder images, dead links, fake controls, and unused scaffold before presenting. 139 + - 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. 140 + - Build semantically first: real headings, landmarks, labels, form associations, button/link semantics, accessible names, and state announcements where needed. 141 + - Calibrate spacing, alignment, grid placement, and vertical rhythm deliberately. Do not accept default gaps, arbitrary margins, unbalanced whitespace, or accidental optical misalignment. 142 + - 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. 143 + - 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. 144 + - Make interaction quality feel finished: keyboard paths, touch targets, feedback timing, scroll behavior, transitions between states, and no hover-only functionality. 145 + - 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. 146 + - 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. 147 + - 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. 148 + - 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. 149 + - Fit the technical context: production build passes, no obvious console errors, no avoidable layout shift, no needless dependency, and no broken asset path. 150 + - If you discover a design question that materially changes the brief or approved direction, stop and ask rather than guessing. 151 + 152 + ## Step 6: Browser-Based Iteration 48 153 49 154 **This step is critical.** Do not stop after the first implementation pass. 50 155 51 - 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. 156 + 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. 157 + 158 + ### Required viewport pass 159 + 160 + Check the experience at the viewports that matter for the brief. Default minimum: 161 + 162 + - Mobile narrow 163 + - Tablet or small laptop 164 + - Desktop wide 165 + 166 + 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. 167 + 168 + ### Critique and fix loop 52 169 53 - Iterate through these checks visually: 170 + 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: 54 171 55 172 1. **Does it match the brief?** Compare the live result against every section of the design brief. Fix discrepancies. 56 - 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. 57 - 3. **Check against impeccable's DON'T guidelines.** Fix any anti-pattern violations. 58 - 4. **Check every state.** Navigate through empty, error, loading, and edge case states. Each one should feel intentional, not like an afterthought. 59 - 5. **Check responsive.** Resize the viewport. Does it adapt well or just shrink? 60 - 6. **Check the details.** Spacing consistency, type hierarchy clarity, color contrast, interactive feedback, motion timing. 173 + 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. 174 + 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. 175 + 4. **Check against impeccable's DON'T guidelines.** Fix any anti-pattern violations. 176 + 5. **Check every state.** Navigate through empty, error, loading, and edge case states. Each one should feel intentional, not like an afterthought. 177 + 6. **Check responsive behavior.** The design should adapt compositionally, not merely shrink. 178 + 7. **Check craft details.** Spacing consistency, optical alignment, type hierarchy, color contrast, image quality, icon coherence, interactive feedback, motion timing, and focus treatment. 179 + 8. **Check performance basics.** No obviously oversized images, avoidable layout thrash, blocking animations, or heavy assets without a reason. 61 180 62 - 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." 181 + 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. 63 182 64 - ## Step 5: Present 183 + ## Step 7: Present 65 184 66 185 Present the result to the user: 67 - 68 186 - Show the feature in its primary state 187 + - Summarize the browser/viewports checked and the most important fixes made after inspection 69 188 - Walk through the key states (empty, error, responsive) 70 - - Explain design decisions that connect back to the design brief 189 + - 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. 190 + - Note any remaining limitations or follow-up risks honestly 71 191 - Ask: "What's working? What isn't?" 72 192 73 193 Iterate based on feedback. Good design is rarely right on the first pass.
+427
.agents/skills/impeccable/reference/document.md
··· 1 + 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. 2 + 3 + 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.). 4 + 5 + ## The frontmatter: token schema 6 + 7 + 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. 8 + 9 + ```yaml 10 + --- 11 + name: <project title> 12 + description: <one-line tagline> 13 + colors: 14 + primary: "#b8422e" 15 + neutral-bg: "#faf7f2" 16 + # ...one entry per extracted color; key = descriptive slug 17 + typography: 18 + display: 19 + fontFamily: "Cormorant Garamond, Georgia, serif" 20 + fontSize: "clamp(2.5rem, 7vw, 4.5rem)" 21 + fontWeight: 300 22 + lineHeight: 1 23 + letterSpacing: "normal" 24 + body: 25 + # ... 26 + rounded: 27 + sm: "4px" 28 + md: "8px" 29 + spacing: 30 + sm: "8px" 31 + md: "16px" 32 + components: 33 + button-primary: 34 + backgroundColor: "{colors.primary}" 35 + textColor: "{colors.neutral-bg}" 36 + rounded: "{rounded.sm}" 37 + padding: "16px 48px" 38 + button-primary-hover: 39 + backgroundColor: "{colors.primary-deep}" 40 + --- 41 + ``` 42 + 43 + Rules that matter: 44 + 45 + - **Token refs** use `{path.to.token}` (e.g. `{colors.primary}`, `{rounded.md}`). Components may reference primitives; primitives may not reference each other. 46 + - **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. 47 + - **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). 48 + - **Scale keys are open-ended.** Use whatever names the project already uses (`warm-ash-cream`, `surface-container-low`). Don't rename to Material defaults. 49 + - **Variants are naming convention, not schema.** `button-primary` / `button-primary-hover` / `button-primary-active` as sibling keys. 50 + 51 + ## The markdown body: six sections (exact order) 52 + 53 + 1. `## Overview` 54 + 2. `## Colors` 55 + 3. `## Typography` 56 + 4. `## Elevation` 57 + 5. `## Components` 58 + 6. `## Do's and Don'ts` 59 + 60 + 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. 61 + 62 + ## When to run 63 + 64 + - The user just ran `$impeccable teach` and needs the visual side documented. 65 + - The skill noticed no `DESIGN.md` exists and nudged the user to create one. 66 + - An existing `DESIGN.md` is stale (the design has drifted). 67 + - Before a large redesign, to capture the current state as a reference. 68 + 69 + 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. 70 + 71 + ## Two paths 72 + 73 + - **Scan mode** (default): the project has design tokens, components, or rendered output. Extract, then confirm descriptive language. Use when there's code to analyze. 74 + - **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. 75 + 76 + 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. 77 + 78 + ## Scan mode (approach C: auto-extract, then confirm descriptive language) 79 + 80 + ### Step 1: Find the design assets 81 + 82 + Search the codebase in priority order: 83 + 84 + 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. 85 + 2. **Tailwind config** — if `tailwind.config.{js,ts,mjs}` exists, read the `theme.extend` block for colors, fontFamily, spacing, borderRadius, boxShadow. 86 + 3. **CSS-in-JS theme files** — styled-components, emotion, vanilla-extract, stitches: look for `theme.ts`, `tokens.ts`, or equivalent. 87 + 4. **Design token files** — `tokens.json`, `design-tokens.json`, Style Dictionary output, W3C token community group format. 88 + 5. **Component library** — scan the main button, card, input, navigation, dialog components. Note their variant APIs and default styles. 89 + 6. **Global stylesheet** — the root CSS file usually has the base typography and color assignments. 90 + 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. 91 + 92 + ### Step 2: Auto-extract what can be auto-extracted 93 + 94 + Build a structured draft from the discovered tokens. For each token class: 95 + 96 + - **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. 97 + - **Typography**: Map observed sizes and weights to the Material hierarchy (display / headline / title / body / label). Note font-family stacks and the scale ratio. 98 + - **Elevation**: Catalogue the shadow vocabulary. If the project is flat and uses tonal layering instead, that's a valid answer — state it explicitly. 99 + - **Components**: For each common component (button, card, input, chip, list item, tooltip, nav), extract shape (radius), color assignment, hover/focus treatment, internal padding. 100 + - **Spacing + layout**: Fold into Overview or relevant Components. The spec does NOT have a Layout section. 101 + 102 + ### Step 2b: Stage the frontmatter 103 + 104 + 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. 105 + 106 + - **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. 107 + - **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`). 108 + - **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). 109 + - **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. 110 + 111 + Skip anything the project doesn't have. Empty scale keys or fabricated tokens pollute the spec. 112 + 113 + ### Step 3: Ask the user for qualitative language 114 + 115 + The following require creative input that cannot be auto-extracted. Group them into one `AskUserQuestion` interaction: 116 + 117 + - **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. 118 + - **Overview voice**: mood adjectives, aesthetic philosophy in 2-3 sentences, anti-references (what the system should not feel like). 119 + - **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. 120 + - **Elevation philosophy**: flat/layered/lifted. If shadows exist, is their role ambient or structural? 121 + - **Component philosophy**: the feel of buttons, cards, inputs in one phrase ("tactile and confident" vs. "refined and restrained"). 122 + 123 + Quote a line from PRODUCT.md when possible so the user sees their own strategic language carry forward. 124 + 125 + ### Step 4: Write DESIGN.md 126 + 127 + 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. 128 + 129 + ```markdown 130 + --- 131 + name: [Project Title] 132 + description: [one-line tagline] 133 + colors: 134 + # ... staged frontmatter from Step 2b 135 + --- 136 + 137 + # Design System: [Project Title] 138 + 139 + ## 1. Overview 140 + 141 + **Creative North Star: "[Named metaphor in quotes]"** 142 + 143 + [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.] 144 + 145 + ## 2. Colors 146 + 147 + [Describe the palette character in one sentence.] 148 + 149 + ### Primary 150 + - **[Descriptive Name]** (#HEX / oklch(...)): [Where and why this color is used. Be specific about context, not just role.] 151 + 152 + ### Secondary (optional — omit if the project has only one accent) 153 + - **[Descriptive Name]** (#HEX): [Role.] 154 + 155 + ### Tertiary (optional) 156 + - **[Descriptive Name]** (#HEX): [Role.] 157 + 158 + ### Neutral 159 + - **[Descriptive Name]** (#HEX): [Text / background / border / divider role.] 160 + - [...] 161 + 162 + ### Named Rules (optional, powerful) 163 + **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."] 164 + 165 + ## 3. Typography 166 + 167 + **Display Font:** [Family] (with [fallback]) 168 + **Body Font:** [Family] (with [fallback]) 169 + **Label/Mono Font:** [Family, if distinct] 170 + 171 + **Character:** [1-2 sentence personality description of the pairing.] 172 + 173 + ### Hierarchy 174 + - **Display** ([weight], [size/clamp], [line-height]): [Purpose — where it appears.] 175 + - **Headline** ([weight], [size], [line-height]): [Purpose.] 176 + - **Title** ([weight], [size], [line-height]): [Purpose.] 177 + - **Body** ([weight], [size], [line-height]): [Purpose. Include max line length like 65–75ch if relevant.] 178 + - **Label** ([weight], [size], [letter-spacing], [case if uppercase]): [Purpose.] 179 + 180 + ### Named Rules (optional) 181 + **The [Rule Name] Rule.** [Short doctrine about type use.] 182 + 183 + ## 4. Elevation 184 + 185 + [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.] 186 + 187 + ### Shadow Vocabulary (if applicable) 188 + - **[Role name]** (`box-shadow: [exact value]`): [When to use it.] 189 + - [...] 190 + 191 + ### Named Rules (optional) 192 + **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)."] 193 + 194 + ## 5. Components 195 + 196 + For each component, lead with a short character line, then specify shape, color assignment, states, and any distinctive behavior. 197 + 198 + ### Buttons 199 + - **Shape:** [radius described, exact value in parens] 200 + - **Primary:** [color assignment + padding, in semantic + exact terms] 201 + - **Hover / Focus:** [transitions, treatments] 202 + - **Secondary / Ghost / Tertiary (if applicable):** [brief description] 203 + 204 + ### Chips (if used) 205 + - **Style:** [background, text color, border treatment] 206 + - **State:** [selected / unselected, filter / action variants] 207 + 208 + ### Cards / Containers 209 + - **Corner Style:** [radius] 210 + - **Background:** [colors used] 211 + - **Shadow Strategy:** [reference Elevation section] 212 + - **Border:** [if any] 213 + - **Internal Padding:** [scale] 214 + 215 + ### Inputs / Fields 216 + - **Style:** [stroke, background, radius] 217 + - **Focus:** [treatment — glow, border shift, etc.] 218 + - **Error / Disabled:** [if applicable] 219 + 220 + ### Navigation 221 + - **Style, typography, default/hover/active states, mobile treatment.** 222 + 223 + ### [Signature Component] (optional — if the project has a distinctive custom component worth documenting) 224 + [Description.] 225 + 226 + ## 6. Do's and Don'ts 227 + 228 + 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. 229 + 230 + ### Do: 231 + - **Do** [specific prescription with exact values / named rule]. 232 + - **Do** [...] 233 + 234 + ### Don't: 235 + - **Don't** [specific prohibition — e.g. "use border-left greater than 1px as a colored stripe"]. 236 + - **Don't** [...] 237 + - **Don't** [...] 238 + ``` 239 + 240 + ### Step 4b: Write DESIGN.json sidecar (extensions only) 241 + 242 + 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. 243 + 244 + 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. 245 + 246 + #### Schema 247 + 248 + ```json 249 + { 250 + "schemaVersion": 2, 251 + "generatedAt": "ISO-8601 string", 252 + "title": "Design System: [Project Title]", 253 + "extensions": { 254 + "colorMeta": { 255 + "primary": { "role": "primary", "displayName": "Editorial Magenta", "canonical": "oklch(60% 0.25 350)", "tonalRamp": ["...", "...", "..."] }, 256 + "warm-ash-cream": { "role": "neutral", "displayName": "Warm Ash Cream", "canonical": "oklch(96% 0.005 350)", "tonalRamp": ["...", "...", "..."] } 257 + }, 258 + "typographyMeta": { 259 + "display": { "displayName": "Display", "purpose": "Hero headlines only." } 260 + }, 261 + "shadows": [ 262 + { "name": "ambient-low", "value": "0 4px 24px rgba(0,0,0,0.12)", "purpose": "Diffuse hover glow under accent elements." } 263 + ], 264 + "motion": [ 265 + { "name": "ease-standard", "value": "cubic-bezier(0.4, 0, 0.2, 1)", "purpose": "Default easing for state transitions." } 266 + ], 267 + "breakpoints": [ 268 + { "name": "sm", "value": "640px" } 269 + ] 270 + }, 271 + "components": [ 272 + { 273 + "name": "Primary Button", 274 + "kind": "button | input | nav | chip | card | custom", 275 + "refersTo": "button-primary", 276 + "description": "One-line what and when.", 277 + "html": "<button class=\"ds-btn-primary\">GET STARTED</button>", 278 + "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); }" 279 + } 280 + ], 281 + "narrative": { 282 + "northStar": "The Editorial Sanctuary", 283 + "overview": "2-3 paragraphs of the philosophy — pulled from DESIGN.md Overview section.", 284 + "keyCharacteristics": ["...", "..."], 285 + "rules": [{ "name": "The One Voice Rule", "body": "...", "section": "colors|typography|elevation" }], 286 + "dos": ["Do use ..."], 287 + "donts": ["Don't use ..."] 288 + } 289 + } 290 + ``` 291 + 292 + **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. 293 + 294 + #### Component translation rules 295 + 296 + 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. 297 + 298 + 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. 299 + 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. 300 + 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. 301 + 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. 302 + 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. 303 + 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. 304 + 305 + #### What to include 306 + 307 + Aim for a tight set of **5-10 components** that best represent the visual system: 308 + 309 + - **Canonical primitives (always include if the project has them):** button (each variant as a separate component entry), input/text field, navigation, chip/tag, card. 310 + - **Signature components (include if distinctive):** hero CTA, featured card, filter pill, any custom pattern the user mentioned as important in PRODUCT.md. 311 + - **Skip the rest.** Utility components, form building blocks, wrapper layouts — not worth documenting unless visually distinctive. 312 + 313 + 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. 314 + 315 + #### Tonal ramps 316 + 317 + 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. 318 + 319 + #### Narrative mapping 320 + 321 + Pull directly from the DESIGN.md you just wrote: 322 + 323 + - `narrative.northStar` → the `**Creative North Star: "..."**` line from Overview 324 + - `narrative.overview` → the philosophy paragraphs from Overview 325 + - `narrative.keyCharacteristics` → the bulleted `**Key Characteristics:**` list 326 + - `narrative.rules` → every `**The [Name] Rule.** [body]` across all sections, tagged with `section` 327 + - `narrative.dos` / `narrative.donts` → the bullet lists from Do's and Don'ts verbatim 328 + 329 + Do not reword. The panel shows these as secondary collapsible context; the same voice that's in the Markdown carries through. 330 + 331 + ### Step 5: Confirm, refine, and refresh session cache 332 + 333 + 1. Show the user the full DESIGN.md you wrote. Briefly highlight the non-obvious creative choices (descriptive color names, atmosphere language, named rules). 334 + 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. 335 + 3. Offer to refine any section: "Want me to revise a section, add component patterns I missed, or adjust the atmosphere language?" 336 + 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. 337 + 338 + ## Seed mode 339 + 340 + For projects with no visual system to extract yet. Produces a minimal scaffold, not a full spec. 341 + 342 + ### Step 1: Confirm seed mode 343 + 344 + 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?" 345 + 346 + If the user prefers to skip, stop. No file. 347 + 348 + ### Step 2: Five questions 349 + 350 + Group into one `AskUserQuestion` interaction. Options must be concrete. 351 + 352 + 1. **Color strategy.** Pick one: 353 + - Restrained — tinted neutrals + one accent ≤10% 354 + - Committed — one saturated color carries 30–60% of the surface 355 + - Full palette — 3–4 named color roles, each deliberate 356 + - Drenched — the surface IS the color 357 + 358 + Then: one hue family or anchor reference ("deep teal", "mustard", "Klim #ff4500 orange"). 359 + 360 + 2. **Typography direction.** Pick one (specific fonts come later): 361 + - Serif display + sans body 362 + - Single sans (warm / technical / geometric / humanist — pick a feel) 363 + - Display + mono 364 + - Mono-forward 365 + - Editorial script + sans 366 + 367 + 3. **Motion energy.** Pick one: 368 + - Restrained — state changes only 369 + - Responsive — feedback + transitions, no choreography 370 + - Choreographed — orchestrated entrances, scroll-driven sequences 371 + 372 + 4. **Three named references.** Brands, products, printed objects. Not adjectives. 373 + 374 + 5. **One anti-reference.** What it should NOT feel like. Also named. 375 + 376 + ### Step 3: Write seed DESIGN.md 377 + 378 + 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. 379 + 380 + Lead the file with: 381 + 382 + ```markdown 383 + <!-- SEED — re-run $impeccable document once there's code to capture the actual tokens and components. --> 384 + ``` 385 + 386 + Per-section guidance in seed mode: 387 + 388 + - **Overview**: Creative North Star and philosophy phrased from the answers (color strategy + motion energy + references). Reference the user's anti-reference directly. 389 + - **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]`. 390 + - **Typography**: the direction the user picked (e.g. "Serif display + sans body"). No font names yet — `[font pairing to be chosen at implementation]`. 391 + - **Elevation**: inferred from motion energy. Restrained/Responsive → flat by default; Choreographed → layered. One sentence. 392 + - **Components**: omit entirely — no components exist yet. 393 + - **Do's and Don'ts**: carry PRODUCT.md's anti-references directly plus the anti-reference named in Q5. 394 + 395 + 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. 396 + 397 + ### Step 4: Confirm and refresh session cache 398 + 399 + 1. Show the seed DESIGN.md. Call out that it is a seed (the marker is the literal commitment). 400 + 2. Tell the user: "Re-run `$impeccable document` once you have some code. That pass will extract real tokens and generate the sidecar." 401 + 3. Run `node .agents/skills/impeccable/scripts/load-context.mjs` once so the seed lands in conversation for the rest of the session. 402 + 403 + ## Style guidelines 404 + 405 + - **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. 406 + - **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. 407 + - **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). 408 + - **Descriptive > technical**: "Gently curved edges (8px radius)" > "rounded-lg". Include the technical value in parens, lead with the description. 409 + - **Functional > decorative**: for each token, explain WHERE and WHY it's used, not just WHAT it is. 410 + - **Exact values in parens**: hex codes, px/rem values, font weights — always the number in parens alongside the description. 411 + - **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. 412 + - **Be forceful**. The voice of a design director. "Prohibited", "forbidden", "never", "always" — not "consider", "might", "prefer". Match PRODUCT.md's tone. 413 + - **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. 414 + - **Reference PRODUCT.md**. The anti-references section of PRODUCT.md should directly inform the Do's and Don'ts section here. Quote or paraphrase. 415 + - **Group colors by role**, not by hex-order or hue-order. Primary / Secondary / Tertiary / Neutral is the spec ordering. 416 + 417 + ## Pitfalls 418 + 419 + - Don't paste raw CSS class names. Translate to descriptive language. 420 + - Don't extract every token. Stop at what's actually reused — one-offs pollute the system. 421 + - Don't invent components that don't exist. If the project only has buttons and cards, only document those. 422 + - Don't overwrite an existing DESIGN.md without asking. 423 + - Don't duplicate content from PRODUCT.md. DESIGN.md is strictly visual. 424 + - 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. 425 + - Don't rename sections even slightly. "Colors" not "Color Palette & Roles". "Typography" not "Typography Rules". Tooling parsing depends on exact headers. 426 + - 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. 427 + - 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
··· 6 6 7 7 Find the design system, component library, or shared UI directory. Understand its structure: component organization, naming conventions, design token structure, import/export conventions. 8 8 9 - **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. 9 + **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. 10 10 11 11 ## Step 2: Identify Patterns 12 12 ··· 60 60 - Update any Storybook or component catalog 61 61 62 62 **NEVER**: 63 - 64 63 - Extract one-off, context-specific implementations without generalization 65 64 - Create components so generic they are useless 66 65 - Extract without considering existing design system conventions
+12 -13
.agents/skills/impeccable/reference/interaction-design.md
··· 4 4 5 5 Every interactive element needs these states designed: 6 6 7 - | State | When | Visual Treatment | 8 - | ------------ | --------------------------- | --------------------------- | 9 - | **Default** | At rest | Base styling | 10 - | **Hover** | Pointer over (not touch) | Subtle lift, color shift | 11 - | **Focus** | Keyboard/programmatic focus | Visible ring (see below) | 12 - | **Active** | Being pressed | Pressed in, darker | 13 - | **Disabled** | Not interactive | Reduced opacity, no pointer | 14 - | **Loading** | Processing | Spinner, skeleton | 15 - | **Error** | Invalid state | Red border, icon, message | 16 - | **Success** | Completed | Green check, confirmation | 7 + | State | When | Visual Treatment | 8 + |-------|------|------------------| 9 + | **Default** | At rest | Base styling | 10 + | **Hover** | Pointer over (not touch) | Subtle lift, color shift | 11 + | **Focus** | Keyboard/programmatic focus | Visible ring (see below) | 12 + | **Active** | Being pressed | Pressed in, darker | 13 + | **Disabled** | Not interactive | Reduced opacity, no pointer | 14 + | **Loading** | Processing | Spinner, skeleton | 15 + | **Error** | Invalid state | Red border, icon, message | 16 + | **Success** | Completed | Green check, confirmation | 17 17 18 18 **The common miss**: Designing hover without focus, or vice versa. They're different. Keyboard users never see hover states. 19 19 ··· 35 35 ``` 36 36 37 37 **Focus ring design**: 38 - 39 38 - High contrast (3:1 minimum against adjacent colors) 40 39 - 2-3px thick 41 40 - Offset from element (not inside it) ··· 67 66 Or use the native `<dialog>` element: 68 67 69 68 ```javascript 70 - const dialog = document.querySelector("dialog"); 71 - dialog.showModal(); // Opens with focus trap, closes on Escape 69 + const dialog = document.querySelector('dialog'); 70 + dialog.showModal(); // Opens with focus trap, closes on Escape 72 71 ``` 73 72 74 73 ## The Popover API
+513
.agents/skills/impeccable/reference/live.md
··· 1 + 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. 2 + 3 + ## Prerequisites 4 + 5 + A running dev server with hot module replacement (Vite, Next.js, Bun, etc.), OR a static HTML file open in the browser. 6 + 7 + ## The contract (read once) 8 + 9 + Execute in order. No step skipped, no step reordered. 10 + 11 + 1. `live.mjs` — boot. 12 + 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. 13 + 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=`. 14 + 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. 15 + 5. On `accept` / `discard` — the poll script already cleaned up; just poll again. 16 + 6. On `exit` — run the cleanup at the bottom. 17 + 18 + Harness policy: 19 + - **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. 20 + - **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. 21 + - **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. 22 + - **Other harnesses**: foreground unless you know stdout reliably returns to this session. 23 + 24 + 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. 25 + 26 + ## Start 27 + 28 + ```bash 29 + node .agents/skills/impeccable/scripts/live.mjs 30 + ``` 31 + 32 + 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. 33 + 34 + `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). 35 + 36 + 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. 37 + 38 + ## Poll loop 39 + 40 + ``` 41 + LOOP: 42 + node .agents/skills/impeccable/scripts/live-poll.mjs # default long timeout; no --timeout= 43 + Read JSON; dispatch on "type" 44 + 45 + "generate" → Handle Generate; reply done; LOOP 46 + "accept" → Handle Accept; LOOP 47 + "discard" → Handle Discard; LOOP 48 + "prefetch" → Handle Prefetch; LOOP 49 + "timeout" → LOOP 50 + "exit" → break → Cleanup 51 + ``` 52 + 53 + ## Handle `generate` 54 + 55 + Event: `{id, action, freeformPrompt?, count, pageUrl, element, screenshotPath?, comments?, strokes?}`. 56 + 57 + Speed matters — the user is watching a spinner. Minimize tool calls by using the `wrap` helper and writing all variants in a single edit. 58 + 59 + ### 1. Read the screenshot (if present) 60 + 61 + `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. 62 + 63 + 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. 64 + 65 + `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). 66 + 67 + Reading annotations precisely: 68 + 69 + - **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. 70 + - **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. 71 + - **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." 72 + - **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. 73 + 74 + ### 2. Wrap the element 75 + 76 + ```bash 77 + node .agents/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" 78 + ``` 79 + 80 + Flag mapping — keep them separate, don't collapse into `--query`: 81 + 82 + - `--element-id` ← `event.element.id` 83 + - `--classes` ← `event.element.classes` joined with commas 84 + - `--tag` ← `event.element.tagName` 85 + 86 + 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. 87 + 88 + Output on success: `{ file, insertLine, commentSyntax }`. 89 + 90 + **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: 91 + 92 + - `{ error: "file_is_generated", file, hint }` — user-supplied `--file` points at a generated file. 93 + - `{ error: "element_not_in_source", generatedMatch, hint }` — element exists only in a generated file (the next build would wipe any edits). 94 + - `{ error: "element_not_found", hint }` — element isn't in any project file; likely runtime-injected (JS component, data-driven render). 95 + 96 + All three carry `fallback: "agent-driven"`. Follow **Handle fallback** below. 97 + 98 + ### 3. Load the action's reference 99 + 100 + 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. 101 + 102 + 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. 103 + 104 + ### 4. Plan three genuinely distinct directions 105 + 106 + Before writing a single line of code, name each variant. 107 + 108 + **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: 109 + 110 + - *Broadsheet masthead with rule-divided columns* (think NYT print edition) 111 + - *Klim Type Foundry specimen page* (dense, technical, catalog-driven) 112 + - *Japanese print-poster minimalism with a single oversize glyph* 113 + - *Bloomberg Terminal status bar* 114 + - *Condé Nast Traveler feature layout* 115 + 116 + Then commit each variant to a different **primary axis** of difference: 117 + 118 + 1. **Hierarchy** — which element commands the eye? 119 + 2. **Layout topology** — stacked / side-by-side / grid / asymmetric / overlay 120 + 3. **Typographic system** — pairing, scale ratio, case/weight strategy 121 + 4. **Color strategy** — Restrained / Committed / Full palette / Drenched 122 + 5. **Density** — minimal / comfortable / dense 123 + 6. **Structural decomposition** — merge, split, progressive disclosure 124 + 125 + Three variants → three DIFFERENT primary axes, not three riffs on color. 126 + 127 + **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. 128 + 129 + **The squint test (before writing code).** Write the three one-sentence descriptions side by side: 130 + 131 + > V1: Broadsheet masthead, ruled columns, 24px ink on cream. 132 + > V2: Enormous italic title, catalog spec rows, heavy monospace data. 133 + > V3: Card-framed poster with one oversize glyph, magenta veil. 134 + 135 + 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. 136 + 137 + **For action-specific invocations**, each variant must vary along the dimension the action names: 138 + 139 + - `bolder` — amplify a different dimension per variant (scale / saturation / structural change). Not three "slightly bigger" variants. 140 + - `quieter` — pull back a different dimension (color / ornament / spacing). 141 + - `distill` — remove a different class of excess (visual noise / redundant content / nested structure). 142 + - `polish` — target a different refinement axis (rhythm / hierarchy / micro-details like corner radii, focus states, optical kerning). 143 + - `typeset` — different type pairing AND different scale ratio each. Not three riffs on one pairing. 144 + - `colorize` — different hue family each (not shades of one hue). Vary chroma and contrast strategy. 145 + - `layout` — different structural arrangement (stacked / side-by-side / grid / asymmetric). Not spacing tweaks. 146 + - `adapt` — different target context per variant (mobile-first / tablet / desktop / print or low-data). Don't make three mobile layouts. 147 + - `animate` — different motion vocabulary (cascade stagger / clip wipe / scale-and-focus / morph / parallax). Not three staggered fades. 148 + - `delight` — different flavor of personality (unexpected micro-interaction / typographic surprise / illustrated accent / sonic-or-haptic moment / easter-egg interaction). 149 + - `overdrive` — different convention broken (scale / structure / motion / input model / state transitions). Skip `overdrive.md`'s "propose and ask" step — live mode is non-interactive. 150 + 151 + ### 5. Apply the freeform prompt (if present) 152 + 153 + `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. 154 + 155 + ### 6. Write all variants in a single edit 156 + 157 + 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`). 158 + 159 + 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). 160 + 161 + ```html 162 + <!-- Variants: insert below this line --> 163 + <style data-impeccable-css="SESSION_ID"> 164 + @scope ([data-impeccable-variant="1"]) { ... } 165 + @scope ([data-impeccable-variant="2"]) { ... } 166 + </style> 167 + <div data-impeccable-variant="1"> 168 + <!-- variant 1: full element replacement (single top-level element) --> 169 + </div> 170 + <div data-impeccable-variant="2" style="display: none"> 171 + <!-- variant 2: full element replacement --> 172 + </div> 173 + <div data-impeccable-variant="3" style="display: none"> 174 + <!-- variant 3: full element replacement --> 175 + </div> 176 + ``` 177 + 178 + **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. 179 + 180 + 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+). 181 + 182 + One edit, all variants — the browser's MutationObserver picks everything up in one pass. 183 + 184 + ### 7. Parameters (composition-sized, 0–4 per variant) 185 + 186 + 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. 187 + 188 + **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.” 189 + 190 + **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. 191 + 192 + **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. 193 + 194 + **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. 195 + 196 + - **Leaf / tiny** — a single button, icon, input, bare heading, solitary paragraph: **0 params.** 197 + - **Small composition** — labeled input, simple card, short callout (≤ ~5 visual children): **0–1** params when one dominant axis is obvious; otherwise **0.** 198 + - **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. 199 + - **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. 200 + 201 + **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. 202 + 203 + **Hard cap per variant** — at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it. 204 + 205 + **How to declare.** Put a JSON manifest on the variant wrapper: 206 + 207 + ```html 208 + <div data-impeccable-variant="1" data-impeccable-params='[ 209 + {"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"}, 210 + {"id":"density","kind":"steps","default":"snug","label":"Density","options":[ 211 + {"value":"airy","label":"Airy"}, 212 + {"value":"snug","label":"Snug"}, 213 + {"value":"packed","label":"Packed"} 214 + ]}, 215 + {"id":"serif","kind":"toggle","default":false,"label":"Serif display"} 216 + ]'> 217 + ...variant content... 218 + </div> 219 + ``` 220 + 221 + **Three kinds:** 222 + 223 + - `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`. 224 + - `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`. 225 + - `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`. 226 + 227 + **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. 228 + 229 + **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. 230 + 231 + **On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment: 232 + 233 + ```html 234 + <!-- impeccable-param-values SESSION_ID: {"color-amount":0.7,"density":"packed"} --> 235 + ``` 236 + 237 + 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. 238 + 239 + ### 8. Signal done 240 + 241 + ```bash 242 + node .agents/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH 243 + ``` 244 + 245 + `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. 246 + 247 + Then run `live-poll.mjs` again immediately. 248 + 249 + ## Handle fallback 250 + 251 + When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here. 252 + 253 + 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. 254 + 255 + ### Step 1: Identify where the element actually lives 256 + 257 + Use the error payload: 258 + 259 + - `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. 260 + - `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. 261 + - `file_is_generated` with `file: "..."` — user pointed at a generated file explicitly. Same resolution as `element_not_in_source`. 262 + 263 + 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. 264 + 265 + ### Step 2: Show three variants in the DOM for preview 266 + 267 + The browser bar is waiting for variants. Even without a wrapper in source, you still need to show something: 268 + 269 + 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 -->`. 270 + 2. Insert your three variant divs inside it, same shape as the deterministic path. 271 + 3. Signal done with `--reply EVENT_ID done --file <served file>`. The browser's no-HMR fallback will fetch and inject. 272 + 273 + This served-file edit is **temporary** — next regen wipes it, and that's fine. The real work happens on accept. 274 + 275 + ### Step 3: On accept, write to true source 276 + 277 + 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: 278 + 279 + - Structural change → edit the template / component source. 280 + - Visual-only change → add or update rules in the appropriate stylesheet; remove the inline `<style>` scope. 281 + - Data-driven → update the data source or the render logic. 282 + 283 + Then remove the temporary wrapper from the served file if it's still there. 284 + 285 + ### Step 4: On discard, clean up the served file 286 + 287 + Remove the wrapper you inserted in Step 2. Nothing else to do. 288 + 289 + ## Handle `accept` 290 + 291 + Event: `{id, variantId, _acceptResult}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically; the browser DOM is already updated. 292 + 293 + - `_acceptResult.handled: true` and `carbonize: false` — nothing to do. Poll again. 294 + - `_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. 295 + - `_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. 296 + - `_acceptResult.handled: false` without `mode` — manual cleanup: read file, find markers, edit. 297 + 298 + ### Required after accept (carbonize) 299 + 300 + 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. 301 + 302 + Do these five steps in the current thread, synchronously, before the next poll. Do not poll again until the file is clean. 303 + 304 + 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. 305 + 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). 306 + 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. 307 + 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. 308 + 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. 309 + 310 + Then poll again. 311 + 312 + 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. 313 + 314 + ## Handle `discard` 315 + 316 + Event: `{id, _acceptResult}`. The poll script already restored the original and removed all variant markers. Nothing to do. Poll again. 317 + 318 + ## Handle `prefetch` 319 + 320 + 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. 321 + 322 + Resolve `pageUrl` to the underlying file: 323 + 324 + - Root `/` → the `pageFile` returned by `live.mjs` (usually `public/index.html` or equivalent). 325 + - 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). 326 + 327 + 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. 328 + 329 + 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. 330 + 331 + ## Exit 332 + 333 + The user can stop live mode by: 334 + - Saying "stop live mode" / "exit live" in chat 335 + - Closing the browser tab (SSE drops, poll returns `exit` after 8s) 336 + - The browser's exit button 337 + 338 + When the poll returns `exit`, proceed to cleanup. If the poll is still running as a background task, kill it first. 339 + 340 + ## Cleanup 341 + 342 + ```bash 343 + node .agents/skills/impeccable/scripts/live-server.mjs stop 344 + ``` 345 + 346 + 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. 347 + 348 + Then: 349 + - Remove any leftover variant wrappers (search for `impeccable-variants-start` markers). 350 + - Remove any leftover carbonize blocks (search for `impeccable-carbonize-start` markers). 351 + 352 + ## First-time setup (config missing or invalid) 353 + 354 + If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write `config.json` at the reported path. 355 + 356 + Schema: 357 + 358 + ```json 359 + { 360 + "files": ["<path-or-glob>", "<path-or-glob>", ...], 361 + "exclude": ["<optional-glob>", ...], 362 + "insertBefore": "</body>", 363 + "commentSyntax": "html", 364 + "cspChecked": true 365 + } 366 + ``` 367 + 368 + `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. 369 + 370 + `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. 371 + 372 + `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). 373 + 374 + **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. 375 + 376 + **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. 377 + 378 + | Framework | `files` | `insertBefore` | `commentSyntax` | 379 + |-----------|---------|----------------|-----------------| 380 + | SPA with single shell (Vite / React / Plain HTML) | `["index.html"]` | `</body>` | `html` | 381 + | Next.js (App Router) | `["app/layout.tsx"]` | `</body>` | `jsx` | 382 + | Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` | 383 + | Nuxt | `["app.vue"]` | `</body>` | `html` | 384 + | Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` | 385 + | Astro | `[" <root layout .astro>"]` | `</body>` | `html` | 386 + | Multi-page (separate HTML per route) | `["public/**/*.html"]` — a glob covering the served directory | `</body>` | `html` | 387 + 388 + Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line. 389 + 390 + 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. 391 + 392 + 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. 393 + 394 + ### Drift-heal warning 395 + 396 + 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: 397 + 398 + ```json 399 + { 400 + "ok": true, 401 + "serverPort": 8400, 402 + "pageFiles": [ "..." ], 403 + "configDrift": { 404 + "orphans": ["public/new-section/index.html", "public/docs/new-command.html"], 405 + "orphanCount": 2, 406 + "hint": "2 HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like \"public/**/*.html\"." 407 + } 408 + } 409 + ``` 410 + 411 + When `configDrift` is present, surface it to the user once per session before entering the poll loop: 412 + 413 + > Noticed N HTML file(s) in the project that aren't in `config.files`: 414 + > 415 + > - `public/new-section/index.html` 416 + > - `public/docs/new-command.html` 417 + > 418 + > Add them, or switch `files` to a glob like `["public/**/*.html"]` and let it track new pages automatically? 419 + 420 + Don't auto-update the config — let the user decide. `configDrift` is `null` when there's no drift. 421 + 422 + ### CSP detection (first-time only) 423 + 424 + If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks. 425 + 426 + Otherwise, run the detection helper: 427 + 428 + ```bash 429 + node .agents/skills/impeccable/scripts/detect-csp.mjs 430 + ``` 431 + 432 + 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. 433 + 434 + - **`null`** — no CSP; skip to writing `config.json` with `cspChecked: true`. 435 + - **`append-arrays`** — CSP defined as structured directive arrays. Auto-patchable. See *append-arrays* below. Covers: 436 + - Monorepo helpers with `additionalScriptSrc` / `additionalConnectSrc` options (Next.js + shared config package) 437 + - SvelteKit `kit.csp.directives` 438 + - Nuxt `nuxt-security` module's `contentSecurityPolicy` 439 + - **`append-string`** — CSP written as a literal value string. Auto-patchable. See *append-string* below. Covers: 440 + - Inline `next.config.*` `headers()` with a CSP literal 441 + - Nuxt `routeRules` / `nitro.routeRules` headers 442 + - **`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. 443 + 444 + #### Consent prompt template 445 + 446 + Use this phrasing so the experience is consistent across agents: 447 + 448 + > **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: 449 + > 450 + > ```diff 451 + > [file: <patchTarget>] 452 + > [exact diff, 2–5 lines] 453 + > ``` 454 + > 455 + > 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] 456 + 457 + 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). 458 + 459 + On "yes": apply the Shape-specific patch below, then write `cspChecked: true`. 460 + 461 + #### append-arrays 462 + 463 + CSP expressed as structured directive arrays. Patch mechanism: declare a dev-only array, spread it into the script-src and connect-src arrays. 464 + 465 + **Declare near the top of the file that holds the CSP arrays:** 466 + 467 + ```ts 468 + // Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV. 469 + const __impeccableLiveDev = 470 + process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : []; 471 + ``` 472 + 473 + **Append `...__impeccableLiveDev` to the script-src and connect-src directive arrays.** Per-framework specifics: 474 + 475 + - **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. 476 + - **SvelteKit** — edit `svelte.config.js`, appending to `kit.csp.directives['script-src']` and `kit.csp.directives['connect-src']`. 477 + - **Nuxt + nuxt-security** — edit `nuxt.config.*`, appending to `security.headers.contentSecurityPolicy['script-src']` and `['connect-src']`. 478 + 479 + Reference outputs: 480 + - `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` (Next.js) 481 + - `tests/framework-fixtures/sveltekit-csp/expected-after-patch.js` (SvelteKit) 482 + 483 + Idempotency: if `__impeccableLiveDev` already exists in the file, the patch is already applied; skip asking and just mark `cspChecked: true`. 484 + 485 + #### append-string 486 + 487 + 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. 488 + 489 + ```ts 490 + // Dev-only allowance so impeccable live mode can load. 491 + const __impeccableLiveDev = 492 + process.env.NODE_ENV === "development" ? " http://localhost:8400" : ""; 493 + ``` 494 + 495 + Then in the CSP value string: 496 + - `script-src 'self' 'unsafe-inline'` → `` `script-src 'self' 'unsafe-inline'${__impeccableLiveDev}` `` 497 + - `connect-src 'self'` → `` `connect-src 'self'${__impeccableLiveDev}` `` 498 + 499 + (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.) 500 + 501 + Per-framework specifics: 502 + - **Next.js inline `headers()`** — edit `next.config.*`, splicing the variable into the CSP value. 503 + - **Nuxt `routeRules`** — edit `nuxt.config.*`, splicing into the CSP in `routeRules['/**'].headers['Content-Security-Policy']`. 504 + 505 + Reference outputs: 506 + - `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` (Next.js) 507 + - `tests/framework-fixtures/nuxt-csp/expected-after-patch.ts` (Nuxt) 508 + 509 + ### Troubleshooting 510 + 511 + 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. 512 + 513 + Then re-run `live.mjs`.
+24 -16
.agents/skills/impeccable/reference/motion-design.md
··· 4 4 5 5 Timing matters more than easing. These durations feel right for most UI: 6 6 7 - | Duration | Use Case | Examples | 8 - | ------------- | ------------------- | ---------------------------------- | 9 - | **100-150ms** | Instant feedback | Button press, toggle, color change | 10 - | **200-300ms** | State changes | Menu open, tooltip, hover states | 11 - | **300-500ms** | Layout changes | Accordion, modal, drawer | 12 - | **500-800ms** | Entrance animations | Page load, hero reveals | 7 + | Duration | Use Case | Examples | 8 + |----------|----------|----------| 9 + | **100-150ms** | Instant feedback | Button press, toggle, color change | 10 + | **200-300ms** | State changes | Menu open, tooltip, hover states | 11 + | **300-500ms** | Layout changes | Accordion, modal, drawer | 12 + | **500-800ms** | Entrance animations | Page load, hero reveals | 13 13 14 14 **Exit animations are faster than entrances**—use ~75% of enter duration. 15 15 ··· 17 17 18 18 **Don't use `ease`.** It's a compromise that's rarely optimal. Instead: 19 19 20 - | Curve | Use For | CSS | 21 - | --------------- | ---------------------------- | -------------------------------- | 22 - | **ease-out** | Elements entering | `cubic-bezier(0.16, 1, 0.3, 1)` | 23 - | **ease-in** | Elements leaving | `cubic-bezier(0.7, 0, 0.84, 0)` | 20 + | Curve | Use For | CSS | 21 + |-------|---------|-----| 22 + | **ease-out** | Elements entering | `cubic-bezier(0.16, 1, 0.3, 1)` | 23 + | **ease-in** | Elements leaving | `cubic-bezier(0.7, 0, 0.84, 0)` | 24 24 | **ease-in-out** | State toggles (there → back) | `cubic-bezier(0.65, 0, 0.35, 1)` | 25 25 26 26 **For micro-interactions, use exponential curves**—they feel natural because they mimic real physics (friction, deceleration): ··· 38 38 39 39 **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. 40 40 41 - ## The Only Two Properties You Should Animate 41 + ## Premium Motion Materials 42 42 43 - **transform** and **opacity** only—everything else causes layout recalculation. For height animations (accordions), use `grid-template-rows: 0fr → 1fr` instead of animating `height` directly. 43 + 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. 44 + 45 + Use the right material for the effect: 46 + 47 + - **Transform / opacity**: movement, press feedback, simple reveals, list choreography. 48 + - **Blur / filter / backdrop-filter**: focus pulls, depth, glass or lens effects, softened entrances, atmospheric transitions. 49 + - **Clip path / masks**: wipes, reveals, editorial cropping, product-like transitions. 50 + - **Shadow / glow / color filters**: energy, affordance, focus, warmth, active state. 51 + - **Grid-template rows or FLIP-style transforms**: expanding and reflowing layout without animating `height` directly. 52 + 53 + 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. 44 54 45 55 ## Staggered Animations 46 56 ··· 59 69 /* Provide alternative for reduced motion */ 60 70 @media (prefers-reduced-motion: reduce) { 61 71 .card { 62 - animation: fade-in 200ms ease-out; /* Crossfade instead of motion */ 72 + animation: fade-in 200ms ease-out; /* Crossfade instead of motion */ 63 73 } 64 74 } 65 75 66 76 /* Or disable entirely */ 67 77 @media (prefers-reduced-motion: reduce) { 68 - *, 69 - *::before, 70 - *::after { 78 + *, *::before, *::after { 71 79 animation-duration: 0.01ms !important; 72 80 transition-duration: 0.01ms !important; 73 81 }
+234
.agents/skills/impeccable/reference/onboard.md
··· 1 + > **Additional context needed**: the "aha moment" you want users to reach, and users' experience level. 2 + 3 + Create or improve onboarding experiences that help users understand, adopt, and succeed with the product quickly. 4 + 5 + ## Assess Onboarding Needs 6 + 7 + Understand what users need to learn and why: 8 + 9 + 1. **Identify the challenge**: 10 + - What are users trying to accomplish? 11 + - What's confusing or unclear about current experience? 12 + - Where do users get stuck or drop off? 13 + - What's the "aha moment" we want users to reach? 14 + 15 + 2. **Understand the users**: 16 + - What's their experience level? (Beginners, power users, mixed?) 17 + - What's their motivation? (Excited and exploring? Required by work?) 18 + - What's their time commitment? (5 minutes? 30 minutes?) 19 + - What alternatives do they know? (Coming from competitor? New to category?) 20 + 21 + 3. **Define success**: 22 + - What's the minimum users need to learn to be successful? 23 + - What's the key action we want them to take? (First project? First invite?) 24 + - How do we know onboarding worked? (Completion rate? Time to value?) 25 + 26 + **CRITICAL**: Onboarding should get users to value as quickly as possible, not teach everything possible. 27 + 28 + ## Onboarding Principles 29 + 30 + Follow these core principles: 31 + 32 + ### Show, Don't Tell 33 + - Demonstrate with working examples, not just descriptions 34 + - Provide real functionality in onboarding, not separate tutorial mode 35 + - Use progressive disclosure, teach one thing at a time 36 + 37 + ### Make It Optional (When Possible) 38 + - Let experienced users skip onboarding 39 + - Don't block access to product 40 + - Provide "Skip" or "I'll explore on my own" options 41 + 42 + ### Time to Value 43 + - Get users to their "aha moment" ASAP 44 + - Front-load most important concepts 45 + - Teach 20% that delivers 80% of value 46 + - Save advanced features for contextual discovery 47 + 48 + ### Context Over Ceremony 49 + - Teach features when users need them, not upfront 50 + - Empty states are onboarding opportunities 51 + - Tooltips and hints at point of use 52 + 53 + ### Respect User Intelligence 54 + - Don't patronize or over-explain 55 + - Be concise and clear 56 + - Assume users can figure out standard patterns 57 + 58 + ## Design Onboarding Experiences 59 + 60 + Create appropriate onboarding for the context: 61 + 62 + ### Initial Product Onboarding 63 + 64 + **Welcome Screen**: 65 + - Clear value proposition (what is this product?) 66 + - What users will learn/accomplish 67 + - Time estimate (honest about commitment) 68 + - Option to skip (for experienced users) 69 + 70 + **Account Setup**: 71 + - Minimal required information (collect more later) 72 + - Explain why you're asking for each piece of information 73 + - Smart defaults where possible 74 + - Social login when appropriate 75 + 76 + **Core Concept Introduction**: 77 + - Introduce 1-3 core concepts (not everything) 78 + - Use simple language and examples 79 + - Interactive when possible (do, don't just read) 80 + - Progress indication (step 1 of 3) 81 + 82 + **First Success**: 83 + - Guide users to accomplish something real 84 + - Pre-populated examples or templates 85 + - Celebrate completion (but don't overdo it) 86 + - Clear next steps 87 + 88 + ### Feature Discovery & Adoption 89 + 90 + **Empty States**: 91 + Instead of blank space, show: 92 + - What will appear here (description + screenshot/illustration) 93 + - Why it's valuable 94 + - Clear CTA to create first item 95 + - Example or template option 96 + 97 + Example: 98 + ``` 99 + No projects yet 100 + Projects help you organize your work and collaborate with your team. 101 + [Create your first project] or [Start from template] 102 + ``` 103 + 104 + **Contextual Tooltips**: 105 + - Appear at relevant moment (first time user sees feature) 106 + - Point directly at relevant UI element 107 + - Brief explanation + benefit 108 + - Dismissable (with "Don't show again" option) 109 + - Optional "Learn more" link 110 + 111 + **Feature Announcements**: 112 + - Highlight new features when they're released 113 + - Show what's new and why it matters 114 + - Let users try immediately 115 + - Dismissable 116 + 117 + **Progressive Onboarding**: 118 + - Teach features when users encounter them 119 + - Badges or indicators on new/unused features 120 + - Unlock complexity gradually (don't show all options immediately) 121 + 122 + ### Guided Tours & Walkthroughs 123 + 124 + **When to use**: 125 + - Complex interfaces with many features 126 + - Significant changes to existing product 127 + - Industry-specific tools needing domain knowledge 128 + 129 + **How to design**: 130 + - Spotlight specific UI elements (dim rest of page) 131 + - Keep steps short (3-7 steps max per tour) 132 + - Allow users to click through tour freely 133 + - Include "Skip tour" option 134 + - Make replayable (help menu) 135 + 136 + **Best practices**: 137 + - Interactive over passive (let users click real buttons) 138 + - Focus on workflow, not features ("Create a project" not "This is the project button") 139 + - Provide sample data so actions work 140 + 141 + ### Interactive Tutorials 142 + 143 + **When to use**: 144 + - Users need hands-on practice 145 + - Concepts are complex or unfamiliar 146 + - High stakes (better to practice in safe environment) 147 + 148 + **How to design**: 149 + - Sandbox environment with sample data 150 + - Clear objectives ("Create a chart showing sales by region") 151 + - Step-by-step guidance 152 + - Validation (confirm they did it right) 153 + - Graduation moment (you're ready!) 154 + 155 + ### Documentation & Help 156 + 157 + **In-product help**: 158 + - Contextual help links throughout interface 159 + - Keyboard shortcut reference 160 + - Search-able help center 161 + - Video tutorials for complex workflows 162 + 163 + **Help patterns**: 164 + - `?` icon near complex features 165 + - "Learn more" links in tooltips 166 + - Keyboard shortcut hints (`⌘K` shown on search box) 167 + 168 + ## Empty State Design 169 + 170 + Every empty state needs: 171 + 172 + ### What Will Be Here 173 + "Your recent projects will appear here" 174 + 175 + ### Why It Matters 176 + "Projects help you organize your work and collaborate with your team" 177 + 178 + ### How to Get Started 179 + [Create project] or [Import from template] 180 + 181 + ### Visual Interest 182 + Illustration or icon (not just text on blank page) 183 + 184 + ### Contextual Help 185 + "Need help getting started? [Watch 2-min tutorial]" 186 + 187 + **Empty state types**: 188 + - **First use**: Never used this feature (emphasize value, provide template) 189 + - **User cleared**: Intentionally deleted everything (light touch, easy to recreate) 190 + - **No results**: Search or filter returned nothing (suggest different query, clear filters) 191 + - **No permissions**: Can't access (explain why, how to get access) 192 + - **Error state**: Failed to load (explain what happened, retry option) 193 + 194 + ## Implementation Patterns 195 + 196 + ### Technical approaches: 197 + 198 + **Tooltip libraries**: Tippy.js, Popper.js 199 + **Tour libraries**: Intro.js, Shepherd.js, React Joyride 200 + **Modal patterns**: Focus trap, backdrop, ESC to close 201 + **Progress tracking**: LocalStorage for "seen" states 202 + **Analytics**: Track completion, drop-off points 203 + 204 + **Storage patterns**: 205 + ```javascript 206 + // Track which onboarding steps user has seen 207 + localStorage.setItem('onboarding-completed', 'true'); 208 + localStorage.setItem('feature-tooltip-seen-reports', 'true'); 209 + ``` 210 + 211 + **IMPORTANT**: Don't show same onboarding twice (annoying). Track completion and respect dismissals. 212 + 213 + **NEVER**: 214 + - Force users through long onboarding before they can use product 215 + - Patronize users with obvious explanations 216 + - Show same tooltip repeatedly (respect dismissals) 217 + - Block all UI during tour (let users explore) 218 + - Create separate tutorial mode disconnected from real product 219 + - Overwhelm with information upfront (progressive disclosure!) 220 + - Hide "Skip" or make it hard to find 221 + - Forget about returning users (don't show initial onboarding again) 222 + 223 + ## Verify Onboarding Quality 224 + 225 + Test with real users: 226 + 227 + - **Time to completion**: Can users complete onboarding quickly? 228 + - **Comprehension**: Do users understand after completing? 229 + - **Action**: Do users take desired next step? 230 + - **Skip rate**: Are too many users skipping? (Maybe it's too long or not valuable) 231 + - **Completion rate**: Are users completing? (If low, simplify) 232 + - **Time to value**: How long until users get first value? 233 + 234 + 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
··· 1 + # Product register 2 + 3 + When design SERVES the product: app UIs, admin dashboards, settings panels, data tables, tools, authenticated surfaces, anything where the user is in a task. 4 + 5 + ## The product slop test 6 + 7 + 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? 8 + 9 + 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. 10 + 11 + ## Typography 12 + 13 + - **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. 14 + - **One family is often right.** Product UIs don't need display/body pairing. A well-tuned sans carries headings, buttons, labels, body, data. 15 + - **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. 16 + - **Tighter scale ratio.** 1.125–1.2 between steps is typical. More type elements here than on brand surfaces; exaggerated contrast creates noise. 17 + - **Line length still applies for prose** (65–75ch). Data and compact UI can run denser — tables at 120ch+ are fine. 18 + 19 + ## Color 20 + 21 + 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. 22 + 23 + - State-rich semantic vocabulary: hover, focus, active, disabled, selected, loading, error, warning, success, info. Standardize these. 24 + - Accent color used for primary actions, current selection, and state indicators only — not decoration. 25 + - A second neutral layer for sidebars, toolbars, and panels (slightly cooler or warmer than the content surface). 26 + 27 + ## Layout 28 + 29 + - Predictable grids. Consistency IS an affordance — users navigate faster when the structure is expected. 30 + - Familiar patterns are features. Standard navigation (top bar, side nav), breadcrumbs, tabs, and form layouts have established user expectations. Don't reinvent for flavor. 31 + - Responsive behavior is structural (collapse sidebar, responsive table, breakpoint-driven columns), not fluid typography. 32 + 33 + ## Components 34 + 35 + Every interactive component has: default, hover, focus, active, disabled, loading, error. Don't ship with half of these. 36 + 37 + - Skeleton states for loading, not spinners in the middle of content. 38 + - Empty states that teach the interface, not "nothing here." 39 + - Consistent affordances across the surface. Same button shape. Same form-control vocabulary. Same icon style. 40 + 41 + ## Motion 42 + 43 + - 150–250 ms on most transitions. Users are in flow — don't make them wait for choreography. 44 + - Motion conveys state, not decoration. State change, feedback, loading, reveal — nothing else. 45 + - No orchestrated page-load sequences. Product loads into a task; users don't want to watch it load. 46 + 47 + ## Product bans (on top of the shared absolute bans) 48 + 49 + - Decorative motion that doesn't convey state. 50 + - Inconsistent component vocabulary across screens. If the "save" button looks different in two places, one is wrong. 51 + - Display fonts in UI labels, buttons, data. 52 + - Reinventing standard affordances for flavor (custom scrollbars, weird form controls, non-standard modals). 53 + - Heavy color or full-saturation accents on inactive states. 54 + 55 + ## Product permissions 56 + 57 + Product can afford things brand surfaces can't. 58 + 59 + - System fonts and familiar sans defaults (Inter, SF Pro, system-ui stacks). 60 + - Standard navigation patterns: top bar + side nav, breadcrumbs, tabs, command palettes. 61 + - Density. Tables with many rows, panels with many labels, dense information when users need it. 62 + - Consistency over surprise. The same visual vocabulary screen to screen is a virtue; delight is saved for moments, not pages.
+14 -20
.agents/skills/impeccable/reference/responsive-design.md
··· 15 15 ```css 16 16 /* Fine pointer (mouse, trackpad) */ 17 17 @media (pointer: fine) { 18 - .button { 19 - padding: 8px 16px; 20 - } 18 + .button { padding: 8px 16px; } 21 19 } 22 20 23 21 /* Coarse pointer (touch, stylus) */ 24 22 @media (pointer: coarse) { 25 - .button { 26 - padding: 12px 20px; 27 - } /* Larger touch target */ 23 + .button { padding: 12px 20px; } /* Larger touch target */ 28 24 } 29 25 30 26 /* Device supports hover */ 31 27 @media (hover: hover) { 32 - .card:hover { 33 - transform: translateY(-2px); 34 - } 28 + .card:hover { transform: translateY(-2px); } 35 29 } 36 30 37 31 /* Device doesn't support hover (touch) */ 38 32 @media (hover: none) { 39 - .card { 40 - /* No hover state - use active instead */ 41 - } 33 + .card { /* No hover state - use active instead */ } 42 34 } 43 35 ``` 44 36 ··· 63 55 ``` 64 56 65 57 **Enable viewport-fit** in your meta tag: 66 - 67 58 ```html 68 - <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> 59 + <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> 69 60 ``` 70 61 71 62 ## Responsive Images: Get It Right ··· 75 66 ```html 76 67 <img 77 68 src="hero-800.jpg" 78 - srcset="hero-400.jpg 400w, hero-800.jpg 800w, hero-1200.jpg 1200w" 69 + srcset=" 70 + hero-400.jpg 400w, 71 + hero-800.jpg 800w, 72 + hero-1200.jpg 1200w 73 + " 79 74 sizes="(max-width: 768px) 100vw, 50vw" 80 75 alt="Hero image" 81 - /> 76 + > 82 77 ``` 83 78 84 79 **How it works**: 85 - 86 80 - `srcset` lists available images with their actual widths (`w` descriptors) 87 81 - `sizes` tells the browser how wide the image will display 88 82 - Browser picks the best file based on viewport width AND device pixel ratio ··· 93 87 94 88 ```html 95 89 <picture> 96 - <source media="(min-width: 768px)" srcset="wide.jpg" /> 97 - <source media="(max-width: 767px)" srcset="tall.jpg" /> 98 - <img src="fallback.jpg" alt="..." /> 90 + <source media="(min-width: 768px)" srcset="wide.jpg"> 91 + <source media="(max-width: 767px)" srcset="tall.jpg"> 92 + <img src="fallback.jpg" alt="..."> 99 93 </picture> 100 94 ``` 101 95
+151
.agents/skills/impeccable/reference/shape.md
··· 1 + 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. 2 + 3 + **Scope**: Design planning only. This command does NOT write code. It produces the thinking that makes code good. 4 + 5 + **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. 6 + 7 + ## Philosophy 8 + 9 + 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. 10 + 11 + ## Phase 1: Discovery Interview 12 + 13 + **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. 14 + 15 + 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. 16 + 17 + ### Interview cadence 18 + 19 + 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. 20 + 21 + - Use the harness's structured question tool when one exists. Otherwise, ask directly in chat and stop. 22 + - Ask **2-3 questions per round**, then wait for answers. 23 + - Treat PRODUCT.md and DESIGN.md as anchors; they reduce repeated questions but do **not** replace shape for craft. Shape is task-specific. 24 + - Round 1 should clarify purpose, audience/context, and success or emotional outcome. 25 + - Round 2 should clarify content/data/states and scope/fidelity. 26 + - Round 3 should clarify visual direction, constraints, and anti-goals when still unresolved. 27 + 28 + ### Purpose & Context 29 + - What is this feature for? What problem does it solve? 30 + - Who specifically will use it? (Not "users"; be specific: role, context, frequency) 31 + - What does success look like? How will you know this feature is working? 32 + - What's the user's state of mind when they reach this feature? (Rushed? Exploring? Anxious? Focused?) 33 + 34 + ### Content & Data 35 + - What content or data does this feature display or collect? 36 + - What are the realistic ranges? (Minimum, typical, maximum, e.g., 0 items, 5 items, 500 items) 37 + - What are the edge cases? (Empty state, error state, first-time use, power user) 38 + - Is any content dynamic? What changes and how often? 39 + 40 + ### Design Direction 41 + 42 + Force a visual decision on three fronts. Skip anything PRODUCT.md or DESIGN.md already answers; ask only what's missing. 43 + 44 + - **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). 45 + - **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. 46 + - **Two or three named anchor references.** Specific products, brands, objects — not adjectives like "modern" or "clean." 47 + 48 + ### Scope 49 + 50 + Always ask. Sketch quality and shipped quality are different outputs; don't guess between them. 51 + 52 + - **Fidelity.** Sketch / mid-fi / high-fi / production-ready? 53 + - **Breadth.** One screen / a flow / a whole surface? 54 + - **Interactivity.** Static visual / interactive prototype / shipped-quality component? 55 + - **Time intent.** Quick exploration, or polish until it ships? 56 + 57 + Scope answers are task-scoped. Don't write them to PRODUCT.md or DESIGN.md — carry them through the design brief only. 58 + 59 + ### Constraints 60 + - Are there technical constraints? (Framework, performance budget, browser support) 61 + - Are there content constraints? (Localization, dynamic text length, user-generated content) 62 + - Mobile/responsive requirements? 63 + - Accessibility requirements beyond WCAG AA? 64 + 65 + ### Anti-Goals 66 + - What should this NOT be? What would be a wrong direction? 67 + - What's the biggest risk of getting this wrong? 68 + 69 + ## Phase 1.5: Visual Direction Probe (Capability-Gated) 70 + 71 + After the discovery interview, generate a small set of visual direction probes **before** writing the final brief when all of these are true: 72 + 73 + - The work is **net-new** or directionally ambiguous enough that visual exploration will clarify the brief. 74 + - The requested fidelity is **mid-fi, high-fi, or production-ready**. Skip for sketch-only planning. 75 + - 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. 76 + 77 + 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. 78 + 79 + Use probes to explore visual lanes, not to replace the brief. 80 + 81 + 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. 82 + 83 + ### What to generate 84 + 85 + Generate **2 to 4** distinct direction probes based on the discovery answers, especially: 86 + 87 + - Color strategy 88 + - Theme scene sentence 89 + - Named anchor references 90 + - Scope and fidelity 91 + 92 + The probes should differ in primary visual direction (hierarchy, topology, density, typographic voice, or color strategy), not just palette tweaks. 93 + 94 + ### How to use the probes 95 + 96 + - Treat them as **direction tests**, not final designs. 97 + - Use them to pressure-test whether the brief is pointing at the right lane. 98 + - Ask the user which direction feels closest, what feels off, and what should carry forward. 99 + - If the probes reveal a mismatch, revise the brief inputs before finalizing the brief. 100 + 101 + ### Important limits 102 + 103 + - Do **not** skip discovery because image generation is available. 104 + - Do **not** treat generated imagery as final UX specification, final copy, or final accessibility behavior. 105 + - Do **not** use this step for minor refinements of existing work. It's for shaping a new surface or clarifying a big directional choice. 106 + 107 + 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. 108 + 109 + ## Phase 2: Design Brief 110 + 111 + 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. 112 + 113 + ### Brief Structure 114 + 115 + **1. Feature Summary** (2-3 sentences) 116 + What this is, who it's for, what it needs to accomplish. 117 + 118 + **2. Primary User Action** 119 + The single most important thing a user should do or understand here. 120 + 121 + **3. Design Direction** 122 + 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. 123 + 124 + If you ran the Visual Direction Probe step, name which probe direction won and what changed in the brief because of it. 125 + 126 + **4. Scope** 127 + Fidelity, breadth, interactivity, and time intent from the Scope section of the interview. Task-scoped — these don't persist beyond the brief. 128 + 129 + **5. Layout Strategy** 130 + High-level spatial approach: what gets emphasis, what's secondary, how information flows. Describe the visual hierarchy and rhythm, not specific CSS. 131 + 132 + **6. Key States** 133 + List every state the feature needs: default, empty, loading, error, success, edge cases. For each, note what the user needs to see and feel. 134 + 135 + **7. Interaction Model** 136 + 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? 137 + 138 + **8. Content Requirements** 139 + What copy, labels, empty state messages, error messages, and microcopy are needed. Note any dynamic content and its realistic ranges. 140 + 141 + **9. Recommended References** 142 + 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). 143 + 144 + **10. Open Questions** 145 + Anything unresolved that the implementer should resolve during build. 146 + 147 + --- 148 + 149 + 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. 150 + 151 + 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.)
+10 -11
.agents/skills/impeccable/reference/spatial-design.md
··· 21 21 ### The Squint Test 22 22 23 23 Blur your eyes (or screenshot and blur). Can you still identify: 24 - 25 24 - The most important element? 26 25 - The second most important? 27 26 - Clear groupings? ··· 32 31 33 32 Don't rely on size alone. Combine: 34 33 35 - | Tool | Strong Hierarchy | Weak Hierarchy | 36 - | ------------ | ------------------------- | ----------------- | 37 - | **Size** | 3:1 ratio or more | <2:1 ratio | 38 - | **Weight** | Bold vs Regular | Medium vs Regular | 39 - | **Color** | High contrast | Similar tones | 40 - | **Position** | Top/left (primary) | Bottom/right | 41 - | **Space** | Surrounded by white space | Crowded | 34 + | Tool | Strong Hierarchy | Weak Hierarchy | 35 + |------|------------------|----------------| 36 + | **Size** | 3:1 ratio or more | <2:1 ratio | 37 + | **Weight** | Bold vs Regular | Medium vs Regular | 38 + | **Color** | High contrast | Similar tones | 39 + | **Position** | Top/left (primary) | Bottom/right | 40 + | **Space** | Surrounded by white space | Crowded | 42 41 43 42 **The best hierarchy uses 2-3 dimensions at once**: A heading that's larger, bolder, AND has more space above it. 44 43 ··· 80 79 81 80 ```css 82 81 .icon-button { 83 - width: 24px; /* Visual size */ 82 + width: 24px; /* Visual size */ 84 83 height: 24px; 85 84 position: relative; 86 85 } 87 86 88 87 .icon-button::before { 89 - content: ""; 88 + content: ''; 90 89 position: absolute; 91 - inset: -10px; /* Expand tap target to 44px */ 90 + inset: -10px; /* Expand tap target to 44px */ 92 91 } 93 92 ``` 94 93
+156
.agents/skills/impeccable/reference/teach.md
··· 1 + # Teach Flow 2 + 3 + Gathers design context for a project and writes two complementary files at the project root: 4 + 5 + - **PRODUCT.md** (strategic): register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why". 6 + - **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". 7 + 8 + Every other impeccable command reads these files before doing any work. 9 + 10 + ## Step 1: Load current state 11 + 12 + Run the shared loader first so you know what already exists: 13 + 14 + ```bash 15 + node .agents/skills/impeccable/scripts/load-context.mjs 16 + ``` 17 + 18 + 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. 19 + 20 + Decision tree: 21 + - **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. 22 + - **PRODUCT.md exists, DESIGN.md missing**: skip to Step 5 — offer to run `$impeccable document` for DESIGN.md. 23 + - **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. 24 + - **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. 25 + - **Just DESIGN.md exists (unusual)**: do Steps 2-4 to produce PRODUCT.md. 26 + 27 + Never silently overwrite an existing file. Always confirm first. 28 + 29 + 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. 30 + 31 + ## Step 2: Explore the codebase 32 + 33 + Before asking questions, thoroughly scan the project to discover what you can: 34 + 35 + - **README and docs**: Project purpose, target audience, any stated goals 36 + - **Package.json / config files**: Tech stack, dependencies, existing design libraries 37 + - **Existing components**: Current design patterns, spacing, typography in use 38 + - **Brand assets**: Logos, favicons, color values already defined 39 + - **Design tokens / CSS variables**: Existing color palettes, font stacks, spacing scales 40 + - **Any style guides or brand documentation** 41 + 42 + Also form a **register hypothesis** from what you find: 43 + 44 + - Brand signals: `/`, `/about`, `/pricing`, `/blog/*`, `/docs/*`, hero sections, big typography, scroll-driven sections, landing-page-shaped content. 45 + - Product signals: `/app/*`, `/dashboard`, `/settings`, `/(auth)`, forms, data tables, side/top nav, app-shell components. 46 + 47 + Register is a hypothesis at this point, not a decision — Step 3 confirms it. 48 + 49 + Note what you've learned and what remains unclear. This exploration feeds both PRODUCT.md and DESIGN.md. 50 + 51 + ## Step 3: Ask strategic questions (for PRODUCT.md) 52 + 53 + 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. 54 + 55 + ### Interview mode, not confirmation mode 56 + 57 + 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. 58 + 59 + - Use the harness's structured question tool when one exists. Otherwise, ask directly in chat and stop. 60 + - Ask **2-3 questions per round**, then wait for answers. 61 + - Use inferred answers as hypotheses or options, not as finished facts. 62 + - Complete at least one real user-answer round before drafting PRODUCT.md, unless every required answer is directly discoverable from repo docs. 63 + - Round 1 should establish register, users/purpose, and desired outcome. 64 + - Round 2 should establish brand personality or references, anti-references, and accessibility needs. 65 + 66 + ### Minimum viable interview 67 + 68 + 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. 69 + 70 + ### Register (ask first — it shapes everything below) 71 + 72 + 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). 73 + 74 + 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?"* 75 + 76 + 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. 77 + 78 + ### Users & Purpose 79 + - Who uses this? What's their context when using it? 80 + - What job are they trying to get done? 81 + - For brand: what emotions should the interface evoke? (confidence, delight, calm, urgency) 82 + - For product: what workflow are they in? What's the primary task on any given screen? 83 + 84 + ### Brand & Personality 85 + - How would you describe the brand personality in 3 words? 86 + - Reference sites or apps that capture the right feel? What specifically about them? 87 + - For brand, push for real-world references in the right lane (tech-minimal, editorial-magazine, consumer-warm, brutalist-grid, etc.) — not generic "modern" adjectives. 88 + - For product, push for category best-tool references (Linear, Figma, Notion, Raycast, Stripe). 89 + - What should this explicitly NOT look like? Any anti-references? 90 + 91 + ### Accessibility & Inclusion 92 + - Specific accessibility requirements? (WCAG level, known user needs) 93 + - Considerations for reduced motion, color blindness, or other accommodations? 94 + 95 + 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. 96 + 97 + ## Step 4: Write PRODUCT.md 98 + 99 + 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. 100 + 101 + Synthesize into a strategic document: 102 + 103 + ```markdown 104 + # Product 105 + 106 + ## Register 107 + 108 + product 109 + 110 + ## Users 111 + [Who they are, their context, the job to be done] 112 + 113 + ## Product Purpose 114 + [What this product does, why it exists, what success looks like] 115 + 116 + ## Brand Personality 117 + [Voice, tone, 3-word personality, emotional goals] 118 + 119 + ## Anti-references 120 + [What this should NOT look like. Specific bad-example sites or patterns to avoid.] 121 + 122 + ## Design Principles 123 + [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".] 124 + 125 + ## Accessibility & Inclusion 126 + [WCAG level, known user needs, considerations] 127 + ``` 128 + 129 + Register is either `brand` or `product` as a bare value. No prose, no commentary. 130 + 131 + Write to `PROJECT_ROOT/PRODUCT.md`. If `.impeccable.md` existed, the loader already renamed it — merge into that content rather than starting from scratch. 132 + 133 + ## Step 5: Decide on DESIGN.md 134 + 135 + Offer `$impeccable document` either way. Two paths: 136 + 137 + - **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?" 138 + - **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?" 139 + 140 + If the user agrees, delegate to `$impeccable document` (it auto-detects scan vs seed). Load its reference and follow that flow. 141 + 142 + If the user prefers to skip, mention they can run `$impeccable document` any time later. 143 + 144 + ## Step 6: Confirm and wrap up 145 + 146 + Summarize: 147 + - Register captured (brand / product) 148 + - What was written (PRODUCT.md, DESIGN.md, or both) 149 + - The 3-5 strategic principles from PRODUCT.md that will guide future work 150 + - If DESIGN.md is pending, remind the user how to generate it later 151 + 152 + **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. 153 + 154 + 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. 155 + 156 + 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
··· 12 12 13 13 **Use fewer sizes with more contrast.** A 5-size system covers most needs: 14 14 15 - | Role | Typical Ratio | Use Case | 16 - | ---- | ------------- | ---------------------- | 17 - | xs | 0.75rem | Captions, legal | 18 - | sm | 0.875rem | Secondary UI, metadata | 19 - | base | 1rem | Body text | 20 - | lg | 1.25-1.5rem | Subheadings, lead text | 21 - | xl+ | 2-4rem | Headlines, hero text | 15 + | Role | Typical Ratio | Use Case | 16 + |------|---------------|----------| 17 + | xs | 0.75rem | Captions, legal | 18 + | sm | 0.875rem | Secondary UI, metadata | 19 + | base | 1rem | Body text | 20 + | lg | 1.25-1.5rem | Subheadings, lead text | 21 + | xl+ | 2-4rem | Headlines, hero text | 22 22 23 23 Popular ratios: 1.25 (major third), 1.333 (perfect fourth), 1.5 (perfect fifth). Pick one and commit. 24 24 ··· 26 26 27 27 Use `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. 28 28 29 - **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. 30 - 31 - ## Font Selection & Pairing 32 - 33 - ### Choosing Distinctive Fonts 34 - 35 - **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. 29 + **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. 36 30 37 - **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. 31 + **Paragraph rhythm**: Pick either space between paragraphs OR first-line indentation. Never both. Digital usually wants space; editorial/long-form can justify indent-only. 38 32 39 - A working selection process: 33 + ## Font Selection & Pairing 40 34 41 - 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." 42 - 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. 43 - 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. 44 - 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. 35 + 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. 45 36 46 - **Anti-reflexes worth defending against**: 37 + ### Anti-reflexes worth defending against 47 38 48 39 - A technical/utilitarian brief does NOT need a serif "for warmth." Most tech tools should look like tech tools. 49 40 - 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. 50 41 - A children's product does NOT need a rounded display font. Kids' books use real type. 51 - - 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. 42 + - 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. 52 43 53 44 **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. 54 45 ··· 57 48 **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). 58 49 59 50 When pairing, contrast on multiple axes: 60 - 61 51 - Serif + Sans (structure contrast) 62 52 - Geometric + Humanist (personality contrast) 63 53 - Condensed display + Wide body (proportion contrast) ··· 71 61 ```css 72 62 /* 1. Use font-display: swap for visibility */ 73 63 @font-face { 74 - font-family: "CustomFont"; 75 - src: url("font.woff2") format("woff2"); 64 + font-family: 'CustomFont'; 65 + src: url('font.woff2') format('woff2'); 76 66 font-display: swap; 77 67 } 78 68 79 69 /* 2. Match fallback metrics to minimize shift */ 80 70 @font-face { 81 - font-family: "CustomFont-Fallback"; 82 - src: local("Arial"); 83 - size-adjust: 105%; /* Scale to match x-height */ 84 - ascent-override: 90%; /* Match ascender height */ 85 - descent-override: 20%; /* Match descender depth */ 86 - line-gap-override: 10%; /* Match line spacing */ 71 + font-family: 'CustomFont-Fallback'; 72 + src: local('Arial'); 73 + size-adjust: 105%; /* Scale to match x-height */ 74 + ascent-override: 90%; /* Match ascender height */ 75 + descent-override: 20%; /* Match descender depth */ 76 + line-gap-override: 10%; /* Match line spacing */ 87 77 } 88 78 89 79 body { 90 - font-family: "CustomFont", "CustomFont-Fallback", sans-serif; 80 + font-family: 'CustomFont', 'CustomFont-Fallback', sans-serif; 91 81 } 92 82 ``` 93 83 94 84 Tools like [Fontaine](https://github.com/unjs/fontaine) calculate these overrides automatically. 95 85 86 + **`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. 87 + 88 + **Preload the critical weight only**: typically the regular-weight body font used above the fold. Preloading every weight costs more bandwidth than it saves. 89 + 90 + **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. 91 + 96 92 ## Modern Web Typography 97 93 98 94 ### Fluid Type ··· 103 99 104 100 **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. 105 101 102 + **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. 103 + 104 + **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. 105 + 106 106 ### OpenType Features 107 107 108 108 Most developers don't know these exist. Use them for polish: 109 109 110 110 ```css 111 111 /* Tabular numbers for data alignment */ 112 - .data-table { 113 - font-variant-numeric: tabular-nums; 114 - } 112 + .data-table { font-variant-numeric: tabular-nums; } 115 113 116 114 /* Proper fractions */ 117 - .recipe-amount { 118 - font-variant-numeric: diagonal-fractions; 119 - } 115 + .recipe-amount { font-variant-numeric: diagonal-fractions; } 120 116 121 117 /* Small caps for abbreviations */ 122 - abbr { 123 - font-variant-caps: all-small-caps; 124 - } 118 + abbr { font-variant-caps: all-small-caps; } 125 119 126 120 /* Disable ligatures in code */ 127 - code { 128 - font-variant-ligatures: none; 129 - } 121 + code { font-variant-ligatures: none; } 130 122 131 123 /* Enable kerning (usually on by default, but be explicit) */ 132 - body { 133 - font-kerning: normal; 134 - } 124 + body { font-kerning: normal; } 135 125 ``` 136 126 137 127 Check what features your font supports at [Wakamai Fondue](https://wakamaifondue.com/). 128 + 129 + ### Rendering polish 130 + 131 + ```css 132 + /* Even out heading line lengths (browser picks better break points) */ 133 + h1, h2, h3 { text-wrap: balance; } 134 + 135 + /* Reduce orphans and ragged endings in long prose */ 136 + article p { text-wrap: pretty; } 137 + 138 + /* Variable fonts: pick the right optical-size master automatically */ 139 + body { font-optical-sizing: auto; } 140 + ``` 141 + 142 + **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. 138 143 139 144 ## Typography System Architecture 140 145
+32 -33
.agents/skills/impeccable/reference/ux-writing.md
··· 4 4 5 5 **Never use "OK", "Submit", or "Yes/No".** These are lazy and ambiguous. Use specific verb + object patterns: 6 6 7 - | Bad | Good | Why | 8 - | ---------- | -------------- | ----------------------------- | 9 - | OK | Save changes | Says what will happen | 10 - | Submit | Create account | Outcome-focused | 11 - | Yes | Delete message | Confirms the action | 12 - | Cancel | Keep editing | Clarifies what "cancel" means | 13 - | Click here | Download PDF | Describes the destination | 7 + | Bad | Good | Why | 8 + |-----|------|-----| 9 + | OK | Save changes | Says what will happen | 10 + | Submit | Create account | Outcome-focused | 11 + | Yes | Delete message | Confirms the action | 12 + | Cancel | Keep editing | Clarifies what "cancel" means | 13 + | Click here | Download PDF | Describes the destination | 14 14 15 15 **For destructive actions**, name the destruction: 16 - 17 16 - "Delete" not "Remove" (delete is permanent, remove implies recoverable) 18 17 - "Delete 5 items" not "Delete selected" (show the count) 19 18 ··· 23 22 24 23 ### Error Message Templates 25 24 26 - | Situation | Template | 27 - | --------------------- | ------------------------------------------------------------------------------ | 28 - | **Format error** | "[Field] needs to be [format]. Example: [example]" | 29 - | **Missing required** | "Please enter [what's missing]" | 30 - | **Permission denied** | "You don't have access to [thing]. [What to do instead]" | 31 - | **Network error** | "We couldn't reach [thing]. Check your connection and [action]." | 32 - | **Server error** | "Something went wrong on our end. We're looking into it. [Alternative action]" | 25 + | Situation | Template | 26 + |-----------|----------| 27 + | **Format error** | "[Field] needs to be [format]. Example: [example]" | 28 + | **Missing required** | "Please enter [what's missing]" | 29 + | **Permission denied** | "You don't have access to [thing]. [What to do instead]" | 30 + | **Network error** | "We couldn't reach [thing]. Check your connection and [action]." | 31 + | **Server error** | "Something went wrong on our end. We're looking into it. [Alternative action]" | 33 32 34 33 ### Don't Blame the User 35 34 ··· 44 43 **Voice** is your brand's personality—consistent everywhere. 45 44 **Tone** adapts to the moment. 46 45 47 - | Moment | Tone Shift | 48 - | ------------------- | -------------------------------------------------------------- | 49 - | Success | Celebratory, brief: "Done! Your changes are live." | 50 - | Error | Empathetic, helpful: "That didn't work. Here's what to try..." | 51 - | Loading | Reassuring: "Saving your work..." | 52 - | Destructive confirm | Serious, clear: "Delete this project? This can't be undone." | 46 + | Moment | Tone Shift | 47 + |--------|------------| 48 + | Success | Celebratory, brief: "Done! Your changes are live." | 49 + | Error | Empathetic, helpful: "That didn't work. Here's what to try..." | 50 + | Loading | Reassuring: "Saving your work..." | 51 + | Destructive confirm | Serious, clear: "Delete this project? This can't be undone." | 53 52 54 53 **Never use humor for errors.** Users are already frustrated. Be helpful, not cute. 55 54 ··· 63 62 64 63 German text is ~30% longer than English. Allocate space: 65 64 66 - | Language | Expansion | 67 - | -------- | ---------------------------------- | 68 - | German | +30% | 69 - | French | +20% | 70 - | Finnish | +30-40% | 71 - | Chinese | -30% (fewer chars, but same width) | 65 + | Language | Expansion | 66 + |----------|-----------| 67 + | German | +30% | 68 + | French | +20% | 69 + | Finnish | +30-40% | 70 + | Chinese | -30% (fewer chars, but same width) | 72 71 73 72 ### Translation-Friendly Patterns 74 73 ··· 78 77 79 78 Pick one term and stick with it: 80 79 81 - | Inconsistent | Consistent | 82 - | -------------------------------- | ---------- | 83 - | Delete / Remove / Trash | Delete | 84 - | Settings / Preferences / Options | Settings | 85 - | Sign in / Log in / Enter | Sign in | 86 - | Create / Add / New | Create | 80 + | Inconsistent | Consistent | 81 + |--------------|------------| 82 + | Delete / Remove / Trash | Delete | 83 + | Settings / Preferences / Options | Settings | 84 + | Sign in / Log in / Enter | Sign in | 85 + | Create / Add / New | Create | 87 86 88 87 Build a terminology glossary and enforce it. Variety creates confusion. 89 88
+105 -46
.agents/skills/impeccable/scripts/cleanup-deprecated.mjs
··· 18 18 * 4. Removes the corresponding entries from skills-lock.json. 19 19 */ 20 20 21 - import { existsSync, readFileSync, writeFileSync, rmSync, lstatSync, unlinkSync } from "node:fs"; 22 - import { join, resolve } from "node:path"; 21 + import { existsSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync, lstatSync, unlinkSync } from 'node:fs'; 22 + import { join, resolve } from 'node:path'; 23 23 24 - // Skills that were renamed, merged, or folded in v2.0 and v2.1. 24 + // Skills that were renamed, merged, or folded in v2.0, v2.1, and v3.0. 25 25 const DEPRECATED_NAMES = [ 26 - "frontend-design", // renamed to impeccable (v2.0) 27 - "teach-impeccable", // folded into /impeccable teach (v2.0) 28 - "arrange", // renamed to layout (v2.1) 29 - "normalize", // merged into polish (v2.1) 30 - "onboard", // merged into harden (v2.1) 31 - "extract", // merged into /impeccable extract (v2.1) 26 + // v2.0 renames 27 + 'frontend-design', // renamed to impeccable 28 + 'teach-impeccable', // folded into /impeccable teach 29 + // v2.1 merges 30 + 'arrange', // renamed to layout 31 + 'normalize', // merged into polish 32 + 'onboard', // merged into harden 33 + 'extract', // merged into /impeccable extract 34 + // v3.0 consolidation: all standalone skills -> /impeccable sub-commands 35 + 'adapt', 36 + 'animate', 37 + 'audit', 38 + 'bolder', 39 + 'clarify', 40 + 'colorize', 41 + 'critique', 42 + 'delight', 43 + 'distill', 44 + 'harden', 45 + 'layout', 46 + 'optimize', 47 + 'overdrive', 48 + 'polish', 49 + 'quieter', 50 + 'shape', 51 + 'typeset', 32 52 ]; 33 53 34 54 // All known harness directories that may contain a skills/ subfolder. 35 55 const HARNESS_DIRS = [ 36 - ".claude", 37 - ".cursor", 38 - ".gemini", 39 - ".codex", 40 - ".agents", 41 - ".trae", 42 - ".trae-cn", 43 - ".pi", 44 - ".opencode", 45 - ".kiro", 46 - ".rovodev", 56 + '.claude', '.cursor', '.gemini', '.codex', '.agents', 57 + '.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev', 47 58 ]; 48 59 60 + // Per-skill fingerprints for SKILL.md bodies that never mentioned 61 + // "impeccable" in their v2.x source. Used as a last-resort match 62 + // when no skills-lock.json exists and the word heuristic fails. 63 + // The strings are lifted verbatim from the v2.x frontmatter 64 + // descriptions, so collisions with hand-written user skills are 65 + // vanishingly unlikely. 66 + const SKILL_FINGERPRINTS = { 67 + harden: 'Make interfaces production-ready: error handling, empty states', 68 + optimize: 'Diagnoses and fixes UI performance across loading speed', 69 + }; 70 + 49 71 /** 50 72 * Walk up from startDir until we find a directory that looks like a 51 73 * project root (has package.json, .git, or skills-lock.json). 52 74 */ 53 75 export function findProjectRoot(startDir = process.cwd()) { 54 76 let dir = resolve(startDir); 55 - const { root } = { root: "/" }; 77 + const { root } = { root: '/' }; 56 78 while (dir !== root) { 57 79 if ( 58 - existsSync(join(dir, "package.json")) || 59 - existsSync(join(dir, ".git")) || 60 - existsSync(join(dir, "skills-lock.json")) 80 + existsSync(join(dir, 'package.json')) || 81 + existsSync(join(dir, '.git')) || 82 + existsSync(join(dir, 'skills-lock.json')) 61 83 ) { 62 84 return dir; 63 85 } 64 - const parent = resolve(dir, ".."); 86 + const parent = resolve(dir, '..'); 65 87 if (parent === dir) break; 66 88 dir = parent; 67 89 } ··· 69 91 } 70 92 71 93 /** 72 - * Check whether a skill directory belongs to Impeccable by reading its 73 - * SKILL.md and looking for the word "impeccable" (case-insensitive). 74 - * Returns false for non-existent paths or skills that don't match. 94 + * Load skills-lock.json from the project root, or null if missing/unreadable. 95 + */ 96 + export function loadLock(projectRoot) { 97 + const lockPath = join(projectRoot, 'skills-lock.json'); 98 + if (!existsSync(lockPath)) return null; 99 + try { 100 + return JSON.parse(readFileSync(lockPath, 'utf-8')); 101 + } catch { 102 + return null; 103 + } 104 + } 105 + 106 + /** 107 + * Check whether a skill directory belongs to Impeccable. Three layered 108 + * signals, in order of reliability: 109 + * 1. Lock source equals "pbakaus/impeccable" (authoritative). 110 + * 2. SKILL.md body contains the word "impeccable". 111 + * 3. SKILL.md body contains a per-skill fingerprint (for harden and 112 + * optimize, whose v2.x SKILL.md never mentioned the pack name). 75 113 */ 76 - export function isImpeccableSkill(skillDir) { 77 - const skillMd = join(skillDir, "SKILL.md"); 114 + export function isImpeccableSkill(skillDir, { skillName, lock } = {}) { 115 + // 1. Authoritative: the lock file claims this skill is ours. 116 + if (skillName && lock?.skills?.[skillName]?.source === 'pbakaus/impeccable') { 117 + return true; 118 + } 119 + const skillMd = join(skillDir, 'SKILL.md'); 78 120 if (!existsSync(skillMd)) return false; 121 + let content; 79 122 try { 80 - const content = readFileSync(skillMd, "utf-8"); 81 - return /impeccable/i.test(content); 123 + content = readFileSync(skillMd, 'utf-8'); 82 124 } catch { 83 125 return false; 84 126 } 127 + // 2. Word-level content heuristic. 128 + if (/impeccable/i.test(content)) return true; 129 + // 3. Per-skill fingerprint for old skills that never mentioned the pack. 130 + // Strip the i- prefix so both `harden` and `i-harden` resolve to the 131 + // same fingerprint entry. 132 + const unprefixed = skillName?.startsWith('i-') ? skillName.slice(2) : skillName; 133 + const fingerprint = unprefixed && SKILL_FINGERPRINTS[unprefixed]; 134 + if (fingerprint && content.includes(fingerprint)) return true; 135 + return false; 85 136 } 86 137 87 138 /** ··· 104 155 export function findSkillsDirs(projectRoot) { 105 156 const dirs = []; 106 157 for (const harness of HARNESS_DIRS) { 107 - const candidate = join(projectRoot, harness, "skills"); 158 + const candidate = join(projectRoot, harness, 'skills'); 108 159 if (existsSync(candidate)) { 109 160 dirs.push(candidate); 110 161 } ··· 114 165 115 166 /** 116 167 * Remove deprecated skill directories/symlinks from all harness dirs. 168 + * Reads skills-lock.json so the authoritative "source" field can 169 + * drive deletion even when SKILL.md never mentions impeccable. 117 170 * Returns an array of paths that were deleted. 118 171 */ 119 - export function removeDeprecatedSkills(projectRoot) { 172 + export function removeDeprecatedSkills(projectRoot, lock) { 173 + if (lock === undefined) lock = loadLock(projectRoot); 120 174 const targets = buildTargetNames(); 121 175 const skillsDirs = findSkillsDirs(projectRoot); 122 176 const deleted = []; ··· 138 192 // Symlink: check the target if it's alive, otherwise treat 139 193 // dangling symlinks to deprecated names as safe to remove. 140 194 const targetAlive = existsSync(skillPath); 141 - const isMatch = targetAlive ? isImpeccableSkill(skillPath) : true; 195 + const isMatch = targetAlive 196 + ? isImpeccableSkill(skillPath, { skillName: name, lock }) 197 + : true; 142 198 if (isMatch) { 143 199 unlinkSync(skillPath); 144 200 deleted.push(skillPath); ··· 147 203 } 148 204 149 205 // Regular directory -- verify it belongs to impeccable 150 - if (isImpeccableSkill(skillPath)) { 206 + if (isImpeccableSkill(skillPath, { skillName: name, lock })) { 151 207 rmSync(skillPath, { recursive: true, force: true }); 152 208 deleted.push(skillPath); 153 209 } ··· 163 219 * Returns the list of removed skill names. 164 220 */ 165 221 export function cleanSkillsLock(projectRoot) { 166 - const lockPath = join(projectRoot, "skills-lock.json"); 222 + const lockPath = join(projectRoot, 'skills-lock.json'); 167 223 if (!existsSync(lockPath)) return []; 168 224 169 225 let lock; 170 226 try { 171 - lock = JSON.parse(readFileSync(lockPath, "utf-8")); 227 + lock = JSON.parse(readFileSync(lockPath, 'utf-8')); 172 228 } catch { 173 229 return []; 174 230 } 175 231 176 - if (!lock.skills || typeof lock.skills !== "object") return []; 232 + if (!lock.skills || typeof lock.skills !== 'object') return []; 177 233 178 234 const targets = buildTargetNames(); 179 235 const removed = []; ··· 182 238 const entry = lock.skills[name]; 183 239 if (!entry) continue; 184 240 // Only remove if it belongs to impeccable 185 - if (entry.source === "pbakaus/impeccable") { 241 + if (entry.source === 'pbakaus/impeccable') { 186 242 delete lock.skills[name]; 187 243 removed.push(name); 188 244 } 189 245 } 190 246 191 247 if (removed.length > 0) { 192 - writeFileSync(lockPath, JSON.stringify(lock, null, 2) + "\n", "utf-8"); 248 + writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n', 'utf-8'); 193 249 } 194 250 195 251 return removed; ··· 197 253 198 254 /** 199 255 * Run the full cleanup. Returns a summary object. 256 + * 257 + * Order matters: read the lock and delete directories first, then 258 + * strip lock entries. Otherwise the authoritative signal is gone by 259 + * the time directory deletion runs. 200 260 */ 201 261 export function cleanup(projectRoot) { 202 262 const root = projectRoot || findProjectRoot(); 203 - const deletedPaths = removeDeprecatedSkills(root); 263 + const lock = loadLock(root); 264 + const deletedPaths = removeDeprecatedSkills(root, lock); 204 265 const removedLockEntries = cleanSkillsLock(root); 205 266 return { deletedPaths, removedLockEntries, projectRoot: root }; 206 267 } ··· 209 270 if (process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname)) { 210 271 const result = cleanup(); 211 272 if (result.deletedPaths.length === 0 && result.removedLockEntries.length === 0) { 212 - console.log("No deprecated Impeccable skills found. Nothing to clean up."); 273 + console.log('No deprecated Impeccable skills found. Nothing to clean up.'); 213 274 } else { 214 275 if (result.deletedPaths.length > 0) { 215 276 console.log(`Removed ${result.deletedPaths.length} deprecated skill(s):`); 216 277 for (const p of result.deletedPaths) console.log(` - ${p}`); 217 278 } 218 279 if (result.removedLockEntries.length > 0) { 219 - console.log( 220 - `Cleaned ${result.removedLockEntries.length} entry/entries from skills-lock.json:`, 221 - ); 280 + console.log(`Cleaned ${result.removedLockEntries.length} entry/entries from skills-lock.json:`); 222 281 for (const name of result.removedLockEntries) console.log(` - ${name}`); 223 282 } 224 283 }
+94
.agents/skills/impeccable/scripts/command-metadata.json
··· 1 + { 2 + "craft": { 3 + "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.", 4 + "argumentHint": "[feature description]" 5 + }, 6 + "teach": { 7 + "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.", 8 + "argumentHint": "" 9 + }, 10 + "document": { 11 + "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.", 12 + "argumentHint": "" 13 + }, 14 + "extract": { 15 + "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.", 16 + "argumentHint": "[target]" 17 + }, 18 + "live": { 19 + "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.", 20 + "argumentHint": "" 21 + }, 22 + "adapt": { 23 + "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.", 24 + "argumentHint": "[target] [context (mobile, tablet, print...)]" 25 + }, 26 + "animate": { 27 + "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.", 28 + "argumentHint": "[target]" 29 + }, 30 + "audit": { 31 + "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.", 32 + "argumentHint": "[area (feature, page, component...)]" 33 + }, 34 + "bolder": { 35 + "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.", 36 + "argumentHint": "[target]" 37 + }, 38 + "clarify": { 39 + "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.", 40 + "argumentHint": "[target]" 41 + }, 42 + "colorize": { 43 + "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 + "argumentHint": "[target]" 45 + }, 46 + "critique": { 47 + "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.", 48 + "argumentHint": "[area (feature, page, component...)]" 49 + }, 50 + "delight": { 51 + "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.", 52 + "argumentHint": "[target]" 53 + }, 54 + "distill": { 55 + "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.", 56 + "argumentHint": "[target]" 57 + }, 58 + "harden": { 59 + "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.", 60 + "argumentHint": "[target]" 61 + }, 62 + "onboard": { 63 + "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.", 64 + "argumentHint": "[target]" 65 + }, 66 + "layout": { 67 + "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.", 68 + "argumentHint": "[target]" 69 + }, 70 + "optimize": { 71 + "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.", 72 + "argumentHint": "[target]" 73 + }, 74 + "overdrive": { 75 + "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.", 76 + "argumentHint": "[target]" 77 + }, 78 + "polish": { 79 + "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.", 80 + "argumentHint": "[target]" 81 + }, 82 + "quieter": { 83 + "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.", 84 + "argumentHint": "[target]" 85 + }, 86 + "shape": { 87 + "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.", 88 + "argumentHint": "[feature to shape]" 89 + }, 90 + "typeset": { 91 + "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.", 92 + "argumentHint": "[target]" 93 + } 94 + }
+820
.agents/skills/impeccable/scripts/design-parser.mjs
··· 1 + // Parse a DESIGN.md (Stitch-spec format) into a structured JSON model that 2 + // the live-mode design-system panel can render. Deterministic, dependency-free. 3 + // 4 + // Two-layer: YAML frontmatter (machine-readable tokens) + markdown body 5 + // (prose with six canonical H2 sections). When frontmatter is present, it's 6 + // exposed on `model.frontmatter` alongside the prose-scraped sections; 7 + // consumers can prefer frontmatter values and fall back to prose. 8 + 9 + const CANONICAL_SECTIONS = [ 10 + 'Overview', 11 + 'Colors', 12 + 'Typography', 13 + 'Elevation', 14 + 'Components', 15 + "Do's and Don'ts", 16 + ]; 17 + 18 + // ---------- Frontmatter (Stitch YAML subset) ---------- 19 + 20 + function parseFrontmatter(md) { 21 + const lines = md.split(/\r?\n/); 22 + if (lines[0]?.trim() !== '---') return { frontmatter: null, body: md }; 23 + 24 + let end = -1; 25 + for (let i = 1; i < lines.length; i++) { 26 + if (lines[i].trim() === '---') { end = i; break; } 27 + } 28 + if (end === -1) return { frontmatter: null, body: md }; 29 + 30 + const yaml = lines.slice(1, end).join('\n'); 31 + const body = lines.slice(end + 1).join('\n'); 32 + try { 33 + return { frontmatter: parseYamlSubset(yaml), body }; 34 + } catch { 35 + return { frontmatter: null, body: md }; 36 + } 37 + } 38 + 39 + // Minimal YAML reader for the Stitch frontmatter subset: scalar maps with 40 + // one level of nested objects (typography roles, components). Indent-based, 41 + // 2-space convention. No arrays, no anchors, no multi-line scalars — Stitch's 42 + // schema doesn't need them and accepting them would require a real YAML 43 + // dependency we don't want to vendor. 44 + function parseYamlSubset(yaml) { 45 + const lines = yaml.split(/\r?\n/); 46 + const root = {}; 47 + const stack = [{ indent: -1, obj: root }]; 48 + 49 + for (const raw of lines) { 50 + // Skip blanks and line-only comments. Don't strip inline comments: 51 + // unquoted hex values start with `#` and can't be safely distinguished 52 + // from a comment after whitespace. 53 + if (!raw.trim() || /^\s*#/.test(raw)) continue; 54 + 55 + const indent = raw.match(/^\s*/)[0].length; 56 + const content = raw.slice(indent); 57 + 58 + const colonIdx = findTopLevelColon(content); 59 + if (colonIdx === -1) continue; 60 + 61 + while (stack.length > 1 && stack[stack.length - 1].indent >= indent) { 62 + stack.pop(); 63 + } 64 + 65 + const key = content.slice(0, colonIdx).trim(); 66 + const rest = content.slice(colonIdx + 1).trim(); 67 + const parent = stack[stack.length - 1].obj; 68 + 69 + if (rest === '') { 70 + const obj = {}; 71 + parent[key] = obj; 72 + stack.push({ indent, obj }); 73 + } else { 74 + parent[key] = parseScalar(rest); 75 + } 76 + } 77 + 78 + return root; 79 + } 80 + 81 + function findTopLevelColon(s) { 82 + let inQuote = null; 83 + for (let i = 0; i < s.length; i++) { 84 + const ch = s[i]; 85 + if (inQuote) { 86 + if (ch === inQuote && s[i - 1] !== '\\') inQuote = null; 87 + } else if (ch === '"' || ch === "'") { 88 + inQuote = ch; 89 + } else if (ch === ':') { 90 + return i; 91 + } 92 + } 93 + return -1; 94 + } 95 + 96 + function parseScalar(raw) { 97 + const s = raw.trim(); 98 + if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) { 99 + return s.slice(1, -1); 100 + } 101 + if (s === 'true') return true; 102 + if (s === 'false') return false; 103 + if (s === 'null' || s === '~') return null; 104 + if (/^-?\d+$/.test(s)) return Number(s); 105 + if (/^-?\d*\.\d+$/.test(s)) return Number(s); 106 + return s; 107 + } 108 + 109 + const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g; 110 + const OKLCH_RE = /oklch\([^)]+\)/gi; 111 + const RGBA_RE = /rgba?\([^)]+\)/gi; 112 + const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/; 113 + const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/; 114 + 115 + // ---------- Section splitting ---------- 116 + 117 + function splitSections(md) { 118 + const lines = md.split(/\r?\n/); 119 + let title = null; 120 + const sections = {}; 121 + let current = null; 122 + 123 + for (const raw of lines) { 124 + const line = raw.trimEnd(); 125 + 126 + if (!title && line.startsWith('# ') && !line.startsWith('## ')) { 127 + title = line.replace(/^#\s+/, '').trim(); 128 + continue; 129 + } 130 + 131 + const h2 = line.match(/^##\s+(?:\d+\.\s*)?([^:\n]+?)(?::\s*(.+))?$/); 132 + if (h2) { 133 + const rawName = normalizeApostrophes(h2[1].trim()); 134 + const subtitle = h2[2] ? h2[2].trim() : null; 135 + const canonical = matchCanonicalSection(rawName); 136 + if (canonical) { 137 + current = { name: canonical, subtitle, lines: [] }; 138 + sections[canonical] = current; 139 + continue; 140 + } 141 + // non-canonical H2 — ignore but stop feeding into current 142 + current = null; 143 + continue; 144 + } 145 + 146 + if (current) current.lines.push(raw); 147 + } 148 + 149 + return { title, sections }; 150 + } 151 + 152 + function normalizeApostrophes(s) { 153 + return s.replace(/[\u2018\u2019]/g, "'"); 154 + } 155 + 156 + function matchCanonicalSection(name) { 157 + const normalized = normalizeApostrophes(name).toLowerCase(); 158 + // Exact match first 159 + for (const c of CANONICAL_SECTIONS) { 160 + if (normalizeApostrophes(c).toLowerCase() === normalized) return c; 161 + } 162 + // Keyword-contained match: "Overview & Creative North Star" -> "Overview", 163 + // "Elevation & Depth" -> "Elevation", etc. 164 + for (const c of CANONICAL_SECTIONS) { 165 + const key = normalizeApostrophes(c).toLowerCase(); 166 + const pattern = new RegExp(`\\b${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`); 167 + if (pattern.test(normalized)) return c; 168 + } 169 + return null; 170 + } 171 + 172 + // ---------- Subsection splitting (inside a canonical section) ---------- 173 + 174 + function splitSubsections(lines) { 175 + const subs = []; 176 + let current = { name: null, lines: [] }; 177 + subs.push(current); 178 + 179 + for (const raw of lines) { 180 + const h3 = raw.match(/^###\s+(.+?)\s*$/); 181 + if (h3) { 182 + current = { name: h3[1].trim(), lines: [] }; 183 + subs.push(current); 184 + continue; 185 + } 186 + current.lines.push(raw); 187 + } 188 + 189 + return subs; 190 + } 191 + 192 + // ---------- Generic helpers ---------- 193 + 194 + function collectParagraphs(lines) { 195 + const paragraphs = []; 196 + let buf = []; 197 + const flush = () => { 198 + if (buf.length) { 199 + paragraphs.push(buf.join(' ').trim()); 200 + buf = []; 201 + } 202 + }; 203 + for (const raw of lines) { 204 + const trimmed = raw.trim(); 205 + if (trimmed === '') { flush(); continue; } 206 + // Horizontal rules (---, ***) and headings/bullets end a paragraph. 207 + if (/^(?:-{3,}|\*{3,}|_{3,})$/.test(trimmed)) { flush(); continue; } 208 + if (raw.startsWith('#') || raw.match(/^[-*]\s/)) { flush(); continue; } 209 + buf.push(trimmed); 210 + } 211 + flush(); 212 + return paragraphs.filter(Boolean); 213 + } 214 + 215 + function collectBullets(lines) { 216 + const bullets = []; 217 + let current = null; 218 + for (const raw of lines) { 219 + const m = raw.match(/^\s*[-*]\s+(.+)$/); 220 + if (m) { 221 + if (current) bullets.push(current); 222 + current = m[1]; 223 + continue; 224 + } 225 + // continuation of a bullet (indented line) 226 + if (current && raw.match(/^\s{2,}\S/)) { 227 + current += ' ' + raw.trim(); 228 + continue; 229 + } 230 + // blank line ends a bullet 231 + if (raw.trim() === '' && current) { 232 + bullets.push(current); 233 + current = null; 234 + } 235 + } 236 + if (current) bullets.push(current); 237 + return bullets; 238 + } 239 + 240 + function stripBold(s) { 241 + return s.replace(/\*\*(.+?)\*\*/g, '$1'); 242 + } 243 + 244 + function extractNamedRules(lines) { 245 + const rules = []; 246 + const seen = new Set(); 247 + 248 + // Style A (Impeccable): "**The X Rule.** body body body" — can span lines. 249 + const joined = lines.join('\n'); 250 + const inlineStart = /\*\*(The [^*]+?Rule)\.\*\*/g; 251 + const inlineMatches = []; 252 + let m; 253 + while ((m = inlineStart.exec(joined)) !== null) { 254 + inlineMatches.push({ name: m[1], start: m.index, end: inlineStart.lastIndex }); 255 + } 256 + for (let i = 0; i < inlineMatches.length; i++) { 257 + const mm = inlineMatches[i]; 258 + const bodyEnd = i + 1 < inlineMatches.length ? inlineMatches[i + 1].start : joined.length; 259 + const body = joined 260 + .slice(mm.end, bodyEnd) 261 + .replace(/\n##[^\n]*$/s, '') 262 + .replace(/\n###[^\n]*$/s, '') 263 + .trim(); 264 + const name = stripBold(mm.name).trim(); 265 + seen.add(name.toLowerCase()); 266 + rules.push({ name, body: stripBold(body) }); 267 + } 268 + 269 + // Style B (Stitch): `### The "X" Rule` or `### The X Fallback`, body is the 270 + // bullets/paragraphs until the next heading. Accept Rule / Fallback / Principle. 271 + for (let i = 0; i < lines.length; i++) { 272 + const h3 = lines[i].match(/^###\s+(.+?)\s*$/); 273 + if (!h3) continue; 274 + const headerName = stripBold(h3[1]).replace(/["“”]/g, '').trim(); 275 + if (!/^The\b.*\b(Rule|Fallback|Principle)\b/i.test(headerName)) continue; 276 + if (seen.has(headerName.toLowerCase())) continue; 277 + 278 + const bodyLines = []; 279 + for (let j = i + 1; j < lines.length; j++) { 280 + if (/^##\s|^###\s/.test(lines[j])) break; 281 + bodyLines.push(lines[j]); 282 + } 283 + const body = stripBold(bodyLines.join('\n').replace(/\n+/g, ' ')).trim(); 284 + if (body) { 285 + seen.add(headerName.toLowerCase()); 286 + rules.push({ name: headerName, body }); 287 + } 288 + } 289 + 290 + // Style C (Stitch bullet form): "* **The Layering Principle:** body" 291 + // Colon/period lives inside the bold, so match "**...**" then inspect. 292 + for (const b of collectBullets(lines)) { 293 + const mm = b.match(/^\*\*([^*]+?)\*\*\s*(.+)$/); 294 + if (!mm) continue; 295 + const nameRaw = mm[1].replace(/[.:]\s*$/, '').replace(/["“”]/g, '').trim(); 296 + if (!/^The\b.+\b(Rule|Fallback|Principle)$/i.test(nameRaw)) continue; 297 + if (seen.has(nameRaw.toLowerCase())) continue; 298 + seen.add(nameRaw.toLowerCase()); 299 + rules.push({ name: nameRaw, body: stripBold(mm[2]).trim() }); 300 + } 301 + 302 + return rules; 303 + } 304 + 305 + // ---------- Per-section extractors ---------- 306 + 307 + function extractOverview(section) { 308 + if (!section) return null; 309 + const text = section.lines.join('\n'); 310 + const northStar = text.match(/\*\*Creative North Star:\s*"([^"]+)"\*\*/); 311 + const keyChars = []; 312 + const keyCharMatch = text.match(/\*\*Key Characteristics:\*\*\s*\n([\s\S]+?)(?:\n##|\n###|$)/); 313 + if (keyCharMatch) { 314 + for (const line of keyCharMatch[1].split('\n')) { 315 + const m = line.match(/^\s*[-*]\s+(.+)$/); 316 + if (m) keyChars.push(stripBold(m[1].trim())); 317 + } 318 + } 319 + 320 + // Philosophy paragraphs: everything that isn't a rule header or key-char block 321 + const paragraphs = collectParagraphs(section.lines).filter( 322 + (p) => 323 + !p.startsWith('**Creative North Star') && 324 + !p.startsWith('**Key Characteristics') 325 + ); 326 + 327 + return { 328 + subtitle: section.subtitle, 329 + creativeNorthStar: northStar ? northStar[1] : null, 330 + philosophy: paragraphs, 331 + keyCharacteristics: keyChars, 332 + }; 333 + } 334 + 335 + function extractColors(section) { 336 + if (!section) return null; 337 + const subs = splitSubsections(section.lines); 338 + 339 + const description = collectParagraphs(subs[0].lines).join(' '); 340 + const groups = []; 341 + const ROLE_KEYWORDS = /^(primary|secondary|tertiary|neutral|accent)\b/i; 342 + 343 + for (const sub of subs.slice(1)) { 344 + if (!sub.name || /Named Rules?/i.test(sub.name) || /^The\s/i.test(sub.name)) continue; 345 + 346 + const bullets = collectBullets(sub.lines); 347 + const parsed = bullets.map((b) => parseColorBullet(b)).filter(Boolean); 348 + if (parsed.length === 0) continue; 349 + 350 + // If every bullet starts with a role keyword (Primary/Secondary/...), promote 351 + // each bullet to its own group. Otherwise keep the subsection as the group. 352 + const allRoleBullets = 353 + parsed.length > 0 && parsed.every((p) => p.name && ROLE_KEYWORDS.test(p.name)); 354 + 355 + if (allRoleBullets) { 356 + for (const p of parsed) { 357 + groups.push({ role: p.name, colors: [p] }); 358 + } 359 + } else { 360 + groups.push({ role: sub.name, colors: parsed }); 361 + } 362 + } 363 + 364 + // If the Colors section has no subsections at all (unlikely), fall back to 365 + // scanning the whole section as a flat bullet list. 366 + if (groups.length === 0) { 367 + const flat = collectBullets(section.lines) 368 + .map((b) => parseColorBullet(b)) 369 + .filter(Boolean); 370 + if (flat.length) { 371 + for (const p of flat) { 372 + if (p.name && ROLE_KEYWORDS.test(p.name)) { 373 + groups.push({ role: p.name, colors: [p] }); 374 + } else { 375 + const fallback = groups.find((g) => g.role === 'Palette'); 376 + if (fallback) fallback.colors.push(p); 377 + else groups.push({ role: 'Palette', colors: [p] }); 378 + } 379 + } 380 + } 381 + } 382 + 383 + return { 384 + subtitle: section.subtitle, 385 + description: description || null, 386 + groups, 387 + rules: extractNamedRules(section.lines), 388 + }; 389 + } 390 + 391 + function parseColorBullet(bullet) { 392 + const text = bullet.trim(); 393 + 394 + // Case 1 (Impeccable): **Name** (value-with-maybe-nested-parens): description 395 + const bold = text.match(/^\*\*(.+?)\*\*\s*(.*)$/); 396 + if (bold && bold[2].startsWith('(')) { 397 + const value = extractParenGroup(bold[2]); 398 + if (value !== null) { 399 + const after = bold[2].slice(value.length + 2).trimStart(); 400 + if (after.startsWith(':')) { 401 + return buildColor(bold[1], value, after.slice(1).trim()); 402 + } 403 + } 404 + } 405 + 406 + // Case 2 (Stitch): **Name (values):** description — value embedded in bold. 407 + const stitch = text.match(/^\*\*([^*]+?)\s*\(([^)]+)\):\*\*\s*(.*)$/); 408 + if (stitch) { 409 + return buildColor(stitch[1].trim(), stitch[2], stitch[3]); 410 + } 411 + 412 + // Case 3: bullet without bold, just hex/oklch inside. 413 + const values = collectColorValues(text); 414 + if (values.length) { 415 + return buildColor(null, values.join(' to '), text); 416 + } 417 + return null; 418 + } 419 + 420 + function extractParenGroup(s) { 421 + if (s[0] !== '(') return null; 422 + let depth = 0; 423 + for (let i = 0; i < s.length; i++) { 424 + if (s[i] === '(') depth++; 425 + else if (s[i] === ')') { 426 + depth--; 427 + if (depth === 0) return s.slice(1, i); 428 + } 429 + } 430 + return null; 431 + } 432 + 433 + function buildColor(name, rawValue, description) { 434 + const values = collectColorValues(rawValue); 435 + const primary = values[0] ?? rawValue.trim(); 436 + return { 437 + name: name ? stripBold(name).trim() : null, 438 + value: primary, 439 + valueRange: values.length > 1 ? values : null, 440 + format: detectFormat(primary), 441 + description: stripBold(description || '').trim() || null, 442 + }; 443 + } 444 + 445 + function collectColorValues(s) { 446 + const out = []; 447 + s.replace(HEX_RE, (v) => { 448 + out.push(v); 449 + return v; 450 + }); 451 + s.replace(OKLCH_RE, (v) => { 452 + out.push(v); 453 + return v; 454 + }); 455 + return out; 456 + } 457 + 458 + function detectFormat(v) { 459 + if (!v) return 'unknown'; 460 + if (v.startsWith('#')) return 'hex'; 461 + if (/^oklch/i.test(v)) return 'oklch'; 462 + if (/^rgb/i.test(v)) return 'rgb'; 463 + return 'unknown'; 464 + } 465 + 466 + function scanInlineColors(lines) { 467 + const out = []; 468 + for (const line of lines) { 469 + if (!/^\s*[-*]\s/.test(line)) continue; 470 + const trimmed = line.replace(/^\s*[-*]\s+/, ''); 471 + const color = parseColorBullet(trimmed); 472 + if (color) out.push(color); 473 + } 474 + return out; 475 + } 476 + 477 + function parseStitchInlineGroups(lines) { 478 + // Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."` 479 + // Each bullet IS its own role. Group them under the spoken role name. 480 + const out = []; 481 + for (const line of lines) { 482 + if (!/^\s*[-*]\s/.test(line)) continue; 483 + const trimmed = line.replace(/^\s*[-*]\s+/, '').trim(); 484 + const m = trimmed.match( 485 + /^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/ 486 + ); 487 + if (m) { 488 + const role = m[1]; 489 + const color = buildColor(role, m[2], m[3]); 490 + out.push({ role, colors: [color] }); 491 + } 492 + } 493 + return out; 494 + } 495 + 496 + function extractTypography(section) { 497 + if (!section) return null; 498 + const text = section.lines.join('\n'); 499 + 500 + const fonts = {}; 501 + // Pattern A: **Display Font:** Family (with fallback) 502 + const fontLineRe = /\*\*([\w\s/]+?)Font:\*\*\s*([^\n(]+?)(?:\s*\(with\s+([^)]+)\))?\s*$/gm; 503 + let fm; 504 + while ((fm = fontLineRe.exec(text)) !== null) { 505 + const rawRole = fm[1].trim().toLowerCase().replace(/\s+/g, '-'); 506 + const role = normalizeFontRole(rawRole) || 'display'; 507 + fonts[role] = { 508 + family: fm[2].trim(), 509 + fallback: fm[3] ? fm[3].trim() : null, 510 + }; 511 + } 512 + 513 + // Pattern B (Stitch): * **Display & Headlines (Noto Serif):** description 514 + if (Object.keys(fonts).length === 0) { 515 + const stitchRe = /\*\*([\w\s&/]+?)\s*\(([^)]+)\):\*\*\s*(.+)/g; 516 + let sm; 517 + while ((sm = stitchRe.exec(text)) !== null) { 518 + const rawRole = sm[1] 519 + .trim() 520 + .toLowerCase() 521 + .replace(/\s*&\s*/g, '-') 522 + .replace(/\s+/g, '-'); 523 + const role = normalizeFontRole(rawRole) || rawRole; 524 + fonts[role] = { family: sm[2].trim(), fallback: null, purpose: sm[3].trim() }; 525 + } 526 + } 527 + 528 + // Character paragraph — either a **Character:** label, or fall back to the 529 + // first free paragraph under the section header (Stitch style). 530 + const characterMatch = text.match(/\*\*Character:\*\*\s*([^\n]+(?:\n[^\n]+)*?)(?=\n\n|\n###|\n##|$)/); 531 + let character = characterMatch ? characterMatch[1].replace(/\n/g, ' ').trim() : null; 532 + if (!character) { 533 + const paragraphs = collectParagraphs(section.lines).filter( 534 + (p) => !/^\*\*[\w\s/&]+Font/i.test(p) && !/^\*\*[\w\s/&]+\([^)]+\)/.test(p) 535 + ); 536 + if (paragraphs.length) character = paragraphs[0]; 537 + } 538 + 539 + // Hierarchy bullets under ### Hierarchy 540 + const subs = splitSubsections(section.lines); 541 + let hierarchy = []; 542 + const hierSub = subs.find((s) => s.name && /hierarch/i.test(s.name)); 543 + if (hierSub) { 544 + const bullets = collectBullets(hierSub.lines); 545 + hierarchy = bullets.map(parseTypeBullet).filter(Boolean); 546 + } 547 + 548 + return { 549 + subtitle: section.subtitle, 550 + fonts, 551 + character, 552 + hierarchy, 553 + rules: extractNamedRules(section.lines), 554 + }; 555 + } 556 + 557 + function normalizeFontRole(raw) { 558 + // Canonical roles the panel cares about: display, body, label, mono. 559 + // Stitch often writes compound roles like "display-&-headlines" or "ui-&-body" 560 + // — collapse them to the first canonical role present. 561 + const tokens = raw.split(/[-/&\s]+/).filter(Boolean); 562 + const priority = ['display', 'headline', 'body', 'ui', 'label', 'mono']; 563 + const canonical = { headline: 'display', ui: 'body' }; 564 + for (const p of priority) { 565 + if (tokens.includes(p)) return canonical[p] || p; 566 + } 567 + return null; 568 + } 569 + 570 + function parseTypeBullet(bullet) { 571 + // - **Display** (family, weight 300, italic, clamp(...), line-height 1): purpose 572 + const m = bullet.match(/^\*\*(.+?)\*\*\s*\(([^)]+)\):\s*(.*)$/); 573 + if (!m) return null; 574 + const name = m[1].trim(); 575 + const specs = m[2].split(',').map((s) => s.trim()); 576 + return { 577 + name, 578 + specs, 579 + purpose: stripBold(m[3] || '').trim() || null, 580 + }; 581 + } 582 + 583 + function extractElevation(section) { 584 + if (!section) return null; 585 + const subs = splitSubsections(section.lines); 586 + 587 + const description = collectParagraphs(subs[0].lines).join(' ') || null; 588 + 589 + const shadows = []; 590 + const seen = new Set(); 591 + const dedupe = (entry) => { 592 + const key = (entry.name || '') + '::' + entry.value; 593 + if (seen.has(key)) return; 594 + seen.add(key); 595 + shadows.push(entry); 596 + }; 597 + 598 + for (const b of collectBullets(section.lines)) { 599 + const parsed = parseShadowBullet(b); 600 + if (parsed) dedupe(parsed); 601 + } 602 + 603 + // Fallback: extract shadows written inline in prose. Stitch style is 604 + // "...use an extra-diffused shadow: `box-shadow: 0 12px 40px rgba(...)`." 605 + for (const p of collectParagraphs(section.lines)) { 606 + for (const inline of extractInlineShadows(p)) dedupe(inline); 607 + } 608 + for (const b of collectBullets(section.lines)) { 609 + for (const inline of extractInlineShadows(b)) dedupe(inline); 610 + } 611 + 612 + return { 613 + subtitle: section.subtitle, 614 + description, 615 + shadows, 616 + rules: extractNamedRules(section.lines), 617 + }; 618 + } 619 + 620 + function extractInlineShadows(text) { 621 + // Find `box-shadow: ...` anywhere in prose and capture the value. Work on the 622 + // raw string so it handles both backtick-fenced and unfenced variants. 623 + const out = []; 624 + const re = /box-shadow\s*:\s*([^`;\n]+)/gi; 625 + let m; 626 + while ((m = re.exec(text)) !== null) { 627 + const value = m[1].replace(/[`.)]+$/, '').trim(); 628 + if (!value) continue; 629 + // Name heuristic: the noun immediately before the shadow phrase. 630 + // e.g. "an extra-diffused shadow: ..." -> "extra-diffused shadow" 631 + const before = text.slice(0, m.index); 632 + const nameMatch = before.match(/\b([A-Za-z][A-Za-z\- ]{2,40})\s+shadow\b[^A-Za-z0-9]*$/i); 633 + let name = null; 634 + if (nameMatch) { 635 + const stripped = nameMatch[1] 636 + .replace(/^(?:use|using|apply|applying|is|are|looks? like)\s+/i, '') 637 + .replace(/^(?:a|an|the)\s+/i, '') 638 + .trim(); 639 + if (stripped) { 640 + name = 641 + stripped.charAt(0).toUpperCase() + stripped.slice(1) + ' shadow'; 642 + } 643 + } 644 + out.push({ 645 + name, 646 + value, 647 + purpose: null, 648 + }); 649 + } 650 + return out; 651 + } 652 + 653 + function parseShadowBullet(bullet) { 654 + // - **Name** (`box-shadow: value`): purpose 655 + // - **Name** (`value`): purpose 656 + // Only accept if the paren content looks like a shadow value (contains px, 657 + // rem, rgba, or box-shadow). This filters out `**Rule Name:**` bullets. 658 + const m = bullet.match(/^\*\*(.+?)\*\*\s*\(`?([^`]+?)`?\):\s*(.*)$/); 659 + if (!m) return null; 660 + const rawValue = m[2].replace(/^box-shadow:\s*/i, '').trim(); 661 + const looksLikeShadow = 662 + /box-shadow|rgba?\(|\bpx\b|\brem\b|^-?\d+\s/i.test(rawValue) && 663 + /\d/.test(rawValue); 664 + if (!looksLikeShadow) return null; 665 + const name = stripBold(m[1]).trim(); 666 + return { 667 + name, 668 + value: rawValue, 669 + purpose: stripBold(m[3] || '').trim() || null, 670 + }; 671 + } 672 + 673 + function extractComponents(section) { 674 + if (!section) return null; 675 + const subs = splitSubsections(section.lines); 676 + const components = []; 677 + 678 + for (const sub of subs.slice(1)) { 679 + if (!sub.name) continue; 680 + 681 + const bullets = collectBullets(sub.lines); 682 + const paragraphs = collectParagraphs(sub.lines); 683 + 684 + const variants = []; 685 + const properties = {}; 686 + 687 + for (const b of bullets) { 688 + // - **Key:** value 689 + const m = b.match(/^\*\*(.+?):?\*\*:?\s*(.+)$/); 690 + if (m) { 691 + const key = stripBold(m[1]).trim(); 692 + const value = stripBold(m[2]).trim(); 693 + // Heuristic: "Primary", "Secondary", "Hover", "Focus" etc are variants; 694 + // "Shape", "Background", "Padding" are properties. 695 + if (/^(primary|secondary|tertiary|ghost|hover|focus|active|disabled|default|error|selected|unselected|state)$/i.test(key.split(/[\s/]/)[0])) { 696 + variants.push({ name: key, description: value }); 697 + } else { 698 + properties[key.toLowerCase()] = value; 699 + } 700 + } 701 + } 702 + 703 + components.push({ 704 + name: sub.name, 705 + description: paragraphs.join(' ') || null, 706 + properties, 707 + variants, 708 + }); 709 + } 710 + 711 + return { 712 + subtitle: section.subtitle, 713 + components, 714 + }; 715 + } 716 + 717 + function extractDosDonts(section) { 718 + if (!section) return null; 719 + const subs = splitSubsections(section.lines); 720 + const dos = []; 721 + const donts = []; 722 + 723 + for (const sub of subs.slice(1)) { 724 + if (!sub.name) continue; 725 + const subName = normalizeApostrophes(sub.name); 726 + const bullets = collectBullets(sub.lines).map((b) => stripBold(b).trim()); 727 + if (/^do'?t?:?$/i.test(subName) || /^do:?$/i.test(subName)) { 728 + dos.push(...bullets); 729 + } else if (/^don'?t:?$/i.test(subName)) { 730 + donts.push(...bullets); 731 + } 732 + } 733 + 734 + // Classify by bullet prefix as a backup (catches loose bullets outside H3 wrappers) 735 + for (const b of collectBullets(section.lines)) { 736 + const stripped = normalizeApostrophes(stripBold(b).trim()); 737 + if (/^don'?t\b/i.test(stripped)) { 738 + if (!donts.some((d) => normalizeApostrophes(d) === stripped)) donts.push(stripped); 739 + } else if (/^do\b/i.test(stripped)) { 740 + if (!dos.some((d) => normalizeApostrophes(d) === stripped)) dos.push(stripped); 741 + } 742 + } 743 + 744 + return { dos, donts }; 745 + } 746 + 747 + // ---------- Coverage assessment ---------- 748 + 749 + function assessCoverage(model) { 750 + const report = {}; 751 + 752 + report.overview = model.overview 753 + ? { 754 + northStar: Boolean(model.overview.creativeNorthStar), 755 + philosophy: model.overview.philosophy.length > 0, 756 + keyCharacteristics: model.overview.keyCharacteristics.length, 757 + } 758 + : 'missing'; 759 + 760 + report.colors = model.colors 761 + ? { 762 + groups: model.colors.groups.length, 763 + totalColors: model.colors.groups.reduce((n, g) => n + g.colors.length, 0), 764 + rules: model.colors.rules.length, 765 + } 766 + : 'missing'; 767 + 768 + report.typography = model.typography 769 + ? { 770 + fonts: Object.keys(model.typography.fonts).length, 771 + hierarchyEntries: model.typography.hierarchy.length, 772 + character: Boolean(model.typography.character), 773 + rules: model.typography.rules.length, 774 + } 775 + : 'missing'; 776 + 777 + report.elevation = model.elevation 778 + ? { 779 + shadows: model.elevation.shadows.length, 780 + rules: model.elevation.rules.length, 781 + description: Boolean(model.elevation.description), 782 + } 783 + : 'missing'; 784 + 785 + report.components = model.components 786 + ? { 787 + count: model.components.components.length, 788 + variantTotal: model.components.components.reduce((n, c) => n + c.variants.length, 0), 789 + } 790 + : 'missing'; 791 + 792 + report.dosDonts = model.dosDonts 793 + ? { 794 + dos: model.dosDonts.dos.length, 795 + donts: model.dosDonts.donts.length, 796 + } 797 + : 'missing'; 798 + 799 + return report; 800 + } 801 + 802 + // ---------- Main ---------- 803 + 804 + export function parseDesignMd(md) { 805 + const { frontmatter, body } = parseFrontmatter(md); 806 + const { title, sections } = splitSections(body); 807 + return { 808 + schemaVersion: 2, 809 + title, 810 + frontmatter, 811 + overview: extractOverview(sections['Overview']), 812 + colors: extractColors(sections['Colors']), 813 + typography: extractTypography(sections['Typography']), 814 + elevation: extractElevation(sections['Elevation']), 815 + components: extractComponents(sections['Components']), 816 + dosDonts: extractDosDonts(sections["Do's and Don'ts"]), 817 + }; 818 + } 819 + 820 + export { assessCoverage };
+198
.agents/skills/impeccable/scripts/detect-csp.mjs
··· 1 + /** 2 + * Scan a project tree for Content-Security-Policy signals and classify the 3 + * shape so the agent knows which patch template to propose. 4 + * 5 + * Used at first-time `live.mjs` setup. Mechanical (grep-based) — no network, 6 + * no dev server, no JS evaluation. The classification drives a user-facing 7 + * consent prompt; the agent does the actual patch writing. 8 + * 9 + * Shapes are named by patch mechanism, not framework origin: 10 + * - "append-arrays": CSP defined as structured directive arrays. Patch 11 + * appends a dev-only localhost entry. Covers: 12 + * - Monorepo helpers with additional*Src options 13 + * (e.g. createBaseNextConfig for Next) 14 + * - SvelteKit kit.csp.directives 15 + * - nuxt-security module's contentSecurityPolicy 16 + * - "append-string": CSP built as a literal value string. Patch splices 17 + * a dev-only token into script-src and connect-src. 18 + * Covers: 19 + * - Inline Next.js headers() with CSP string 20 + * - Nuxt routeRules / nitro.routeRules CSP headers 21 + * - "middleware": CSP set dynamically in middleware.{ts,js}. 22 + * Detected but not auto-patched in v1. 23 + * - "meta-tag": <meta http-equiv="Content-Security-Policy"> in 24 + * layout files. Detected but not auto-patched in v1. 25 + * - null: no CSP signals found; no patch needed. 26 + */ 27 + 28 + import fs from 'node:fs'; 29 + import path from 'node:path'; 30 + 31 + const SKIP_DIRS = new Set([ 32 + 'node_modules', 33 + '.git', 34 + '.next', 35 + '.turbo', 36 + '.svelte-kit', 37 + '.nuxt', 38 + '.astro', 39 + 'dist', 40 + 'build', 41 + 'out', 42 + '.vercel', 43 + ]); 44 + 45 + const SCAN_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.tsx', '.jsx']); 46 + const LAYOUT_EXTS = new Set(['.tsx', '.jsx', '.astro', '.vue', '.svelte', '.html']); 47 + const MAX_DEPTH = 6; 48 + const MAX_READ_BYTES = 64 * 1024; 49 + 50 + // append-arrays signals: CSP expressed as structured directive arrays 51 + const MONOREPO_HELPER_SIGNALS = [ 52 + /\bbuildCSPConfig\b/, 53 + /\bbuildSecurityHeaders\b/, 54 + /\badditionalScriptSrc\b/, 55 + /\badditionalConnectSrc\b/, 56 + /\bcreateBaseNextConfig\b/, 57 + ]; 58 + const SVELTEKIT_CSP_SIGNALS = [ 59 + /\bkit\s*:/, 60 + /\bcsp\s*:/, 61 + /\bdirectives\s*:/, 62 + ]; 63 + const NUXT_SECURITY_SIGNALS = [ 64 + /['"]nuxt-security['"]/, 65 + /\bcontentSecurityPolicy\b/, 66 + ]; 67 + 68 + // append-string signals: CSP written as a literal value string 69 + const INLINE_HEADER_SIGNALS = [ 70 + /["']Content-Security-Policy["']/i, 71 + /\bscript-src\b/, 72 + /\bconnect-src\b/, 73 + ]; 74 + const NUXT_ROUTE_RULES_SIGNALS = [ 75 + /\brouteRules\b/, 76 + /Content-Security-Policy/i, 77 + /\bscript-src\b/, 78 + ]; 79 + 80 + const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i; 81 + const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i; 82 + 83 + /** 84 + * @param {string} cwd Project root. 85 + * @returns {{ shape: string|null, signals: string[] }} 86 + */ 87 + export function detectCsp(cwd = process.cwd()) { 88 + const hits = { appendArrays: [], appendString: [], middleware: [], metaTag: [] }; 89 + 90 + walk(cwd, cwd, 0, (absPath, relPath, body) => { 91 + const ext = path.extname(absPath); 92 + const base = path.basename(absPath).toLowerCase(); 93 + const isConfig = (name) => 94 + new RegExp('(^|/)' + name + '\\.config\\.').test(relPath); 95 + 96 + // === append-arrays candidates === 97 + 98 + // Monorepo CSP helper: packages/*/src/.../(config|security)/* 99 + if (SCAN_EXTS.has(ext) && 100 + /packages\/[^/]+\/src\/.*(config|next-config|security)/.test(relPath) && 101 + MONOREPO_HELPER_SIGNALS.some((re) => re.test(body))) { 102 + hits.appendArrays.push(relPath); 103 + return; 104 + } 105 + 106 + // SvelteKit kit.csp.directives 107 + if (SCAN_EXTS.has(ext) && isConfig('svelte') && 108 + SVELTEKIT_CSP_SIGNALS.every((re) => re.test(body))) { 109 + hits.appendArrays.push(relPath); 110 + return; 111 + } 112 + 113 + // Nuxt nuxt-security module 114 + if (SCAN_EXTS.has(ext) && isConfig('nuxt') && 115 + NUXT_SECURITY_SIGNALS.every((re) => re.test(body))) { 116 + hits.appendArrays.push(relPath); 117 + return; 118 + } 119 + 120 + // === append-string candidates === 121 + 122 + // Inline headers in Next/Nuxt/SvelteKit/Astro/Vite config 123 + if (SCAN_EXTS.has(ext) && 124 + /(^|\/)(next|nuxt|vite|astro|svelte)\.config\./.test(relPath) && 125 + INLINE_HEADER_SIGNALS.every((re) => re.test(body))) { 126 + // Nuxt routeRules is a sub-shape of append-string; we already covered 127 + // nuxt-security above via return, so any remaining Nuxt CSP match here 128 + // is a route-rules / inline-headers case. Either way, same patch 129 + // mechanism. 130 + hits.appendString.push(relPath); 131 + return; 132 + } 133 + 134 + // === detect-only shapes === 135 + 136 + if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') && 137 + MIDDLEWARE_HINT.test(body)) { 138 + hits.middleware.push(relPath); 139 + } 140 + 141 + if (LAYOUT_EXTS.has(ext) && META_TAG_HINT.test(body)) { 142 + hits.metaTag.push(relPath); 143 + } 144 + }); 145 + 146 + // Priority: append-arrays > append-string > middleware > meta-tag. 147 + // Structured patches are safer than string splices; runtime and HTML 148 + // injection patches are less reliable and v1 doesn't auto-apply them. 149 + if (hits.appendArrays.length > 0) { 150 + return { shape: 'append-arrays', signals: hits.appendArrays }; 151 + } 152 + if (hits.appendString.length > 0) { 153 + return { shape: 'append-string', signals: hits.appendString }; 154 + } 155 + if (hits.middleware.length > 0) { 156 + return { shape: 'middleware', signals: hits.middleware }; 157 + } 158 + if (hits.metaTag.length > 0) { 159 + return { shape: 'meta-tag', signals: hits.metaTag }; 160 + } 161 + return { shape: null, signals: [] }; 162 + } 163 + 164 + function walk(root, dir, depth, visit) { 165 + if (depth > MAX_DEPTH) return; 166 + let entries; 167 + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } 168 + catch { return; } 169 + 170 + for (const entry of entries) { 171 + const abs = path.join(dir, entry.name); 172 + if (entry.isDirectory()) { 173 + if (SKIP_DIRS.has(entry.name)) continue; 174 + walk(root, abs, depth + 1, visit); 175 + continue; 176 + } 177 + if (!entry.isFile()) continue; 178 + const ext = path.extname(entry.name); 179 + if (!SCAN_EXTS.has(ext) && !LAYOUT_EXTS.has(ext)) continue; 180 + let body; 181 + try { 182 + const fd = fs.openSync(abs, 'r'); 183 + try { 184 + const buf = Buffer.alloc(MAX_READ_BYTES); 185 + const n = fs.readSync(fd, buf, 0, MAX_READ_BYTES, 0); 186 + body = buf.slice(0, n).toString('utf-8'); 187 + } finally { fs.closeSync(fd); } 188 + } catch { continue; } 189 + visit(abs, path.relative(root, abs), body); 190 + } 191 + } 192 + 193 + // CLI mode 194 + const _running = process.argv[1]; 195 + if (_running?.endsWith('detect-csp.mjs') || _running?.endsWith('detect-csp.mjs/')) { 196 + const result = detectCsp(process.cwd()); 197 + console.log(JSON.stringify(result, null, 2)); 198 + }
+69
.agents/skills/impeccable/scripts/is-generated.mjs
··· 1 + /** 2 + * Decide whether a given file is "generated" (regenerated by a build step, 3 + * unsafe to write variants into) or "source" (safe to edit, changes persist). 4 + * 5 + * Why this matters: when the user picks an element on a page whose underlying 6 + * file is regenerated by a build step (e.g. `scripts/build-sub-pages.js` 7 + * rewriting `public/docs/*.html`), writing variants or accepted changes into 8 + * that file is silent data loss — the next build wipes them. 9 + * 10 + * Signals, in order of reliability: 11 + * 1. Git check-ignore: gitignored files are assumed generated. 12 + * 2. File-header markers ("GENERATED", "DO NOT EDIT", "AUTO-GENERATED") 13 + * within the first ~300 characters — catches non-git projects. 14 + */ 15 + 16 + import { execSync } from 'node:child_process'; 17 + import fs from 'node:fs'; 18 + import path from 'node:path'; 19 + 20 + const HEADER_SCAN_BYTES = 300; 21 + const HEADER_MARKERS = [ 22 + /@generated\b/i, 23 + /\bGENERATED\s+FILE\b/, 24 + /\bAUTO-?GENERATED\b/i, 25 + /\bDO\s+NOT\s+EDIT\b/i, 26 + ]; 27 + 28 + /** 29 + * @param {string} filePath - absolute or cwd-relative path 30 + * @param {object} [options] 31 + * @param {string} [options.cwd] - project root (defaults to process.cwd()) 32 + */ 33 + export function isGeneratedFile(filePath, options = {}) { 34 + const cwd = options.cwd || process.cwd(); 35 + const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath); 36 + 37 + if (isGitIgnored(absPath, cwd)) return true; 38 + if (hasGeneratedHeader(absPath)) return true; 39 + return false; 40 + } 41 + 42 + function isGitIgnored(absPath, cwd) { 43 + try { 44 + execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, { 45 + cwd, 46 + stdio: 'ignore', 47 + }); 48 + return true; // exit 0 = ignored 49 + } catch (err) { 50 + // Exit code 1 = not ignored. Exit code 128 = not a git repo or other error. 51 + // In both cases, treat as "not known to be ignored." 52 + return false; 53 + } 54 + } 55 + 56 + function hasGeneratedHeader(absPath) { 57 + let fd; 58 + try { 59 + fd = fs.openSync(absPath, 'r'); 60 + const buf = Buffer.alloc(HEADER_SCAN_BYTES); 61 + const bytesRead = fs.readSync(fd, buf, 0, HEADER_SCAN_BYTES, 0); 62 + const head = buf.slice(0, bytesRead).toString('utf-8'); 63 + return HEADER_MARKERS.some((re) => re.test(head)); 64 + } catch { 65 + return false; 66 + } finally { 67 + if (fd !== undefined) { try { fs.closeSync(fd); } catch {} } 68 + } 69 + }
+465
.agents/skills/impeccable/scripts/live-accept.mjs
··· 1 + /** 2 + * CLI helper: deterministic accept/discard of variant sessions. 3 + * 4 + * Usage: 5 + * node live-accept.mjs --id SESSION_ID --discard 6 + * node live-accept.mjs --id SESSION_ID --variant N 7 + * 8 + * For discard: removes the entire variant wrapper and restores the original. 9 + * For accept: replaces the wrapper with the chosen variant's content. If the 10 + * session had a colocated <style> block, it's preserved with carbonize markers 11 + * for a background agent to integrate into the project's CSS. 12 + * 13 + * Output: JSON to stdout. 14 + */ 15 + 16 + import fs from 'node:fs'; 17 + import path from 'node:path'; 18 + import { isGeneratedFile } from './is-generated.mjs'; 19 + 20 + const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; 21 + 22 + // --------------------------------------------------------------------------- 23 + // CLI 24 + // --------------------------------------------------------------------------- 25 + 26 + export async function acceptCli() { 27 + const args = process.argv.slice(2); 28 + 29 + if (args.includes('--help') || args.includes('-h')) { 30 + console.log(`Usage: node live-accept.mjs [options] 31 + 32 + Deterministic accept/discard for live variant sessions. 33 + 34 + Modes: 35 + --discard Remove variants, restore original 36 + --variant N Accept variant N, discard the rest 37 + 38 + Required: 39 + --id SESSION_ID Session ID of the variant wrapper 40 + 41 + Output (JSON): 42 + { handled, file, carbonize }`); 43 + process.exit(0); 44 + } 45 + 46 + const id = argVal(args, '--id'); 47 + const variantNum = argVal(args, '--variant'); 48 + const paramValuesRaw = argVal(args, '--param-values'); 49 + const isDiscard = args.includes('--discard'); 50 + 51 + if (!id) { console.error('Missing --id'); process.exit(1); } 52 + if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); } 53 + 54 + let paramValues = null; 55 + if (paramValuesRaw) { 56 + try { paramValues = JSON.parse(paramValuesRaw); } 57 + catch { paramValues = null; } // malformed blob: skip the comment rather than failing the accept 58 + } 59 + 60 + // Find the file containing this session's markers 61 + const found = findSessionFile(id, process.cwd()); 62 + if (!found) { 63 + console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id })); 64 + process.exit(0); 65 + } 66 + 67 + const { file: targetFile, content, lines } = found; 68 + const relFile = path.relative(process.cwd(), targetFile); 69 + 70 + // Bail if the session lives in a generated file. The agent manually wrote 71 + // the wrapper there for preview, and is responsible for writing the 72 + // accepted variant to true source (or cleaning up on discard). See 73 + // "Handle fallback" in live.md. 74 + if (isGeneratedFile(targetFile, { cwd: process.cwd() })) { 75 + console.log(JSON.stringify({ 76 + handled: false, 77 + mode: 'fallback', 78 + file: relFile, 79 + hint: 'Session is in a generated file. Persist the accepted variant in source; do not rely on this script.', 80 + })); 81 + process.exit(0); 82 + } 83 + 84 + if (isDiscard) { 85 + const result = handleDiscard(id, lines, targetFile); 86 + console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result })); 87 + } else { 88 + const result = handleAccept(id, variantNum, lines, targetFile, paramValues); 89 + // Single-line attention-grabber when cleanup is required. The full 90 + // five-step checklist lives in reference/live.md (loaded once per 91 + // session); repeating it per-event would waste tokens. 92 + if (result.carbonize) { 93 + result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + relFile + '. See reference/live.md "Required after accept".'; 94 + } 95 + console.log(JSON.stringify({ handled: true, file: relFile, ...result })); 96 + } 97 + } 98 + 99 + // --------------------------------------------------------------------------- 100 + // Discard 101 + // --------------------------------------------------------------------------- 102 + 103 + function handleDiscard(id, lines, targetFile) { 104 + const block = findMarkerBlock(id, lines); 105 + if (!block) return { handled: false, error: 'Markers not found' }; 106 + 107 + const original = extractOriginal(lines, block); 108 + const indent = lines[block.start].match(/^(\s*)/)[1]; 109 + 110 + // De-indent the original content back to the marker's indentation level 111 + const restored = deindentContent(original, indent); 112 + 113 + const newLines = [ 114 + ...lines.slice(0, block.start), 115 + ...restored, 116 + ...lines.slice(block.end + 1), 117 + ]; 118 + fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8'); 119 + return {}; 120 + } 121 + 122 + // --------------------------------------------------------------------------- 123 + // Accept 124 + // --------------------------------------------------------------------------- 125 + 126 + function handleAccept(id, variantNum, lines, targetFile, paramValues) { 127 + const block = findMarkerBlock(id, lines); 128 + if (!block) return { handled: false, error: 'Markers not found' }; 129 + 130 + const indent = lines[block.start].match(/^(\s*)/)[1]; 131 + const commentSyntax = detectCommentSyntax(targetFile); 132 + 133 + // Extract the chosen variant's inner content 134 + const variantContent = extractVariant(lines, block, variantNum); 135 + if (!variantContent) return { handled: false, error: 'Variant ' + variantNum + ' not found' }; 136 + 137 + // Extract CSS block if present 138 + const cssContent = extractCss(lines, block, id); 139 + 140 + // Check if carbonizing is needed: 141 + // - CSS block exists, OR 142 + // - variant HTML contains helper classes/attributes that need cleanup 143 + const variantText = variantContent.join('\n'); 144 + const hasHelperAttrs = variantText.includes('data-impeccable-variant'); 145 + const needsCarbonize = !!(cssContent || hasHelperAttrs); 146 + 147 + // Build the replacement 148 + const restored = deindentContent(variantContent, indent); 149 + const replacement = []; 150 + 151 + if (cssContent) { 152 + const isJsx = commentSyntax.open === '{/*'; 153 + replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close); 154 + // JSX targets need the CSS body wrapped in a template literal so that the 155 + // `{` and `}` in CSS rules don't get parsed as JSX expressions. 156 + replacement.push(indent + '<style data-impeccable-css="' + id + '">' + (isJsx ? '{`' : '')); 157 + // Re-indent CSS content to match 158 + for (const cssLine of cssContent) { 159 + replacement.push(indent + cssLine.trimStart()); 160 + } 161 + replacement.push(indent + (isJsx ? '`}</style>' : '</style>')); 162 + if (paramValues && Object.keys(paramValues).length > 0) { 163 + // Preserve the user's knob positions for the carbonize-cleanup agent 164 + // to bake into the final CSS when it collapses scoped rules. 165 + replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close); 166 + } 167 + replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close); 168 + } 169 + 170 + // Keep the `@scope ([data-impeccable-variant="N"])` selectors in the 171 + // carbonize CSS block working visually by re-wrapping the accepted content 172 + // in a data-impeccable-variant="N" div with `display: contents` (so layout 173 + // isn't affected). The carbonize agent strips this attribute + wrapper when 174 + // it moves the CSS to a proper stylesheet. 175 + // 176 + // Style attribute syntax has to follow the host file's flavor — JSX files 177 + // need the object form, otherwise React 19 throws "Failed to set indexed 178 + // property [0] on CSSStyleDeclaration" while parsing the string char-by-char. 179 + if (cssContent) { 180 + const isJsx = commentSyntax.open === '{/*'; 181 + const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"'; 182 + replacement.push(indent + '<div data-impeccable-variant="' + variantNum + '" ' + styleAttr + '>'); 183 + replacement.push(...restored); 184 + replacement.push(indent + '</div>'); 185 + } else { 186 + replacement.push(...restored); 187 + } 188 + 189 + const newLines = [ 190 + ...lines.slice(0, block.start), 191 + ...replacement, 192 + ...lines.slice(block.end + 1), 193 + ]; 194 + fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8'); 195 + 196 + return { carbonize: needsCarbonize }; 197 + } 198 + 199 + // --------------------------------------------------------------------------- 200 + // Parsing helpers 201 + // --------------------------------------------------------------------------- 202 + 203 + /** 204 + * Find the start/end marker lines for a session. 205 + * Returns { start, end } (0-indexed line numbers) or null. 206 + */ 207 + function findMarkerBlock(id, lines) { 208 + let start = -1; 209 + let end = -1; 210 + const startPattern = 'impeccable-variants-start ' + id; 211 + const endPattern = 'impeccable-variants-end ' + id; 212 + 213 + for (let i = 0; i < lines.length; i++) { 214 + if (start === -1 && lines[i].includes(startPattern)) start = i; 215 + if (lines[i].includes(endPattern)) { end = i; break; } 216 + } 217 + 218 + return (start !== -1 && end !== -1) ? { start, end } : null; 219 + } 220 + 221 + /** 222 + * Join wrapper lines into a single string with `<style>` elements removed so 223 + * marker matching and div-depth tracking aren't confused by: 224 + * - CSS `@scope ([data-impeccable-variant="N"])` strings that look like the 225 + * HTML marker we're searching for 226 + * - JSX self-closing `<style ... />` (no separate `</style>` to close on) 227 + * - Same-line `<style>…</style>` blocks 228 + * - Multi-line `<style>\n…\n</style>` blocks 229 + */ 230 + function stripStyleAndJoin(lines, block) { 231 + const out = []; 232 + let inStyle = false; 233 + for (let i = block.start; i <= block.end; i++) { 234 + let line = lines[i]; 235 + 236 + if (!inStyle) { 237 + // Strip any complete <style> elements on this line (self-closed or 238 + // same-line-closed), including their body content. 239 + line = line 240 + .replace(/<style\b[^>]*>[\s\S]*?<\/style\s*>/g, '') 241 + .replace(/<style\b[^>]*\/\s*>/g, ''); 242 + 243 + // If a <style> opener remains (multi-line body starts here), strip from 244 + // the opener to end-of-line and flip into skip mode. 245 + const openerIdx = line.search(/<style\b/); 246 + if (openerIdx !== -1) { 247 + line = line.slice(0, openerIdx); 248 + inStyle = true; 249 + } 250 + out.push(line); 251 + } else { 252 + // In multi-line style body; drop everything until we see </style>. 253 + const closeIdx = line.search(/<\/style\s*>/); 254 + if (closeIdx !== -1) { 255 + inStyle = false; 256 + out.push(line.slice(closeIdx).replace(/<\/style\s*>/, '')); 257 + } 258 + // else: skip line entirely 259 + } 260 + } 261 + return out.join('\n'); 262 + } 263 + 264 + /** 265 + * Find the inner content of `<TAG ...attrMatch...>…</TAG>` inside `text`, 266 + * handling nested same-tag elements via depth counting. `attrMatch` is a 267 + * regex source fragment that must appear inside the opener tag. 268 + * Returns the inner string (may be empty), or null if not found. 269 + */ 270 + function extractInnerByAttr(text, attrMatch) { 271 + const openerRe = new RegExp('<([A-Za-z][A-Za-z0-9]*)\\b[^>]*' + attrMatch + '[^>]*>'); 272 + const openMatch = text.match(openerRe); 273 + if (!openMatch) return null; 274 + 275 + const tagName = openMatch[1]; 276 + const innerStart = openMatch.index + openMatch[0].length; 277 + 278 + // Match any opener or closer of this tag name after innerStart. 279 + // (Does not match self-closing <TAG … />, which doesn't contribute to depth.) 280 + const tagRe = new RegExp('<(?:/)?' + tagName + '\\b[^>]*>', 'g'); 281 + tagRe.lastIndex = innerStart; 282 + 283 + let depth = 1; 284 + let m; 285 + while ((m = tagRe.exec(text))) { 286 + const isClose = m[0].startsWith('</'); 287 + const isSelfClose = !isClose && /\/\s*>$/.test(m[0]); 288 + if (isClose) { 289 + depth--; 290 + if (depth === 0) return text.slice(innerStart, m.index); 291 + } else if (!isSelfClose) { 292 + depth++; 293 + } 294 + } 295 + return null; 296 + } 297 + 298 + /** 299 + * Extract the original element content from within the variant wrapper. 300 + * Returns an array of lines. 301 + */ 302 + function extractOriginal(lines, block) { 303 + const text = stripStyleAndJoin(lines, block); 304 + const inner = extractInnerByAttr(text, 'data-impeccable-variant="original"'); 305 + if (inner === null) return []; 306 + return inner.split('\n'); 307 + } 308 + 309 + /** 310 + * Extract a specific variant's inner content (stripping the wrapper div). 311 + * Returns an array of lines, or null if not found. 312 + */ 313 + function extractVariant(lines, block, variantNum) { 314 + const text = stripStyleAndJoin(lines, block); 315 + const inner = extractInnerByAttr(text, 'data-impeccable-variant="' + variantNum + '"'); 316 + if (inner === null) return null; 317 + const result = inner.split('\n'); 318 + // Collapse a lone empty leading/trailing line (common after string splice). 319 + while (result.length > 1 && result[0].trim() === '') result.shift(); 320 + while (result.length > 1 && result[result.length - 1].trim() === '') result.pop(); 321 + return result.length > 0 ? result : null; 322 + } 323 + 324 + /** 325 + * Extract the colocated <style> block content (between the style tags). 326 + * Returns an array of CSS lines, or null if no style block found. 327 + * 328 + * Handles three shapes of `<style data-impeccable-css="ID" ...>`: 329 + * 1. Self-closing: `<style ... />` — no body; return null (nothing to carbonize). 330 + * 2. Same-line open+close: `<style>...</style>` — return the inner content. 331 + * 3. Multi-line: `<style>` on one line, `</style>` on a later line — return 332 + * the lines between them. 333 + */ 334 + function extractCss(lines, block, id) { 335 + const styleAttr = 'data-impeccable-css="' + id + '"'; 336 + let inStyle = false; 337 + const content = []; 338 + 339 + for (let i = block.start; i <= block.end; i++) { 340 + const line = lines[i]; 341 + 342 + if (!inStyle && line.includes(styleAttr)) { 343 + // Self-closing: nothing to carbonize. 344 + if (/<style\b[^>]*\/\s*>/.test(line)) return null; 345 + // Same-line open + close: extract inner text. 346 + const sameLine = line.match(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/); 347 + if (sameLine) { 348 + const inner = sameLine[1]; 349 + return inner.length > 0 ? inner.split('\n') : null; 350 + } 351 + inStyle = true; 352 + continue; // skip the <style> opening tag 353 + } 354 + 355 + if (inStyle) { 356 + // Detect </style> anywhere on the line — JSX template-literal closes 357 + // (`}</style>`) put the close mid-line, and we don't want to absorb the 358 + // template-literal punctuation as CSS content. 359 + const closeIdx = line.indexOf('</style>'); 360 + if (closeIdx !== -1) break; 361 + content.push(line); 362 + } 363 + } 364 + 365 + return content.length > 0 ? content : null; 366 + } 367 + 368 + /** 369 + * De-indent content that was indented by live-wrap.mjs. 370 + * The wrap script adds `indent + ' '` (4 extra spaces) to each line. 371 + * We restore to just `indent` level. 372 + */ 373 + function deindentContent(contentLines, baseIndent) { 374 + // Find the minimum indentation in the content to determine how much was added 375 + let minIndent = Infinity; 376 + for (const line of contentLines) { 377 + if (line.trim() === '') continue; 378 + const leadingSpaces = line.match(/^(\s*)/)[1].length; 379 + minIndent = Math.min(minIndent, leadingSpaces); 380 + } 381 + if (minIndent === Infinity) minIndent = 0; 382 + 383 + // Strip the extra indentation and re-add base indent 384 + return contentLines.map(line => { 385 + if (line.trim() === '') return ''; 386 + return baseIndent + line.slice(minIndent); 387 + }); 388 + } 389 + 390 + function detectCommentSyntax(filePath) { 391 + const ext = path.extname(filePath).toLowerCase(); 392 + if (ext === '.jsx' || ext === '.tsx') { 393 + return { open: '{/*', close: '*/}' }; 394 + } 395 + return { open: '<!--', close: '-->' }; 396 + } 397 + 398 + // --------------------------------------------------------------------------- 399 + // File search (find the file containing session markers) 400 + // --------------------------------------------------------------------------- 401 + 402 + function findSessionFile(id, cwd) { 403 + const marker = 'impeccable-variants-start ' + id; 404 + const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.']; 405 + const seen = new Set(); 406 + 407 + for (const dir of searchDirs) { 408 + const absDir = path.join(cwd, dir); 409 + if (!fs.existsSync(absDir)) continue; 410 + const result = searchDir(absDir, marker, seen, 0); 411 + if (result) { 412 + const content = fs.readFileSync(result, 'utf-8'); 413 + return { file: result, content, lines: content.split('\n') }; 414 + } 415 + } 416 + return null; 417 + } 418 + 419 + function searchDir(dir, query, seen, depth) { 420 + if (depth > 5) return null; 421 + let realDir; 422 + try { realDir = fs.realpathSync(dir); } catch { return null; } 423 + if (seen.has(realDir)) return null; 424 + seen.add(realDir); 425 + 426 + let entries; 427 + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } 428 + catch { return null; } 429 + 430 + for (const entry of entries) { 431 + if (!entry.isFile()) continue; 432 + if (!EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue; 433 + const filePath = path.join(dir, entry.name); 434 + try { 435 + const content = fs.readFileSync(filePath, 'utf-8'); 436 + if (content.includes(query)) return filePath; 437 + } catch { /* skip */ } 438 + } 439 + 440 + for (const entry of entries) { 441 + if (!entry.isDirectory()) continue; 442 + if (['node_modules', '.git', 'dist', 'build'].includes(entry.name)) continue; 443 + const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1); 444 + if (result) return result; 445 + } 446 + 447 + return null; 448 + } 449 + 450 + // --------------------------------------------------------------------------- 451 + // Utilities 452 + // --------------------------------------------------------------------------- 453 + 454 + function argVal(args, flag) { 455 + const idx = args.indexOf(flag); 456 + return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null; 457 + } 458 + 459 + // Auto-execute when run directly 460 + const _running = process.argv[1]; 461 + if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs/')) { 462 + acceptCli(); 463 + } 464 + 465 + export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax };
+4777
.agents/skills/impeccable/scripts/live-browser.js
··· 1 + /** 2 + * Impeccable Live Variant Mode — Browser Script 3 + * 4 + * Injected into the user's page via <script src="http://localhost:PORT/live.js">. 5 + * The server prepends window.__IMPECCABLE_TOKEN__ and window.__IMPECCABLE_PORT__ 6 + * before this code. 7 + * 8 + * UI: a single floating bar that morphs between three states — 9 + * configure (pick action + go), generating (progressive dots), and cycling 10 + * (prev/next + accept/discard). Feels like Spotlight, not a modal. 11 + */ 12 + (function () { 13 + 'use strict'; 14 + if (typeof window === 'undefined') return; 15 + 16 + // Guard against double-init. Bun's HTML loader may process the <script> tag 17 + // and create a bundled copy alongside the external load, or HMR may re-execute. 18 + // Check BEFORE reading token/port to catch all cases. 19 + if (window.__IMPECCABLE_LIVE_INIT__) return; 20 + window.__IMPECCABLE_LIVE_INIT__ = true; 21 + 22 + const TOKEN = window.__IMPECCABLE_TOKEN__; 23 + const PORT = window.__IMPECCABLE_PORT__; 24 + if (!TOKEN || !PORT) { 25 + window.__IMPECCABLE_LIVE_INIT__ = false; // reset so the real load can init 26 + return; 27 + } 28 + 29 + // --------------------------------------------------------------------------- 30 + // Design tokens 31 + // --------------------------------------------------------------------------- 32 + 33 + // Brand magenta is pinned to the site token (--color-accent in main.css) 34 + // so Accept / knobs / cycle-dots match the site's accent, not a washed 35 + // theme-adjusted one. 36 + const C = { 37 + brand: 'oklch(60% 0.25 350)', 38 + brandHov: 'oklch(52% 0.25 350)', 39 + brandSoft: 'oklch(60% 0.25 350 / 0.15)', 40 + ink: 'oklch(15% 0.01 350)', 41 + ash: 'oklch(55% 0 0)', 42 + paper: 'oklch(98% 0.005 350 / 0.92)', 43 + paperSolid:'oklch(98% 0.005 350)', 44 + mist: 'oklch(90% 0.01 350 / 0.6)', 45 + white: 'oklch(99% 0 0)', 46 + }; 47 + const FONT = 'system-ui, -apple-system, sans-serif'; 48 + const MONO = 'ui-monospace, SFMono-Regular, Menlo, monospace'; 49 + // z-index: detect overlays use 99999, so our UI must be above them 50 + const Z = { highlight: 100001, bar: 100005, picker: 100007, toast: 100010 }; 51 + const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; // ease-out-quint 52 + const PREFIX = 'impeccable-live'; 53 + const HIGHLIGHT_TRANSITION = 54 + 'top 140ms ' + EASE + 55 + ', left 140ms ' + EASE + 56 + ', width 140ms ' + EASE + 57 + ', height 140ms ' + EASE + 58 + ', opacity 150ms ease'; 59 + const TOOLTIP_TRANSITION = 60 + 'top 140ms ' + EASE + ', left 140ms ' + EASE + ', opacity 150ms ease'; 61 + 62 + const SKIP_TAGS = new Set([ 63 + 'html', 'head', 'body', 'script', 'style', 'link', 'meta', 'noscript', 'br', 'wbr', 64 + ]); 65 + 66 + // SVG icons stack above each chip label. All strokes use currentColor so the 67 + // icon recolors to C.brand when its chip is selected. 20x20 render, 24-viewBox, 68 + // 1.5 stroke — visually consistent with the Foundation grid on the homepage. 69 + 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"'; 70 + const ICONS = { 71 + impeccable: `<svg ${ICON_ATTRS}><path d="M4 20l4-1L18 9l-3-3L5 16z"/><path d="M14 7l3 3"/></svg>`, 72 + 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>`, 73 + 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>`, 74 + distill: `<svg ${ICON_ATTRS}><path d="M4 5h16l-6 8v7l-4-2v-5z"/></svg>`, 75 + 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>`, 76 + 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>`, 77 + 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>`, 78 + 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>`, 79 + 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>`, 80 + 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>`, 81 + delight: `<svg ${ICON_ATTRS}><path d="M12 3l2 6 6 2-6 2-2 6-2-6-6-2 6-2z"/></svg>`, 82 + overdrive: `<svg ${ICON_ATTRS}><path d="M13 3L5 13h5l-1 8 9-12h-6z"/></svg>`, 83 + }; 84 + 85 + const ACTIONS = [ 86 + { value: 'impeccable', label: 'Freeform' }, 87 + { value: 'bolder', label: 'Bolder' }, 88 + { value: 'quieter', label: 'Quieter' }, 89 + { value: 'distill', label: 'Distill' }, 90 + { value: 'polish', label: 'Polish' }, 91 + { value: 'typeset', label: 'Typeset' }, 92 + { value: 'colorize', label: 'Colorize' }, 93 + { value: 'layout', label: 'Layout' }, 94 + { value: 'adapt', label: 'Adapt' }, 95 + { value: 'animate', label: 'Animate' }, 96 + { value: 'delight', label: 'Delight' }, 97 + { value: 'overdrive', label: 'Overdrive' }, 98 + ]; 99 + 100 + // --------------------------------------------------------------------------- 101 + // State 102 + // --------------------------------------------------------------------------- 103 + 104 + let state = 'IDLE'; 105 + let hoveredElement = null; 106 + let selectedElement = null; 107 + let currentSessionId = null; 108 + let expectedVariants = 0; 109 + let arrivedVariants = 0; 110 + let visibleVariant = 0; 111 + let variantObserver = null; 112 + let hasProjectContext = false; 113 + let selectedAction = 'impeccable'; 114 + let selectedCount = 3; 115 + 116 + // Scroll lock — holds window.scrollY at a fixed value while the session is 117 + // active, so HMR DOM patches and variant swaps can't drift the page. See 118 + // startScrollLock / stopScrollLock below. 119 + let scrollLockObserver = null; 120 + let scrollLockTargetY = null; 121 + let scrollLockRaf = null; 122 + let scrollLockAbort = null; 123 + 124 + // Dedicated key for scroll position — SEPARATE from LS_KEY so that 125 + // saveSession's state updates don't clobber a carefully-captured scrollY. 126 + // (Previously: saveSession wrote scrollY alongside state, so every call 127 + // during resume overwrote the pre-reload value with whatever the browser 128 + // had landed on, typically 0.) 129 + const SCROLL_KEY_SUFFIX = '-scroll'; 130 + function writeScrollY(y) { 131 + try { localStorage.setItem(LS_KEY + SCROLL_KEY_SUFFIX, String(y)); } catch {} 132 + } 133 + function readScrollY() { 134 + try { 135 + const raw = localStorage.getItem(LS_KEY + SCROLL_KEY_SUFFIX); 136 + if (raw == null) return null; 137 + const n = parseFloat(raw); 138 + return isFinite(n) ? n : null; 139 + } catch { return null; } 140 + } 141 + function clearScrollY() { 142 + try { localStorage.removeItem(LS_KEY + SCROLL_KEY_SUFFIX); } catch {} 143 + } 144 + 145 + // Pre-empt the browser: apply manual scroll restoration and jump to the 146 + // saved scrollY at script-parse time. Retries on fonts.ready and load 147 + // are essential: scrollTo(y) clamps to the current document.scrollHeight, 148 + // which is often hundreds of pixels short of the final value until 149 + // async-loaded fonts swap in and reflow. 150 + try { 151 + history.scrollRestoration = 'manual'; 152 + const savedY = readScrollY(); 153 + if (savedY != null) { 154 + const apply = () => { 155 + if (Math.abs(window.scrollY - savedY) > 0.5) { 156 + console.log('[impeccable.scroll] early restore', { from: window.scrollY, to: savedY }); 157 + window.scrollTo(0, savedY); 158 + } 159 + }; 160 + apply(); 161 + if (document.fonts?.ready) document.fonts.ready.then(apply).catch(() => {}); 162 + window.addEventListener('load', apply, { once: true }); 163 + } 164 + } catch {} 165 + 166 + // UI refs 167 + let highlightEl = null; 168 + let tooltipEl = null; 169 + let barEl = null; 170 + let pickerEl = null; 171 + let toastEl = null; 172 + let scrollRaf = null; 173 + 174 + // --------------------------------------------------------------------------- 175 + // Helpers 176 + // --------------------------------------------------------------------------- 177 + 178 + function own(el) { 179 + return el && (el.id?.startsWith(PREFIX) || el.closest?.('[id^="' + PREFIX + '"]')); 180 + } 181 + 182 + function pickable(el) { 183 + if (!el || el.nodeType !== 1) return false; 184 + if (SKIP_TAGS.has(el.tagName.toLowerCase())) return false; 185 + if (own(el)) return false; 186 + const r = el.getBoundingClientRect(); 187 + return r.width >= 20 && r.height >= 20; 188 + } 189 + 190 + function desc(el) { 191 + if (!el) return ''; 192 + let s = el.tagName.toLowerCase(); 193 + if (el.id) s += '#' + el.id; 194 + else if (el.classList.length) s += '.' + [...el.classList].slice(0, 2).join('.'); 195 + return s; 196 + } 197 + 198 + function id8() { return crypto.randomUUID().replace(/-/g, '').slice(0, 8); } 199 + 200 + // Modal-aware chrome: keep our floating UI clickable inside Radix / 201 + // Headless UI / vaul portals. 202 + // 203 + // Two host-page behaviors break us when the picked element lives inside a 204 + // modal dialog: 205 + // 206 + // 1. Modal scroll-lock disables outside pointer events. Radix's 207 + // `DismissableLayer` sets `document.body.style.pointerEvents = 'none'` 208 + // while a modal is open and only restores `auto` on the layer. Our 209 + // chrome inherits `none` from <body> and becomes unclickable. 210 + // 2. The dialog's outside-interaction handler (Radix's 211 + // `usePointerDownOutside`) listens at document level and dismisses 212 + // the dialog whenever a `pointerdown` lands outside the layer node. 213 + // Our chrome is a sibling of <body>, so Radix classifies our clicks 214 + // as outside and tears the dialog down mid-task. 215 + // 216 + // We can't reliably re-parent our chrome into the dialog subtree (z-index 217 + // stacking, scroll containers, theming all become host-page concerns), so 218 + // we defang both behaviors at our root: 219 + // 220 + // - `pointer-events: auto !important` overrides the inherited `none`. 221 + // - Stop `pointerdown` / `mousedown` propagation so the document-level 222 + // dismiss listener never fires for our clicks. 223 + // - Stop `focusin` propagation so any focus shifts inside our chrome 224 + // don't read as "focus moved outside the dialog" to focus traps. 225 + // 226 + // Click events still bubble normally — only the early pointer/focus 227 + // signals that drive outside-interaction detection are silenced. 228 + function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) { 229 + if (!rootEl) return; 230 + if (setPointerEvents) { 231 + rootEl.style.setProperty('pointer-events', 'auto', 'important'); 232 + } 233 + const stop = (e) => e.stopPropagation(); 234 + rootEl.addEventListener('pointerdown', stop); 235 + rootEl.addEventListener('mousedown', stop); 236 + rootEl.addEventListener('focusin', stop); 237 + } 238 + 239 + // --------------------------------------------------------------------------- 240 + // Highlight overlay 241 + // --------------------------------------------------------------------------- 242 + 243 + function initHighlight() { 244 + highlightEl = document.createElement('div'); 245 + highlightEl.id = PREFIX + '-highlight'; 246 + Object.assign(highlightEl.style, { 247 + position: 'fixed', top: '0', left: '0', width: '0', height: '0', 248 + border: '2px solid ' + C.brand, borderRadius: '3px', 249 + pointerEvents: 'none', zIndex: Z.highlight, boxSizing: 'border-box', 250 + transition: HIGHLIGHT_TRANSITION, 251 + display: 'none', opacity: '0', 252 + }); 253 + document.body.appendChild(highlightEl); 254 + 255 + tooltipEl = document.createElement('div'); 256 + tooltipEl.id = PREFIX + '-tooltip'; 257 + Object.assign(tooltipEl.style, { 258 + position: 'fixed', 259 + background: C.ink, color: C.white, 260 + fontFamily: MONO, fontSize: '10px', fontWeight: '500', 261 + padding: '2px 6px', borderRadius: '3px', 262 + zIndex: Z.highlight + 1, pointerEvents: 'none', 263 + whiteSpace: 'nowrap', display: 'none', 264 + letterSpacing: '0.02em', 265 + transition: TOOLTIP_TRANSITION, 266 + }); 267 + document.body.appendChild(tooltipEl); 268 + } 269 + 270 + function showHighlight(el) { 271 + if (!el || !highlightEl) return; 272 + const r = el.getBoundingClientRect(); 273 + const top = (r.top - 2) + 'px', left = (r.left - 2) + 'px'; 274 + const width = (r.width + 4) + 'px', height = (r.height + 4) + 'px'; 275 + const tipTop = r.top - 20; 276 + const tipY = (tipTop < 4 ? r.bottom + 4 : tipTop) + 'px'; 277 + const tipX = Math.max(4, r.left) + 'px'; 278 + tooltipEl.textContent = desc(el); 279 + 280 + const hiWasHidden = highlightEl.style.display === 'none' || highlightEl.style.opacity === '0'; 281 + if (hiWasHidden) { 282 + // Snap to first target without animating from (0,0), then fade in. 283 + highlightEl.style.transition = 'none'; 284 + Object.assign(highlightEl.style, { top, left, width, height, display: 'block' }); 285 + tooltipEl.style.transition = 'none'; 286 + Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block' }); 287 + void highlightEl.offsetWidth; 288 + highlightEl.style.transition = HIGHLIGHT_TRANSITION; 289 + highlightEl.style.opacity = '1'; 290 + tooltipEl.style.transition = TOOLTIP_TRANSITION; 291 + tooltipEl.style.opacity = '1'; 292 + } else { 293 + Object.assign(highlightEl.style, { top, left, width, height, display: 'block', opacity: '1' }); 294 + Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block', opacity: '1' }); 295 + } 296 + } 297 + 298 + function hideHighlight() { 299 + if (highlightEl) { highlightEl.style.opacity = '0'; highlightEl.style.display = 'none'; } 300 + if (tooltipEl) { tooltipEl.style.opacity = '0'; tooltipEl.style.display = 'none'; } 301 + } 302 + 303 + // --------------------------------------------------------------------------- 304 + // Annotation overlay (comment pins + magenta strokes) 305 + // 306 + // Active while state === 'CONFIGURING'. The overlay is a fixed-positioned 307 + // sibling of <body> mirroring selectedElement's bounding rect. Click (no 308 + // drag) drops a comment pin; drag paints a magenta SVG stroke. All coords 309 + // are stored in element-local CSS px so they survive scroll / resize and 310 + // correlate directly with the captured PNG. 311 + // --------------------------------------------------------------------------- 312 + 313 + const DRAG_THRESHOLD = 5; // px — below this, treat pointerup as a click 314 + const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it 315 + let annotOverlayEl = null; 316 + let annotSvgEl = null; 317 + let annotPinsEl = null; 318 + let annotClearChipEl = null; 319 + let annotState = { comments: [], strokes: [] }; 320 + let annotActive = false; 321 + // `annotPointer` is either: 322 + // { kind: 'new', x0, y0, moved, strokeEl, strokePoints } creating a stroke/pin 323 + // { kind: 'pin', idx, startPointer, startPin, moved } dragging an existing pin 324 + let annotPointer = null; 325 + let annotEditing = null; // { idx, input, wrapEl } 326 + let annotLastPinClick = { idx: -1, time: 0 }; // for click-click-to-delete 327 + 328 + function initAnnotOverlay() { 329 + annotOverlayEl = document.createElement('div'); 330 + annotOverlayEl.id = PREFIX + '-annot'; 331 + Object.assign(annotOverlayEl.style, { 332 + position: 'fixed', top: '0', left: '0', width: '0', height: '0', 333 + pointerEvents: 'auto', zIndex: Z.highlight + 2, 334 + display: 'none', overflow: 'visible', 335 + cursor: 'crosshair', touchAction: 'none', 336 + }); 337 + 338 + annotSvgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); 339 + annotSvgEl.id = PREFIX + '-annot-svg'; 340 + Object.assign(annotSvgEl.style, { 341 + position: 'absolute', top: '0', left: '0', 342 + width: '100%', height: '100%', 343 + // The SVG itself doesn't absorb clicks; individual hit-paths opt-in via 344 + // pointer-events=stroke so gaps still fall through to the overlay. 345 + pointerEvents: 'none', overflow: 'visible', 346 + }); 347 + annotOverlayEl.appendChild(annotSvgEl); 348 + 349 + annotPinsEl = document.createElement('div'); 350 + annotPinsEl.id = PREFIX + '-annot-pins'; 351 + Object.assign(annotPinsEl.style, { 352 + position: 'absolute', inset: '0', 353 + pointerEvents: 'none', 354 + }); 355 + annotOverlayEl.appendChild(annotPinsEl); 356 + 357 + annotClearChipEl = document.createElement('div'); 358 + annotClearChipEl.id = PREFIX + '-annot-clear'; 359 + annotClearChipEl.dataset.annotClear = 'true'; 360 + annotClearChipEl.textContent = 'Clear'; 361 + Object.assign(annotClearChipEl.style, { 362 + position: 'absolute', top: '8px', right: '8px', 363 + background: C.ink, color: C.white, 364 + fontFamily: FONT, fontSize: '10px', fontWeight: '500', 365 + letterSpacing: '0.08em', textTransform: 'uppercase', 366 + padding: '5px 12px', borderRadius: '999px', 367 + cursor: 'pointer', pointerEvents: 'auto', 368 + display: 'none', userSelect: 'none', 369 + boxShadow: '0 1px 3px rgba(0,0,0,0.2)', 370 + }); 371 + annotOverlayEl.appendChild(annotClearChipEl); 372 + 373 + annotOverlayEl.addEventListener('pointerdown', onAnnotDown); 374 + annotOverlayEl.addEventListener('pointermove', onAnnotMove); 375 + annotOverlayEl.addEventListener('pointerup', onAnnotUp); 376 + annotOverlayEl.addEventListener('pointercancel', onAnnotUp); 377 + document.body.appendChild(annotOverlayEl); 378 + // Modal-host friendliness: pointer-events is already 'auto' on this 379 + // overlay; we only need to silence the host's outside-interaction 380 + // listeners. Don't override pointer-events here (the overlay toggles 381 + // visibility via display:none, which is fine). 382 + defangOutsideHandlers(annotOverlayEl, { setPointerEvents: false }); 383 + } 384 + 385 + function updateClearChip() { 386 + if (!annotClearChipEl) return; 387 + const hasAny = annotState.comments.length > 0 || annotState.strokes.length > 0; 388 + annotClearChipEl.style.display = hasAny ? 'block' : 'none'; 389 + } 390 + 391 + function showAnnotOverlay(el) { 392 + if (!annotOverlayEl || !el) return; 393 + annotActive = true; 394 + positionAnnotOverlay(el); 395 + annotOverlayEl.style.display = 'block'; 396 + } 397 + 398 + function hideAnnotOverlay() { 399 + annotActive = false; 400 + if (annotOverlayEl) annotOverlayEl.style.display = 'none'; 401 + // Drop any in-progress edit without touching annotState — clearAnnotations 402 + // (if the caller is exiting configure mode) handles state reset. 403 + annotEditing = null; 404 + } 405 + 406 + function positionAnnotOverlay(el) { 407 + if (!annotOverlayEl || !el) return; 408 + const r = el.getBoundingClientRect(); 409 + Object.assign(annotOverlayEl.style, { 410 + top: r.top + 'px', left: r.left + 'px', 411 + width: r.width + 'px', height: r.height + 'px', 412 + }); 413 + annotSvgEl.setAttribute('viewBox', '0 0 ' + r.width + ' ' + r.height); 414 + } 415 + 416 + function clearAnnotations() { 417 + annotState.comments = []; 418 + annotState.strokes = []; 419 + if (annotSvgEl) while (annotSvgEl.firstChild) annotSvgEl.removeChild(annotSvgEl.firstChild); 420 + if (annotPinsEl) annotPinsEl.innerHTML = ''; 421 + annotPointer = null; 422 + annotEditing = null; 423 + annotLastPinClick = { idx: -1, time: 0 }; 424 + updateClearChip(); 425 + } 426 + 427 + // Rebuild the SVG layer. Each stroke gets a wider invisible hit path 428 + // beneath the visible magenta path so clicks register on thin lines. 429 + function redrawStrokes() { 430 + while (annotSvgEl.firstChild) annotSvgEl.removeChild(annotSvgEl.firstChild); 431 + annotState.strokes.forEach((s, idx) => { 432 + const d = pointsToPath(s.points); 433 + const hit = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 434 + hit.setAttribute('d', d); 435 + hit.setAttribute('stroke', 'transparent'); 436 + hit.setAttribute('stroke-width', '16'); 437 + hit.setAttribute('stroke-linecap', 'round'); 438 + hit.setAttribute('stroke-linejoin', 'round'); 439 + hit.setAttribute('fill', 'none'); 440 + hit.setAttribute('pointer-events', 'stroke'); 441 + hit.style.cursor = 'pointer'; 442 + hit.dataset.annotStroke = String(idx); 443 + annotSvgEl.appendChild(hit); 444 + const visible = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 445 + visible.setAttribute('d', d); 446 + visible.setAttribute('stroke', C.brand); 447 + visible.setAttribute('stroke-width', '3'); 448 + visible.setAttribute('stroke-linecap', 'round'); 449 + visible.setAttribute('stroke-linejoin', 'round'); 450 + visible.setAttribute('fill', 'none'); 451 + visible.setAttribute('pointer-events', 'none'); 452 + annotSvgEl.appendChild(visible); 453 + }); 454 + updateClearChip(); 455 + } 456 + 457 + function localCoords(e) { 458 + const rect = annotOverlayEl.getBoundingClientRect(); 459 + return { x: e.clientX - rect.left, y: e.clientY - rect.top }; 460 + } 461 + 462 + function onAnnotDown(e) { 463 + if (!annotActive) return; 464 + 465 + // 1) Clear chip → wipe all annotations 466 + if (e.target.closest?.('[data-annot-clear]')) { 467 + if (annotEditing) annotEditing = null; 468 + clearAnnotations(); 469 + renderAllPins(); 470 + redrawStrokes(); 471 + e.stopPropagation(); e.preventDefault(); 472 + return; 473 + } 474 + 475 + // 2) Stroke hit path → delete that stroke 476 + const strokeHit = e.target.closest?.('[data-annot-stroke]'); 477 + if (strokeHit) { 478 + const idx = parseInt(strokeHit.dataset.annotStroke, 10); 479 + if (Number.isInteger(idx)) { 480 + annotState.strokes.splice(idx, 1); 481 + redrawStrokes(); 482 + } 483 + e.stopPropagation(); e.preventDefault(); 484 + return; 485 + } 486 + 487 + // 3) Pin → drag, edit, or delete-on-double-click 488 + const pinWrap = e.target.closest?.('[data-annot-pin]'); 489 + if (pinWrap) { 490 + const idx = parseInt(pinWrap.dataset.annotPin, 10); 491 + if (!Number.isInteger(idx)) return; 492 + // Double-click (two pointerdowns on the same pin within window) → delete. 493 + const now = Date.now(); 494 + if (annotLastPinClick.idx === idx && now - annotLastPinClick.time < PIN_DBL_CLICK_MS) { 495 + if (annotEditing && annotEditing.idx === idx) annotEditing = null; 496 + annotState.comments.splice(idx, 1); 497 + annotLastPinClick = { idx: -1, time: 0 }; 498 + renderAllPins(); 499 + e.stopPropagation(); e.preventDefault(); 500 + return; 501 + } 502 + annotLastPinClick = { idx, time: now }; 503 + // If editing a different pin, commit that edit before starting here. 504 + if (annotEditing && annotEditing.idx !== idx) finalizeEditingPin(); 505 + // If already editing THIS pin and the user clicked the dot, let the 506 + // input keep focus (don't start a drag — the click wasn't meant as one). 507 + if (annotEditing && annotEditing.idx === idx) return; 508 + const p = localCoords(e); 509 + const pin = annotState.comments[idx]; 510 + annotPointer = { 511 + kind: 'pin', idx, 512 + startPointer: p, 513 + startPin: { x: pin.x, y: pin.y }, 514 + moved: false, 515 + }; 516 + try { annotOverlayEl.setPointerCapture(e.pointerId); } catch {} 517 + e.stopPropagation(); e.preventDefault(); 518 + return; 519 + } 520 + 521 + // 4) Empty area → commit any open edit, then start new annotation 522 + if (annotEditing) { 523 + finalizeEditingPin(); 524 + e.stopPropagation(); e.preventDefault(); 525 + return; 526 + } 527 + const p = localCoords(e); 528 + annotPointer = { kind: 'new', x0: p.x, y0: p.y, moved: false, strokeEl: null, strokePoints: null }; 529 + try { annotOverlayEl.setPointerCapture(e.pointerId); } catch {} 530 + e.stopPropagation(); e.preventDefault(); 531 + } 532 + 533 + function onAnnotMove(e) { 534 + if (!annotActive || !annotPointer) return; 535 + const p = localCoords(e); 536 + 537 + if (annotPointer.kind === 'pin') { 538 + const dx = p.x - annotPointer.startPointer.x; 539 + const dy = p.y - annotPointer.startPointer.y; 540 + if (!annotPointer.moved) { 541 + if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return; 542 + annotPointer.moved = true; 543 + } 544 + const pin = annotState.comments[annotPointer.idx]; 545 + if (!pin) { annotPointer = null; return; } 546 + pin.x = annotPointer.startPin.x + dx; 547 + pin.y = annotPointer.startPin.y + dy; 548 + renderAllPins(); 549 + e.stopPropagation(); 550 + return; 551 + } 552 + 553 + // kind === 'new' 554 + const dx = p.x - annotPointer.x0, dy = p.y - annotPointer.y0; 555 + if (!annotPointer.moved) { 556 + if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return; 557 + annotPointer.moved = true; 558 + const strokeEl = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 559 + strokeEl.setAttribute('stroke', C.brand); 560 + strokeEl.setAttribute('stroke-width', '3'); 561 + strokeEl.setAttribute('stroke-linecap', 'round'); 562 + strokeEl.setAttribute('stroke-linejoin', 'round'); 563 + strokeEl.setAttribute('fill', 'none'); 564 + strokeEl.setAttribute('pointer-events', 'none'); 565 + annotSvgEl.appendChild(strokeEl); 566 + annotPointer.strokeEl = strokeEl; 567 + annotPointer.strokePoints = [[annotPointer.x0, annotPointer.y0]]; 568 + } 569 + annotPointer.strokePoints.push([p.x, p.y]); 570 + annotPointer.strokeEl.setAttribute('d', pointsToPath(annotPointer.strokePoints)); 571 + e.stopPropagation(); 572 + } 573 + 574 + function onAnnotUp(e) { 575 + if (!annotActive || !annotPointer) return; 576 + 577 + if (annotPointer.kind === 'pin') { 578 + const wasDrag = annotPointer.moved; 579 + const idx = annotPointer.idx; 580 + try { annotOverlayEl.releasePointerCapture(e.pointerId); } catch {} 581 + annotPointer = null; 582 + if (wasDrag) { 583 + // A drag is an intentional reposition; a follow-up click shouldn't be 584 + // interpreted as a double-click-to-delete. 585 + annotLastPinClick = { idx: -1, time: 0 }; 586 + } else { 587 + beginEditPin(idx); 588 + } 589 + e.stopPropagation(); 590 + return; 591 + } 592 + 593 + // kind === 'new' 594 + const wasDrag = annotPointer.moved; 595 + if (wasDrag) { 596 + annotState.strokes.push({ points: annotPointer.strokePoints }); 597 + // Swap the temporary preview SVG path for the full render with hit paths. 598 + redrawStrokes(); 599 + } else { 600 + const idx = annotState.comments.length; 601 + annotState.comments.push({ x: annotPointer.x0, y: annotPointer.y0, text: '' }); 602 + renderAllPins(); 603 + beginEditPin(idx); 604 + } 605 + try { annotOverlayEl.releasePointerCapture(e.pointerId); } catch {} 606 + annotPointer = null; 607 + e.stopPropagation(); 608 + } 609 + 610 + function pointsToPath(points) { 611 + if (!points || points.length === 0) return ''; 612 + let d = 'M' + points[0][0].toFixed(1) + ' ' + points[0][1].toFixed(1); 613 + for (let i = 1; i < points.length; i++) { 614 + d += ' L' + points[i][0].toFixed(1) + ' ' + points[i][1].toFixed(1); 615 + } 616 + return d; 617 + } 618 + 619 + function renderAllPins() { 620 + annotPinsEl.innerHTML = ''; 621 + annotState.comments.forEach((c, idx) => { 622 + annotPinsEl.appendChild(buildPinElement(c, idx)); 623 + }); 624 + updateClearChip(); 625 + } 626 + 627 + function buildPinElement(comment, idx) { 628 + const interactive = idx >= 0; 629 + const wrap = document.createElement('div'); 630 + if (interactive) wrap.dataset.annotPin = String(idx); 631 + Object.assign(wrap.style, { 632 + position: 'absolute', 633 + left: (comment.x - 7) + 'px', top: (comment.y - 7) + 'px', 634 + pointerEvents: interactive ? 'auto' : 'none', 635 + display: 'flex', alignItems: 'flex-start', gap: '6px', 636 + cursor: interactive ? 'grab' : 'default', 637 + touchAction: 'none', 638 + }); 639 + const dot = document.createElement('div'); 640 + Object.assign(dot.style, { 641 + width: '14px', height: '14px', borderRadius: '50%', 642 + background: C.brand, border: '2px solid ' + C.white, 643 + boxShadow: '0 1px 3px rgba(0,0,0,0.25)', 644 + flexShrink: '0', 645 + }); 646 + wrap.appendChild(dot); 647 + 648 + if (comment.text) { 649 + const bubble = document.createElement('div'); 650 + bubble.textContent = comment.text; 651 + Object.assign(bubble.style, { 652 + background: C.ink, color: C.white, 653 + fontFamily: FONT, fontSize: '12px', lineHeight: '1.4', 654 + padding: '4px 8px', borderRadius: '3px', 655 + marginTop: '-2px', maxWidth: '220px', 656 + pointerEvents: 'none', whiteSpace: 'pre-wrap', 657 + wordBreak: 'break-word', 658 + }); 659 + wrap.appendChild(bubble); 660 + } 661 + return wrap; 662 + } 663 + 664 + function beginEditPin(idx) { 665 + const wrapEl = annotPinsEl.querySelector('[data-annot-pin="' + idx + '"]'); 666 + if (!wrapEl) return; 667 + // Strip any existing bubble (but keep the dot) 668 + wrapEl.querySelectorAll('div:not(:first-child)').forEach(n => n.remove()); 669 + const input = document.createElement('input'); 670 + input.type = 'text'; 671 + input.placeholder = 'Note…'; 672 + Object.assign(input.style, { 673 + background: C.ink, color: C.white, 674 + fontFamily: FONT, fontSize: '12px', lineHeight: '1.4', 675 + padding: '4px 8px', borderRadius: '3px', 676 + border: '1px solid ' + C.brand, 677 + outline: 'none', marginTop: '-2px', 678 + width: '220px', pointerEvents: 'auto', 679 + }); 680 + const originalText = annotState.comments[idx].text || ''; 681 + input.value = originalText; 682 + wrapEl.appendChild(input); 683 + annotEditing = { idx, input, wrapEl, originalText }; 684 + input.addEventListener('keydown', onAnnotInputKey, true); 685 + input.addEventListener('blur', () => { 686 + // Fires on both focus-loss and programmatic blur; commit unless we 687 + // already handled it. 688 + if (annotEditing && annotEditing.input === input) finalizeEditingPin(); 689 + }); 690 + // Stop clicks/pointerdowns inside the input from bubbling to the overlay 691 + ['pointerdown', 'click'].forEach(ev => { 692 + input.addEventListener(ev, e => e.stopPropagation()); 693 + }); 694 + setTimeout(() => input.focus(), 0); 695 + } 696 + 697 + function onAnnotInputKey(e) { 698 + if (e.key === 'Enter') { 699 + e.preventDefault(); e.stopPropagation(); 700 + finalizeEditingPin(); 701 + } else if (e.key === 'Escape') { 702 + e.preventDefault(); e.stopPropagation(); 703 + cancelEditingPin(); 704 + } else { 705 + // Keep arrows / backspace from hitting global handlers 706 + e.stopPropagation(); 707 + } 708 + } 709 + 710 + function finalizeEditingPin() { 711 + if (!annotEditing) return; 712 + const { idx, input } = annotEditing; 713 + const text = input.value.trim(); 714 + annotEditing = null; 715 + if (text) annotState.comments[idx].text = text; 716 + else annotState.comments.splice(idx, 1); 717 + renderAllPins(); 718 + } 719 + 720 + function cancelEditingPin() { 721 + if (!annotEditing) return; 722 + const { idx, originalText } = annotEditing; 723 + annotEditing = null; 724 + // If the pin had text before this edit, revert to it. If it was a 725 + // just-created empty pin, Escape removes it. 726 + if (originalText) { 727 + annotState.comments[idx].text = originalText; 728 + } else { 729 + annotState.comments.splice(idx, 1); 730 + } 731 + renderAllPins(); 732 + } 733 + 734 + // Build a detached annotation subtree suitable for injection into the clone 735 + // modern-screenshot creates. Coordinates are element-local so this slots 736 + // straight into an element that's been made position:relative. Takes an 737 + // explicit snapshot so it works after annotState has been cleared. 738 + function buildAnnotationsForCapture(rect, snapshot) { 739 + const comments = snapshot ? snapshot.comments : annotState.comments; 740 + const strokes = snapshot ? snapshot.strokes : annotState.strokes; 741 + if (comments.length === 0 && strokes.length === 0) return null; 742 + const wrap = document.createElement('div'); 743 + Object.assign(wrap.style, { 744 + position: 'absolute', top: '0', left: '0', 745 + width: rect.width + 'px', height: rect.height + 'px', 746 + pointerEvents: 'none', overflow: 'visible', 747 + }); 748 + if (strokes.length > 0) { 749 + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); 750 + svg.setAttribute('viewBox', '0 0 ' + rect.width + ' ' + rect.height); 751 + Object.assign(svg.style, { 752 + position: 'absolute', top: '0', left: '0', 753 + width: '100%', height: '100%', overflow: 'visible', 754 + }); 755 + for (const s of strokes) { 756 + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 757 + path.setAttribute('stroke', C.brand); 758 + path.setAttribute('stroke-width', '3'); 759 + path.setAttribute('stroke-linecap', 'round'); 760 + path.setAttribute('stroke-linejoin', 'round'); 761 + path.setAttribute('fill', 'none'); 762 + path.setAttribute('d', pointsToPath(s.points)); 763 + svg.appendChild(path); 764 + } 765 + wrap.appendChild(svg); 766 + } 767 + for (const c of comments) { 768 + // idx=-1 means non-interactive; pointerEvents stay off in the clone 769 + wrap.appendChild(buildPinElement(c, -1)); 770 + } 771 + return wrap; 772 + } 773 + 774 + // --------------------------------------------------------------------------- 775 + // Element context extraction 776 + // --------------------------------------------------------------------------- 777 + 778 + function extractContext(el) { 779 + const cs = getComputedStyle(el); 780 + const r = el.getBoundingClientRect(); 781 + const props = {}; 782 + for (const sheet of document.styleSheets) { 783 + try { 784 + for (const rule of sheet.cssRules) { 785 + if (rule.style) for (let i = 0; i < rule.style.length; i++) { 786 + const p = rule.style[i]; 787 + if (p.startsWith('--') && !props[p]) { 788 + const v = cs.getPropertyValue(p).trim(); 789 + if (v) props[p] = v; 790 + } 791 + } 792 + } 793 + } catch { /* cross-origin */ } 794 + } 795 + return { 796 + tagName: el.tagName.toLowerCase(), id: el.id || null, 797 + classes: [...el.classList], 798 + textContent: (el.textContent || '').slice(0, 500), 799 + outerHTML: el.outerHTML.slice(0, 10000), 800 + computedStyles: { 801 + 'font-family': cs.fontFamily, 'font-size': cs.fontSize, 802 + 'font-weight': cs.fontWeight, 'line-height': cs.lineHeight, 803 + 'color': cs.color, 'background': cs.background, 804 + 'background-color': cs.backgroundColor, 805 + 'padding': cs.padding, 'margin': cs.margin, 806 + 'display': cs.display, 'position': cs.position, 807 + 'gap': cs.gap, 'border-radius': cs.borderRadius, 808 + 'box-shadow': cs.boxShadow, 809 + }, 810 + cssCustomProperties: props, 811 + parentContext: el.parentElement 812 + ? '<' + el.parentElement.tagName.toLowerCase() 813 + + (el.parentElement.id ? ' id="' + el.parentElement.id + '"' : '') 814 + + (el.parentElement.className ? ' class="' + el.parentElement.className + '"' : '') 815 + + '>' 816 + : null, 817 + boundingRect: { width: Math.round(r.width), height: Math.round(r.height) }, 818 + }; 819 + } 820 + 821 + // --------------------------------------------------------------------------- 822 + // The Bar — one floating element, three modes 823 + // --------------------------------------------------------------------------- 824 + 825 + // Contextual-bar palette. Cached at init so every build*Row reads a 826 + // consistent set of colors; detectPageTheme runs once rather than on every 827 + // phase transition. 828 + let BP = null; 829 + 830 + // Bar shadow variants. The default projects down + subtle around. When 831 + // the Tune popover opens below the bar, a downward shadow lands on the 832 + // dark popover and reads as a bright ghost line. We swap to UP-only while 833 + // tune is open below so the popover's top edge is clean. 834 + const BAR_SHADOW_DEFAULT = '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)'; 835 + const BAR_SHADOW_UP = '0 -4px 20px oklch(0% 0 0 / 0.08), 0 -1px 3px oklch(0% 0 0 / 0.06)'; 836 + const BAR_SHADOW_DOWN = BAR_SHADOW_DEFAULT; 837 + 838 + function initBar() { 839 + BP = barPaletteForTheme(detectPageTheme()); 840 + barEl = document.createElement('div'); 841 + barEl.id = PREFIX + '-bar'; 842 + Object.assign(barEl.style, { 843 + position: 'fixed', zIndex: Z.bar, 844 + display: 'none', opacity: '0', 845 + transform: 'translateY(6px)', 846 + transition: 'opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE, 847 + background: BP.surface, 848 + backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)', 849 + border: '1px solid ' + BP.hairline, 850 + borderRadius: '10px', 851 + boxShadow: BAR_SHADOW_DEFAULT, 852 + transition: 'box-shadow 0.2s ease, opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE, 853 + fontFamily: FONT, fontSize: '13px', color: BP.text, 854 + padding: '6px', 855 + maxWidth: '520px', minWidth: '320px', 856 + }); 857 + document.body.appendChild(barEl); 858 + defangOutsideHandlers(barEl); 859 + } 860 + 861 + function positionBar() { 862 + if (!barEl || !selectedElement) return; 863 + const r = selectedElement.getBoundingClientRect(); 864 + const barH = barEl.offsetHeight || 44; 865 + const barW = barEl.offsetWidth || 380; 866 + const GLOBAL_BAR_RESERVE = 64; // global bar height + bottom margin + breathing room 867 + const GAP = 8; 868 + 869 + // Prefer below the element; fall back to above; if neither fits (element 870 + // taller than viewport), pin to a stable viewport anchor so the bar 871 + // doesn't teleport between top and bottom as the user scrolls. 872 + let top; 873 + const belowTop = r.bottom + GAP; 874 + const aboveTop = r.top - barH - GAP; 875 + if (belowTop + barH + GAP <= window.innerHeight - GLOBAL_BAR_RESERVE) { 876 + top = belowTop; 877 + } else if (aboveTop >= GAP) { 878 + top = aboveTop; 879 + } else { 880 + top = window.innerHeight - barH - GLOBAL_BAR_RESERVE; 881 + } 882 + 883 + let left = r.left + (r.width - barW) / 2; 884 + if (left < GAP) left = GAP; 885 + if (left + barW > window.innerWidth - GAP) left = window.innerWidth - barW - GAP; 886 + Object.assign(barEl.style, { top: top + 'px', left: left + 'px' }); 887 + } 888 + 889 + function showBar(mode) { 890 + barEl.innerHTML = ''; 891 + if (mode === 'configure') barEl.appendChild(buildConfigureRow()); 892 + else if (mode === 'generating') barEl.appendChild(buildGeneratingRow()); 893 + else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); 894 + barEl.style.display = 'block'; 895 + positionBar(); 896 + requestAnimationFrame(() => { 897 + barEl.style.opacity = '1'; 898 + barEl.style.transform = 'translateY(0)'; 899 + }); 900 + } 901 + 902 + function hideBar() { 903 + if (!barEl) return; 904 + barEl.style.opacity = '0'; 905 + barEl.style.transform = 'translateY(6px)'; 906 + setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250); 907 + hideActionPicker(); 908 + closeTunePopover(); 909 + } 910 + 911 + function updateBarContent(mode) { 912 + if (!barEl || barEl.style.display === 'none') return; 913 + barEl.innerHTML = ''; 914 + // Reset bar styling to the theme-aware palette 915 + barEl.style.background = BP.surface; 916 + barEl.style.border = '1px solid ' + BP.hairline; 917 + if (mode === 'configure') barEl.appendChild(buildConfigureRow()); 918 + else if (mode === 'generating') barEl.appendChild(buildGeneratingRow()); 919 + else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); 920 + else if (mode === 'saving') barEl.appendChild(buildSavingRow()); 921 + else if (mode === 'confirmed') { 922 + barEl.appendChild(buildConfirmedRow()); 923 + barEl.style.background = 'oklch(95% 0.05 145)'; 924 + barEl.style.border = '1px solid oklch(75% 0.12 145 / 0.4)'; 925 + } 926 + } 927 + 928 + // --- Configure row --- 929 + 930 + function buildConfigureRow() { 931 + const row = el('div', { 932 + display: 'flex', alignItems: 'center', gap: '4px', 933 + }); 934 + 935 + // Action pill 936 + const pill = el('button', { 937 + display: 'inline-flex', alignItems: 'center', gap: '4px', 938 + padding: '5px 10px', borderRadius: '6px', 939 + background: BP.mark, color: BP.markText, 940 + fontFamily: FONT, fontSize: '12px', fontWeight: '500', 941 + border: 'none', cursor: 'pointer', 942 + transition: 'background 0.12s ease, transform 0.1s ease', 943 + whiteSpace: 'nowrap', flexShrink: '0', 944 + }); 945 + pill.textContent = actionLabel() + ' \u25BE'; 946 + pill.addEventListener('mouseenter', () => pill.style.background = BP.accent); 947 + pill.addEventListener('mouseleave', () => pill.style.background = BP.mark); 948 + pill.addEventListener('mousedown', () => pill.style.transform = 'scale(0.97)'); 949 + pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)'); 950 + pill.addEventListener('click', (e) => { e.stopPropagation(); toggleActionPicker(); }); 951 + row.appendChild(pill); 952 + 953 + // Freeform input. Focus state shows an accent-colored border only — 954 + // an earlier version tinted the background with `BP.accentSoft`, which 955 + // composited against the dark bar surface to a murky purple where the 956 + // browser's default placeholder gray was unreadable. Placeholder color 957 + // is set explicitly via a one-shot stylesheet keyed off this input's id 958 + // so it picks up the bar's `textDim` token in both themes. 959 + const input = document.createElement('input'); 960 + input.id = PREFIX + '-input'; 961 + input.type = 'text'; 962 + input.placeholder = selectedAction === 'impeccable' ? 'describe what you want...' : 'refine further (optional)...'; 963 + Object.assign(input.style, { 964 + flex: '1', minWidth: '0', 965 + padding: '5px 8px', borderRadius: '6px', 966 + border: '1px solid transparent', background: 'transparent', 967 + fontFamily: FONT, fontSize: '12px', color: BP.text, 968 + outline: 'none', 969 + transition: 'border-color 0.15s ease', 970 + }); 971 + if (!document.getElementById(PREFIX + '-input-style')) { 972 + const s = document.createElement('style'); 973 + s.id = PREFIX + '-input-style'; 974 + s.textContent = 975 + '#' + PREFIX + '-input::placeholder { color: ' + BP.textDim + '; opacity: 1; }'; 976 + document.head.appendChild(s); 977 + } 978 + input.addEventListener('focus', () => { 979 + input.style.borderColor = BP.accent; 980 + }); 981 + input.addEventListener('blur', () => { 982 + input.style.borderColor = 'transparent'; 983 + }); 984 + input.addEventListener('keydown', (e) => { 985 + if (e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); handleGo(); return; } 986 + if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); input.blur(); hideBar(); state = 'PICKING'; return; } 987 + // Let arrow keys pass through to the element picker when the input is empty 988 + if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && !input.value) return; 989 + e.stopPropagation(); 990 + }); 991 + row.appendChild(input); 992 + 993 + // Variant count toggle 994 + const count = el('button', { 995 + padding: '4px 6px', borderRadius: '5px', 996 + border: '1px solid ' + BP.hairline, background: 'transparent', 997 + fontFamily: MONO, fontSize: '11px', fontWeight: '600', 998 + color: BP.textDim, cursor: 'pointer', 999 + transition: 'color 0.12s ease, border-color 0.12s ease', 1000 + flexShrink: '0', whiteSpace: 'nowrap', 1001 + }); 1002 + count.textContent = '\u00D7' + selectedCount; 1003 + count.title = 'Variants: click to change'; 1004 + count.addEventListener('mouseenter', () => { count.style.color = BP.text; count.style.borderColor = BP.text; }); 1005 + count.addEventListener('mouseleave', () => { count.style.color = BP.textDim; count.style.borderColor = BP.hairline; }); 1006 + count.addEventListener('click', (e) => { 1007 + e.stopPropagation(); 1008 + selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1; 1009 + count.textContent = '\u00D7' + selectedCount; 1010 + }); 1011 + row.appendChild(count); 1012 + 1013 + // Go button 1014 + const go = el('button', { 1015 + padding: '5px 12px', borderRadius: '6px', 1016 + border: 'none', background: BP.accent, color: BP.mark, 1017 + fontFamily: FONT, fontSize: '12px', fontWeight: '600', 1018 + cursor: 'pointer', 1019 + transition: 'filter 0.12s ease, transform 0.1s ease', 1020 + flexShrink: '0', whiteSpace: 'nowrap', 1021 + }); 1022 + go.textContent = 'Go \u2192'; 1023 + go.addEventListener('mouseenter', () => go.style.filter = 'brightness(1.1)'); 1024 + go.addEventListener('mouseleave', () => go.style.filter = 'none'); 1025 + go.addEventListener('mousedown', () => go.style.transform = 'scale(0.97)'); 1026 + go.addEventListener('mouseup', () => go.style.transform = 'scale(1)'); 1027 + go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); }); 1028 + row.appendChild(go); 1029 + 1030 + // Auto-focus input after a beat 1031 + setTimeout(() => input.focus(), 60); 1032 + return row; 1033 + } 1034 + 1035 + // --- Generating row --- 1036 + 1037 + function buildGeneratingRow() { 1038 + const row = el('div', { 1039 + display: 'flex', alignItems: 'center', gap: '8px', 1040 + padding: '2px 4px', 1041 + }); 1042 + 1043 + // Action label 1044 + const label = el('span', { 1045 + fontWeight: '600', fontSize: '12px', color: BP.text, 1046 + flexShrink: '0', whiteSpace: 'nowrap', 1047 + }); 1048 + label.textContent = actionLabel(); 1049 + row.appendChild(label); 1050 + 1051 + // Dots 1052 + row.appendChild(buildDots(false)); 1053 + 1054 + // Status 1055 + const status = el('span', { 1056 + fontSize: '11px', color: BP.textDim, whiteSpace: 'nowrap', 1057 + marginLeft: 'auto', 1058 + }); 1059 + // Variants currently arrive atomically in a single file edit, so a 1060 + // per-variant counter would lie. Say what's true. 1061 + status.textContent = arrivedVariants < expectedVariants 1062 + ? 'Generating ' + expectedVariants + ' variants...' 1063 + : 'Done'; 1064 + row.appendChild(status); 1065 + 1066 + return row; 1067 + } 1068 + 1069 + // --- Cycling row --- 1070 + 1071 + 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>'; 1072 + 1073 + function buildCyclingRow() { 1074 + const row = el('div', { 1075 + display: 'flex', alignItems: 'center', gap: '6px', 1076 + padding: '1px 2px', 1077 + }); 1078 + 1079 + // Prev 1080 + const prev = navBtn('\u2190'); 1081 + prev.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(-1); }); 1082 + if (visibleVariant <= 1) prev.style.opacity = '0.3'; 1083 + row.appendChild(prev); 1084 + 1085 + // Dots (clickable) 1086 + row.appendChild(buildDots(true)); 1087 + 1088 + // Counter 1089 + const counter = el('span', { 1090 + fontFamily: MONO, fontSize: '11px', fontWeight: '500', 1091 + color: BP.textDim, minWidth: '24px', textAlign: 'center', 1092 + }); 1093 + counter.textContent = visibleVariant + '/' + arrivedVariants; 1094 + row.appendChild(counter); 1095 + 1096 + // Next 1097 + const next = navBtn('\u2192'); 1098 + next.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(1); }); 1099 + if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3'; 1100 + row.appendChild(next); 1101 + 1102 + // Tune chip — only when the visible variant exposes params 1103 + const visParams = parseVariantParams(getVisibleVariantEl()); 1104 + const hasParams = visParams.length > 0; 1105 + if (hasParams) { 1106 + const tune = el('button', { 1107 + display: 'inline-flex', alignItems: 'center', gap: '6px', 1108 + padding: '4px 10px', borderRadius: '5px', 1109 + border: '1px solid transparent', 1110 + background: tuneOpen ? BP.accentSoft : 'transparent', 1111 + color: tuneOpen ? BP.accent : BP.text, 1112 + fontFamily: FONT, fontSize: '11px', fontWeight: '500', 1113 + cursor: 'pointer', 1114 + transition: 'color 0.12s ease, background 0.12s ease', 1115 + whiteSpace: 'nowrap', 1116 + }); 1117 + tune.innerHTML = TUNE_ICON_SVG; 1118 + const tuneLabel = document.createElement('span'); 1119 + tuneLabel.textContent = 'Tune'; 1120 + tune.appendChild(tuneLabel); 1121 + const tuneBadge = document.createElement('span'); 1122 + Object.assign(tuneBadge.style, { 1123 + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', 1124 + minWidth: '16px', height: '16px', padding: '0 4px', 1125 + borderRadius: '999px', 1126 + background: tuneOpen ? C.brand : BP.hairline, 1127 + color: tuneOpen ? 'oklch(98% 0 0)' : 'inherit', 1128 + fontFamily: MONO, fontSize: '9.5px', fontWeight: '600', 1129 + lineHeight: '1', 1130 + boxSizing: 'border-box', 1131 + }); 1132 + tuneBadge.textContent = String(visParams.length); 1133 + tune.appendChild(tuneBadge); 1134 + tune.title = 'Tune this variant (' + visParams.length + ' knob' + (visParams.length === 1 ? '' : 's') + ')'; 1135 + tune.addEventListener('mouseenter', () => { 1136 + if (!tuneOpen) tune.style.background = BP.accentSoft; 1137 + }); 1138 + tune.addEventListener('mouseleave', () => { 1139 + if (!tuneOpen) tune.style.background = 'transparent'; 1140 + }); 1141 + tune.addEventListener('click', (e) => { e.stopPropagation(); toggleTunePopover(); }); 1142 + tune.dataset.iceqTune = '1'; 1143 + row.appendChild(tune); 1144 + } 1145 + 1146 + // Spacer 1147 + row.appendChild(el('div', { flex: '1' })); 1148 + 1149 + // Accept — primary action, uses the site's saturated brand magenta 1150 + // with paper-white text, not the theme-muted BP.accent. 1151 + const accept = el('button', { 1152 + padding: '5px 14px', borderRadius: '5px', 1153 + border: 'none', background: C.brand, color: 'oklch(98% 0 0)', 1154 + fontFamily: FONT, fontSize: '11px', fontWeight: '600', 1155 + cursor: 'pointer', transition: 'filter 0.12s ease, transform 0.1s ease', 1156 + whiteSpace: 'nowrap', 1157 + }); 1158 + accept.textContent = '\u2713 Accept'; 1159 + accept.addEventListener('mouseenter', () => accept.style.filter = 'brightness(1.08)'); 1160 + accept.addEventListener('mouseleave', () => accept.style.filter = 'none'); 1161 + accept.addEventListener('mousedown', () => accept.style.transform = 'scale(0.97)'); 1162 + accept.addEventListener('mouseup', () => accept.style.transform = 'scale(1)'); 1163 + accept.addEventListener('click', (e) => { e.stopPropagation(); handleAccept(); }); 1164 + if (arrivedVariants === 0) { accept.style.opacity = '0.3'; accept.style.pointerEvents = 'none'; } 1165 + row.appendChild(accept); 1166 + 1167 + // Discard 1168 + const discard = el('button', { 1169 + padding: '4px 6px', borderRadius: '5px', 1170 + border: '1px solid ' + BP.hairline, background: 'transparent', 1171 + fontFamily: FONT, fontSize: '11px', color: BP.textDim, 1172 + cursor: 'pointer', transition: 'color 0.12s ease, border-color 0.12s ease', 1173 + }); 1174 + discard.textContent = '\u2715'; 1175 + discard.title = 'Discard all variants'; 1176 + discard.addEventListener('mouseenter', () => { discard.style.color = BP.text; discard.style.borderColor = BP.text; }); 1177 + discard.addEventListener('mouseleave', () => { discard.style.color = BP.textDim; discard.style.borderColor = BP.hairline; }); 1178 + discard.addEventListener('click', (e) => { e.stopPropagation(); handleDiscard(); }); 1179 + row.appendChild(discard); 1180 + 1181 + return row; 1182 + } 1183 + 1184 + // --- Shared UI builders --- 1185 + 1186 + // --- Saving row (waiting for agent to process accept/discard) --- 1187 + 1188 + function buildSavingRow() { 1189 + const row = el('div', { 1190 + display: 'flex', alignItems: 'center', gap: '8px', 1191 + padding: '2px 8px', 1192 + }); 1193 + const spinner = el('div', { 1194 + width: '14px', height: '14px', borderRadius: '50%', 1195 + border: '2px solid ' + BP.hairline, 1196 + borderTopColor: BP.accent, 1197 + animation: 'impeccable-spin 0.6s linear infinite', 1198 + flexShrink: '0', 1199 + }); 1200 + row.appendChild(spinner); 1201 + const label = el('span', { 1202 + fontSize: '12px', color: BP.textDim, fontWeight: '500', 1203 + }); 1204 + label.textContent = 'Applying variant...'; 1205 + row.appendChild(label); 1206 + 1207 + // Inject the keyframes if not already present 1208 + if (!document.getElementById(PREFIX + '-keyframes')) { 1209 + const style = document.createElement('style'); 1210 + style.id = PREFIX + '-keyframes'; 1211 + style.textContent = '@keyframes impeccable-spin { to { transform: rotate(360deg); } }'; 1212 + document.head.appendChild(style); 1213 + } 1214 + return row; 1215 + } 1216 + 1217 + // --- Confirmed row (green success, auto-dismisses) --- 1218 + 1219 + function buildConfirmedRow() { 1220 + const row = el('div', { 1221 + display: 'flex', alignItems: 'center', gap: '8px', 1222 + padding: '2px 8px', 1223 + }); 1224 + const check = el('span', { 1225 + fontSize: '15px', lineHeight: '1', flexShrink: '0', 1226 + color: 'oklch(45% 0.15 145)', 1227 + }); 1228 + check.textContent = '\u2713'; 1229 + row.appendChild(check); 1230 + const label = el('span', { 1231 + fontSize: '12px', color: 'oklch(35% 0.1 145)', fontWeight: '600', 1232 + }); 1233 + label.textContent = 'Variant applied'; 1234 + row.appendChild(label); 1235 + return row; 1236 + } 1237 + 1238 + // --- Shared UI builders --- 1239 + 1240 + function buildDots(clickable) { 1241 + const container = el('div', { 1242 + display: 'flex', alignItems: 'center', gap: '4px', 1243 + }); 1244 + for (let i = 1; i <= expectedVariants; i++) { 1245 + const arrived = i <= arrivedVariants; 1246 + const active = i === visibleVariant; 1247 + // active: solid site-brand magenta dot. arrived+inactive: muted neutral. 1248 + // pending (not yet arrived): faint outline ring. No borders on arrived 1249 + // dots — the previous "accent ring + ash fill" combo read as noisy 1250 + // magenta chips, especially when all variants had arrived and every 1251 + // dot wore an accent ring. 1252 + const dotBg = active ? C.brand 1253 + : arrived ? BP.textDim 1254 + : 'transparent'; 1255 + const dotBorder = arrived ? 'none' : '1.5px solid ' + BP.hairline; 1256 + const dot = el('div', { 1257 + width: active ? '8px' : '6px', 1258 + height: active ? '8px' : '6px', 1259 + borderRadius: '50%', 1260 + background: dotBg, 1261 + border: dotBorder, 1262 + boxSizing: 'border-box', 1263 + transition: 'all 0.2s ' + EASE, 1264 + cursor: (clickable && arrived) ? 'pointer' : 'default', 1265 + transform: arrived ? 'scale(1)' : 'scale(0.85)', 1266 + opacity: arrived ? (active ? '1' : '0.6') : '0.4', 1267 + }); 1268 + if (clickable && arrived) { 1269 + const idx = i; 1270 + dot.addEventListener('click', (e) => { 1271 + e.stopPropagation(); 1272 + visibleVariant = idx; 1273 + showVariantInDOM(currentSessionId, idx); 1274 + updateSelectedElement(); 1275 + updateBarContent('cycling'); 1276 + }); 1277 + } 1278 + container.appendChild(dot); 1279 + } 1280 + return container; 1281 + } 1282 + 1283 + function navBtn(text) { 1284 + const b = el('button', { 1285 + width: '26px', height: '26px', borderRadius: '5px', 1286 + border: '1px solid ' + BP.hairline, background: 'transparent', 1287 + color: BP.text, fontFamily: FONT, fontSize: '13px', 1288 + cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', 1289 + transition: 'border-color 0.12s ease, background 0.12s ease', 1290 + padding: '0', lineHeight: '1', 1291 + }); 1292 + b.textContent = text; 1293 + b.addEventListener('mouseenter', () => { b.style.borderColor = BP.text; }); 1294 + b.addEventListener('mouseleave', () => { b.style.borderColor = BP.hairline; }); 1295 + return b; 1296 + } 1297 + 1298 + function actionLabel() { 1299 + const a = ACTIONS.find(a => a.value === selectedAction); 1300 + return a ? a.label : 'Freeform'; 1301 + } 1302 + 1303 + function el(tag, styles) { 1304 + const e = document.createElement(tag); 1305 + if (styles) Object.assign(e.style, styles); 1306 + return e; 1307 + } 1308 + 1309 + // --------------------------------------------------------------------------- 1310 + // Action picker popover 1311 + // --------------------------------------------------------------------------- 1312 + 1313 + function initActionPicker() { 1314 + const P = barPaletteForTheme(detectPageTheme()); 1315 + pickerEl = document.createElement('div'); 1316 + pickerEl.id = PREFIX + '-picker'; 1317 + Object.assign(pickerEl.style, { 1318 + position: 'fixed', zIndex: Z.picker, 1319 + display: 'none', opacity: '0', 1320 + transform: 'scale(0.96) translateY(4px)', 1321 + transformOrigin: 'bottom left', 1322 + transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE, 1323 + background: P.surface, 1324 + border: '1px solid ' + P.hairline, 1325 + borderRadius: '10px', 1326 + boxShadow: '0 8px 30px oklch(0% 0 0 / 0.10), 0 2px 6px oklch(0% 0 0 / 0.06)', 1327 + padding: '6px', 1328 + fontFamily: FONT, 1329 + backdropFilter: 'blur(10px)', 1330 + WebkitBackdropFilter: 'blur(10px)', 1331 + }); 1332 + 1333 + // Build the chip grid 1334 + const grid = el('div', { 1335 + display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '3px', 1336 + }); 1337 + 1338 + ACTIONS.forEach(action => { 1339 + const chip = el('button', { 1340 + display: 'flex', flexDirection: 'column', alignItems: 'center', 1341 + gap: '4px', 1342 + padding: '8px 6px', borderRadius: '6px', 1343 + border: 'none', 1344 + background: action.value === selectedAction ? P.accentSoft : 'transparent', 1345 + color: action.value === selectedAction ? P.accent : P.text, 1346 + fontFamily: FONT, fontSize: '11px', fontWeight: '500', 1347 + cursor: 'pointer', 1348 + transition: 'background 0.1s ease, color 0.1s ease', 1349 + textAlign: 'center', whiteSpace: 'nowrap', 1350 + }); 1351 + const iconWrap = el('span', { 1352 + display: 'flex', alignItems: 'center', justifyContent: 'center', 1353 + height: '20px', opacity: '0.9', 1354 + }); 1355 + iconWrap.innerHTML = ICONS[action.value] || ''; 1356 + const labelEl = el('span', { lineHeight: '1' }); 1357 + labelEl.textContent = action.label; 1358 + chip.appendChild(iconWrap); 1359 + chip.appendChild(labelEl); 1360 + chip.dataset.action = action.value; 1361 + chip.addEventListener('mouseenter', () => { 1362 + if (action.value !== selectedAction) chip.style.background = P.accentSoft; 1363 + }); 1364 + chip.addEventListener('mouseleave', () => { 1365 + chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent'; 1366 + }); 1367 + chip.addEventListener('click', (e) => { 1368 + e.stopPropagation(); 1369 + selectedAction = action.value; 1370 + hideActionPicker(); 1371 + updateBarContent('configure'); 1372 + }); 1373 + grid.appendChild(chip); 1374 + }); 1375 + 1376 + pickerEl.appendChild(grid); 1377 + document.body.appendChild(pickerEl); 1378 + defangOutsideHandlers(pickerEl); 1379 + 1380 + // Cache the palette on the picker so toggleActionPicker's state refresh 1381 + // uses the same theme-aware colors when it repaints chips. 1382 + pickerEl.__iceq_palette = P; 1383 + } 1384 + 1385 + function toggleActionPicker() { 1386 + if (pickerEl.style.display !== 'none') { hideActionPicker(); return; } 1387 + // Rebuild chips to reflect current selection 1388 + const P = pickerEl.__iceq_palette || barPaletteForTheme(detectPageTheme()); 1389 + pickerEl.querySelectorAll('button').forEach(chip => { 1390 + const isActive = chip.dataset.action === selectedAction; 1391 + chip.style.background = isActive ? P.accentSoft : 'transparent'; 1392 + chip.style.color = isActive ? P.accent : P.text; 1393 + }); 1394 + // Position above the bar 1395 + const barRect = barEl.getBoundingClientRect(); 1396 + const pickerH = 170; // approximate; grows with icon + label rows 1397 + let top = barRect.top - pickerH - 6; 1398 + if (top < 8) top = barRect.bottom + 6; 1399 + Object.assign(pickerEl.style, { 1400 + top: top + 'px', left: barRect.left + 'px', 1401 + display: 'block', 1402 + }); 1403 + requestAnimationFrame(() => { 1404 + pickerEl.style.opacity = '1'; 1405 + pickerEl.style.transform = 'scale(1) translateY(0)'; 1406 + }); 1407 + } 1408 + 1409 + function hideActionPicker() { 1410 + if (!pickerEl) return; 1411 + pickerEl.style.opacity = '0'; 1412 + pickerEl.style.transform = 'scale(0.96) translateY(4px)'; 1413 + setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180); 1414 + } 1415 + 1416 + // --------------------------------------------------------------------------- 1417 + // Params panel (per-variant coarse controls) 1418 + // 1419 + // Variants may declare a parameter manifest via a JSON attribute on the 1420 + // variant wrapper: 1421 + // 1422 + // <div data-impeccable-variant="1" 1423 + // data-impeccable-params='[{"id":"density","kind":"steps",...}]'> 1424 + // 1425 + // The panel docks to the right edge of the outline during CYCLING and 1426 + // exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped 1427 + // CSS can respond instantly without regeneration: 1428 + // 1429 + // range / numeric toggle → CSS var (`--p-<id>`) used via var(--p-foo, N) 1430 + // steps / boolean toggle → data-p-<id> attribute used via :scope[data-p-foo="..."] 1431 + // 1432 + // On variant switch, values reset to that variant's declared defaults. 1433 + // On accept, current values are sent in the event payload so the agent 1434 + // can bake them into the source-file write. 1435 + // --------------------------------------------------------------------------- 1436 + 1437 + let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide) 1438 + let paramsPanelInner = null; // translating content (carries bg, padding, knobs) 1439 + let paramsPanelBody = null; // grid holding the knob cells 1440 + let paramsCurrentValues = {}; // {paramId: value} — mirror of the visible variant's live values 1441 + let tuneOpen = false; // whether the Tune popover is open right now 1442 + 1443 + // Theme-aware Tune popover. Appears as a drawer that slides out from the 1444 + // contextual bar's bar-facing edge (below if the bar sits below the 1445 + // element, above otherwise). Same width as the bar. Auto-wraps to extra 1446 + // rows when the knobs exceed one row. The bar's border-radius on the 1447 + // popover side goes flat while open so the two shapes read as one. 1448 + let paramsPanelPalette = null; 1449 + 1450 + function initParamsPanel() { 1451 + paramsPanelPalette = barPaletteForTheme(detectPageTheme()); 1452 + const P = paramsPanelPalette; 1453 + 1454 + // Single element, always in the DOM. The slide animation is a CSS mask 1455 + // with mask-size growing from 0% to 100% along the bar-facing axis — no 1456 + // display toggle, no opacity toggle, no transform trickery. The mask 1457 + // hides everything initially; as it grows, content is revealed from 1458 + // the bar edge outward. 1459 + paramsPanelEl = document.createElement('div'); 1460 + paramsPanelEl.id = PREFIX + '-params-panel'; 1461 + Object.assign(paramsPanelEl.style, { 1462 + position: 'fixed', zIndex: String(Z.bar - 1), 1463 + background: P.surfaceDeep, 1464 + color: P.text, 1465 + fontFamily: FONT, 1466 + padding: '14px 18px', 1467 + boxSizing: 'border-box', 1468 + borderRadius: '0 0 10px 10px', 1469 + pointerEvents: 'none', 1470 + backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)', 1471 + 1472 + // clip-path is the same conceptual reveal as mask but with rock-solid 1473 + // transition support across engines. Closed state clips from the far 1474 + // edge; open = inset(0) shows everything. 1475 + clipPath: 'inset(0 0 100% 0)', 1476 + transition: 'clip-path 0.44s ' + EASE, 1477 + 1478 + // Park off-screen until positionParamsPanel places it. These are NOT 1479 + // in the transition list, so they snap instantly — no fly-in from the 1480 + // top-left when first shown. 1481 + top: '-9999px', left: '-9999px', width: '0', 1482 + }); 1483 + 1484 + paramsPanelBody = el('div', { 1485 + display: 'grid', 1486 + gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))', 1487 + gap: '12px 16px', 1488 + }); 1489 + 1490 + paramsPanelEl.appendChild(paramsPanelBody); 1491 + document.body.appendChild(paramsPanelEl); 1492 + // Don't override pointer-events: the panel toggles between 'none' (closed, 1493 + // click-through) and 'auto' (open) on its own. Just silence the host's 1494 + // outside-interaction listeners while the panel is open. 1495 + defangOutsideHandlers(paramsPanelEl, { setPointerEvents: false }); 1496 + paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code 1497 + } 1498 + 1499 + function getVisibleVariantEl() { 1500 + if (!currentSessionId) return null; 1501 + const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); 1502 + if (!wrapper) return null; 1503 + return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); 1504 + } 1505 + 1506 + function parseVariantParams(variantEl) { 1507 + if (!variantEl) return []; 1508 + const raw = variantEl.getAttribute('data-impeccable-params'); 1509 + if (!raw) return []; 1510 + try { 1511 + const parsed = JSON.parse(raw); 1512 + return Array.isArray(parsed) ? parsed : []; 1513 + } catch (err) { 1514 + console.warn('[impeccable] Invalid data-impeccable-params JSON:', err.message); 1515 + return []; 1516 + } 1517 + } 1518 + 1519 + function applyParamValue(variantEl, param, value) { 1520 + if (!variantEl) return; 1521 + const attr = 'data-p-' + param.id; 1522 + if (param.kind === 'range') { 1523 + variantEl.style.setProperty('--p-' + param.id, String(value)); 1524 + } else if (param.kind === 'toggle') { 1525 + const on = !!value; 1526 + variantEl.style.setProperty('--p-' + param.id, on ? '1' : '0'); 1527 + if (on) variantEl.setAttribute(attr, 'on'); 1528 + else variantEl.removeAttribute(attr); 1529 + } else if (param.kind === 'steps') { 1530 + variantEl.setAttribute(attr, String(value)); 1531 + } 1532 + } 1533 + 1534 + function applyParamDefaults(variantEl, params) { 1535 + paramsCurrentValues = {}; 1536 + for (const p of params) { 1537 + paramsCurrentValues[p.id] = p.default; 1538 + applyParamValue(variantEl, p, p.default); 1539 + } 1540 + } 1541 + 1542 + function formatRangeValue(input) { 1543 + const max = parseFloat(input.max), min = parseFloat(input.min); 1544 + const v = parseFloat(input.value); 1545 + if (!isFinite(v)) return input.value; 1546 + return (max - min) <= 2 ? v.toFixed(2) : String(Math.round(v)); 1547 + } 1548 + 1549 + function buildParamsPanel(variantEl, params) { 1550 + const P = paramsPanelPalette || barPaletteForTheme(detectPageTheme()); 1551 + paramsPanelBody.innerHTML = ''; 1552 + for (const p of params) { 1553 + const row = el('div', { display: 'flex', flexDirection: 'column', gap: '6px' }); 1554 + const labelRow = el('div', { 1555 + display: 'flex', justifyContent: 'space-between', 1556 + alignItems: 'baseline', gap: '8px', 1557 + }); 1558 + const lbl = el('span', { 1559 + fontSize: '10.5px', fontWeight: '600', color: P.text, 1560 + letterSpacing: '0.03em', 1561 + }); 1562 + lbl.textContent = p.label || p.id; 1563 + labelRow.appendChild(lbl); 1564 + const readout = el('span', { 1565 + fontSize: '10.5px', color: P.textDim, 1566 + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', 1567 + }); 1568 + labelRow.appendChild(readout); 1569 + row.appendChild(labelRow); 1570 + 1571 + if (p.kind === 'range') { 1572 + const input = document.createElement('input'); 1573 + input.type = 'range'; 1574 + input.min = String(p.min != null ? p.min : 0); 1575 + input.max = String(p.max != null ? p.max : 1); 1576 + input.step = String(p.step != null ? p.step : 0.05); 1577 + input.value = String(p.default); 1578 + Object.assign(input.style, { 1579 + width: '100%', accentColor: C.brand, cursor: 'pointer', 1580 + }); 1581 + readout.textContent = formatRangeValue(input); 1582 + input.addEventListener('input', (e) => { 1583 + e.stopPropagation(); 1584 + const v = parseFloat(input.value); 1585 + paramsCurrentValues[p.id] = v; 1586 + readout.textContent = formatRangeValue(input); 1587 + applyParamValue(variantEl, p, v); 1588 + }); 1589 + row.appendChild(input); 1590 + } else if (p.kind === 'toggle') { 1591 + const initial = !!p.default; 1592 + readout.textContent = initial ? 'On' : 'Off'; 1593 + const track = el('button', { 1594 + position: 'relative', width: '36px', height: '20px', 1595 + borderRadius: '10px', border: 'none', padding: '0', 1596 + cursor: 'pointer', 1597 + background: initial ? C.brand : P.hairline, 1598 + transition: 'background 0.15s ease', 1599 + alignSelf: 'flex-start', 1600 + }); 1601 + const knob = el('span', { 1602 + position: 'absolute', top: '2px', 1603 + left: initial ? '18px' : '2px', 1604 + width: '16px', height: '16px', borderRadius: '50%', 1605 + background: 'oklch(98% 0 0)', 1606 + transition: 'left 0.18s ' + EASE, 1607 + boxShadow: '0 1px 2px oklch(0% 0 0 / 0.2)', 1608 + }); 1609 + track.appendChild(knob); 1610 + track.addEventListener('click', (e) => { 1611 + e.stopPropagation(); 1612 + const next = !paramsCurrentValues[p.id]; 1613 + paramsCurrentValues[p.id] = next; 1614 + track.style.background = next ? C.brand : P.hairline; 1615 + knob.style.left = next ? '18px' : '2px'; 1616 + readout.textContent = next ? 'On' : 'Off'; 1617 + applyParamValue(variantEl, p, next); 1618 + }); 1619 + row.appendChild(track); 1620 + } else if (p.kind === 'steps') { 1621 + const opts = (p.options || []).map(o => 1622 + typeof o === 'string' ? { value: o, label: o } : o 1623 + ); 1624 + const activeOpt = opts.find(o => o.value === p.default) || opts[0]; 1625 + readout.textContent = activeOpt ? activeOpt.label : String(p.default); 1626 + const segRow = el('div', { 1627 + display: 'grid', 1628 + gridTemplateColumns: 'repeat(' + opts.length + ', 1fr)', 1629 + gap: '1px', padding: '2px', 1630 + background: P.hairline, borderRadius: '5px', 1631 + }); 1632 + const segBtns = []; 1633 + opts.forEach(o => { 1634 + const active = o.value === p.default; 1635 + const b = el('button', { 1636 + padding: '5px 4px', border: 'none', borderRadius: '3px', 1637 + background: active ? C.brand : 'transparent', 1638 + color: active ? 'oklch(98% 0 0)' : P.text, 1639 + fontFamily: FONT, fontSize: '10.5px', fontWeight: '500', 1640 + cursor: 'pointer', whiteSpace: 'nowrap', 1641 + transition: 'background 0.1s ease, color 0.1s ease', 1642 + }); 1643 + b.textContent = o.label; 1644 + b.addEventListener('click', (e) => { 1645 + e.stopPropagation(); 1646 + paramsCurrentValues[p.id] = o.value; 1647 + readout.textContent = o.label; 1648 + segBtns.forEach(({ btn, val }) => { 1649 + const on = val === o.value; 1650 + btn.style.background = on ? C.brand : 'transparent'; 1651 + btn.style.color = on ? 'oklch(98% 0 0)' : P.text; 1652 + }); 1653 + applyParamValue(variantEl, p, o.value); 1654 + }); 1655 + segRow.appendChild(b); 1656 + segBtns.push({ btn: b, val: o.value }); 1657 + }); 1658 + row.appendChild(segRow); 1659 + } 1660 + 1661 + paramsPanelBody.appendChild(row); 1662 + } 1663 + } 1664 + 1665 + // Decide which way the popover opens: away from the picked element. If the 1666 + // bar landed below the element, popover slides DOWN from the bar's bottom. 1667 + // If the bar landed above, popover slides UP from the bar's top. 1668 + function popoverDirection() { 1669 + if (!barEl || !selectedElement) return 'below'; 1670 + const br = barEl.getBoundingClientRect(); 1671 + const er = selectedElement.getBoundingClientRect(); 1672 + return br.top >= er.bottom - 4 ? 'below' : 'above'; 1673 + } 1674 + 1675 + // The popover overlaps the bar by OVERLAP px on the bar-facing side. With 1676 + // popover z-index below bar, that overlap sits behind bar (invisible) and 1677 + // reinforces the "tucked behind" feel. Padding compensates so the real 1678 + // content starts flush with bar's outer edge. 1679 + const TUNE_OVERLAP = 6; 1680 + 1681 + // Closed clip-path depends on direction: for 'below' clip from the far 1682 + // (bottom) edge so the reveal grows downward from the bar; for 'above' 1683 + // clip from the top edge so the reveal grows upward from the bar. 1684 + function closedClipPath(direction) { 1685 + return direction === 'below' ? 'inset(0 0 100% 0)' : 'inset(100% 0 0 0)'; 1686 + } 1687 + 1688 + function setClipPath(value, withTransition) { 1689 + const saved = paramsPanelEl.style.transition; 1690 + if (!withTransition) paramsPanelEl.style.transition = 'none'; 1691 + paramsPanelEl.style.clipPath = value; 1692 + if (!withTransition) { 1693 + void paramsPanelEl.offsetHeight; 1694 + paramsPanelEl.style.transition = saved; 1695 + } 1696 + } 1697 + 1698 + function positionParamsPanel() { 1699 + if (!paramsPanelEl || !barEl || barEl.style.display === 'none') return; 1700 + const br = barEl.getBoundingClientRect(); 1701 + const direction = popoverDirection(); 1702 + const prevDirection = paramsPanelEl.dataset.tuneDirection; 1703 + 1704 + // top/left/width are NOT in the transition list, so they snap instantly. 1705 + paramsPanelEl.style.left = br.left + 'px'; 1706 + paramsPanelEl.style.width = br.width + 'px'; 1707 + 1708 + if (direction === 'below') { 1709 + paramsPanelEl.style.top = (br.bottom - TUNE_OVERLAP) + 'px'; 1710 + paramsPanelEl.style.borderRadius = '0 0 10px 10px'; 1711 + paramsPanelEl.style.paddingTop = (14 + TUNE_OVERLAP) + 'px'; 1712 + paramsPanelEl.style.paddingBottom = '14px'; 1713 + } else { 1714 + const ih = paramsPanelEl.offsetHeight || 80; 1715 + paramsPanelEl.style.top = (br.top - ih + TUNE_OVERLAP) + 'px'; 1716 + paramsPanelEl.style.borderRadius = '10px 10px 0 0'; 1717 + paramsPanelEl.style.paddingTop = '14px'; 1718 + paramsPanelEl.style.paddingBottom = (14 + TUNE_OVERLAP) + 'px'; 1719 + } 1720 + paramsPanelEl.dataset.tuneDirection = direction; 1721 + 1722 + // If currently closed and direction flipped (or first-time setup), 1723 + // snap the clip-path to the new direction's closed pose without 1724 + // transitioning (so the clip doesn't slide across the element). 1725 + if (!tuneOpen && (!prevDirection || prevDirection !== direction)) { 1726 + setClipPath(closedClipPath(direction), false); 1727 + } 1728 + } 1729 + 1730 + function showParamsPanel() { 1731 + if (!paramsPanelEl) return; 1732 + positionParamsPanel(); 1733 + paramsPanelEl.style.pointerEvents = 'auto'; 1734 + // rAF so the positioning paint commits before the transition fires. 1735 + requestAnimationFrame(() => { 1736 + setClipPath('inset(0 0 0 0)', true); 1737 + }); 1738 + } 1739 + 1740 + function hideParamsPanel() { 1741 + if (!paramsPanelEl) return; 1742 + paramsPanelEl.style.pointerEvents = 'none'; 1743 + const direction = paramsPanelEl.dataset.tuneDirection || 'below'; 1744 + setClipPath(closedClipPath(direction), true); 1745 + } 1746 + 1747 + // Build/rebuild the panel's contents for the current variant AND apply 1748 + // its defaults to the variant wrapper (so scoped CSS responds even before 1749 + // the user opens the popover). Visibility is governed by tuneOpen. 1750 + function refreshParamsPanel() { 1751 + if (state !== 'CYCLING') { 1752 + paramsCurrentValues = {}; 1753 + tuneOpen = false; 1754 + hideParamsPanel(); 1755 + return; 1756 + } 1757 + const variantEl = getVisibleVariantEl(); 1758 + const params = parseVariantParams(variantEl); 1759 + if (!variantEl || params.length === 0) { 1760 + paramsCurrentValues = {}; 1761 + tuneOpen = false; 1762 + hideParamsPanel(); 1763 + return; 1764 + } 1765 + applyParamDefaults(variantEl, params); 1766 + buildParamsPanel(variantEl, params); 1767 + if (tuneOpen) { 1768 + // If already visible (variant cycled while open), refresh in place 1769 + // instead of re-running the clip-path animation. 1770 + const alreadyVisible = paramsPanelEl.style.display === 'block' 1771 + && paramsPanelEl.style.opacity === '1'; 1772 + if (alreadyVisible) positionParamsPanel(); 1773 + else showParamsPanel(); 1774 + } else { 1775 + hideParamsPanel(); 1776 + } 1777 + } 1778 + 1779 + function toggleTunePopover() { 1780 + if (tuneOpen) { closeTunePopover(); return; } 1781 + openTunePopover(); 1782 + } 1783 + 1784 + function openTunePopover() { 1785 + if (state !== 'CYCLING') return; 1786 + const variantEl = getVisibleVariantEl(); 1787 + const params = parseVariantParams(variantEl); 1788 + if (!variantEl || params.length === 0) return; 1789 + // Build fresh to ensure the current variant's controls are shown. 1790 + applyParamDefaults(variantEl, params); 1791 + buildParamsPanel(variantEl, params); 1792 + tuneOpen = true; 1793 + showParamsPanel(); 1794 + // Kill the bar's shadow on the popover-facing side so the dark popover 1795 + // doesn't pick up a bright glow line. 1796 + if (barEl) { 1797 + const direction = paramsPanelEl?.dataset.tuneDirection || 'below'; 1798 + barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN; 1799 + } 1800 + // Re-render the bar so the Tune chip picks up the active styling. 1801 + updateBarContent('cycling'); 1802 + } 1803 + 1804 + function closeTunePopover() { 1805 + tuneOpen = false; 1806 + hideParamsPanel(); 1807 + if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT; 1808 + if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') { 1809 + updateBarContent('cycling'); 1810 + } 1811 + } 1812 + 1813 + // --------------------------------------------------------------------------- 1814 + // Variant cycling in DOM 1815 + // --------------------------------------------------------------------------- 1816 + 1817 + function showVariantInDOM(sessionId, num) { 1818 + const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); 1819 + if (!wrapper) return; 1820 + for (const child of wrapper.children) { 1821 + const v = child.dataset ? child.dataset.impeccableVariant : null; 1822 + if (!v) continue; 1823 + child.style.display = (v === String(num)) ? '' : 'none'; 1824 + } 1825 + // Unconditional refresh — covers first-reveal (no-op if state isn't 1826 + // CYCLING yet, the subsequent CYCLING transition triggers its own 1827 + // refresh) and every cycle step. 1828 + refreshParamsPanel(); 1829 + } 1830 + 1831 + /** 1832 + * No-HMR fallback: fetch the raw source file from the live server, 1833 + * parse it, extract the variant wrapper, and inject it into the live DOM. 1834 + * This works even when the dev server caches HTML (Bun, static servers). 1835 + */ 1836 + function injectVariantsFromSource(filePath, sessionId) { 1837 + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); 1838 + fetch(url) 1839 + .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) 1840 + .then(html => { 1841 + // Parse the raw source HTML 1842 + const parser = new DOMParser(); 1843 + const doc = parser.parseFromString(html, 'text/html'); 1844 + const srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); 1845 + if (!srcWrapper) { 1846 + console.error('[impeccable] Variant wrapper not found in source file.'); 1847 + return; 1848 + } 1849 + 1850 + // Find the original element in the live DOM. 1851 + // The original is inside the wrapper in the source. We find the 1852 + // corresponding element in the live DOM by matching the first child's 1853 + // tag + classes from the original snapshot. 1854 + const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); 1855 + if (!origContent) return; 1856 + 1857 + const tag = origContent.tagName.toLowerCase(); 1858 + const cls = origContent.className; 1859 + let liveEl = null; 1860 + if (origContent.id) { 1861 + liveEl = document.getElementById(origContent.id); 1862 + } else if (cls) { 1863 + // Find by tag + exact class match 1864 + const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); 1865 + for (const c of candidates) { 1866 + if (c.className === cls && !own(c)) { liveEl = c; break; } 1867 + } 1868 + } 1869 + 1870 + if (!liveEl) { 1871 + console.error('[impeccable] Could not find original element in live DOM.'); 1872 + return; 1873 + } 1874 + 1875 + // Replace the live element with the full wrapper from source 1876 + const wrapper = srcWrapper.cloneNode(true); 1877 + liveEl.parentElement.replaceChild(wrapper, liveEl); 1878 + 1879 + // Update state: count variants, show the first one 1880 + const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); 1881 + arrivedVariants = variants.length; 1882 + expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); 1883 + visibleVariant = 1; 1884 + showVariantInDOM(sessionId, 1); 1885 + 1886 + // Update selectedElement to the visible variant's content 1887 + selectedElement = pickVariantContent(wrapper, 1) || wrapper.parentElement; 1888 + 1889 + state = 'CYCLING'; 1890 + hideShaderOverlay(); 1891 + updateBarContent('cycling'); 1892 + refreshParamsPanel(); 1893 + saveSession(); 1894 + console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); 1895 + }) 1896 + .catch(err => { 1897 + console.error('[impeccable] Failed to fetch source:', err); 1898 + showToast('Could not load variants. Try refreshing the page.', 5000); 1899 + }); 1900 + } 1901 + 1902 + function cycleVariant(dir) { 1903 + const next = visibleVariant + dir; 1904 + if (next < 1 || next > arrivedVariants) return; 1905 + visibleVariant = next; 1906 + showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself 1907 + updateSelectedElement(); 1908 + updateBarContent('cycling'); 1909 + saveSession(); 1910 + } 1911 + 1912 + function updateSelectedElement() { 1913 + if (!currentSessionId) return; 1914 + const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); 1915 + if (!wrapper) return; 1916 + const visEl = pickVariantContent(wrapper, visibleVariant); 1917 + if (visEl) selectedElement = visEl; 1918 + } 1919 + 1920 + // Resolve the element that represents the variant's visible content. 1921 + // Contract: each variant div should contain exactly one top-level element 1922 + // (the full replacement). In practice a model may ship loose siblings or 1923 + // lead with <style>/<script>. Be defensive: skip non-visual elements, and 1924 + // if the variant has multiple element children, use the variant div itself 1925 + // (it wraps all of them and gets correct bounds). 1926 + function pickVariantContent(wrapper, index) { 1927 + if (!wrapper) return null; 1928 + const variantDiv = wrapper.querySelector('[data-impeccable-variant="' + index + '"]'); 1929 + if (!variantDiv) return null; 1930 + const NON_VISUAL = new Set(['STYLE', 'SCRIPT', 'LINK', 'META', 'TEMPLATE']); 1931 + const visual = []; 1932 + for (const child of variantDiv.children) { 1933 + if (!NON_VISUAL.has(child.tagName)) visual.push(child); 1934 + } 1935 + if (visual.length === 1) return visual[0]; 1936 + return variantDiv; 1937 + } 1938 + 1939 + // Hold window.scrollY at a fixed value across DOM mutations inside the 1940 + // session's wrapper (HMR patches, variant inserts, cycle swaps). 1941 + function startScrollLock(sessionId, initialTargetY) { 1942 + stopScrollLock(); 1943 + scrollLockTargetY = typeof initialTargetY === 'number' && isFinite(initialTargetY) 1944 + ? initialTargetY 1945 + : window.scrollY; 1946 + console.log('[impeccable.scroll] startScrollLock', { sessionId, scrollY: window.scrollY, targetY: scrollLockTargetY, initialOverride: initialTargetY }); 1947 + 1948 + try { history.scrollRestoration = 'manual'; } catch {} 1949 + 1950 + const prevHtmlAnchor = document.documentElement.style.overflowAnchor; 1951 + const prevBodyAnchor = document.body.style.overflowAnchor; 1952 + document.documentElement.style.overflowAnchor = 'none'; 1953 + document.body.style.overflowAnchor = 'none'; 1954 + 1955 + const correct = (why) => { 1956 + scrollLockRaf = null; 1957 + if (scrollLockTargetY == null) return; 1958 + const before = window.scrollY; 1959 + const delta = before - scrollLockTargetY; 1960 + if (Math.abs(delta) < 0.5) { 1961 + console.log('[impeccable.scroll] correct noop', { why, scrollY: before, targetY: scrollLockTargetY }); 1962 + return; 1963 + } 1964 + window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); 1965 + console.log('[impeccable.scroll] corrected', { why, from: before, to: scrollLockTargetY, delta, nowAt: window.scrollY }); 1966 + }; 1967 + const schedule = (why) => { 1968 + if (scrollLockRaf != null) return; 1969 + scrollLockRaf = requestAnimationFrame(() => correct(why)); 1970 + }; 1971 + 1972 + scrollLockObserver = new MutationObserver((mutations) => { 1973 + for (const m of mutations) { 1974 + if (m.target?.closest?.('[data-impeccable-variants="' + sessionId + '"]')) { 1975 + const childAdds = Array.from(m.addedNodes).map(n => n.nodeType === 1 ? (n.tagName + (n.dataset?.impeccableVariant ? ('[variant=' + n.dataset.impeccableVariant + ']') : '')) : n.nodeType).join(','); 1976 + console.log('[impeccable.scroll] mutation inside wrapper', { type: m.type, target: m.target?.tagName, adds: childAdds, scrollYBefore: window.scrollY, targetY: scrollLockTargetY }); 1977 + schedule('mutation-in-wrapper'); 1978 + return; 1979 + } 1980 + for (const n of m.addedNodes) { 1981 + if (n.nodeType === 1 && (n.matches?.('[data-impeccable-variants="' + sessionId + '"]') || n.querySelector?.('[data-impeccable-variants="' + sessionId + '"]'))) { 1982 + console.log('[impeccable.scroll] wrapper node added', { tag: n.tagName, scrollYBefore: window.scrollY, targetY: scrollLockTargetY }); 1983 + schedule('wrapper-added'); 1984 + return; 1985 + } 1986 + } 1987 + } 1988 + }); 1989 + scrollLockObserver.observe(document.body, { childList: true, subtree: true }); 1990 + 1991 + scrollLockAbort = new AbortController(); 1992 + scrollLockAbort.signal.addEventListener('abort', () => { 1993 + document.documentElement.style.overflowAnchor = prevHtmlAnchor; 1994 + document.body.style.overflowAnchor = prevBodyAnchor; 1995 + }, { once: true }); 1996 + const sig = { signal: scrollLockAbort.signal }; 1997 + // Track whether the most recent scroll came from a user gesture. We 1998 + // gate user-scroll re-anchoring on this flag so programmatic smooth 1999 + // scrolls (browser reload-restore, scrollIntoView from other scripts) 2000 + // don't accidentally update our target. 2001 + let userGestureAt = 0; 2002 + const USER_GESTURE_WINDOW_MS = 250; 2003 + 2004 + const reanchor = (why) => { 2005 + if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } 2006 + const prevTarget = scrollLockTargetY; 2007 + scrollLockTargetY = window.scrollY; 2008 + writeScrollY(scrollLockTargetY); 2009 + console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY }); 2010 + }; 2011 + const markGesture = (why) => { 2012 + userGestureAt = performance.now(); 2013 + reanchor(why); 2014 + }; 2015 + window.addEventListener('wheel', () => markGesture('wheel'), { passive: true, ...sig }); 2016 + window.addEventListener('touchstart', () => markGesture('touchstart'), { passive: true, ...sig }); 2017 + window.addEventListener('touchmove', () => markGesture('touchmove'), { passive: true, ...sig }); 2018 + window.addEventListener('keydown', (e) => { 2019 + if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) markGesture('key:' + e.key); 2020 + }, sig); 2021 + 2022 + // Correct on EVERY scroll event: whether it's the browser's 2023 + // post-reload animated restore or some other script calling 2024 + // scrollIntoView, we want to snap back immediately. Only skip if a 2025 + // user gesture fired in the last 250ms. 2026 + let lastLoggedScrollY = window.scrollY; 2027 + window.addEventListener('scroll', () => { 2028 + const now = window.scrollY; 2029 + if (Math.abs(now - lastLoggedScrollY) > 5) { 2030 + console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY }); 2031 + lastLoggedScrollY = now; 2032 + } 2033 + if (scrollLockTargetY == null) return; 2034 + if (performance.now() - userGestureAt < USER_GESTURE_WINDOW_MS) return; 2035 + if (Math.abs(now - scrollLockTargetY) < 0.5) return; 2036 + console.log('[impeccable.scroll] scroll-event snap', { from: now, to: scrollLockTargetY }); 2037 + window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); 2038 + }, { passive: true, ...sig }); 2039 + 2040 + // Apply target synchronously, not via rAF — racing the browser's 2041 + // restore or a smooth-scroll animation means we want to win now. 2042 + if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) { 2043 + window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); 2044 + console.log('[impeccable.scroll] startScrollLock initial apply', { to: scrollLockTargetY }); 2045 + } 2046 + } 2047 + 2048 + function stopScrollLock() { 2049 + if (scrollLockObserver) { scrollLockObserver.disconnect(); scrollLockObserver = null; } 2050 + if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } 2051 + if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; } 2052 + scrollLockTargetY = null; 2053 + // NOTE: do NOT clear the persistent scroll key here. startScrollLock 2054 + // calls us as a reset, and clearing the key would nuke the Go-time 2055 + // scrollY that the next resume needs to read. 2056 + } 2057 + 2058 + // --------------------------------------------------------------------------- 2059 + // MutationObserver for progressive variant reveal 2060 + // --------------------------------------------------------------------------- 2061 + 2062 + function startVariantObserver(sessionId) { 2063 + let updating = false; // re-entrancy guard 2064 + 2065 + const obs = new MutationObserver((mutations) => { 2066 + if (updating) return; 2067 + 2068 + // Only react to mutations that add nodes with data-impeccable-variant, 2069 + // or mutations inside the variant wrapper. Ignore our own bar/UI changes. 2070 + let dominated = false; 2071 + for (const m of mutations) { 2072 + if (m.target.closest?.('[data-impeccable-variants]')) { dominated = true; break; } 2073 + for (const n of m.addedNodes) { 2074 + if (n.nodeType !== 1) continue; 2075 + // Direct hit: the added node itself is the wrapper or a variant. 2076 + if (n.dataset?.impeccableVariants || n.dataset?.impeccableVariant) { 2077 + dominated = true; break; 2078 + } 2079 + // Subtree hit: framework HMR (notably SvelteKit) sometimes replaces 2080 + // a whole subtree where the wrapper is a descendant of the added 2081 + // node. Without this check, the observer ignores those mutations 2082 + // and the session stays in GENERATING forever. 2083 + if (n.querySelector?.('[data-impeccable-variants],[data-impeccable-variant]')) { 2084 + dominated = true; break; 2085 + } 2086 + } 2087 + if (dominated) break; 2088 + } 2089 + if (!dominated) return; 2090 + 2091 + const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); 2092 + if (!wrapper) return; 2093 + 2094 + // Re-anchor selectedElement if it was detached by live-wrap's HMR swap. 2095 + // Without this, the shader / highlight / bar track a zero-rect phantom 2096 + // and the overlay appears frozen. 2097 + if (selectedElement && !document.body.contains(selectedElement)) { 2098 + selectedElement = pickVariantContent(wrapper, 'original') || wrapper; 2099 + } 2100 + 2101 + const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); 2102 + const count = variants.length; 2103 + 2104 + // Nothing new 2105 + if (count <= arrivedVariants) return; 2106 + 2107 + updating = true; 2108 + arrivedVariants = count; 2109 + if (visibleVariant === 0 && arrivedVariants > 0) { 2110 + visibleVariant = 1; 2111 + showVariantInDOM(sessionId, 1); 2112 + // showVariantInDOM hid the original (display:none); if we were still 2113 + // anchored to the original's content, its boundingRect is now zero 2114 + // and the bar snaps to (0,0). Re-point at the visible variant instead. 2115 + const visEl = pickVariantContent(wrapper, visibleVariant); 2116 + if (visEl) selectedElement = visEl; 2117 + } 2118 + 2119 + const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0'); 2120 + if (expected > 0) expectedVariants = expected; 2121 + 2122 + if (arrivedVariants >= expectedVariants && expectedVariants > 0) { 2123 + state = 'CYCLING'; 2124 + hideShaderOverlay(); 2125 + updateBarContent('cycling'); 2126 + refreshParamsPanel(); 2127 + } else if (state === 'GENERATING') { 2128 + updateBarContent('generating'); 2129 + } 2130 + saveSession(); 2131 + updating = false; 2132 + }); 2133 + 2134 + obs.observe(document.body, { childList: true, subtree: true }); 2135 + return obs; 2136 + } 2137 + 2138 + // --------------------------------------------------------------------------- 2139 + // Bar scroll tracking 2140 + // --------------------------------------------------------------------------- 2141 + 2142 + function startScrollTracking() { 2143 + function tick() { 2144 + if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') { 2145 + positionBar(); 2146 + showHighlight(selectedElement); 2147 + if (tuneOpen) positionParamsPanel(); 2148 + } 2149 + if (annotActive) positionAnnotOverlay(selectedElement); 2150 + // Shader overlay (via debug P toggle or generation) is repositioned 2151 + // by its own branch below; debug no longer has a separate overlay. 2152 + if (shaderState) positionShaderOverlay(); 2153 + scrollRaf = requestAnimationFrame(tick); 2154 + } 2155 + scrollRaf = requestAnimationFrame(tick); 2156 + } 2157 + 2158 + function stopScrollTracking() { 2159 + if (scrollRaf) { cancelAnimationFrame(scrollRaf); scrollRaf = null; } 2160 + } 2161 + 2162 + // --------------------------------------------------------------------------- 2163 + // SSE (server→browser) + fetch POST (browser→server) 2164 + // Zero-dependency replacement for WebSocket. 2165 + // --------------------------------------------------------------------------- 2166 + 2167 + let evtSource = null; 2168 + let sseRetries = 0; 2169 + const SSE_MAX_RETRIES = 20; // generous: heartbeats keep the connection alive, so retries mean real trouble 2170 + 2171 + function connectSSE() { 2172 + evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN); 2173 + 2174 + evtSource.onopen = () => { 2175 + sseRetries = 0; // reset on successful (re)connect 2176 + }; 2177 + 2178 + evtSource.onmessage = (e) => { 2179 + sseRetries = 0; // reset on any successful message 2180 + let msg; try { msg = JSON.parse(e.data); } catch { return; } 2181 + switch (msg.type) { 2182 + case 'connected': 2183 + hasProjectContext = !!msg.hasProjectContext; 2184 + if (!hasProjectContext) showToast('No PRODUCT.md found. Variants will be brand-agnostic. Run /impeccable teach to generate one.', 7000); 2185 + console.log('[impeccable] Live mode connected.'); 2186 + if (state === 'IDLE') state = 'PICKING'; 2187 + break; 2188 + case 'done': 2189 + // Variants already arrived via HMR → normal transition. 2190 + if (arrivedVariants >= expectedVariants && expectedVariants > 0) { 2191 + if (state === 'GENERATING') { 2192 + state = 'CYCLING'; 2193 + updateBarContent('cycling'); 2194 + refreshParamsPanel(); 2195 + } 2196 + break; 2197 + } 2198 + // Variants are in source but not in the DOM yet. Common when the 2199 + // picked element lived inside conditional render (closed modal, 2200 + // hidden tab, a route the user navigated away from). The variant 2201 + // MutationObserver stays armed and auto-transitions to CYCLING 2202 + // the moment the wrapper actually mounts. Nudge the user toward 2203 + // that path with a toast — better than the prior force-reload 2204 + // which reset framework state and left the session stuck. 2205 + setTimeout(() => { 2206 + if (arrivedVariants >= expectedVariants && expectedVariants > 0) return; 2207 + if (state !== 'GENERATING') return; 2208 + showToast( 2209 + "Variants ready. If the picked element isn't visible, retrace the path that revealed it — they'll appear automatically.", 2210 + 15000, 2211 + ); 2212 + }, 2000); 2213 + break; 2214 + case 'error': 2215 + console.error('[impeccable] Error:', msg.message); 2216 + showToast('Error: ' + msg.message, 5000); 2217 + hideBar(); 2218 + state = 'PICKING'; 2219 + break; 2220 + } 2221 + }; 2222 + 2223 + evtSource.onerror = () => { 2224 + sseRetries++; 2225 + if (sseRetries <= SSE_MAX_RETRIES) { 2226 + console.log('[impeccable] SSE connection lost. Retry ' + sseRetries + '/' + SSE_MAX_RETRIES + '...'); 2227 + return; // EventSource auto-reconnects 2228 + } 2229 + // Server is gone. Clean up gracefully. 2230 + console.log('[impeccable] Live server unreachable. Cleaning up UI.'); 2231 + evtSource.close(); 2232 + evtSource = null; 2233 + handleServerLost(); 2234 + }; 2235 + } 2236 + 2237 + /** Server died or became unreachable. Reset UI to a clean state. */ 2238 + function handleServerLost() { 2239 + if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') { 2240 + showToast('Live server disconnected. Session ended.', 5000); 2241 + } 2242 + hideBar(); 2243 + hideHighlight(); 2244 + hideShaderOverlay(); 2245 + hideAnnotOverlay(); 2246 + stopScrollTracking(); 2247 + if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } 2248 + stopScrollLock(); 2249 + clearScrollY(); 2250 + clearSession(); 2251 + selectedElement = null; 2252 + currentSessionId = null; 2253 + selectedAction = 'impeccable'; 2254 + state = 'IDLE'; 2255 + } 2256 + 2257 + function sendEvent(msg) { 2258 + msg.token = TOKEN; 2259 + fetch('http://localhost:' + PORT + '/events', { 2260 + method: 'POST', 2261 + headers: { 'Content-Type': 'application/json' }, 2262 + body: JSON.stringify(msg), 2263 + }).catch(err => console.error('[impeccable] Failed to send event:', err)); 2264 + } 2265 + 2266 + // --------------------------------------------------------------------------- 2267 + // Event handlers 2268 + // --------------------------------------------------------------------------- 2269 + 2270 + function handleMouseMove(e) { 2271 + if (state !== 'PICKING' || !pickActive) return; 2272 + const target = document.elementFromPoint(e.clientX, e.clientY); 2273 + if (!target || !pickable(target) || target === hoveredElement) return; 2274 + hoveredElement = target; 2275 + showHighlight(target); 2276 + } 2277 + 2278 + function handleClick(e) { 2279 + // Close action picker on any outside click 2280 + if (pickerEl?.style.display !== 'none' && !own(e.target)) { 2281 + hideActionPicker(); 2282 + } 2283 + // Close Tune popover on outside click (anything outside panel + bar) 2284 + if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) { 2285 + closeTunePopover(); 2286 + } 2287 + // In CONFIGURING: click outside the bar and selected element returns to PICKING 2288 + if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { 2289 + hideBar(); 2290 + stopScrollTracking(); 2291 + hideAnnotOverlay(); 2292 + clearAnnotations(); 2293 + state = 'PICKING'; 2294 + hoveredElement = null; 2295 + hideHighlight(); 2296 + return; 2297 + } 2298 + if (state !== 'PICKING' || !pickActive) return; 2299 + if (own(e.target)) return; 2300 + if (!hoveredElement || !pickable(hoveredElement)) return; 2301 + e.preventDefault(); 2302 + e.stopPropagation(); 2303 + selectedElement = hoveredElement; 2304 + state = 'CONFIGURING'; 2305 + showHighlight(selectedElement); 2306 + clearAnnotations(); 2307 + showAnnotOverlay(selectedElement); 2308 + showBar('configure'); 2309 + startScrollTracking(); 2310 + maybePrefetchPage(); 2311 + maybeWarnConditionalAncestor(selectedElement); 2312 + } 2313 + 2314 + /** 2315 + * Surface a brief, non-blocking heads-up when the picked element lives 2316 + * inside a container whose visibility is gated by ephemeral state — modals, 2317 + * collapsible panels, popovers, off-screen tab panels. If HMR remounts the 2318 + * parent during generation (Vite Fast Refresh, SvelteKit page reload), the 2319 + * variants land in source but stay invisible until the user re-opens the 2320 + * container. Telling the user upfront is much friendlier than the silent 2321 + * timeout-then-toast that they'd otherwise hit. 2322 + * 2323 + * Heuristic, intentionally narrow — only fires for unambiguous cases so 2324 + * we don't cry wolf on every nested element. 2325 + */ 2326 + function maybeWarnConditionalAncestor(el) { 2327 + let node = el?.parentElement; 2328 + let depth = 0; 2329 + while (node && depth < 12) { 2330 + // 1. Active dialog / modal 2331 + if (node.getAttribute && node.getAttribute('role') === 'dialog' 2332 + && node.getAttribute('aria-modal') === 'true') { 2333 + showToast('Heads up: this element lives inside a dialog. If state resets during generation, you may need to re-open it.', 6000); 2334 + return; 2335 + } 2336 + // 2. Common Radix / shadcn / headless-ui open-state attribute 2337 + if (node.dataset && node.dataset.state === 'open') { 2338 + showToast('Heads up: this element lives inside an open panel. If state resets during generation, you may need to re-open it.', 6000); 2339 + return; 2340 + } 2341 + // 3. Tab panel — only meaningful when the page also shows ANOTHER 2342 + // tab as selected. A single tabpanel with no tablist is just a static 2343 + // section in disguise and isn't conditional. 2344 + if (node.getAttribute && node.getAttribute('role') === 'tabpanel') { 2345 + const list = document.querySelector('[role="tablist"]'); 2346 + if (list) { 2347 + const tabs = list.querySelectorAll('[role="tab"]'); 2348 + if (tabs.length > 1) { 2349 + showToast('Heads up: this element lives in a tab panel. If state resets during generation, switch back to this tab.', 6000); 2350 + return; 2351 + } 2352 + } 2353 + } 2354 + // 4. Collapsible: aria-expanded sibling. Look for the trigger button. 2355 + if (node.id) { 2356 + const trigger = document.querySelector(`[aria-controls="${CSS.escape(node.id)}"][aria-expanded="true"]`); 2357 + if (trigger) { 2358 + showToast('Heads up: this element lives inside an expandable section. If state resets during generation, re-expand it.', 6000); 2359 + return; 2360 + } 2361 + } 2362 + node = node.parentElement; 2363 + depth++; 2364 + } 2365 + } 2366 + 2367 + // Fire a lightweight prefetch event the first time the user selects an 2368 + // element on a given route. The agent uses this to Read the underlying file 2369 + // into context before Go is hit, shaving the read off the critical path. 2370 + // Dedupe per session by pathname — clicking around on the same page doesn't 2371 + // re-fire. 2372 + // 2373 + // DISABLED: quick-Go workflows pay an extra harness round trip because 2374 + // prefetch + generate arrive as two events instead of one. Re-enable with 2375 + // a browser-side debounce (~800–1000ms, cancelled on Go) if we want to 2376 + // resurrect this. Server validator and skill dispatch remain in place so 2377 + // flipping this flag is the only change needed. 2378 + const PREFETCH_ENABLED = false; 2379 + const prefetchedPaths = new Set(); 2380 + function maybePrefetchPage() { 2381 + if (!PREFETCH_ENABLED) return; 2382 + const path = location.pathname; 2383 + if (prefetchedPaths.has(path)) return; 2384 + prefetchedPaths.add(path); 2385 + sendEvent({ type: 'prefetch', pageUrl: path }); 2386 + } 2387 + 2388 + function handleKeyDown(e) { 2389 + // When the annotation input is focused, let it handle its own keys. 2390 + if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; 2391 + if (e.key === 'Escape') { 2392 + e.preventDefault(); 2393 + if (pickerEl?.style.display !== 'none') { hideActionPicker(); return; } 2394 + if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); state = 'PICKING'; return; } 2395 + if (state === 'CYCLING') { handleDiscard(); return; } 2396 + if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt 2397 + if (state === 'PICKING') { 2398 + // Use togglePick so the "Pick" button in the global bar also flips 2399 + // off, otherwise the bar stays lit while nothing else is active. 2400 + if (pickActive) togglePick(); 2401 + else { hideHighlight(); state = 'IDLE'; } 2402 + return; 2403 + } 2404 + } 2405 + 2406 + // Arrow/Enter nav works in PICKING (hover) and CONFIGURING (selected, input empty) 2407 + var navEl = (state === 'PICKING') ? hoveredElement : (state === 'CONFIGURING') ? selectedElement : null; 2408 + if (navEl && (e.key === 'ArrowUp' || e.key === 'ArrowDown' || (e.key === 'Enter' && state === 'PICKING'))) { 2409 + let next = null; 2410 + if (e.key === 'ArrowDown' && !e.shiftKey) { 2411 + next = navEl.nextElementSibling; 2412 + while (next && !pickable(next)) next = next.nextElementSibling; 2413 + } else if (e.key === 'ArrowUp' && !e.shiftKey) { 2414 + next = navEl.previousElementSibling; 2415 + while (next && !pickable(next)) next = next.previousElementSibling; 2416 + } else if (e.key === 'ArrowUp' && e.shiftKey) { 2417 + next = navEl.parentElement; 2418 + if (next && !pickable(next)) next = null; 2419 + } else if (e.key === 'ArrowDown' && e.shiftKey) { 2420 + next = navEl.firstElementChild; 2421 + while (next && !pickable(next)) next = next.nextElementSibling; 2422 + } else if (e.key === 'Enter') { 2423 + e.preventDefault(); 2424 + selectedElement = hoveredElement; 2425 + state = 'CONFIGURING'; 2426 + showHighlight(selectedElement); 2427 + clearAnnotations(); 2428 + showAnnotOverlay(selectedElement); 2429 + showBar('configure'); 2430 + startScrollTracking(); 2431 + return; 2432 + } 2433 + if (next) { 2434 + e.preventDefault(); 2435 + if (state === 'PICKING') { 2436 + hoveredElement = next; 2437 + } else { 2438 + // CONFIGURING: re-select the new element and refresh the bar 2439 + selectedElement = next; 2440 + clearAnnotations(); 2441 + showAnnotOverlay(next); 2442 + showBar('configure'); 2443 + startScrollTracking(); 2444 + } 2445 + showHighlight(next); 2446 + next.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); 2447 + } 2448 + return; 2449 + } 2450 + 2451 + if (state === 'CYCLING') { 2452 + if (e.key === 'ArrowLeft') { e.preventDefault(); cycleVariant(-1); } 2453 + if (e.key === 'ArrowRight') { e.preventDefault(); cycleVariant(1); } 2454 + if (e.key === 'Enter') { e.preventDefault(); handleAccept(); } 2455 + } 2456 + } 2457 + 2458 + function handleGo() { 2459 + if (!selectedElement || state !== 'CONFIGURING') return; 2460 + const input = document.getElementById(PREFIX + '-input'); 2461 + const prompt = input ? input.value.trim() : ''; 2462 + 2463 + // Commit any pending pin edit BEFORE we snapshot annotations. 2464 + if (annotEditing) finalizeEditingPin(); 2465 + 2466 + currentSessionId = id8(); 2467 + expectedVariants = selectedCount; 2468 + arrivedVariants = 0; 2469 + visibleVariant = 0; 2470 + 2471 + // Flip to GENERATING immediately so the bar morphs without waiting on 2472 + // capture + upload. The event is emitted from captureAndEmit() once the 2473 + // screenshot is uploaded (or capture fails — we still emit, just without 2474 + // screenshotPath). 2475 + const elForCapture = selectedElement; 2476 + const captureRect = elForCapture.getBoundingClientRect(); 2477 + const snapshot = { 2478 + comments: annotState.comments.map(c => ({ x: c.x, y: c.y, text: c.text })), 2479 + strokes: annotState.strokes.map(s => ({ points: s.points.map(p => [p[0], p[1]]) })), 2480 + }; 2481 + const basePayload = { 2482 + type: 'generate', id: currentSessionId, 2483 + action: selectedAction, 2484 + freeformPrompt: prompt || undefined, 2485 + count: selectedCount, 2486 + pageUrl: location.pathname, 2487 + element: extractContext(elForCapture), 2488 + }; 2489 + if (snapshot.comments.length > 0) basePayload.comments = snapshot.comments; 2490 + if (snapshot.strokes.length > 0) basePayload.strokes = snapshot.strokes; 2491 + 2492 + // Hide the interactive overlay so it doesn't linger during generation. 2493 + hideAnnotOverlay(); 2494 + clearAnnotations(); 2495 + 2496 + state = 'GENERATING'; 2497 + showBar('generating'); 2498 + saveSession(); 2499 + writeScrollY(window.scrollY); 2500 + if (variantObserver) variantObserver.disconnect(); 2501 + variantObserver = startVariantObserver(currentSessionId); 2502 + console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId }); 2503 + startScrollLock(currentSessionId); 2504 + 2505 + captureAndEmit(elForCapture, basePayload, snapshot, captureRect); 2506 + } 2507 + 2508 + // --------------------------------------------------------------------------- 2509 + // Screenshot capture + upload 2510 + // --------------------------------------------------------------------------- 2511 + 2512 + let msLoadPromise = null; 2513 + function loadModernScreenshot() { 2514 + if (window.modernScreenshot) return Promise.resolve(window.modernScreenshot); 2515 + if (msLoadPromise) return msLoadPromise; 2516 + msLoadPromise = new Promise((resolve, reject) => { 2517 + const s = document.createElement('script'); 2518 + s.src = 'http://localhost:' + PORT + '/modern-screenshot.js'; 2519 + s.onload = () => resolve(window.modernScreenshot); 2520 + s.onerror = () => { msLoadPromise = null; reject(new Error('modern-screenshot failed to load')); }; 2521 + document.head.appendChild(s); 2522 + }); 2523 + return msLoadPromise; 2524 + } 2525 + 2526 + // Collect @font-face rules from every stylesheet on the page. Cross-origin 2527 + // sheets (Google Fonts, Typekit, etc.) throw SecurityError on .cssRules 2528 + // access, so modern-screenshot can't embed them on its own — the resulting 2529 + // SVG falls back to system fonts and text re-wraps + renders with different 2530 + // weight. We fetch the raw CSS text (CORS-permitted for these providers), 2531 + // extract @font-face blocks, inline the referenced font files as base64 2532 + // data URIs (SVGs rasterized via canvas can't fetch external resources, 2533 + // so URLs inside the SVG silently fail without this), and pass the result 2534 + // to modern-screenshot as font.cssText. 2535 + const FONT_EXT_RE = /\.(woff2?|ttf|otf|eot)(\?.*)?$/i; 2536 + const FONT_MIME = { 2537 + woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf', otf: 'font/otf', eot: 'application/vnd.ms-fontobject', 2538 + }; 2539 + function bufferToBase64(buf) { 2540 + const bytes = new Uint8Array(buf); 2541 + let binary = ''; 2542 + const CHUNK = 0x8000; 2543 + for (let i = 0; i < bytes.length; i += CHUNK) { 2544 + binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK)); 2545 + } 2546 + return btoa(binary); 2547 + } 2548 + async function inlineFontUrls(cssText) { 2549 + const urlRe = /url\((['"]?)(https?:\/\/[^'")\s]+)\1\)/g; 2550 + const urls = new Set(); 2551 + let m; 2552 + while ((m = urlRe.exec(cssText))) { 2553 + if (FONT_EXT_RE.test(m[2])) urls.add(m[2]); 2554 + } 2555 + const map = new Map(); 2556 + await Promise.all([...urls].map(async (url) => { 2557 + try { 2558 + const res = await fetch(url); 2559 + if (!res.ok) return; 2560 + const buf = await res.arrayBuffer(); 2561 + const ext = url.toLowerCase().match(FONT_EXT_RE)?.[1] || 'woff2'; 2562 + const mime = FONT_MIME[ext] || 'application/octet-stream'; 2563 + map.set(url, 'data:' + mime + ';base64,' + bufferToBase64(buf)); 2564 + } catch { /* skip; fall through to URL */ } 2565 + })); 2566 + return cssText.replace(urlRe, (orig, q, url) => { 2567 + const data = map.get(url); 2568 + return data ? 'url(' + q + data + q + ')' : orig; 2569 + }); 2570 + } 2571 + async function collectFontCssText() { 2572 + const chunks = []; 2573 + const fontFaceRe = /@font-face\s*\{[^}]*\}/g; 2574 + for (const sheet of document.styleSheets) { 2575 + try { 2576 + const rules = sheet.cssRules; 2577 + for (const rule of rules) { 2578 + if (rule.constructor.name === 'CSSFontFaceRule' || rule.cssText?.startsWith('@font-face')) { 2579 + chunks.push(rule.cssText); 2580 + } 2581 + } 2582 + } catch { 2583 + if (!sheet.href) continue; 2584 + try { 2585 + const res = await fetch(sheet.href); 2586 + if (!res.ok) continue; 2587 + const text = await res.text(); 2588 + let m2; 2589 + while ((m2 = fontFaceRe.exec(text))) chunks.push(m2[0]); 2590 + } catch { /* ignore; capture is best-effort */ } 2591 + } 2592 + } 2593 + if (chunks.length === 0) return ''; 2594 + return inlineFontUrls(chunks.join('\n')); 2595 + } 2596 + 2597 + // True if `s` is a computed color string that renders as nothing 2598 + // (explicit `transparent`, or `rgba(...)` with alpha 0). 2599 + function isTransparentColor(s) { 2600 + if (!s) return true; 2601 + if (s === 'transparent') return true; 2602 + const m = /rgba?\(([^)]+)\)/.exec(s); 2603 + if (!m) return false; 2604 + const parts = m[1].split(',').map((p) => p.trim()); 2605 + if (parts.length === 4) return parseFloat(parts[3]) === 0; 2606 + return false; 2607 + } 2608 + 2609 + // modern-screenshot force-sets `background-color: X !important` on the 2610 + // cloned root whenever `backgroundColor` is passed, clobbering the 2611 + // element's own background. So we only pass it when the element is 2612 + // genuinely transparent (no own color, no own image) — in that case 2613 + // we resolve up the DOM to the nearest opaque ancestor so the capture 2614 + // sits on the page's real background instead of rendering black. 2615 + function resolveCanvasBackground(el) { 2616 + const own = getComputedStyle(el); 2617 + if (!isTransparentColor(own.backgroundColor)) return null; 2618 + if (own.backgroundImage && own.backgroundImage !== 'none') return null; 2619 + let node = el.parentElement; 2620 + while (node) { 2621 + const cs = getComputedStyle(node); 2622 + if (!isTransparentColor(cs.backgroundColor)) return cs.backgroundColor; 2623 + node = node.parentElement; 2624 + } 2625 + return ( 2626 + getComputedStyle(document.body).backgroundColor || 2627 + getComputedStyle(document.documentElement).backgroundColor || 2628 + '#ffffff' 2629 + ); 2630 + } 2631 + 2632 + // Capture the element (with current annotations baked in) and return a PNG 2633 + // Blob. Shared between the Go flow (uploads it to the server) and the 2634 + // debug toggle (displays it as an overlay for side-by-side comparison). 2635 + async function captureElementToBlob(el, snapshot, rect) { 2636 + try { if (document.fonts?.ready) await document.fonts.ready; } catch {} 2637 + const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0); 2638 + let annotNode = null; 2639 + let savedPosition = null; 2640 + if (hasAnnotations) { 2641 + const pos = getComputedStyle(el).position; 2642 + if (pos === 'static') { 2643 + savedPosition = el.style.position; 2644 + el.style.position = 'relative'; 2645 + } 2646 + annotNode = buildAnnotationsForCapture(rect, snapshot); 2647 + el.appendChild(annotNode); 2648 + } 2649 + try { 2650 + const ms = await loadModernScreenshot(); 2651 + const fontCssText = await collectFontCssText(); 2652 + const backgroundColor = resolveCanvasBackground(el); 2653 + return await ms.domToBlob(el, { 2654 + scale: Math.min(window.devicePixelRatio || 1, 2), 2655 + font: fontCssText ? { cssText: fontCssText } : undefined, 2656 + ...(backgroundColor ? { backgroundColor } : {}), 2657 + }); 2658 + } finally { 2659 + if (annotNode) annotNode.remove(); 2660 + if (savedPosition !== null) el.style.position = savedPosition; 2661 + } 2662 + } 2663 + 2664 + async function captureAndEmit(el, basePayload, snapshot, rect) { 2665 + let screenshotPath; 2666 + let blob; 2667 + try { 2668 + blob = await captureElementToBlob(el, snapshot, rect); 2669 + } catch (err) { 2670 + console.warn('[impeccable] capture failed, proceeding without screenshot:', err); 2671 + } 2672 + // Light up the shader overlay the moment capture is ready — no reason to 2673 + // wait for the upload to complete before the user sees something alive. 2674 + if (blob && state === 'GENERATING') { 2675 + showShaderOverlay(el, blob, rect); 2676 + } 2677 + // Only upload + forward the screenshot when annotations (comments/strokes) 2678 + // are present. Without annotations the image is pure visual anchoring — 2679 + // it biases the model toward the current rendering and works against the 2680 + // three-distinct-directions brief. 2681 + const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0); 2682 + if (blob && hasAnnotations) { 2683 + try { 2684 + const uploadRes = await fetch( 2685 + 'http://localhost:' + PORT + '/annotation?token=' + encodeURIComponent(TOKEN) + 2686 + '&eventId=' + encodeURIComponent(basePayload.id), 2687 + { method: 'POST', headers: { 'Content-Type': 'image/png' }, body: blob }, 2688 + ); 2689 + if (uploadRes.ok) { 2690 + const { path: p } = await uploadRes.json(); 2691 + screenshotPath = p; 2692 + } else { 2693 + console.warn('[impeccable] annotation upload failed:', uploadRes.status); 2694 + } 2695 + } catch (err) { 2696 + console.warn('[impeccable] annotation upload failed:', err); 2697 + } 2698 + } 2699 + sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload); 2700 + } 2701 + 2702 + // --------------------------------------------------------------------------- 2703 + // Shader overlay — renders the captured screenshot as a WebGL texture and 2704 + // runs an editorial "ink-wash" fragment shader over it during generation. 2705 + // A single rolling band sweeps top-to-bottom, desaturating + tinting magenta 2706 + // and leaving a soft trail. Makes the wait feel like a letterpress scan 2707 + // instead of a dead spinner. 2708 + // --------------------------------------------------------------------------- 2709 + 2710 + const SHADER_VS = `attribute vec2 a_position; 2711 + attribute vec2 a_uv; 2712 + varying vec2 v_uv; 2713 + void main() { 2714 + v_uv = a_uv; 2715 + gl_Position = vec4(a_position, 0.0, 1.0); 2716 + }`; 2717 + 2718 + const SHADER_FS = `precision highp float; 2719 + uniform sampler2D u_texture; 2720 + uniform float u_time; 2721 + uniform vec2 u_resolution; 2722 + uniform vec3 u_accent; 2723 + varying vec2 v_uv; 2724 + 2725 + // Asymmetric roller band. Product of two one-sided smoothsteps — peaks at 2726 + // d=0 with a short sharp leading ramp and a longer soft trailing tail. Clean 2727 + // outside the [-leadW, trailW] range (no rogue "trail=1 everywhere below" 2728 + // failure that reversed-edge smoothstep would give). 2729 + float bandAt(float d, float leadW, float trailW) { 2730 + float above = smoothstep(-leadW, 0.0, d); 2731 + float below = 1.0 - smoothstep(0.0, trailW, d); 2732 + return above * below; 2733 + } 2734 + 2735 + void main() { 2736 + vec2 uv = v_uv; 2737 + // Roller sweeps top-to-bottom with small overshoot so each cycle enters 2738 + // and exits the element cleanly. 2739 + float phase = fract(u_time / 3.4); 2740 + float y = phase * 1.25 - 0.12; 2741 + float band = bandAt(uv.y - y, 0.05, 0.32); 2742 + 2743 + // Halftone cell grid (fixed ~10 px pitch). 2744 + float cellPx = 10.0; 2745 + vec2 gridUv = uv * u_resolution / cellPx; 2746 + vec2 cellId = floor(gridUv); 2747 + vec2 cellUv = fract(gridUv) - 0.5; 2748 + vec2 sampleCenter = (cellId + 0.5) * cellPx / u_resolution; 2749 + vec3 cellImg = texture2D(u_texture, sampleCenter).rgb; 2750 + float luma = dot(cellImg, vec3(0.299, 0.587, 0.114)); 2751 + // Darker cells → bigger magenta dots (classic risograph halftone curve). 2752 + float radius = sqrt(clamp(1.0 - luma, 0.0, 1.0)) * 0.56; 2753 + float dotMask = smoothstep(radius + 0.06, radius, length(cellUv)); 2754 + vec3 paper = vec3(0.975, 0.965, 0.955); 2755 + vec3 dotLayer = mix(paper, u_accent, dotMask); 2756 + 2757 + // Blend the halftone layer in where the roller is passing; leave the 2758 + // element pristine elsewhere. 2759 + vec3 base = texture2D(u_texture, uv).rgb; 2760 + gl_FragColor = vec4(mix(base, dotLayer, band), 1.0); 2761 + }`; 2762 + 2763 + // Editorial Magenta converted to approximate sRGB 0-1 (matches oklch(60% 0.25 350)) 2764 + const SHADER_ACCENT = [0.82, 0.16, 0.47]; 2765 + let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } 2766 + 2767 + function compileShader(gl, type, source) { 2768 + const sh = gl.createShader(type); 2769 + gl.shaderSource(sh, source); 2770 + gl.compileShader(sh); 2771 + if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { 2772 + const info = gl.getShaderInfoLog(sh); 2773 + gl.deleteShader(sh); 2774 + throw new Error('shader compile failed: ' + info); 2775 + } 2776 + return sh; 2777 + } 2778 + 2779 + function positionShaderOverlay() { 2780 + if (!shaderState || !selectedElement) return; 2781 + const r = selectedElement.getBoundingClientRect(); 2782 + Object.assign(shaderState.canvas.style, { 2783 + top: r.top + 'px', left: r.left + 'px', 2784 + width: r.width + 'px', height: r.height + 'px', 2785 + }); 2786 + } 2787 + 2788 + function hideShaderOverlay() { 2789 + if (!shaderState) return; 2790 + if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); 2791 + if (shaderState.canvas) shaderState.canvas.remove(); 2792 + const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); 2793 + try { lose?.loseContext(); } catch {} 2794 + shaderState = null; 2795 + } 2796 + 2797 + async function showShaderOverlay(el, blob, rect) { 2798 + hideShaderOverlay(); 2799 + if (!blob || !el) return; 2800 + const canvas = document.createElement('canvas'); 2801 + canvas.id = PREFIX + '-shader'; 2802 + const dpr = Math.min(window.devicePixelRatio || 1, 2); 2803 + canvas.width = Math.max(1, Math.floor(rect.width * dpr)); 2804 + canvas.height = Math.max(1, Math.floor(rect.height * dpr)); 2805 + Object.assign(canvas.style, { 2806 + position: 'fixed', 2807 + top: rect.top + 'px', left: rect.left + 'px', 2808 + width: rect.width + 'px', height: rect.height + 'px', 2809 + pointerEvents: 'none', 2810 + zIndex: Z.bar - 1, 2811 + }); 2812 + document.body.appendChild(canvas); 2813 + 2814 + const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false }) 2815 + || canvas.getContext('experimental-webgl'); 2816 + if (!gl) { 2817 + // WebGL unavailable — fall back to a plain <img> overlay so the user 2818 + // still sees something meaningful during generation. 2819 + canvas.remove(); 2820 + const img = document.createElement('img'); 2821 + img.src = URL.createObjectURL(blob); 2822 + img.id = PREFIX + '-shader'; 2823 + // Copy positioning via cssText. Object.assign across CSSStyleDeclaration 2824 + // throws in modern Chromium because the source's indexed properties 2825 + // (style[0], [1], ...) are read-only and the engine forbids writing 2826 + // them on the destination. 2827 + img.style.cssText = canvas.style.cssText; 2828 + img.style.outline = '2px dashed ' + C.brand; 2829 + img.style.outlineOffset = '-2px'; 2830 + document.body.appendChild(img); 2831 + shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 }; 2832 + return; 2833 + } 2834 + 2835 + let program, texture; 2836 + try { 2837 + const vs = compileShader(gl, gl.VERTEX_SHADER, SHADER_VS); 2838 + const fs = compileShader(gl, gl.FRAGMENT_SHADER, SHADER_FS); 2839 + program = gl.createProgram(); 2840 + gl.attachShader(program, vs); 2841 + gl.attachShader(program, fs); 2842 + gl.linkProgram(program); 2843 + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { 2844 + throw new Error('program link failed: ' + gl.getProgramInfoLog(program)); 2845 + } 2846 + // Full-screen quad 2847 + const buf = gl.createBuffer(); 2848 + gl.bindBuffer(gl.ARRAY_BUFFER, buf); 2849 + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ 2850 + -1, -1, 0, 1, 2851 + 1, -1, 1, 1, 2852 + -1, 1, 0, 0, 2853 + -1, 1, 0, 0, 2854 + 1, -1, 1, 1, 2855 + 1, 1, 1, 0, 2856 + ]), gl.STATIC_DRAW); 2857 + const posLoc = gl.getAttribLocation(program, 'a_position'); 2858 + const uvLoc = gl.getAttribLocation(program, 'a_uv'); 2859 + gl.enableVertexAttribArray(posLoc); 2860 + gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 16, 0); 2861 + gl.enableVertexAttribArray(uvLoc); 2862 + gl.vertexAttribPointer(uvLoc, 2, gl.FLOAT, false, 16, 8); 2863 + } catch (err) { 2864 + console.warn('[impeccable] shader setup failed:', err); 2865 + canvas.remove(); 2866 + return; 2867 + } 2868 + 2869 + // Upload the screenshot as a texture 2870 + let bitmap; 2871 + try { 2872 + bitmap = await createImageBitmap(blob); 2873 + } catch { 2874 + // Safari fallback: go via a regular Image 2875 + const imgUrl = URL.createObjectURL(blob); 2876 + const img = new Image(); 2877 + img.src = imgUrl; 2878 + await new Promise((r, rej) => { img.onload = r; img.onerror = rej; }); 2879 + bitmap = img; 2880 + URL.revokeObjectURL(imgUrl); 2881 + } 2882 + texture = gl.createTexture(); 2883 + gl.bindTexture(gl.TEXTURE_2D, texture); 2884 + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); 2885 + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); 2886 + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); 2887 + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); 2888 + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); 2889 + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bitmap); 2890 + if (bitmap.close) bitmap.close(); 2891 + 2892 + const uTime = gl.getUniformLocation(program, 'u_time'); 2893 + const uRes = gl.getUniformLocation(program, 'u_resolution'); 2894 + const uAccent = gl.getUniformLocation(program, 'u_accent'); 2895 + const uTex = gl.getUniformLocation(program, 'u_texture'); 2896 + const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; 2897 + 2898 + shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; 2899 + function frame() { 2900 + if (!shaderState) return; 2901 + const elapsed = (performance.now() - shaderState.startTime) / 1000; 2902 + const t = shaderState.reduced ? 0.0 : elapsed; 2903 + gl.viewport(0, 0, canvas.width, canvas.height); 2904 + gl.useProgram(program); 2905 + gl.activeTexture(gl.TEXTURE0); 2906 + gl.bindTexture(gl.TEXTURE_2D, texture); 2907 + gl.uniform1i(uTex, 0); 2908 + gl.uniform1f(uTime, t); 2909 + gl.uniform2f(uRes, canvas.width, canvas.height); 2910 + gl.uniform3f(uAccent, SHADER_ACCENT[0], SHADER_ACCENT[1], SHADER_ACCENT[2]); 2911 + gl.drawArrays(gl.TRIANGLES, 0, 6); 2912 + shaderState.rafId = requestAnimationFrame(frame); 2913 + } 2914 + frame(); 2915 + } 2916 + 2917 + function handleAccept() { 2918 + if (!currentSessionId || arrivedVariants === 0) return; 2919 + const acceptPayload = { type: 'accept', id: currentSessionId, variantId: String(visibleVariant) }; 2920 + if (Object.keys(paramsCurrentValues).length > 0) { 2921 + acceptPayload.paramValues = { ...paramsCurrentValues }; 2922 + } 2923 + sendEvent(acceptPayload); 2924 + markSessionHandled(); 2925 + 2926 + // The accepted variant is already the only visible child of the wrapper 2927 + // (all other variants are display:none). HMR from the source rewrite will 2928 + // replace the wrapper imminently. Don't eagerly replaceChild here — React 2929 + // reconciliation races with our mutation and throws NotFoundError in Next 2930 + // 16 / Turbopack. Schedule a fallback that runs the manual swap only if 2931 + // HMR hasn't cleaned up by then (keeps static-server flows working). 2932 + const acceptedSessionId = currentSessionId; 2933 + const acceptedVariant = visibleVariant; 2934 + 2935 + state = 'CONFIRMED'; 2936 + updateBarContent('confirmed'); 2937 + setTimeout(function() { 2938 + hideBar(); 2939 + hideHighlight(); 2940 + stopScrollTracking(); 2941 + if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } 2942 + stopScrollLock(); 2943 + clearScrollY(); 2944 + clearSession(); 2945 + selectedElement = null; 2946 + currentSessionId = null; 2947 + selectedAction = 'impeccable'; 2948 + state = 'PICKING'; 2949 + }, 1800); 2950 + 2951 + // Static-server / no-HMR fallback: if the wrapper is still around 2s after 2952 + // the cleanup above, swap it out manually. By now React has either moved 2953 + // on or the app isn't React at all. Preserve the `data-impeccable-variant="N"` 2954 + // div (with display:contents) so @scope rules anchored to the variant 2955 + // attribute keep matching until reload replaces it with the carbonize block. 2956 + setTimeout(function() { 2957 + const wrapper = document.querySelector('[data-impeccable-variants="' + acceptedSessionId + '"]'); 2958 + if (!wrapper) return; 2959 + const accepted = wrapper.querySelector('[data-impeccable-variant="' + acceptedVariant + '"]'); 2960 + if (accepted && accepted.firstElementChild) { 2961 + const parent = wrapper.parentElement; 2962 + if (!parent) return; 2963 + accepted.style.display = 'contents'; 2964 + parent.replaceChild(accepted, wrapper); 2965 + } 2966 + }, 2000); 2967 + } 2968 + 2969 + function handleDiscard() { 2970 + if (!currentSessionId) return; 2971 + sendEvent({ type: 'discard', id: currentSessionId }); 2972 + markSessionHandled(); 2973 + // Instant DOM restore + fire-and-forget (script handles file cleanup) 2974 + cleanup(); 2975 + } 2976 + 2977 + // --------------------------------------------------------------------------- 2978 + // Session persistence via localStorage 2979 + // --------------------------------------------------------------------------- 2980 + // Survives page reloads, browser close/reopen, HMR, and accidental refreshes. 2981 + 2982 + const LS_KEY = PREFIX + '-session'; 2983 + 2984 + function saveSession() { 2985 + if (!currentSessionId) return; 2986 + // NOTE: scrollY is stored under a separate key (writeScrollY). Storing 2987 + // it here would overwrite the Go-time value every time state changes. 2988 + try { 2989 + localStorage.setItem(LS_KEY, JSON.stringify({ 2990 + id: currentSessionId, 2991 + state: state, 2992 + action: selectedAction, 2993 + count: selectedCount, 2994 + expected: expectedVariants, 2995 + arrived: arrivedVariants, 2996 + visible: visibleVariant, 2997 + })); 2998 + } catch { /* quota exceeded or private mode */ } 2999 + } 3000 + 3001 + function loadSession() { 3002 + try { 3003 + const raw = localStorage.getItem(LS_KEY); 3004 + return raw ? JSON.parse(raw) : null; 3005 + } catch { return null; } 3006 + } 3007 + 3008 + function clearSession() { 3009 + try { localStorage.removeItem(LS_KEY); } catch {} 3010 + } 3011 + 3012 + /** Mark session as handled (accepted/discarded). The agent will clean up 3013 + * the source, but until it does the wrapper is still in the HTML. This 3014 + * prevents resumeSession from picking it up again after reload. */ 3015 + function markSessionHandled() { 3016 + if (!currentSessionId) return; 3017 + try { 3018 + localStorage.setItem(LS_KEY + '-handled', currentSessionId); 3019 + } catch {} 3020 + } 3021 + 3022 + function isSessionHandled(id) { 3023 + try { 3024 + return localStorage.getItem(LS_KEY + '-handled') === id; 3025 + } catch { return false; } 3026 + } 3027 + 3028 + function clearHandled() { 3029 + try { localStorage.removeItem(LS_KEY + '-handled'); } catch {} 3030 + } 3031 + 3032 + function cleanup() { 3033 + // Hide the wrapper immediately so variants disappear. DON'T structurally 3034 + // mutate the DOM yet — HMR from the agent's source rewrite is on its way, 3035 + // and a manual replaceChild under React causes NotFoundError when the 3036 + // reconciler later tries to remove a wrapper we already removed. 3037 + // Schedule a 2s fallback that does the manual swap only if HMR hasn't 3038 + // replaced the wrapper by then (keeps static-server / no-HMR flows alive). 3039 + const cleanupSessionId = currentSessionId; 3040 + if (cleanupSessionId) { 3041 + const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); 3042 + if (wrapper) wrapper.style.display = 'none'; 3043 + } 3044 + setTimeout(function() { 3045 + if (!cleanupSessionId) return; 3046 + const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); 3047 + if (!wrapper) return; 3048 + const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); 3049 + if (orig) { 3050 + const content = orig.firstElementChild; 3051 + if (content) { 3052 + wrapper.parentElement.replaceChild(content, wrapper); 3053 + return; 3054 + } 3055 + } 3056 + wrapper.remove(); 3057 + }, 2000); 3058 + hideBar(); 3059 + hideHighlight(); 3060 + stopScrollTracking(); 3061 + if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } 3062 + stopScrollLock(); 3063 + clearScrollY(); 3064 + clearSession(); 3065 + selectedElement = null; 3066 + currentSessionId = null; 3067 + selectedAction = 'impeccable'; 3068 + state = 'PICKING'; 3069 + } 3070 + 3071 + // --------------------------------------------------------------------------- 3072 + // Toast 3073 + // --------------------------------------------------------------------------- 3074 + 3075 + function showToast(message, duration) { 3076 + if (toastEl) toastEl.remove(); 3077 + // Stack the toast above the global bar (which sits at bottom:14px) so 3078 + // the two never overlap. Read the bar's actual rect — its height varies 3079 + // with hover-expanded labels — and fall back to a sensible default 3080 + // when the bar isn't mounted yet. 3081 + const barRect = globalBarEl?.getBoundingClientRect(); 3082 + const barTopFromBottom = barRect && barRect.height > 0 3083 + ? Math.max(16, window.innerHeight - barRect.top + 12) 3084 + : 16; 3085 + toastEl = el('div', { 3086 + position: 'fixed', bottom: barTopFromBottom + 'px', left: '50%', 3087 + transform: 'translateX(-50%) translateY(8px)', 3088 + background: C.ink, color: C.white, 3089 + fontFamily: FONT, fontSize: '12px', 3090 + padding: '8px 16px', borderRadius: '8px', 3091 + zIndex: Z.toast, opacity: '0', 3092 + transition: 'opacity 0.25s ' + EASE + ', transform 0.25s ' + EASE, 3093 + pointerEvents: 'none', maxWidth: '420px', textAlign: 'center', 3094 + }); 3095 + toastEl.id = PREFIX + '-toast'; 3096 + toastEl.textContent = message; 3097 + document.body.appendChild(toastEl); 3098 + requestAnimationFrame(() => { 3099 + toastEl.style.opacity = '1'; 3100 + toastEl.style.transform = 'translateX(-50%) translateY(0)'; 3101 + }); 3102 + setTimeout(() => { 3103 + if (toastEl) { 3104 + toastEl.style.opacity = '0'; 3105 + toastEl.style.transform = 'translateX(-50%) translateY(8px)'; 3106 + setTimeout(() => { if (toastEl) { toastEl.remove(); toastEl = null; } }, 250); 3107 + } 3108 + }, duration); 3109 + } 3110 + 3111 + // --------------------------------------------------------------------------- 3112 + // Init 3113 + // --------------------------------------------------------------------------- 3114 + 3115 + // Resume an active variant session after HMR/page reload. 3116 + // If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote 3117 + // variants before HMR fired. Pick up where we left off. 3118 + function resumeSession() { 3119 + const wrapper = document.querySelector('[data-impeccable-variants]'); 3120 + if (!wrapper) { clearSession(); clearHandled(); return false; } 3121 + 3122 + const sessionId = wrapper.dataset.impeccableVariants; 3123 + 3124 + // Don't resume if this session was already accepted/discarded 3125 + if (isSessionHandled(sessionId)) return false; 3126 + 3127 + currentSessionId = sessionId; 3128 + expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || '0'); 3129 + const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); 3130 + arrivedVariants = variants.length; 3131 + 3132 + // Restore state from localStorage if available 3133 + const saved = loadSession(); 3134 + if (saved && saved.id === sessionId) { 3135 + visibleVariant = (saved.visible > 0 && saved.visible <= arrivedVariants) ? saved.visible : (arrivedVariants > 0 ? 1 : 0); 3136 + if (saved.action) selectedAction = saved.action; 3137 + if (saved.count) selectedCount = saved.count; 3138 + } else { 3139 + visibleVariant = arrivedVariants > 0 ? 1 : 0; 3140 + } 3141 + 3142 + // Find the visible variant's content element for highlight positioning. 3143 + // Try the visible variant first, fall back to the original's content. 3144 + const visEl = visibleVariant > 0 ? pickVariantContent(wrapper, visibleVariant) : null; 3145 + const origEl = pickVariantContent(wrapper, 'original'); 3146 + selectedElement = visEl || origEl || wrapper.parentElement; 3147 + 3148 + // Set display state BEFORE starting observer (avoid triggering it) 3149 + if (visibleVariant > 0) showVariantInDOM(currentSessionId, visibleVariant); 3150 + 3151 + state = arrivedVariants >= expectedVariants ? 'CYCLING' : 'GENERATING'; 3152 + showBar(state === 'CYCLING' ? 'cycling' : 'generating'); 3153 + startScrollTracking(); 3154 + // Build the params panel for the restored visible variant. Previously 3155 + // this was missed on page-reload resume: showVariantInDOM above fires 3156 + // refreshParamsPanel, but state was still IDLE at that moment so it 3157 + // hid. Now that state is CYCLING, re-fire. 3158 + if (state === 'CYCLING') refreshParamsPanel(); 3159 + saveSession(); 3160 + 3161 + // Start observing for more variants AFTER initial setup 3162 + if (variantObserver) variantObserver.disconnect(); 3163 + variantObserver = startVariantObserver(currentSessionId); 3164 + 3165 + // Hold the target at its saved viewport top through any subsequent 3166 + // HMR patches, variant inserts, or cycle swaps. 3167 + startScrollLock(currentSessionId, readScrollY()); 3168 + 3169 + // If we reloaded mid-generation (Bun's HTML HMR destroys the shader 3170 + // canvas), re-capture the original's content and restart the shader so 3171 + // the wait doesn't go dead. 3172 + if (state === 'GENERATING' && origEl) { 3173 + (async () => { 3174 + try { 3175 + const rect = origEl.getBoundingClientRect(); 3176 + if (rect.width === 0 || rect.height === 0) return; 3177 + const blob = await captureElementToBlob(origEl, null, rect); 3178 + if (blob && state === 'GENERATING') { 3179 + showShaderOverlay(origEl, blob, rect); 3180 + } 3181 + } catch (err) { 3182 + console.warn('[impeccable] shader resume failed:', err); 3183 + } 3184 + })(); 3185 + } 3186 + return true; 3187 + } 3188 + 3189 + // --------------------------------------------------------------------------- 3190 + // Global bar (always visible at bottom) 3191 + // --------------------------------------------------------------------------- 3192 + 3193 + let globalBarEl = null; 3194 + let detectActive = false; 3195 + let pickActive = true; 3196 + let detectCount = 0; 3197 + let detectScriptLoaded = false; 3198 + 3199 + // Theme-aware color palette for the global bar. We detect the page's 3200 + // ambient background and invert — dark bar on light pages, light bar on 3201 + // dark pages. This keeps the bar from fighting with the host design. 3202 + function detectPageTheme() { 3203 + try { 3204 + // Dev override: set localStorage 'impeccable-dev-theme' to 'light' or 3205 + // 'dark' to preview the opposite palette without actually changing the 3206 + // page bg. Used for screenshots and theme QA. 3207 + const override = localStorage.getItem('impeccable-dev-theme'); 3208 + if (override === 'light' || override === 'dark') return override; 3209 + 3210 + // Walk body → html, taking the first opaque background. The browser's 3211 + // default body / html background is `rgba(0, 0, 0, 0)`, which a naive 3212 + // regex would read as black and mislabel a perfectly white page as 3213 + // dark. Honoring alpha avoids that — and falling through to <html> 3214 + // catches the common pattern of a bg only on <html> (or only on body). 3215 + function readOpaque(el) { 3216 + if (!el) return null; 3217 + const bg = getComputedStyle(el).backgroundColor; 3218 + const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)/); 3219 + if (!m) return null; 3220 + const alpha = m[4] == null ? 1 : parseFloat(m[4]); 3221 + if (alpha < 0.5) return null; // transparent / nearly transparent → skip 3222 + return [+m[1], +m[2], +m[3]]; 3223 + } 3224 + 3225 + const rgb = readOpaque(document.body) || readOpaque(document.documentElement); 3226 + // Both transparent → fall back to the browser's effective canvas color. 3227 + // White is the universal default; only one in a thousand sites swaps it 3228 + // via `color-scheme: dark` on <html>, and `prefers-color-scheme` lets 3229 + // us catch that case. 3230 + if (!rgb) { 3231 + return matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; 3232 + } 3233 + const [r, g, b] = rgb; 3234 + // Perceptual luminance (Rec. 709) 3235 + const L = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; 3236 + return L > 0.55 ? 'light' : 'dark'; 3237 + } catch { return 'light'; } 3238 + } 3239 + 3240 + function barPaletteForTheme(theme) { 3241 + if (theme === 'dark') { 3242 + // Light bar on dark page 3243 + return { 3244 + surface: 'oklch(98% 0 0 / 0.92)', 3245 + surfaceDeep: 'oklch(92% 0.005 60 / 0.96)', // slightly deeper, faint warm 3246 + hairline: 'oklch(70% 0 0 / 0.35)', 3247 + text: 'oklch(15% 0 0)', 3248 + textDim: 'oklch(45% 0 0)', 3249 + accent: 'oklch(60% 0.25 350)', 3250 + accentSoft: 'oklch(60% 0.25 350 / 0.18)', 3251 + mark: 'oklch(98% 0 0)', // logo mark fill 3252 + markText: 'oklch(15% 0 0)', // logo "/" color 3253 + exitHover: 'oklch(85% 0 0 / 0.5)', 3254 + }; 3255 + } 3256 + // Dark bar on light page. Bar is a warm charcoal, logo slab is much 3257 + // deeper so the rounded-right shape reads as a clear sculpted mark. 3258 + return { 3259 + surface: 'oklch(26% 0 0 / 0.94)', 3260 + surfaceDeep: 'oklch(18% 0 0 / 0.96)', // darker sand for Tune popover 3261 + hairline: 'oklch(42% 0 0 / 0.5)', 3262 + text: 'oklch(96% 0 0)', 3263 + textDim: 'oklch(72% 0 0)', 3264 + accent: 'oklch(72% 0.22 350)', 3265 + accentSoft: 'oklch(72% 0.22 350 / 0.22)', 3266 + mark: 'oklch(8% 0 0)', 3267 + markText: 'oklch(96% 0 0)', 3268 + exitHover: 'oklch(36% 0 0 / 0.6)', 3269 + }; 3270 + } 3271 + 3272 + // Impeccable logo mark — matches the site-header SVG (rounded square + "/"). 3273 + function brandMarkSvg(fill, ink, size = 18) { 3274 + return `<svg width="${size}" height="${size}" viewBox="0 0 32 32" aria-hidden="true"> 3275 + <rect width="32" height="32" rx="7" fill="${fill}"/> 3276 + <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> 3277 + </svg>`; 3278 + } 3279 + 3280 + function initGlobalBar() { 3281 + const theme = detectPageTheme(); 3282 + const P = barPaletteForTheme(theme); 3283 + 3284 + // Custom focus-visible for bar buttons. Browser default is a heavy 3285 + // blue ring that looks jarring on the dark capsule. Replace with a 3286 + // soft accent-tinted inner ring that respects the bar's palette. 3287 + if (!document.getElementById(PREFIX + '-bar-focus-style')) { 3288 + const s = document.createElement('style'); 3289 + s.id = PREFIX + '-bar-focus-style'; 3290 + s.textContent = 3291 + '#' + PREFIX + '-global-bar button:focus { outline: none; }' + 3292 + '#' + PREFIX + '-global-bar button:focus-visible {' + 3293 + ' outline: none;' + 3294 + ' box-shadow: 0 0 0 2px ' + P.accentSoft + ', 0 0 0 3px ' + P.accent + ';' + 3295 + '}'; 3296 + document.head.appendChild(s); 3297 + } 3298 + 3299 + globalBarEl = el('div', { 3300 + position: 'fixed', bottom: '14px', left: '50%', 3301 + transform: 'translateX(-50%) translateY(20px)', 3302 + zIndex: Z.bar + 5, 3303 + display: 'flex', alignItems: 'stretch', 3304 + gap: '2px', 3305 + background: P.surface, 3306 + backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)', 3307 + border: '1px solid ' + P.hairline, 3308 + borderRadius: '10px', 3309 + boxShadow: '0 4px 20px oklch(0% 0 0 / 0.12), 0 1px 3px oklch(0% 0 0 / 0.08)', 3310 + fontFamily: FONT, fontSize: '12px', lineHeight: '1', 3311 + opacity: '0', 3312 + overflow: 'hidden', // clip the full-bleed brand mark to the bar radius 3313 + transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE, 3314 + }); 3315 + globalBarEl.id = PREFIX + '-global-bar'; 3316 + globalBarEl.dataset.theme = theme; 3317 + 3318 + // Brand mark — fills bar height on the left. Left side inherits the bar's 3319 + // rounded corner via overflow:hidden; right side is a clean hard edge since 3320 + // the near-black/charcoal contrast does the shape-defining work. 3321 + const brand = el('span', { 3322 + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', 3323 + alignSelf: 'stretch', 3324 + padding: '0 12px 0 14px', 3325 + background: P.mark, 3326 + color: P.markText, 3327 + fontFamily: 'system-ui, -apple-system, sans-serif', 3328 + fontWeight: '500', 3329 + fontSize: '18px', lineHeight: '1', 3330 + }); 3331 + brand.textContent = '/'; 3332 + brand.title = 'Impeccable'; 3333 + globalBarEl.appendChild(brand); 3334 + 3335 + // Inner wrapper: holds the toggles with normal bar padding. 3336 + const inner = el('div', { 3337 + display: 'flex', alignItems: 'center', 3338 + padding: '4px 5px', gap: '2px', 3339 + }); 3340 + inner.id = PREFIX + '-global-bar-inner'; 3341 + globalBarEl.appendChild(inner); 3342 + 3343 + // --- button factory: icon-only at rest, label slides in on hover/active --- 3344 + function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) { 3345 + const b = el('button', { 3346 + position: 'relative', 3347 + display: 'inline-flex', alignItems: 'center', 3348 + padding: '6px 8px', borderRadius: '7px', 3349 + border: 'none', background: 'transparent', 3350 + color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500', 3351 + cursor: 'pointer', 3352 + transition: 'background 0.15s ease, color 0.15s ease', 3353 + whiteSpace: 'nowrap', overflow: 'hidden', 3354 + }); 3355 + b.id = id; 3356 + b.title = ariaLabel || label || ''; 3357 + b.setAttribute('aria-label', ariaLabel || label || ''); 3358 + b.innerHTML = svg + (label 3359 + ? `<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>` 3360 + : ''); 3361 + const labelEl = b.querySelector('.icon-btn-label'); 3362 + const expand = () => { 3363 + if (!labelEl) return; 3364 + labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px'; 3365 + }; 3366 + const collapse = () => { 3367 + if (!labelEl || b.dataset.active === 'true') return; 3368 + labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0'; 3369 + }; 3370 + // Per-button hover only changes color (no layout). The label expand/ 3371 + // collapse is driven by the bar-level mouseenter/mouseleave so moving 3372 + // the mouse between adjacent buttons doesn't trigger per-button width 3373 + // thrashing — the whole bar grows once and shrinks once. 3374 + b.addEventListener('mouseenter', () => { if (b.dataset.active !== 'true') b.style.color = P.text; }); 3375 + b.addEventListener('mouseleave', () => { if (b.dataset.active !== 'true') b.style.color = P.textDim; }); 3376 + b.addEventListener('click', onClick); 3377 + b._expandLabel = expand; 3378 + b._collapseLabel = collapse; 3379 + return b; 3380 + } 3381 + 3382 + // Pick toggle — starts active (primary intent when entering live mode). 3383 + const pickBtn = makeIconBtn({ 3384 + id: PREFIX + '-pick-toggle', 3385 + 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>', 3386 + label: 'Pick', 3387 + ariaLabel: 'Pick element', 3388 + onClick: () => togglePick(), 3389 + }); 3390 + pickBtn.style.background = P.accentSoft; 3391 + pickBtn.style.color = P.accent; 3392 + pickBtn.dataset.active = 'true'; 3393 + pickBtn._expandLabel(); 3394 + inner.appendChild(pickBtn); 3395 + 3396 + // Detect toggle 3397 + const detectBtn = makeIconBtn({ 3398 + id: PREFIX + '-detect-toggle', 3399 + 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>', 3400 + label: 'Detect', 3401 + ariaLabel: 'Detect anti-patterns', 3402 + onClick: () => toggleDetect(), 3403 + }); 3404 + const detectBadge = el('span', { 3405 + fontSize: '10px', fontWeight: '600', 3406 + padding: '0px 5px', borderRadius: '7px', lineHeight: '16px', 3407 + background: P.accent, color: P.surface.includes('18%') ? 'oklch(18% 0 0)' : 'oklch(98% 0 0)', 3408 + display: 'none', fontFamily: MONO, marginLeft: '4px', 3409 + }); 3410 + detectBadge.id = PREFIX + '-detect-badge'; 3411 + detectBtn.appendChild(detectBadge); 3412 + inner.appendChild(detectBtn); 3413 + 3414 + // DESIGN.md panel toggle — quartet of color squares as the mark. 3415 + const designBtn = makeIconBtn({ 3416 + id: PREFIX + '-design-toggle', 3417 + 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"> 3418 + <span style="background:oklch(60% 0.25 350)"></span> 3419 + <span style="background:oklch(60% 0.15 45)"></span> 3420 + <span style="background:oklch(55% 0.12 250)"></span> 3421 + <span style="background:oklch(30% 0 0)"></span> 3422 + </span>`, 3423 + label: 'DESIGN.md', 3424 + ariaLabel: 'Toggle DESIGN.md panel', 3425 + labelFont: MONO, 3426 + onClick: () => toggleDesignPanel(), 3427 + }); 3428 + inner.appendChild(designBtn); 3429 + 3430 + // Thin divider before the exit button 3431 + const divider = el('span', { 3432 + width: '1px', height: '18px', 3433 + background: P.hairline, 3434 + margin: '0 4px 0 2px', 3435 + }); 3436 + inner.appendChild(divider); 3437 + 3438 + // Exit × on the right — intentionally subtle (textDim at rest, text on 3439 + // hover) so it sits behind the active toggles in visual hierarchy. 3440 + // 3441 + // Explicit padding + box-sizing here is load-bearing: a host page like 3442 + // `button { padding: 0.5rem 1rem; }` (very common in resets) would 3443 + // otherwise inflate this 24x24 button into 56x40 and push the SVG out 3444 + // of the visible bar — the X stays invisible even though the styles in 3445 + // DevTools look fine. Every other chrome button sets padding inline; 3446 + // this one needed it too. 3447 + const exitBtn = el('button', { 3448 + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', 3449 + padding: '0', boxSizing: 'border-box', 3450 + width: '24px', height: '24px', borderRadius: '6px', 3451 + border: 'none', background: 'transparent', 3452 + color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0', 3453 + cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease', 3454 + }); 3455 + 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>'; 3456 + exitBtn.title = 'Exit live mode'; 3457 + exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = P.text; exitBtn.style.background = P.exitHover; }); 3458 + exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = P.textDim; exitBtn.style.background = 'transparent'; }); 3459 + exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); }); 3460 + inner.appendChild(exitBtn); 3461 + 3462 + // Bar-level hover: expand every toggle's label at once; collapse on leave. 3463 + // Buttons with dataset.active="true" ignore collapse (their label stays). 3464 + const toggles = [pickBtn, detectBtn, designBtn]; 3465 + globalBarEl.addEventListener('mouseenter', () => { 3466 + toggles.forEach((t) => t._expandLabel && t._expandLabel()); 3467 + }); 3468 + globalBarEl.addEventListener('mouseleave', () => { 3469 + toggles.forEach((t) => t._collapseLabel && t._collapseLabel()); 3470 + }); 3471 + 3472 + document.body.appendChild(globalBarEl); 3473 + defangOutsideHandlers(globalBarEl); 3474 + 3475 + requestAnimationFrame(() => { 3476 + globalBarEl.style.opacity = '1'; 3477 + globalBarEl.style.transform = 'translateX(-50%) translateY(0)'; 3478 + }); 3479 + 3480 + // Listen for detection results AND ready signal 3481 + window.addEventListener('message', onDetectMessage); 3482 + } 3483 + 3484 + function updateGlobalBarState() { 3485 + const detectToggle = document.getElementById(PREFIX + '-detect-toggle'); 3486 + const detectBadge = document.getElementById(PREFIX + '-detect-badge'); 3487 + const pickToggle = document.getElementById(PREFIX + '-pick-toggle'); 3488 + const designToggle = document.getElementById(PREFIX + '-design-toggle'); 3489 + const theme = globalBarEl?.dataset.theme || 'light'; 3490 + const P = barPaletteForTheme(theme); 3491 + 3492 + // Sync one toggle's active state, colors, and slide-label visibility. 3493 + function sync(btn, active) { 3494 + if (!btn) return; 3495 + btn.style.background = active ? P.accentSoft : 'transparent'; 3496 + btn.style.color = active ? P.accent : P.textDim; 3497 + btn.dataset.active = active ? 'true' : 'false'; 3498 + if (active && btn._expandLabel) btn._expandLabel(); 3499 + else if (!active && btn._collapseLabel) btn._collapseLabel(); 3500 + } 3501 + sync(pickToggle, pickActive); 3502 + sync(detectToggle, detectActive); 3503 + sync(designToggle, designState.open); 3504 + 3505 + // If the bar is currently under the cursor, keep all labels expanded — 3506 + // otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md) 3507 + // would collapse its label while the user's mouse is still on the bar. 3508 + if (globalBarEl && globalBarEl.matches(':hover')) { 3509 + [pickToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.()); 3510 + } 3511 + 3512 + if (detectBadge) { 3513 + detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none'; 3514 + detectBadge.textContent = detectCount; 3515 + } 3516 + 3517 + // When pick is active, make detect overlays click-through so the picker works 3518 + document.querySelectorAll('.impeccable-overlay').forEach(o => { 3519 + o.style.pointerEvents = pickActive ? 'none' : ''; 3520 + }); 3521 + } 3522 + 3523 + let detectReady = false; // true once detect script posts 'impeccable-ready' 3524 + let detectPendingScan = false; // scan requested before script was ready 3525 + 3526 + function toggleDetect() { 3527 + detectActive = !detectActive; 3528 + updateGlobalBarState(); 3529 + 3530 + if (detectActive) { 3531 + if (!detectScriptLoaded) { 3532 + detectPendingScan = true; 3533 + loadDetectScript(); 3534 + } else if (detectReady) { 3535 + window.postMessage({ source: 'impeccable-command', action: 'scan' }, '*'); 3536 + } else { 3537 + detectPendingScan = true; 3538 + } 3539 + } else { 3540 + window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*'); 3541 + detectCount = 0; 3542 + updateGlobalBarState(); 3543 + } 3544 + } 3545 + 3546 + function togglePick() { 3547 + pickActive = !pickActive; 3548 + updateGlobalBarState(); 3549 + 3550 + if (!pickActive) { 3551 + // Disabling pick clears any in-flight selection and UI: highlight, 3552 + // contextual bar, selectedElement. Otherwise a stale selection sits 3553 + // on screen with no obvious way to dismiss. 3554 + hideHighlight(); 3555 + hideBar(); 3556 + hideActionPicker(); 3557 + selectedElement = null; 3558 + if (state === 'PICKING' || state === 'CONFIGURING') state = 'IDLE'; 3559 + } else { 3560 + if (state === 'IDLE') state = 'PICKING'; 3561 + } 3562 + } 3563 + 3564 + function loadDetectScript() { 3565 + if (detectScriptLoaded) return; 3566 + detectScriptLoaded = true; 3567 + const s = document.createElement('script'); 3568 + s.src = 'http://localhost:' + PORT + '/detect.js'; 3569 + s.dataset.impeccableExtension = 'true'; 3570 + document.head.appendChild(s); 3571 + } 3572 + 3573 + function onDetectMessage(e) { 3574 + if (!e.data || typeof e.data.source !== 'string') return; 3575 + // Detection script is loaded and ready 3576 + if (e.data.source === 'impeccable-ready') { 3577 + detectReady = true; 3578 + if (detectPendingScan && detectActive) { 3579 + detectPendingScan = false; 3580 + window.postMessage({ source: 'impeccable-command', action: 'scan' }, '*'); 3581 + } 3582 + } 3583 + // Scan results arrived 3584 + if (e.data.source === 'impeccable-results') { 3585 + detectCount = e.data.count || 0; 3586 + updateGlobalBarState(); 3587 + } 3588 + } 3589 + 3590 + /** Full teardown: remove all UI, disconnect SSE, clean up. */ 3591 + function teardown() { 3592 + cleanup(); 3593 + hideBar(); 3594 + if (globalBarEl) { 3595 + globalBarEl.style.transform = 'translateY(100%)'; 3596 + setTimeout(() => { if (globalBarEl) globalBarEl.remove(); globalBarEl = null; }, 300); 3597 + } 3598 + if (highlightEl) { highlightEl.remove(); highlightEl = null; } 3599 + if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; } 3600 + if (barEl) { barEl.remove(); barEl = null; } 3601 + if (pickerEl) { pickerEl.remove(); pickerEl = null; } 3602 + if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; } 3603 + if (evtSource) { evtSource.close(); evtSource = null; } 3604 + document.removeEventListener('mousemove', handleMouseMove, true); 3605 + document.removeEventListener('click', handleClick, true); 3606 + document.removeEventListener('keydown', handleKeyDown, true); 3607 + window.removeEventListener('message', onDetectMessage); 3608 + // Remove detection overlays 3609 + window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*'); 3610 + state = 'IDLE'; 3611 + window.__IMPECCABLE_LIVE_INIT__ = false; 3612 + console.log('[impeccable] Live mode exited.'); 3613 + } 3614 + 3615 + // --------------------------------------------------------------------------- 3616 + // Design System Panel — visualizes the project's DESIGN.json sidecar 3617 + // --------------------------------------------------------------------------- 3618 + 3619 + const DESIGN_PREFS_KEY = 'impeccable-live-design-panel'; 3620 + const DESIGN_PANEL_WIDTH = 440; 3621 + 3622 + let designHost = null; 3623 + let designShadow = null; 3624 + let designState = { 3625 + open: false, 3626 + tab: 'visual', // 'visual' | 'raw' 3627 + parsed: null, // parseDesignMd output (frontmatter + body sections) 3628 + sidecar: null, // DESIGN.json v2 payload (extensions + components + narrative) 3629 + hasMd: false, 3630 + hasSidecar: false, 3631 + present: null, // true/false once fetch resolves 3632 + raw: null, // raw DESIGN.md for the raw tab 3633 + mdNewerThanJson: false, // stale-hint flag 3634 + loading: false, 3635 + error: null, 3636 + collapsed: { // narrative-section accordion state 3637 + rules: true, dosdonts: true, overview: true, 3638 + }, 3639 + }; 3640 + 3641 + function loadDesignPrefs() { 3642 + // `open` is intentionally NOT persisted — the panel always starts closed 3643 + // so live mode doesn't auto-slide a big panel over the page on startup. 3644 + try { 3645 + const raw = localStorage.getItem(DESIGN_PREFS_KEY); 3646 + if (!raw) return; 3647 + const prefs = JSON.parse(raw); 3648 + if (prefs.tab === 'visual' || prefs.tab === 'raw') designState.tab = prefs.tab; 3649 + if (prefs.collapsed && typeof prefs.collapsed === 'object') { 3650 + Object.assign(designState.collapsed, prefs.collapsed); 3651 + } 3652 + } catch { /* ignore */ } 3653 + } 3654 + 3655 + function saveDesignPrefs() { 3656 + try { 3657 + localStorage.setItem(DESIGN_PREFS_KEY, JSON.stringify({ 3658 + tab: designState.tab, 3659 + collapsed: designState.collapsed, 3660 + })); 3661 + } catch { /* ignore */ } 3662 + } 3663 + 3664 + function initDesignPanel() { 3665 + designHost = document.createElement('div'); 3666 + designHost.id = PREFIX + '-design-host'; 3667 + Object.assign(designHost.style, { 3668 + position: 'fixed', top: '0', left: '0', 3669 + width: '0', height: '0', 3670 + zIndex: String(Z.bar + 10), 3671 + pointerEvents: 'none', 3672 + }); 3673 + designShadow = designHost.attachShadow({ mode: 'open' }); 3674 + 3675 + const style = document.createElement('style'); 3676 + // Theme-match the bar: dark chrome on light pages, light chrome on dark pages. 3677 + const theme = detectPageTheme(); 3678 + style.textContent = designPanelCss(barPaletteForTheme(theme)); 3679 + designShadow.appendChild(style); 3680 + 3681 + const root = document.createElement('div'); 3682 + root.className = 'root'; 3683 + designShadow.appendChild(root); 3684 + 3685 + document.body.appendChild(designHost); 3686 + // The host is pointer-events: none; the panel inside the shadow DOM 3687 + // manages its own auto/none. Events bubble through the shadow boundary, 3688 + // so attaching here silences host-page outside-interaction handlers 3689 + // without touching the host's click-through behavior. 3690 + defangOutsideHandlers(designHost, { setPointerEvents: false }); 3691 + 3692 + loadDesignPrefs(); 3693 + renderDesignChrome(); 3694 + if (designState.open) { 3695 + fetchDesignSystem(); 3696 + } 3697 + } 3698 + 3699 + // Neutral panel palette — deliberately NOT Impeccable-branded. The panel is 3700 + // a viewer of the project's design system, not an Impeccable surface. 3701 + const DP = { 3702 + canvas: 'oklch(94% 0 0)', // panel background 3703 + tile: 'oklch(98.5% 0 0)', // card-on-canvas 3704 + tileAlt: 'oklch(96% 0 0)', // subtler tile for inner surfaces 3705 + ink: 'oklch(15% 0 0)', 3706 + ink2: 'oklch(35% 0 0)', 3707 + meta: 'oklch(55% 0 0)', 3708 + hairline: 'oklch(88% 0 0)', 3709 + hairlineSoft: 'oklch(92% 0 0)', 3710 + amber: 'oklch(70% 0.13 65)', // stale-hint accent 3711 + amberBg: 'oklch(95% 0.05 80)', 3712 + }; 3713 + 3714 + function designPanelCss(BP) { 3715 + // BP = bar palette (theme-aware, matches the global bar). 3716 + // DP = internal content palette (neutral, so tiles render colors true). 3717 + return ` 3718 + :host, .root { all: initial; } 3719 + .root { 3720 + font-family: ${FONT}; 3721 + color: ${DP.ink}; 3722 + pointer-events: none; 3723 + } 3724 + .root * { box-sizing: border-box; } 3725 + button { font: inherit; color: inherit; } 3726 + 3727 + /* --- Panel shell: chrome matches the bar; body canvas stays neutral --- */ 3728 + .panel { 3729 + position: fixed; top: 12px; bottom: 72px; right: 12px; 3730 + width: ${DESIGN_PANEL_WIDTH}px; max-width: calc(100vw - 24px); 3731 + background: ${BP.surface}; 3732 + border: 1px solid ${BP.hairline}; 3733 + border-radius: 14px; 3734 + backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); 3735 + box-shadow: 0 20px 60px oklch(0% 0 0 / 0.18), 0 4px 12px oklch(0% 0 0 / 0.08); 3736 + display: flex; flex-direction: column; 3737 + transform: translateX(calc(100% + 24px)); 3738 + opacity: 0; 3739 + transition: transform 0.35s ${EASE}, opacity 0.25s ${EASE}; 3740 + pointer-events: none; 3741 + overflow: hidden; 3742 + } 3743 + .panel[data-open="true"] { transform: translateX(0); opacity: 1; pointer-events: auto; } 3744 + 3745 + .panel-header { 3746 + display: flex; align-items: center; gap: 10px; 3747 + padding: 10px 10px 10px 14px; 3748 + background: transparent; 3749 + border-bottom: 1px solid ${BP.hairline}; 3750 + } 3751 + .panel-title { 3752 + flex: 1; min-width: 0; 3753 + font-family: ${MONO}; 3754 + font-size: 11.5px; font-weight: 600; 3755 + letter-spacing: 0.02em; 3756 + color: ${BP.text}; 3757 + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; 3758 + } 3759 + .panel-close { 3760 + border: none; background: transparent; color: ${BP.textDim}; 3761 + width: 26px; height: 26px; border-radius: 7px; 3762 + display: inline-flex; align-items: center; justify-content: center; 3763 + cursor: pointer; transition: background 0.15s ease, color 0.15s ease; 3764 + } 3765 + .panel-close:hover { background: ${BP.hairline}; color: ${BP.text}; } 3766 + 3767 + .tabs { 3768 + display: inline-flex; padding: 2px; 3769 + background: ${BP.hairline}; 3770 + border-radius: 7px; 3771 + gap: 2px; 3772 + } 3773 + .tab { 3774 + border: none; background: transparent; 3775 + padding: 4px 10px; border-radius: 5px; 3776 + font-family: ${MONO}; 3777 + font-size: 10px; font-weight: 600; letter-spacing: 0.08em; 3778 + text-transform: uppercase; 3779 + color: ${BP.textDim}; cursor: pointer; 3780 + transition: background 0.15s ease, color 0.15s ease; 3781 + } 3782 + .tab[data-active="true"] { background: ${BP.surface}; color: ${BP.text}; } 3783 + 3784 + .panel-body { 3785 + flex: 1; overflow-y: auto; 3786 + padding: 12px 12px 20px; 3787 + background: ${DP.canvas}; 3788 + scrollbar-width: thin; 3789 + scrollbar-color: ${DP.hairline} transparent; 3790 + } 3791 + .panel-body::-webkit-scrollbar { width: 8px; } 3792 + .panel-body::-webkit-scrollbar-thumb { background: ${DP.hairline}; border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; } 3793 + 3794 + /* --- States --- */ 3795 + .empty, .loading, .error { 3796 + margin: 16px 4px; 3797 + padding: 28px 20px; text-align: center; 3798 + background: ${DP.tile}; border-radius: 14px; 3799 + color: ${DP.ink2}; font-size: 13px; line-height: 1.55; 3800 + } 3801 + .empty strong { color: ${DP.ink}; display: block; margin-bottom: 6px; font-size: 14px; } 3802 + .empty code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 6px; border-radius: 4px; font-size: 12px; color: ${DP.ink}; } 3803 + .error { color: oklch(45% 0.15 25); } 3804 + 3805 + /* --- Stale hint --- */ 3806 + .stale { 3807 + display: flex; align-items: center; gap: 8px; 3808 + margin: 8px 4px 12px; 3809 + padding: 8px 12px; 3810 + background: ${DP.amberBg}; 3811 + border-radius: 10px; 3812 + font-size: 11.5px; color: ${DP.ink2}; 3813 + } 3814 + .stale-dot { width: 8px; height: 8px; border-radius: 50%; background: ${DP.amber}; flex-shrink: 0; } 3815 + .stale-text { flex: 1; min-width: 0; } 3816 + .stale-text strong { color: ${DP.ink}; font-weight: 600; } 3817 + 3818 + /* --- Parsed-md fallback banner --- */ 3819 + .parsed-md-cta { 3820 + margin: 8px 4px 14px; 3821 + padding: 14px 16px; 3822 + background: ${DP.tile}; 3823 + border: 1px dashed ${DP.hairline}; 3824 + border-radius: 12px; 3825 + font-size: 12px; color: ${DP.ink2}; line-height: 1.55; 3826 + } 3827 + .parsed-md-cta strong { color: ${DP.ink}; display: block; margin-bottom: 4px; font-size: 13px; font-weight: 600; } 3828 + .parsed-md-cta code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 5px; border-radius: 4px; font-size: 11.5px; color: ${DP.ink}; } 3829 + 3830 + /* --- Tile primitives --- */ 3831 + .tile { 3832 + position: relative; 3833 + background: ${DP.tile}; 3834 + border-radius: 16px; 3835 + padding: 16px; 3836 + margin: 0 4px 10px; 3837 + } 3838 + .tile-row { margin: 0 4px 10px; display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } 3839 + .tile-row .tile { margin: 0; } 3840 + .tile-meta { 3841 + display: flex; align-items: baseline; justify-content: space-between; 3842 + gap: 10px; 3843 + font-family: ${MONO}; 3844 + font-size: 10px; font-weight: 500; letter-spacing: 0.1em; text-transform: uppercase; 3845 + color: ${DP.meta}; 3846 + } 3847 + .tile-meta .name { color: ${DP.ink}; font-weight: 600; letter-spacing: 0.05em; text-transform: none; font-family: ${FONT}; font-size: 12.5px; } 3848 + 3849 + /* --- Color tile --- */ 3850 + .c-tile { cursor: pointer; transition: transform 0.2s ${EASE}; } 3851 + .c-tile:hover { transform: translateY(-1px); } 3852 + .c-hero { 3853 + height: 72px; border-radius: 10px; margin-top: 10px; 3854 + box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.05); 3855 + } 3856 + .c-ramp { 3857 + display: flex; gap: 0; height: 14px; border-radius: 4px; overflow: hidden; 3858 + margin-top: 8px; 3859 + box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.04); 3860 + } 3861 + .c-ramp > span { flex: 1; } 3862 + .c-desc { margin-top: 8px; font-size: 11.5px; line-height: 1.45; color: ${DP.ink2}; } 3863 + 3864 + /* --- Type tile --- */ 3865 + .t-tile { } 3866 + .t-specimen { 3867 + margin: 4px 0 6px; 3868 + color: ${DP.ink}; 3869 + line-height: 0.9; 3870 + } 3871 + .t-family { margin-top: 4px; font-size: 12px; font-weight: 600; color: ${DP.ink}; } 3872 + .t-purpose { margin-top: 4px; font-size: 11px; line-height: 1.45; color: ${DP.ink2}; } 3873 + 3874 + /* --- Shadow tile --- */ 3875 + .s-tile { } 3876 + .s-surface { 3877 + height: 60px; margin: 8px 2px 10px; 3878 + background: ${DP.tile}; 3879 + border-radius: 10px; 3880 + } 3881 + .s-value { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; word-break: break-all; line-height: 1.4; } 3882 + .s-purpose { margin-top: 4px; font-size: 11px; color: ${DP.ink2}; line-height: 1.45; } 3883 + 3884 + /* --- Radii strip --- */ 3885 + .r-strip { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 10px; } 3886 + .r-item { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; min-width: 60px; } 3887 + .r-sample { width: 44px; height: 44px; background: ${DP.canvas}; box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.08); } 3888 + .r-label { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.05em; text-transform: uppercase; } 3889 + .r-val { font-family: ${MONO}; font-size: 10px; color: ${DP.ink}; } 3890 + 3891 + /* --- Component tile (hosts live primitives) --- */ 3892 + .cmp-tile { } 3893 + .cmp-stage { 3894 + margin: 12px -4px 0; 3895 + padding: 18px 16px 10px; 3896 + border-top: 1px solid ${DP.hairlineSoft}; 3897 + display: flex; flex-direction: column; align-items: center; justify-content: center; 3898 + gap: 14px; 3899 + min-height: 68px; 3900 + } 3901 + .cmp-stage + .cmp-stage { border-top: 1px dashed ${DP.hairlineSoft}; } 3902 + .cmp-sublabel { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.06em; } 3903 + .cmp-kind { font-family: ${MONO}; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: ${DP.meta}; } 3904 + 3905 + /* --- Collapsible --- */ 3906 + .coll { 3907 + margin: 0 4px 8px; 3908 + background: ${DP.tile}; 3909 + border-radius: 12px; 3910 + overflow: hidden; 3911 + } 3912 + .coll-head { 3913 + display: flex; align-items: center; gap: 10px; 3914 + width: 100%; 3915 + padding: 12px 14px; 3916 + background: transparent; border: none; 3917 + cursor: pointer; text-align: left; 3918 + font-family: ${FONT}; font-size: 12.5px; font-weight: 600; color: ${DP.ink}; 3919 + transition: background 0.12s ease; 3920 + } 3921 + .coll-head:hover { background: ${DP.tileAlt}; } 3922 + .coll-chev { 3923 + width: 12px; height: 12px; flex-shrink: 0; 3924 + color: ${DP.meta}; 3925 + transition: transform 0.2s ${EASE}; 3926 + } 3927 + .coll[data-open="true"] .coll-chev { transform: rotate(90deg); } 3928 + .coll-count { margin-left: auto; font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.05em; } 3929 + .coll-body { padding: 0 14px 14px; display: none; } 3930 + .coll[data-open="true"] .coll-body { display: block; } 3931 + 3932 + .rule-card { 3933 + padding: 10px 0; 3934 + border-top: 1px solid ${DP.hairlineSoft}; 3935 + } 3936 + .rule-card:first-child { border-top: none; padding-top: 2px; } 3937 + .rule-card .name { font-size: 11.5px; font-weight: 700; color: ${DP.ink}; margin-bottom: 3px; } 3938 + .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; } 3939 + .rule-card .body { font-size: 11.5px; color: ${DP.ink2}; line-height: 1.5; } 3940 + 3941 + .coll .dos { display: grid; gap: 0; margin-top: 2px; } 3942 + .coll .do, .coll .dont { 3943 + position: relative; 3944 + padding: 8px 0 8px 22px; 3945 + font-size: 11.5px; line-height: 1.5; color: ${DP.ink2}; 3946 + border-top: 1px solid ${DP.hairlineSoft}; 3947 + } 3948 + .coll .do:first-child, .coll .dont:first-child, 3949 + .coll .do:first-of-type { border-top: none; } 3950 + .coll .do + .dont { border-top: 1px solid ${DP.hairlineSoft}; } 3951 + .coll .do::before, .coll .dont::before { 3952 + content: ''; position: absolute; left: 4px; top: 13px; 3953 + width: 8px; height: 8px; border-radius: 50%; 3954 + } 3955 + .coll .do::before { background: oklch(62% 0.16 145); } 3956 + .coll .dont::before { background: oklch(58% 0.22 25); } 3957 + 3958 + .coll .overview-body { 3959 + font-size: 12px; line-height: 1.55; color: ${DP.ink2}; 3960 + } 3961 + .coll .overview-body .north-star { 3962 + display: block; font-family: ${FONT}; font-style: italic; 3963 + font-size: 15px; line-height: 1.3; color: ${DP.ink}; 3964 + margin-bottom: 8px; 3965 + } 3966 + .coll .overview-body p { margin: 0 0 8px; } 3967 + .coll .overview-body ul { margin: 6px 0 0; padding-left: 16px; font-size: 11.5px; } 3968 + .coll .overview-body li { margin-bottom: 3px; } 3969 + 3970 + /* --- raw tab markdown (unchanged layout, neutralized palette) --- */ 3971 + .md { padding: 4px 10px 20px; font-size: 13px; line-height: 1.6; color: ${DP.ink}; } 3972 + .md h1, .md h2, .md h3, .md h4 { margin: 20px 0 8px; color: ${DP.ink}; font-weight: 600; } 3973 + .md h1 { font-size: 18px; } 3974 + .md h2 { font-size: 15px; padding-bottom: 4px; border-bottom: 1px solid ${DP.hairlineSoft}; } 3975 + .md h3 { font-size: 13px; } 3976 + .md h4 { font-size: 12px; color: ${DP.meta}; } 3977 + .md p { margin: 0 0 10px; } 3978 + .md ul, .md ol { margin: 0 0 10px; padding-left: 20px; } 3979 + .md li { margin-bottom: 4px; } 3980 + .md code { font-family: ${MONO}; font-size: 12px; background: ${DP.canvas}; padding: 1px 5px; border-radius: 4px; } 3981 + .md pre { font-family: ${MONO}; font-size: 12px; background: ${DP.canvas}; padding: 10px 12px; border-radius: 8px; overflow-x: auto; margin: 0 0 10px; } 3982 + .md pre code { background: none; padding: 0; } 3983 + .md strong { font-weight: 700; } 3984 + .md em { font-style: italic; } 3985 + .md a { color: ${DP.ink}; text-decoration: underline; } 3986 + .md hr { border: none; border-top: 1px solid ${DP.hairlineSoft}; margin: 16px 0; } 3987 + `; 3988 + } 3989 + 3990 + function renderDesignChrome() { 3991 + const root = designShadow.querySelector('.root'); 3992 + root.innerHTML = ''; 3993 + 3994 + // (Panel toggle lives in the global bar — no floating FAB.) 3995 + // Panel 3996 + const panel = document.createElement('aside'); 3997 + panel.className = 'panel'; 3998 + panel.setAttribute('data-open', designState.open ? 'true' : 'false'); 3999 + panel.appendChild(buildDesignHeader()); 4000 + const body = document.createElement('div'); 4001 + body.className = 'panel-body'; 4002 + body.id = 'panel-body'; 4003 + panel.appendChild(body); 4004 + root.appendChild(panel); 4005 + 4006 + renderDesignBody(); 4007 + } 4008 + 4009 + function buildDesignHeader() { 4010 + const header = document.createElement('div'); 4011 + header.className = 'panel-header'; 4012 + 4013 + const title = document.createElement('div'); 4014 + title.className = 'panel-title'; 4015 + title.textContent = 'DESIGN.md'; 4016 + header.appendChild(title); 4017 + 4018 + const tabs = document.createElement('div'); 4019 + tabs.className = 'tabs'; 4020 + for (const t of [['visual', 'Visual'], ['raw', 'Raw']]) { 4021 + const btn = document.createElement('button'); 4022 + btn.className = 'tab'; 4023 + btn.textContent = t[1]; 4024 + btn.setAttribute('data-active', designState.tab === t[0] ? 'true' : 'false'); 4025 + btn.addEventListener('click', () => { 4026 + if (designState.tab === t[0]) return; 4027 + designState.tab = t[0]; 4028 + saveDesignPrefs(); 4029 + renderDesignChrome(); 4030 + if (t[0] === 'raw' && designState.raw === null && !designState.loading) { 4031 + fetchDesignSystem(); // raw is part of the same fetch pair 4032 + } 4033 + }); 4034 + tabs.appendChild(btn); 4035 + } 4036 + header.appendChild(tabs); 4037 + 4038 + const close = document.createElement('button'); 4039 + close.className = 'panel-close'; 4040 + close.innerHTML = '&#x2715;'; 4041 + close.setAttribute('aria-label', 'Close panel'); 4042 + close.addEventListener('click', toggleDesignPanel); 4043 + header.appendChild(close); 4044 + 4045 + return header; 4046 + } 4047 + 4048 + function toggleDesignPanel() { 4049 + designState.open = !designState.open; 4050 + renderDesignChrome(); 4051 + updateGlobalBarState(); 4052 + if (designState.open && designState.present === null && !designState.loading) { 4053 + fetchDesignSystem(); 4054 + } 4055 + } 4056 + 4057 + async function fetchDesignSystem() { 4058 + designState.loading = true; 4059 + designState.error = null; 4060 + renderDesignBody(); 4061 + try { 4062 + const [jsonRes, rawRes] = await Promise.all([ 4063 + fetch(`http://localhost:${PORT}/design-system.json?token=${TOKEN}`, { cache: 'no-store' }), 4064 + fetch(`http://localhost:${PORT}/design-system/raw?token=${TOKEN}`, { cache: 'no-store' }), 4065 + ]); 4066 + const jsonData = await jsonRes.json(); 4067 + designState.present = jsonData.present === true; 4068 + designState.parsed = jsonData.parsed || null; 4069 + designState.sidecar = jsonData.sidecar || null; 4070 + designState.hasMd = !!jsonData.hasMd; 4071 + designState.hasSidecar = !!jsonData.hasSidecar; 4072 + designState.mdNewerThanJson = !!jsonData.mdNewerThanJson; 4073 + designState.raw = designState.present && rawRes.ok ? await rawRes.text() : null; 4074 + designState.error = jsonData.parseError || jsonData.sidecarError || null; 4075 + } catch (err) { 4076 + designState.error = err?.message || 'Failed to load design system.'; 4077 + } finally { 4078 + designState.loading = false; 4079 + renderDesignChrome(); // refresh title from data 4080 + } 4081 + } 4082 + 4083 + function renderDesignBody() { 4084 + const body = designShadow.querySelector('#panel-body'); 4085 + if (!body) return; 4086 + body.innerHTML = ''; 4087 + 4088 + if (designState.loading) { 4089 + body.appendChild(msgDiv('loading', 'Loading design system…')); 4090 + return; 4091 + } 4092 + if (designState.error) { 4093 + body.appendChild(msgDiv('error', designState.error)); 4094 + return; 4095 + } 4096 + if (designState.present === false) { 4097 + const empty = document.createElement('div'); 4098 + empty.className = 'empty'; 4099 + empty.innerHTML = `<strong>No DESIGN.md yet</strong>Create one by running <code>/impeccable document</code> in your terminal, then re-open this panel.`; 4100 + body.appendChild(empty); 4101 + return; 4102 + } 4103 + 4104 + if (designState.tab === 'raw') { 4105 + renderRawTab(body, designState.raw || ''); 4106 + return; 4107 + } 4108 + 4109 + // Visual tab — single unified render path. 4110 + if (designState.mdNewerThanJson) body.appendChild(renderStaleHint()); 4111 + if (designState.hasMd && !designState.hasSidecar) { 4112 + body.appendChild(renderParsedMdCta()); 4113 + } 4114 + renderDesignVisual(body, designState.parsed, designState.sidecar); 4115 + } 4116 + 4117 + function msgDiv(cls, text) { 4118 + const d = document.createElement('div'); 4119 + d.className = cls; 4120 + d.textContent = text; 4121 + return d; 4122 + } 4123 + 4124 + function renderStaleHint() { 4125 + const box = document.createElement('div'); 4126 + box.className = 'stale'; 4127 + box.innerHTML = ` 4128 + <span class="stale-dot"></span> 4129 + <span class="stale-text"><strong>DESIGN.md is newer than DESIGN.json.</strong> Run <code>/impeccable document</code> to refresh the sidecar.</span> 4130 + `; 4131 + return box; 4132 + } 4133 + 4134 + function renderParsedMdCta() { 4135 + const box = document.createElement('div'); 4136 + box.className = 'parsed-md-cta'; 4137 + 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.`; 4138 + return box; 4139 + } 4140 + 4141 + // --- Unified render: merge parsed DESIGN.md frontmatter with sidecar v2 --- 4142 + 4143 + function renderDesignVisual(body, parsed, sidecar) { 4144 + const frontmatter = parsed?.frontmatter || {}; 4145 + const extensions = sidecar?.extensions || {}; 4146 + const proseColors = parsed?.colors || null; 4147 + 4148 + const colors = buildColorModels(frontmatter.colors, extensions.colorMeta, proseColors); 4149 + if (colors.length) renderColorTiles(body, colors); 4150 + 4151 + const types = buildTypographyModels(frontmatter.typography, extensions.typographyMeta); 4152 + if (types.length) renderTypeTiles(body, types); 4153 + 4154 + const radii = buildRadiiModels(frontmatter.rounded); 4155 + if (radii.length) renderRadiiTile(body, radii); 4156 + 4157 + if (extensions.shadows?.length) renderShadowTiles(body, extensions.shadows); 4158 + 4159 + const components = sidecar?.components || []; 4160 + if (components.length) renderComponentTiles(body, components); 4161 + 4162 + // Narrative: sidecar wins if present (richer, agent-curated). Otherwise 4163 + // synthesize from prose sections. 4164 + const narrative = sidecar?.narrative || synthesizeNarrative(parsed); 4165 + if (narrative.rules?.length) body.appendChild(renderRulesCollapsible(narrative.rules)); 4166 + if ((narrative.dos?.length || narrative.donts?.length)) body.appendChild(renderDosDontsCollapsible(narrative)); 4167 + if (narrative.overview || narrative.northStar || narrative.keyCharacteristics?.length) { 4168 + body.appendChild(renderOverviewCollapsible(narrative)); 4169 + } 4170 + 4171 + if (body.childElementCount === 0) { 4172 + body.appendChild(msgDiv('empty', 'No design system data available.')); 4173 + } 4174 + } 4175 + 4176 + // Frontmatter primitives + sidecar colorMeta → tile-ready color models. 4177 + // A matching prose bullet (when the slug sits in the bullet text) supplies 4178 + // description as a last-resort fallback. 4179 + function buildColorModels(fmColors, colorMeta, proseColors) { 4180 + if (!fmColors) return []; 4181 + const meta = colorMeta || {}; 4182 + return Object.entries(fmColors).map(([key, value]) => { 4183 + const m = meta[key] || {}; 4184 + return { 4185 + role: m.role || humanizeKey(key), 4186 + name: m.displayName || humanizeKey(key), 4187 + value: value, 4188 + canonical: m.canonical || null, 4189 + description: m.description || findProseDescription(proseColors, key, m.displayName), 4190 + tonalRamp: m.tonalRamp || null, 4191 + }; 4192 + }); 4193 + } 4194 + 4195 + function buildTypographyModels(fmTypography, typographyMeta) { 4196 + if (!fmTypography) return []; 4197 + const meta = typographyMeta || {}; 4198 + return Object.entries(fmTypography).map(([key, spec]) => { 4199 + const m = meta[key] || {}; 4200 + const { family, fallback } = splitFontFamily(spec?.fontFamily); 4201 + return { 4202 + role: key, 4203 + name: m.displayName || humanizeKey(key), 4204 + family, 4205 + fallback, 4206 + weight: spec?.fontWeight ?? 400, 4207 + // fontStyle isn't in Stitch's frontmatter schema; the sidecar carries 4208 + // it when a role is rendered in italic (e.g. display italic). 4209 + style: m.style || 'normal', 4210 + sampleSize: spec?.fontSize || '1rem', 4211 + lineHeight: spec?.lineHeight != null ? String(spec.lineHeight) : '', 4212 + letterSpacing: spec?.letterSpacing, 4213 + purpose: m.purpose, 4214 + }; 4215 + }); 4216 + } 4217 + 4218 + function buildRadiiModels(fmRounded) { 4219 + if (!fmRounded) return []; 4220 + return Object.entries(fmRounded).map(([name, value]) => ({ name, value })); 4221 + } 4222 + 4223 + function splitFontFamily(stack) { 4224 + if (!stack || typeof stack !== 'string') return { family: '', fallback: '' }; 4225 + const parts = stack.split(',').map((s) => s.trim().replace(/^['"]|['"]$/g, '')); 4226 + return { family: parts[0] || '', fallback: parts.slice(1).join(', ') }; 4227 + } 4228 + 4229 + function humanizeKey(k) { 4230 + return String(k || '').replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); 4231 + } 4232 + 4233 + function findProseDescription(proseColors, key, displayName) { 4234 + if (!proseColors || !proseColors.groups) return null; 4235 + const needles = [key, displayName].filter(Boolean).map((s) => s.toLowerCase()); 4236 + for (const g of proseColors.groups) { 4237 + for (const c of g.colors || []) { 4238 + const hay = String(c.name || '').toLowerCase(); 4239 + if (hay && needles.some((n) => hay.includes(n) || n.includes(hay))) { 4240 + return c.description || null; 4241 + } 4242 + } 4243 + } 4244 + return null; 4245 + } 4246 + 4247 + function synthesizeNarrative(parsed) { 4248 + if (!parsed) return {}; 4249 + const md = parsed; 4250 + return { 4251 + northStar: md.overview?.creativeNorthStar, 4252 + overview: (md.overview?.philosophy || []).join(' '), 4253 + keyCharacteristics: md.overview?.keyCharacteristics || [], 4254 + rules: [ 4255 + ...(md.colors?.rules || []).map((r) => ({ ...r, section: 'colors' })), 4256 + ...(md.typography?.rules || []).map((r) => ({ ...r, section: 'typography' })), 4257 + ...(md.elevation?.rules || []).map((r) => ({ ...r, section: 'elevation' })), 4258 + ], 4259 + dos: md.dosDonts?.dos || [], 4260 + donts: md.dosDonts?.donts || [], 4261 + }; 4262 + } 4263 + 4264 + function renderColorTiles(body, colors) { 4265 + for (const c of colors) { 4266 + const tile = document.createElement('div'); 4267 + tile.className = 'tile c-tile'; 4268 + tile.title = 'Click to copy'; 4269 + tile.addEventListener('click', () => copyToClipboard(c.value)); 4270 + 4271 + const meta = document.createElement('div'); 4272 + meta.className = 'tile-meta'; 4273 + meta.innerHTML = `<span class="name">${escapeHtml(c.name || c.role || 'Color')}</span><span>${escapeHtml(c.value || '')}</span>`; 4274 + tile.appendChild(meta); 4275 + 4276 + const hero = document.createElement('div'); 4277 + hero.className = 'c-hero'; 4278 + hero.style.background = c.value; 4279 + tile.appendChild(hero); 4280 + 4281 + const ramp = synthesizeRamp(c); 4282 + if (ramp.length) { 4283 + const r = document.createElement('div'); 4284 + r.className = 'c-ramp'; 4285 + r.innerHTML = ramp.map((v) => `<span style="background:${cssSafe(v)}"></span>`).join(''); 4286 + tile.appendChild(r); 4287 + } 4288 + 4289 + if (c.description) { 4290 + const d = document.createElement('div'); 4291 + d.className = 'c-desc'; 4292 + d.textContent = c.description; 4293 + tile.appendChild(d); 4294 + } 4295 + body.appendChild(tile); 4296 + } 4297 + } 4298 + 4299 + function synthesizeRamp(c) { 4300 + if (c.tonalRamp?.length) return c.tonalRamp; 4301 + // If base value is OKLCH, synthesize an 8-step ramp across lightness. 4302 + const m = typeof c.value === 'string' && c.value.match(/^oklch\(\s*([\d.]+)%\s+([\d.]+)\s+([\d.]+)\s*(?:\/\s*([\d.]+))?\s*\)$/i); 4303 + if (!m) return []; 4304 + const [, , chroma, hue] = m; 4305 + const steps = [20, 32, 44, 56, 68, 80, 90, 96]; 4306 + return steps.map((l) => `oklch(${l}% ${chroma} ${hue})`); 4307 + } 4308 + 4309 + function renderTypeTiles(body, types) { 4310 + for (const t of types) { 4311 + const tile = document.createElement('div'); 4312 + tile.className = 'tile t-tile'; 4313 + 4314 + const meta = document.createElement('div'); 4315 + meta.className = 'tile-meta'; 4316 + meta.innerHTML = `<span>${escapeHtml(t.role || '')}</span><span>${escapeHtml(t.weight || '')} ${escapeHtml(t.style === 'italic' ? 'italic' : '')}</span>`; 4317 + tile.appendChild(meta); 4318 + 4319 + const specimen = document.createElement('div'); 4320 + specimen.className = 't-specimen'; 4321 + specimen.textContent = 'Aa'; 4322 + specimen.style.fontFamily = fontStack(t); 4323 + specimen.style.fontWeight = String(t.weight || 400); 4324 + specimen.style.fontStyle = t.style || 'normal'; 4325 + specimen.style.fontSize = '56px'; // Fixed specimen size — compare faces, not scales. 4326 + specimen.style.letterSpacing = 'normal'; 4327 + specimen.style.textTransform = 'none'; 4328 + tile.appendChild(specimen); 4329 + 4330 + // The system's actual sample size for this role, shown as small mono meta below. 4331 + if (t.sampleSize) { 4332 + const scale = document.createElement('div'); 4333 + scale.style.cssText = 'font-family:' + MONO + '; font-size: 10px; color:' + DP.meta + '; margin-top: 2px;'; 4334 + scale.textContent = t.sampleSize; 4335 + tile.appendChild(scale); 4336 + } 4337 + 4338 + const family = document.createElement('div'); 4339 + family.className = 't-family'; 4340 + family.textContent = t.family || t.name || ''; 4341 + tile.appendChild(family); 4342 + 4343 + if (t.purpose) { 4344 + const p = document.createElement('div'); 4345 + p.className = 't-purpose'; 4346 + p.textContent = t.purpose; 4347 + tile.appendChild(p); 4348 + } 4349 + body.appendChild(tile); 4350 + } 4351 + } 4352 + 4353 + function fontStack(t) { 4354 + const fam = t.family || ''; 4355 + const fb = t.fallback || ''; 4356 + if (fam && /[,\s]/.test(fam) && !fam.includes("'") && !fam.includes('"')) { 4357 + return `"${fam}", ${fb}`; 4358 + } 4359 + return fam && fb ? `"${fam}", ${fb}` : (fam || fb); 4360 + } 4361 + 4362 + function renderRadiiTile(body, radii) { 4363 + const tile = document.createElement('div'); 4364 + tile.className = 'tile'; 4365 + const meta = document.createElement('div'); 4366 + meta.className = 'tile-meta'; 4367 + meta.innerHTML = `<span class="name">Corner Radii</span><span>${radii.length}</span>`; 4368 + tile.appendChild(meta); 4369 + 4370 + const strip = document.createElement('div'); 4371 + strip.className = 'r-strip'; 4372 + for (const r of radii) { 4373 + const item = document.createElement('div'); 4374 + item.className = 'r-item'; 4375 + const s = document.createElement('div'); 4376 + s.className = 'r-sample'; 4377 + s.style.borderRadius = r.value || '0'; 4378 + item.appendChild(s); 4379 + const lbl = document.createElement('div'); 4380 + lbl.className = 'r-label'; 4381 + lbl.textContent = r.name || ''; 4382 + item.appendChild(lbl); 4383 + const val = document.createElement('div'); 4384 + val.className = 'r-val'; 4385 + val.textContent = r.value || ''; 4386 + item.appendChild(val); 4387 + strip.appendChild(item); 4388 + } 4389 + tile.appendChild(strip); 4390 + body.appendChild(tile); 4391 + } 4392 + 4393 + function renderShadowTiles(body, shadows) { 4394 + for (const sh of shadows) { 4395 + const tile = document.createElement('div'); 4396 + tile.className = 'tile s-tile'; 4397 + 4398 + const meta = document.createElement('div'); 4399 + meta.className = 'tile-meta'; 4400 + meta.innerHTML = `<span class="name">${escapeHtml(sh.name || 'Shadow')}</span><span>Elevation</span>`; 4401 + tile.appendChild(meta); 4402 + 4403 + const surface = document.createElement('div'); 4404 + surface.className = 's-surface'; 4405 + surface.style.boxShadow = sh.value || 'none'; 4406 + tile.appendChild(surface); 4407 + 4408 + const val = document.createElement('div'); 4409 + val.className = 's-value'; 4410 + val.textContent = sh.value || ''; 4411 + tile.appendChild(val); 4412 + 4413 + if (sh.purpose) { 4414 + const p = document.createElement('div'); 4415 + p.className = 's-purpose'; 4416 + p.textContent = sh.purpose; 4417 + tile.appendChild(p); 4418 + } 4419 + body.appendChild(tile); 4420 + } 4421 + } 4422 + 4423 + function renderComponentTiles(body, components) { 4424 + // Group consecutive components that share a kind into one tile. This avoids 4425 + // a pile of one-component tiles (e.g., three button variants = three tiles) 4426 + // and reads more like a proper category. 4427 + const groups = groupByKind(components); 4428 + 4429 + for (const group of groups) { 4430 + const tile = document.createElement('div'); 4431 + tile.className = 'tile cmp-tile'; 4432 + 4433 + const meta = document.createElement('div'); 4434 + meta.className = 'tile-meta'; 4435 + const groupTitle = group.length === 1 4436 + ? (group[0].name || group[0].kind || 'Component') 4437 + : titleForKind(group[0].kind, group.length); 4438 + meta.innerHTML = `<span class="name">${escapeHtml(groupTitle)}</span><span class="cmp-kind">${escapeHtml(group[0].kind || '')}</span>`; 4439 + tile.appendChild(meta); 4440 + 4441 + for (const c of group) { 4442 + const stage = document.createElement('div'); 4443 + stage.className = 'cmp-stage'; 4444 + 4445 + // Render the component in its own shadow root so its CSS can't bleed. 4446 + const host = document.createElement('div'); 4447 + const sub = host.attachShadow({ mode: 'open' }); 4448 + const style = document.createElement('style'); 4449 + style.textContent = c.css || ''; 4450 + sub.appendChild(style); 4451 + const container = document.createElement('div'); 4452 + container.innerHTML = c.html || ''; 4453 + sub.appendChild(container); 4454 + stage.appendChild(host); 4455 + 4456 + // Show component name as a sublabel only when the tile groups >1 item, 4457 + // or when the component's display name differs from its kind. 4458 + const showSublabel = group.length > 1; 4459 + if (showSublabel) { 4460 + const lbl = document.createElement('div'); 4461 + lbl.className = 'cmp-sublabel'; 4462 + lbl.textContent = c.name || ''; 4463 + stage.appendChild(lbl); 4464 + } 4465 + tile.appendChild(stage); 4466 + } 4467 + 4468 + // Single shared description if all items carry the same one; otherwise 4469 + // skip — per-item descriptions clutter a grouped tile. 4470 + if (group.length === 1 && group[0].description) { 4471 + const d = document.createElement('div'); 4472 + d.className = 'c-desc'; 4473 + d.textContent = group[0].description; 4474 + tile.appendChild(d); 4475 + } 4476 + body.appendChild(tile); 4477 + } 4478 + } 4479 + 4480 + function groupByKind(components) { 4481 + const groups = []; 4482 + for (const c of components) { 4483 + const last = groups[groups.length - 1]; 4484 + if (last && last[0].kind && c.kind === last[0].kind) { 4485 + last.push(c); 4486 + } else { 4487 + groups.push([c]); 4488 + } 4489 + } 4490 + return groups; 4491 + } 4492 + 4493 + function titleForKind(kind, count) { 4494 + const labels = { 4495 + button: 'Buttons', 4496 + input: 'Inputs', 4497 + nav: 'Navigation', 4498 + chip: 'Chips', 4499 + card: 'Cards', 4500 + custom: 'Components', 4501 + }; 4502 + return labels[kind] || (kind ? kind.charAt(0).toUpperCase() + kind.slice(1) + 's' : 'Components'); 4503 + } 4504 + 4505 + // --- Collapsibles --------------------------------------------------------- 4506 + 4507 + function buildCollapsible(key, label, count) { 4508 + const wrap = document.createElement('div'); 4509 + wrap.className = 'coll'; 4510 + wrap.setAttribute('data-open', designState.collapsed[key] ? 'false' : 'true'); 4511 + 4512 + const head = document.createElement('button'); 4513 + head.className = 'coll-head'; 4514 + head.innerHTML = ` 4515 + <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> 4516 + <span>${escapeHtml(label)}</span> 4517 + ${count != null ? `<span class="coll-count">${escapeHtml(String(count))}</span>` : ''} 4518 + `; 4519 + head.addEventListener('click', () => { 4520 + designState.collapsed[key] = !designState.collapsed[key]; 4521 + saveDesignPrefs(); 4522 + renderDesignBody(); 4523 + }); 4524 + wrap.appendChild(head); 4525 + 4526 + const body = document.createElement('div'); 4527 + body.className = 'coll-body'; 4528 + wrap.appendChild(body); 4529 + return { wrap, body }; 4530 + } 4531 + 4532 + function renderRulesCollapsible(rules) { 4533 + const { wrap, body } = buildCollapsible('rules', 'Named Rules', rules.length); 4534 + for (const r of rules) { 4535 + const card = document.createElement('div'); 4536 + card.className = 'rule-card'; 4537 + const name = document.createElement('div'); 4538 + name.className = 'name'; 4539 + name.innerHTML = `${escapeHtml(r.name)}${r.section ? `<span class="section">${escapeHtml(r.section)}</span>` : ''}`; 4540 + card.appendChild(name); 4541 + const b = document.createElement('div'); 4542 + b.className = 'body'; 4543 + b.textContent = r.body || ''; 4544 + card.appendChild(b); 4545 + body.appendChild(card); 4546 + } 4547 + return wrap; 4548 + } 4549 + 4550 + function renderDosDontsCollapsible(n) { 4551 + const total = (n.dos?.length || 0) + (n.donts?.length || 0); 4552 + const { wrap, body } = buildCollapsible('dosdonts', "Do's and Don'ts", total); 4553 + const grid = document.createElement('div'); 4554 + grid.className = 'dos'; 4555 + for (const d of n.dos || []) { 4556 + const el = document.createElement('div'); 4557 + el.className = 'do'; 4558 + el.innerHTML = inlineMd(d); 4559 + grid.appendChild(el); 4560 + } 4561 + for (const d of n.donts || []) { 4562 + const el = document.createElement('div'); 4563 + el.className = 'dont'; 4564 + el.innerHTML = inlineMd(d); 4565 + grid.appendChild(el); 4566 + } 4567 + body.appendChild(grid); 4568 + return wrap; 4569 + } 4570 + 4571 + function renderOverviewCollapsible(n) { 4572 + const { wrap, body } = buildCollapsible('overview', 'Overview', null); 4573 + const ov = document.createElement('div'); 4574 + ov.className = 'overview-body'; 4575 + if (n.northStar) { 4576 + const star = document.createElement('span'); 4577 + star.className = 'north-star'; 4578 + star.textContent = '“' + n.northStar + '”'; 4579 + ov.appendChild(star); 4580 + } 4581 + if (n.overview) { 4582 + const p = document.createElement('p'); 4583 + p.innerHTML = inlineMd(n.overview); 4584 + ov.appendChild(p); 4585 + } 4586 + if (n.keyCharacteristics?.length) { 4587 + const ul = document.createElement('ul'); 4588 + ul.innerHTML = n.keyCharacteristics.map((k) => `<li>${inlineMd(k)}</li>`).join(''); 4589 + ov.appendChild(ul); 4590 + } 4591 + body.appendChild(ov); 4592 + return wrap; 4593 + } 4594 + 4595 + function cssSafe(v) { 4596 + // Strip anything outside valid CSS value chars to prevent injection via 4597 + // DESIGN.json values rendered into inline style strings. 4598 + return String(v).replace(/[<>"'`\n]/g, ''); 4599 + } 4600 + 4601 + // --- Raw tab: minimal markdown renderer (subset) -------------------------- 4602 + 4603 + function renderRawTab(body, md) { 4604 + const wrap = document.createElement('div'); 4605 + wrap.className = 'md'; 4606 + wrap.innerHTML = renderMarkdown(md); 4607 + body.appendChild(wrap); 4608 + } 4609 + 4610 + function renderMarkdown(md) { 4611 + const lines = md.split(/\r?\n/); 4612 + const out = []; 4613 + let i = 0; 4614 + let inCode = false; 4615 + let codeBuf = []; 4616 + let paraBuf = []; 4617 + let listBuf = []; // array of { indent, html } 4618 + let listType = null; // 'ul' | 'ol' 4619 + 4620 + const flushPara = () => { 4621 + if (paraBuf.length) { 4622 + out.push(`<p>${inlineMd(paraBuf.join(' '))}</p>`); 4623 + paraBuf = []; 4624 + } 4625 + }; 4626 + const flushList = () => { 4627 + if (listBuf.length) { 4628 + out.push(buildListHtml(listBuf, listType)); 4629 + listBuf = []; 4630 + listType = null; 4631 + } 4632 + }; 4633 + const flushAll = () => { flushPara(); flushList(); }; 4634 + 4635 + for (; i < lines.length; i++) { 4636 + const line = lines[i]; 4637 + 4638 + // Code fence 4639 + const fence = line.match(/^```(\w*)\s*$/); 4640 + if (fence) { 4641 + if (!inCode) { flushAll(); inCode = true; codeBuf = []; } 4642 + else { 4643 + out.push(`<pre><code>${escapeHtml(codeBuf.join('\n'))}</code></pre>`); 4644 + inCode = false; 4645 + } 4646 + continue; 4647 + } 4648 + if (inCode) { codeBuf.push(line); continue; } 4649 + 4650 + if (line.trim() === '') { flushAll(); continue; } 4651 + 4652 + const hr = line.match(/^\s*(?:---+|\*\*\*+)\s*$/); 4653 + if (hr) { flushAll(); out.push('<hr />'); continue; } 4654 + 4655 + const heading = line.match(/^(#{1,4})\s+(.+)$/); 4656 + if (heading) { 4657 + flushAll(); 4658 + const lvl = heading[1].length; 4659 + out.push(`<h${lvl}>${inlineMd(heading[2])}</h${lvl}>`); 4660 + continue; 4661 + } 4662 + 4663 + const bullet = line.match(/^(\s*)([-*])\s+(.+)$/); 4664 + const ordered = line.match(/^(\s*)(\d+)\.\s+(.+)$/); 4665 + if (bullet || ordered) { 4666 + flushPara(); 4667 + const m = bullet || ordered; 4668 + const indent = Math.floor(m[1].length / 2); 4669 + const t = bullet ? 'ul' : 'ol'; 4670 + if (listType && listType !== t) flushList(); 4671 + listType = t; 4672 + listBuf.push({ indent, html: inlineMd(m[3]) }); 4673 + continue; 4674 + } 4675 + 4676 + paraBuf.push(line); 4677 + } 4678 + flushAll(); 4679 + if (inCode && codeBuf.length) { 4680 + out.push(`<pre><code>${escapeHtml(codeBuf.join('\n'))}</code></pre>`); 4681 + } 4682 + return out.join('\n'); 4683 + } 4684 + 4685 + function buildListHtml(items, type) { 4686 + // Nest by indent (one level deep is plenty for DESIGN.md). 4687 + let html = `<${type}>`; 4688 + let lastIndent = 0; 4689 + for (const it of items) { 4690 + if (it.indent > lastIndent) html += `<${type}>`; 4691 + else if (it.indent < lastIndent) html += `</${type}>`.repeat(lastIndent - it.indent); 4692 + html += `<li>${it.html}</li>`; 4693 + lastIndent = it.indent; 4694 + } 4695 + html += `</${type}>`.repeat(lastIndent + 1); 4696 + return html; 4697 + } 4698 + 4699 + function inlineMd(text) { 4700 + // Order matters: escape first, then re-inject tags. 4701 + let s = escapeHtml(text); 4702 + // Code spans 4703 + s = s.replace(/`([^`]+)`/g, (_, code) => `<code>${code}</code>`); 4704 + // Links [text](url) 4705 + s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, t, u) => `<a href="${u}" target="_blank" rel="noopener noreferrer">${t}</a>`); 4706 + // Bold 4707 + s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>'); 4708 + // Italic (only single *…*, skip if inside bold already handled) 4709 + s = s.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1<em>$2</em>'); 4710 + return s; 4711 + } 4712 + 4713 + function highlightBold(text) { 4714 + return inlineMd(text); 4715 + } 4716 + 4717 + function escapeHtml(s) { 4718 + return String(s) 4719 + .replace(/&/g, '&amp;') 4720 + .replace(/</g, '&lt;') 4721 + .replace(/>/g, '&gt;') 4722 + .replace(/"/g, '&quot;') 4723 + .replace(/'/g, '&#39;'); 4724 + } 4725 + 4726 + function copyToClipboard(text) { 4727 + if (!text) return; 4728 + try { 4729 + navigator.clipboard.writeText(text); 4730 + showToast('Copied: ' + text); 4731 + } catch { /* ignore */ } 4732 + } 4733 + 4734 + // --------------------------------------------------------------------------- 4735 + // Init 4736 + // --------------------------------------------------------------------------- 4737 + 4738 + function init() { 4739 + try { history.scrollRestoration = 'manual'; } catch {} 4740 + initHighlight(); 4741 + initAnnotOverlay(); 4742 + initBar(); 4743 + initActionPicker(); 4744 + initParamsPanel(); 4745 + initGlobalBar(); 4746 + initDesignPanel(); 4747 + document.addEventListener('mousemove', handleMouseMove, true); 4748 + document.addEventListener('click', handleClick, true); 4749 + document.addEventListener('keydown', handleKeyDown, true); 4750 + connectSSE(); 4751 + 4752 + // Check for an active session to resume (variant wrapper already in DOM after HMR) 4753 + if (!resumeSession()) { 4754 + console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.'); 4755 + // SvelteKit (and any framework that hydrates after HTML parse) may add 4756 + // the variant wrapper AFTER init runs. Watch for it and retry resume 4757 + // once it appears. Disconnect on first hit. 4758 + const scout = new MutationObserver(() => { 4759 + const wrapper = document.querySelector('[data-impeccable-variants]'); 4760 + if (!wrapper) return; 4761 + scout.disconnect(); 4762 + if (resumeSession()) { 4763 + console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); 4764 + } 4765 + }); 4766 + scout.observe(document.body, { childList: true, subtree: true }); 4767 + } else { 4768 + console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).'); 4769 + } 4770 + } 4771 + 4772 + if (document.readyState === 'loading') { 4773 + document.addEventListener('DOMContentLoaded', init); 4774 + } else { 4775 + init(); 4776 + } 4777 + })();
+436
.agents/skills/impeccable/scripts/live-inject.mjs
··· 1 + /** 2 + * CLI helper: insert/remove the live variant mode script tag in the project's 3 + * main HTML entry point. 4 + * 5 + * On first live run, the agent generates `config.json` in this script's 6 + * directory with the project's insertion target (framework-specific). On 7 + * every subsequent run, this script handles insert/remove deterministically 8 + * with zero LLM involvement. 9 + * 10 + * Usage: 11 + * node live-inject.mjs --port PORT # Insert the live script tag 12 + * node live-inject.mjs --remove # Remove the live script tag 13 + * node live-inject.mjs --check # Check whether config.json exists 14 + */ 15 + 16 + import fs from 'node:fs'; 17 + import path from 'node:path'; 18 + import { fileURLToPath } from 'node:url'; 19 + 20 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 21 + const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, 'config.json'); 22 + const MARKER_OPEN_TEXT = 'impeccable-live-start'; 23 + const MARKER_CLOSE_TEXT = 'impeccable-live-end'; 24 + 25 + /** 26 + * Hard-excluded directory patterns. These are NEVER user-facing pages and 27 + * matching them would silently inject tracking scripts into third-party 28 + * code. The user cannot turn these off via config — they are the floor. 29 + */ 30 + const HARD_EXCLUDES = [ 31 + '**/node_modules/**', 32 + '**/.git/**', 33 + ]; 34 + 35 + export async function injectCli() { 36 + const args = process.argv.slice(2); 37 + 38 + if (args.includes('--help') || args.includes('-h')) { 39 + console.log(`Usage: node live-inject.mjs [options] 40 + 41 + Insert or remove the live mode script tag in the project's HTML entry point. 42 + Reads configuration from config.json (in this same directory). 43 + 44 + Modes: 45 + --port PORT Insert script tag pointing at http://localhost:PORT/live.js 46 + --remove Remove the script tag (if present) 47 + --check Print whether config.json exists and its content 48 + 49 + Output (JSON): 50 + { ok, file, inserted|removed, config? }`); 51 + process.exit(0); 52 + } 53 + 54 + if (args.includes('--check')) { 55 + if (!fs.existsSync(CONFIG_PATH)) { 56 + console.log(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH })); 57 + process.exit(0); 58 + } 59 + let cfg; 60 + try { 61 + cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); 62 + } catch (err) { 63 + console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH })); 64 + return; 65 + } 66 + try { 67 + validateConfig(cfg); 68 + } catch (err) { 69 + console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH })); 70 + return; 71 + } 72 + console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH })); 73 + return; 74 + } 75 + 76 + // Load config 77 + if (!fs.existsSync(CONFIG_PATH)) { 78 + console.error(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH })); 79 + process.exit(1); 80 + } 81 + const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); 82 + validateConfig(config); 83 + 84 + const resolvedFiles = resolveFiles(process.cwd(), config); 85 + 86 + if (args.includes('--remove')) { 87 + const results = resolvedFiles.map((relFile) => { 88 + const absFile = path.resolve(process.cwd(), relFile); 89 + if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; 90 + const content = fs.readFileSync(absFile, 'utf-8'); 91 + const detagged = removeTag(content, config.commentSyntax); 92 + const updated = revertCspMeta(detagged); 93 + if (updated === content) return { file: relFile, removed: false, note: 'no tag present' }; 94 + fs.writeFileSync(absFile, updated, 'utf-8'); 95 + return { 96 + file: relFile, 97 + removed: detagged !== content, 98 + cspReverted: updated !== detagged, 99 + }; 100 + }); 101 + console.log(JSON.stringify({ ok: true, results })); 102 + return; 103 + } 104 + 105 + // Insert mode — need --port 106 + const portIdx = args.indexOf('--port'); 107 + const port = portIdx !== -1 ? parseInt(args[portIdx + 1], 10) : NaN; 108 + if (!Number.isFinite(port)) { 109 + console.error(JSON.stringify({ ok: false, error: 'missing_port' })); 110 + process.exit(1); 111 + } 112 + 113 + const results = resolvedFiles.map((relFile) => { 114 + const absFile = path.resolve(process.cwd(), relFile); 115 + if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; 116 + const content = fs.readFileSync(absFile, 'utf-8'); 117 + const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax)); 118 + const withTag = insertTag(withoutOld, config, port); 119 + if (withTag === withoutOld) { 120 + return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter }; 121 + } 122 + const updated = patchCspMeta(withTag, port); 123 + fs.writeFileSync(absFile, updated, 'utf-8'); 124 + return { 125 + file: relFile, 126 + inserted: true, 127 + cspPatched: updated !== withTag, 128 + }; 129 + }); 130 + const anyInserted = results.some((r) => r.inserted); 131 + console.log(JSON.stringify({ ok: anyInserted, port, results })); 132 + if (!anyInserted) process.exit(1); 133 + } 134 + 135 + /** 136 + * Expand config.files (which may contain glob patterns) into a literal list 137 + * of existing file paths relative to rootDir. Literal entries pass through; 138 + * glob patterns are expanded via fs.globSync. HARD_EXCLUDES and config.exclude 139 + * are applied as filters. Duplicates are removed. Order is preserved by 140 + * first appearance. 141 + */ 142 + export function resolveFiles(rootDir, config) { 143 + const patterns = config.files; 144 + const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; 145 + const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; 146 + const excludeRegexes = allExcludes.map(globToRegex); 147 + 148 + const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); 149 + const isGlob = (s) => /[*?[]/.test(s); 150 + 151 + const seen = new Set(); 152 + const out = []; 153 + for (const pat of patterns) { 154 + if (!isGlob(pat)) { 155 + // Literal path — include even if it doesn't exist yet; the caller 156 + // reports file_not_found per-entry. Exclude list doesn't apply to 157 + // explicit literal entries (user named it on purpose). 158 + if (!seen.has(pat)) { 159 + seen.add(pat); 160 + out.push(pat); 161 + } 162 + continue; 163 + } 164 + let matches; 165 + try { 166 + matches = fs.globSync(pat, { cwd: rootDir, withFileTypes: true }); 167 + } catch { 168 + continue; 169 + } 170 + for (const ent of matches) { 171 + if (!ent.isFile || !ent.isFile()) continue; 172 + const abs = path.join(ent.parentPath || ent.path || rootDir, ent.name); 173 + const rel = path.relative(rootDir, abs).split(path.sep).join('/'); 174 + if (isExcluded(rel)) continue; 175 + if (seen.has(rel)) continue; 176 + seen.add(rel); 177 + out.push(rel); 178 + } 179 + } 180 + return out; 181 + } 182 + 183 + /** 184 + * Convert a glob pattern to a RegExp. Supports: 185 + * ** → any number of path segments (including zero) 186 + * * → any chars except `/` 187 + * ? → any single char except `/` 188 + * Paths are normalized to forward slashes before matching. 189 + */ 190 + function globToRegex(pattern) { 191 + let re = ''; 192 + let i = 0; 193 + while (i < pattern.length) { 194 + const c = pattern[i]; 195 + if (c === '*') { 196 + if (pattern[i + 1] === '*') { 197 + // ** — any number of segments, including zero. Handle the common 198 + // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. 199 + if (pattern[i + 2] === '/') { 200 + re += '(?:.*/)?'; 201 + i += 3; 202 + } else { 203 + re += '.*'; 204 + i += 2; 205 + } 206 + } else { 207 + re += '[^/]*'; 208 + i += 1; 209 + } 210 + } else if (c === '?') { 211 + re += '[^/]'; 212 + i += 1; 213 + } else if (/[.+^${}()|[\]\\]/.test(c)) { 214 + re += '\\' + c; 215 + i += 1; 216 + } else { 217 + re += c; 218 + i += 1; 219 + } 220 + } 221 + return new RegExp('^' + re + '$'); 222 + } 223 + 224 + // --------------------------------------------------------------------------- 225 + // Core operations 226 + // --------------------------------------------------------------------------- 227 + 228 + function validateConfig(cfg) { 229 + if (!cfg || typeof cfg !== 'object') throw new Error('config.json must be an object'); 230 + if (!Array.isArray(cfg.files) || cfg.files.length === 0) { 231 + throw new Error('config.files (non-empty string array) required'); 232 + } 233 + if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) { 234 + throw new Error('config.files must contain only non-empty strings'); 235 + } 236 + if (cfg.exclude !== undefined) { 237 + if (!Array.isArray(cfg.exclude)) { 238 + throw new Error('config.exclude, if present, must be a string array'); 239 + } 240 + if (!cfg.exclude.every((f) => typeof f === 'string' && f.length > 0)) { 241 + throw new Error('config.exclude must contain only non-empty strings'); 242 + } 243 + } 244 + if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') { 245 + throw new Error('config.insertBefore or config.insertAfter (string) required'); 246 + } 247 + if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') { 248 + throw new Error("config.commentSyntax must be 'html' or 'jsx'"); 249 + } 250 + if (cfg.cspChecked !== undefined && typeof cfg.cspChecked !== 'boolean') { 251 + throw new Error("config.cspChecked, if present, must be a boolean"); 252 + } 253 + } 254 + 255 + function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; } 256 + function commentClose(syntax) { return syntax === 'jsx' ? '*/}' : '-->'; } 257 + 258 + function buildTagBlock(syntax, port) { 259 + const open = commentOpen(syntax); 260 + const close = commentClose(syntax); 261 + return ( 262 + open + ' ' + MARKER_OPEN_TEXT + ' ' + close + '\n' + 263 + '<script src="http://localhost:' + port + '/live.js"></script>\n' + 264 + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' 265 + ); 266 + } 267 + 268 + function insertTag(content, config, port) { 269 + const block = buildTagBlock(config.commentSyntax, port); 270 + // insertBefore: match the LAST occurrence. Anchors like `</body>` naturally 271 + // belong at the end, and the same literal can appear earlier in code blocks 272 + // within rendered documentation pages. 273 + if (config.insertBefore) { 274 + const idx = content.lastIndexOf(config.insertBefore); 275 + if (idx === -1) return content; 276 + return content.slice(0, idx) + block + content.slice(idx); 277 + } 278 + // insertAfter: match the FIRST occurrence — typical anchors like `<head>` or 279 + // `<body>` open near the top of the document. 280 + const idx = content.indexOf(config.insertAfter); 281 + if (idx === -1) return content; 282 + const after = idx + config.insertAfter.length; 283 + // Preserve a single trailing newline if the anchor didn't end with one 284 + const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; 285 + return prefix + block + content.slice(prefix.length); 286 + } 287 + 288 + /** 289 + * Remove the live script block. Matches either HTML or JSX comment markers 290 + * regardless of config (so stale tags from a wrong config can still be cleaned). 291 + * 292 + * Indent-preserving: captures any whitespace immediately preceding the opener 293 + * marker and re-emits it in place of the removed block. `insertTag` inserted 294 + * the block *after* the original line's indent and *before* the anchor (e.g. 295 + * `</body>`), which moved the indent onto the opener line and left the anchor 296 + * unindented. Replacing the whole block (plus its trailing newline) with just 297 + * the captured indent hands the indent back to the anchor that follows. 298 + */ 299 + function removeTag(content, _syntax) { 300 + const patterns = [ 301 + /([ \t]*)<!--\s*impeccable-live-start\s*-->[\s\S]*?<!--\s*impeccable-live-end\s*-->[ \t]*\n/, 302 + /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}[ \t]*\n/, 303 + ]; 304 + for (const pat of patterns) { 305 + const next = content.replace(pat, '$1'); 306 + if (next !== content) return next; 307 + } 308 + return content; 309 + } 310 + 311 + // --------------------------------------------------------------------------- 312 + // Content-Security-Policy meta-tag patcher 313 + // 314 + // When the user's HTML carries `<meta http-equiv="Content-Security-Policy">`, 315 + // the cross-origin load of /live.js (and the SSE/POST connection back to 316 + // localhost:PORT) is blocked unless the CSP explicitly allows that origin. 317 + // 318 + // On insert: append `http://localhost:PORT` to `script-src` and `connect-src`, 319 + // and stash the original `content` value in a `data-impeccable-csp-original` 320 + // attribute (base64) so revert is exact. 321 + // 322 + // On remove: detect the marker attribute, decode it, restore the original 323 + // content value verbatim, drop the marker. 324 + // 325 + // Header-based CSP (Next.js headers, Nuxt routeRules, SvelteKit kit.csp, 326 + // shared helpers) is NOT patched here — those need framework-specific config 327 + // edits and are handled via the existing detect-csp.mjs reference output. 328 + // Only the in-source meta-tag form gets the auto-patch. 329 + // --------------------------------------------------------------------------- 330 + 331 + const CSP_MARKER_ATTR = 'data-impeccable-csp-original'; 332 + 333 + function findCspMetaTags(content) { 334 + const out = []; 335 + const tagRe = /<meta\s+([^>]*?)\/?>/gis; 336 + let m; 337 + while ((m = tagRe.exec(content)) !== null) { 338 + const attrs = m[1]; 339 + if (!/(http-equiv|httpEquiv)\s*=\s*(['"])Content-Security-Policy\2/i.test(attrs)) continue; 340 + out.push({ start: m.index, end: m.index + m[0].length, full: m[0], attrs }); 341 + } 342 + return out; 343 + } 344 + 345 + function getAttr(attrs, name) { 346 + const re = new RegExp(`\\b${name}\\s*=\\s*(['"])([\\s\\S]*?)\\1`, 'i'); 347 + const m = attrs.match(re); 348 + return m ? { quote: m[1], value: m[2], full: m[0] } : null; 349 + } 350 + 351 + function appendOriginToDirective(csp, directive, origin) { 352 + const re = new RegExp(`(^|;)(\\s*)(${directive})\\s+([^;]*)`, 'i'); 353 + const m = csp.match(re); 354 + if (m) { 355 + const tokens = m[4].trim().split(/\s+/); 356 + if (tokens.includes(origin)) return csp; 357 + return csp.replace(re, `${m[1]}${m[2]}${m[3]} ${[...tokens, origin].join(' ')}`); 358 + } 359 + // Directive missing — add it. Use 'self' + origin so we don't inadvertently 360 + // narrow the policy compared to the default-src fallback (most users with 361 + // an explicit CSP have 'self' there). 362 + return csp.trim().replace(/;?\s*$/, '') + `; ${directive} 'self' ${origin}`; 363 + } 364 + 365 + export function patchCspMeta(content, port) { 366 + const tags = findCspMetaTags(content); 367 + if (tags.length === 0) return content; 368 + const origin = `http://localhost:${port}`; 369 + 370 + // Walk last-to-first so prior splices don't invalidate later indices. 371 + let result = content; 372 + for (let i = tags.length - 1; i >= 0; i--) { 373 + const tag = tags[i]; 374 + const attrs = tag.attrs; 375 + if (getAttr(attrs, CSP_MARKER_ATTR)) continue; // already patched 376 + const contentAttr = getAttr(attrs, 'content'); 377 + if (!contentAttr) continue; 378 + 379 + const original = contentAttr.value; 380 + let patched = original; 381 + patched = appendOriginToDirective(patched, 'script-src', origin); 382 + patched = appendOriginToDirective(patched, 'connect-src', origin); 383 + // The shader overlay during 'generating' creates a screenshot via 384 + // URL.createObjectURL, producing a `blob:` URL — img-src 'self' rejects 385 + // those. Add `blob:` so the overlay doesn't throw a CSP violation. 386 + patched = appendOriginToDirective(patched, 'img-src', 'blob:'); 387 + if (patched === original) continue; 388 + 389 + const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`; 390 + const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`; 391 + const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker; 392 + const newTag = tag.full.replace(attrs, newAttrs); 393 + 394 + result = result.slice(0, tag.start) + newTag + result.slice(tag.end); 395 + } 396 + return result; 397 + } 398 + 399 + export function revertCspMeta(content) { 400 + const tags = findCspMetaTags(content); 401 + if (tags.length === 0) return content; 402 + 403 + let result = content; 404 + for (let i = tags.length - 1; i >= 0; i--) { 405 + const tag = tags[i]; 406 + const origAttr = getAttr(tag.attrs, CSP_MARKER_ATTR); 407 + if (!origAttr) continue; 408 + const contentAttr = getAttr(tag.attrs, 'content'); 409 + if (!contentAttr) continue; 410 + 411 + let originalValue; 412 + try { originalValue = Buffer.from(origAttr.value, 'base64').toString('utf-8'); } 413 + catch { continue; } 414 + 415 + const newContentAttr = `content=${contentAttr.quote}${originalValue}${contentAttr.quote}`; 416 + let newAttrs = tag.attrs.replace(contentAttr.full, newContentAttr); 417 + // Drop the marker attribute and any single space immediately preceding it. 418 + newAttrs = newAttrs.replace(new RegExp(`\\s*${origAttr.full}`), ''); 419 + const newTag = tag.full.replace(tag.attrs, newAttrs); 420 + 421 + result = result.slice(0, tag.start) + newTag + result.slice(tag.end); 422 + } 423 + return result; 424 + } 425 + 426 + // --------------------------------------------------------------------------- 427 + // Auto-execute 428 + // --------------------------------------------------------------------------- 429 + 430 + const _running = process.argv[1]; 431 + if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) { 432 + injectCli(); 433 + } 434 + 435 + export { insertTag, removeTag, validateConfig, buildTagBlock }; 436 + // patchCspMeta + revertCspMeta are exported above where they're defined.
+187
.agents/skills/impeccable/scripts/live-poll.mjs
··· 1 + /** 2 + * CLI client for the live variant mode poll/reply protocol. 3 + * 4 + * Usage: 5 + * npx impeccable poll # Block until browser event, print JSON 6 + * npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly 7 + * npx impeccable poll --reply <id> done # Reply "done" to event <id> 8 + * npx impeccable poll --reply <id> error "msg" # Reply with error 9 + */ 10 + 11 + import { execSync } from 'node:child_process'; 12 + import fs from 'node:fs'; 13 + import path from 'node:path'; 14 + import os from 'node:os'; 15 + import { fileURLToPath } from 'node:url'; 16 + 17 + // Node's built-in fetch (undici under the hood) enforces a 300s headers 18 + // timeout that can't be lowered per-request. We cap each request below 19 + // that ceiling and loop in `pollOnce` to synthesize a long poll without 20 + // depending on the standalone undici package. 21 + const PER_REQUEST_TIMEOUT_MS = 270_000; 22 + 23 + const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); 24 + 25 + function readServerInfo() { 26 + try { 27 + return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); 28 + } catch { 29 + console.error('No running live server found. Start one with: npx impeccable live'); 30 + process.exit(1); 31 + } 32 + } 33 + 34 + export async function pollCli() { 35 + const args = process.argv.slice(2); 36 + 37 + if (args.includes('--help') || args.includes('-h')) { 38 + console.log(`Usage: impeccable poll [options] 39 + 40 + Wait for a browser event from the live variant server, or reply to one. 41 + 42 + Modes: 43 + poll Block until a browser event arrives, print JSON 44 + poll --reply <id> done Reply "done" to event <id> 45 + poll --reply <id> error "msg" Reply with an error message 46 + 47 + Options: 48 + --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 49 + --help Show this help message`); 50 + process.exit(0); 51 + } 52 + 53 + const info = readServerInfo(); 54 + const base = `http://localhost:${info.port}`; 55 + 56 + // Reply mode: npx impeccable poll --reply <id> <status> [--file path] [message] 57 + const replyIdx = args.indexOf('--reply'); 58 + if (replyIdx !== -1) { 59 + const id = args[replyIdx + 1]; 60 + const status = args[replyIdx + 2] || 'done'; 61 + const fileIdx = args.indexOf('--file'); 62 + const filePath = fileIdx !== -1 && fileIdx + 1 < args.length ? args[fileIdx + 1] : undefined; 63 + // Message is any remaining positional arg that isn't a flag 64 + const message = args.find((a, i) => i > replyIdx + 2 && !a.startsWith('--') && i !== fileIdx + 1) || undefined; 65 + 66 + if (!id) { 67 + console.error('Usage: npx impeccable poll --reply <id> <status> [--file path] [message]'); 68 + process.exit(1); 69 + } 70 + 71 + try { 72 + const res = await fetch(`${base}/poll`, { 73 + method: 'POST', 74 + headers: { 'Content-Type': 'application/json' }, 75 + body: JSON.stringify({ 76 + token: info.token, 77 + id, 78 + type: status, 79 + message, 80 + file: filePath, 81 + }), 82 + }); 83 + 84 + if (!res.ok) { 85 + const body = await res.json().catch(() => ({})); 86 + console.error(`Reply failed (${res.status}):`, body.error || res.statusText); 87 + process.exit(1); 88 + } 89 + 90 + // Success — silent exit (agent doesn't need output for replies) 91 + } catch (err) { 92 + if (err.cause?.code === 'ECONNREFUSED') { 93 + console.error('Live server not running. Start one with: npx impeccable live'); 94 + } else { 95 + console.error('Reply failed:', err.message); 96 + } 97 + process.exit(1); 98 + } 99 + return; 100 + } 101 + 102 + // Poll mode: block until browser event. Default 10 min. Node's built-in 103 + // fetch enforces a 300s headers timeout, so we loop in slices under that 104 + // ceiling and keep re-polling until we get a real event or the user's 105 + // total timeout runs out. 106 + const timeoutArg = args.find(a => a.startsWith('--timeout=')); 107 + const totalTimeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600000; 108 + 109 + const deadline = Date.now() + totalTimeout; 110 + let event; 111 + try { 112 + while (true) { 113 + const remaining = deadline - Date.now(); 114 + if (remaining <= 0) { 115 + event = { type: 'timeout' }; 116 + break; 117 + } 118 + const slice = Math.min(remaining, PER_REQUEST_TIMEOUT_MS); 119 + const res = await fetch(`${base}/poll?token=${info.token}&timeout=${slice}`); 120 + 121 + if (res.status === 401) { 122 + console.error('Authentication failed. The server token may have changed.'); 123 + console.error('Try restarting: npx impeccable live stop && npx impeccable live'); 124 + process.exit(1); 125 + } 126 + 127 + if (!res.ok) { 128 + console.error(`Poll failed: ${res.status} ${res.statusText}`); 129 + process.exit(1); 130 + } 131 + 132 + const next = await res.json(); 133 + // Server-side timeout means no browser event arrived in this slice. 134 + // Loop and re-poll until we get a real event or we hit the user's 135 + // total deadline. 136 + if (next?.type === 'timeout' && Date.now() < deadline) continue; 137 + event = next; 138 + break; 139 + } 140 + 141 + // Auto-handle accept/discard via deterministic script 142 + if (event.type === 'accept' || event.type === 'discard') { 143 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 144 + const acceptScript = path.join(__dirname, 'live-accept.mjs'); 145 + const scriptArgs = event.type === 'discard' 146 + ? ['--id', event.id, '--discard'] 147 + : ['--id', event.id, '--variant', event.variantId]; 148 + if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) { 149 + // Pass through a JSON blob; the shell-safe wrap uses single quotes because 150 + // values are finite {id, number|string|boolean} pairs from a validated payload. 151 + scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`); 152 + } 153 + try { 154 + const out = execSync( 155 + `node "${acceptScript}" ${scriptArgs.join(' ')}`, 156 + { encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 } 157 + ); 158 + event._acceptResult = JSON.parse(out.trim()); 159 + } catch (err) { 160 + event._acceptResult = { handled: false, error: err.message }; 161 + } 162 + } 163 + 164 + // Second signal path: stderr banner in case the agent parses stdout 165 + // JSON but skips nested fields. One line is enough — the full checklist 166 + // is in reference/live.md. 167 + if (event._acceptResult?.carbonize === true) { 168 + process.stderr.write('\n⚠ Carbonize cleanup REQUIRED before next poll. See reference/live.md "Required after accept".\n\n'); 169 + } 170 + 171 + // Print the event as JSON — the agent reads this from stdout 172 + console.log(JSON.stringify(event)); 173 + } catch (err) { 174 + if (err.cause?.code === 'ECONNREFUSED') { 175 + console.error('Live server not running. Start one with: npx impeccable live'); 176 + } else { 177 + console.error('Poll failed:', err.message); 178 + } 179 + process.exit(1); 180 + } 181 + } 182 + 183 + // Auto-execute when run directly 184 + const _running = process.argv[1]; 185 + if (_running?.endsWith('live-poll.mjs') || _running?.endsWith('live-poll.mjs/')) { 186 + pollCli(); 187 + }
+679
.agents/skills/impeccable/scripts/live-server.mjs
··· 1 + #!/usr/bin/env node 2 + /** 3 + * Live variant mode server (self-contained, zero dependencies). 4 + * 5 + * Serves the browser script (/live.js), the detection overlay (/detect.js), 6 + * uses Server-Sent Events (SSE) for server→browser push, and HTTP POST for 7 + * browser→server events. Agent communicates via HTTP long-poll (/poll). 8 + * 9 + * Usage: 10 + * node <scripts_path>/live-server.mjs # start 11 + * node <scripts_path>/live-server.mjs stop # stop + remove injected live.js tag 12 + * node <scripts_path>/live-server.mjs stop --keep-inject # stop only 13 + * node <scripts_path>/live-server.mjs --help 14 + */ 15 + 16 + import http from 'node:http'; 17 + import { randomUUID } from 'node:crypto'; 18 + import { spawn, execFileSync } from 'node:child_process'; 19 + import fs from 'node:fs'; 20 + import path from 'node:path'; 21 + import net from 'node:net'; 22 + import { fileURLToPath } from 'node:url'; 23 + import { parseDesignMd } from './design-parser.mjs'; 24 + 25 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 26 + // PID file in the project root so both the server and agent can find it 27 + // predictably (os.tmpdir() varies across platforms). 28 + const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); 29 + const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway 30 + const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s 31 + 32 + // --------------------------------------------------------------------------- 33 + // Port detection 34 + // --------------------------------------------------------------------------- 35 + 36 + async function findOpenPort(start = 8400) { 37 + return new Promise((resolve) => { 38 + const srv = net.createServer(); 39 + srv.listen(start, '127.0.0.1', () => { 40 + const port = srv.address().port; 41 + srv.close(() => resolve(port)); 42 + }); 43 + srv.on('error', () => resolve(findOpenPort(start + 1))); 44 + }); 45 + } 46 + 47 + // --------------------------------------------------------------------------- 48 + // Session state 49 + // --------------------------------------------------------------------------- 50 + 51 + const state = { 52 + token: null, 53 + port: null, 54 + sseClients: new Set(), // SSE response objects (server→browser push) 55 + pendingEvents: [], // browser events waiting for agent poll 56 + pendingPolls: [], // agent poll callbacks waiting for browser events 57 + exitTimer: null, 58 + sessionDir: null, // per-session tmp dir for annotation screenshots 59 + }; 60 + 61 + // Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; 62 + // cap at 10 MB to guard against runaway writes from a misbehaving client. 63 + const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024; 64 + 65 + function enqueueEvent(event) { 66 + if (state.pendingPolls.length > 0) { 67 + state.pendingPolls.shift()(event); 68 + } else { 69 + state.pendingEvents.push(event); 70 + } 71 + } 72 + 73 + /** Push a message to all connected SSE clients. */ 74 + function broadcast(msg) { 75 + const data = 'data: ' + JSON.stringify(msg) + '\n\n'; 76 + for (const res of state.sseClients) { 77 + try { res.write(data); } catch { /* client gone */ } 78 + } 79 + } 80 + 81 + // --------------------------------------------------------------------------- 82 + // Load scripts 83 + // --------------------------------------------------------------------------- 84 + 85 + function loadBrowserScripts() { 86 + // Detection script: look relative to the skill scripts dir, then fall back 87 + // to the npm package location (src/detect-antipatterns-browser.js). 88 + // This one IS cached — detect.js rarely changes during a session. 89 + const detectPaths = [ 90 + path.join(__dirname, '..', '..', '..', '..', 'src', 'detect-antipatterns-browser.js'), 91 + path.join(process.cwd(), 'node_modules', 'impeccable', 'src', 'detect-antipatterns-browser.js'), 92 + ]; 93 + let detectScript = ''; 94 + for (const p of detectPaths) { 95 + try { detectScript = fs.readFileSync(p, 'utf-8'); break; } catch { /* try next */ } 96 + } 97 + 98 + // live-browser.js: DO NOT cache. Return the path so the /live.js handler 99 + // can re-read on every request. Editing the browser script during iteration 100 + // should land on the next tab reload, not require a server restart. 101 + const livePath = path.join(__dirname, 'live-browser.js'); 102 + if (!fs.existsSync(livePath)) { 103 + process.stderr.write('Error: live-browser.js not found at ' + livePath + '\n'); 104 + process.exit(1); 105 + } 106 + 107 + return { detectScript, livePath }; 108 + } 109 + 110 + function hasProjectContext() { 111 + // PRODUCT.md carries brand voice / anti-references — that's what determines 112 + // whether variants are brand-aware. DESIGN.md (visual tokens) is a separate 113 + // concern, surfaced by the design panel's own empty state. Legacy 114 + // .impeccable.md is auto-migrated to PRODUCT.md by load-context.mjs. 115 + try { 116 + fs.accessSync(path.join(process.cwd(), 'PRODUCT.md'), fs.constants.R_OK); 117 + return true; 118 + } catch { return false; } 119 + } 120 + 121 + function statOrNull(filePath) { 122 + try { return fs.statSync(filePath); } catch { return null; } 123 + } 124 + 125 + // --------------------------------------------------------------------------- 126 + // Validation (inline — no external import needed for self-contained script) 127 + // --------------------------------------------------------------------------- 128 + 129 + const VISUAL_ACTIONS = [ 130 + 'impeccable', 'bolder', 'quieter', 'distill', 'polish', 'typeset', 131 + 'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive', 132 + ]; 133 + 134 + function validateEvent(msg) { 135 + if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message'; 136 + switch (msg.type) { 137 + case 'generate': 138 + if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id'; 139 + if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action'; 140 + if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8'; 141 + if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context'; 142 + // Optional annotation fields (all-or-nothing: if any present, all must be well-formed). 143 + if (msg.screenshotPath !== undefined && typeof msg.screenshotPath !== 'string') return 'generate: screenshotPath must be string'; 144 + if (msg.comments !== undefined && !Array.isArray(msg.comments)) return 'generate: comments must be array'; 145 + if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array'; 146 + return null; 147 + case 'accept': 148 + if (!msg.id) return 'accept: missing id'; 149 + if (!msg.variantId) return 'accept: missing variantId'; 150 + if (msg.paramValues !== undefined) { 151 + if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) { 152 + return 'accept: paramValues must be an object'; 153 + } 154 + } 155 + return null; 156 + case 'discard': 157 + return msg.id ? null : 'discard: missing id'; 158 + case 'exit': 159 + return null; 160 + case 'prefetch': 161 + if (!msg.pageUrl || typeof msg.pageUrl !== 'string') return 'prefetch: missing pageUrl'; 162 + return null; 163 + default: 164 + return 'Unknown event type: ' + msg.type; 165 + } 166 + } 167 + 168 + // --------------------------------------------------------------------------- 169 + // HTTP request handler 170 + // --------------------------------------------------------------------------- 171 + 172 + function createRequestHandler({ detectScript, livePath }) { 173 + return (req, res) => { 174 + const url = new URL(req.url, `http://localhost:${state.port}`); 175 + res.setHeader('Access-Control-Allow-Origin', '*'); 176 + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); 177 + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); 178 + if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } 179 + 180 + const p = url.pathname; 181 + 182 + // --- Scripts --- 183 + if (p === '/live.js') { 184 + // Re-read from disk each request so edits to live-browser.js land on 185 + // the next tab reload. No-store headers prevent browser caching across 186 + // sessions — during iteration, a cached old script silently breaks 187 + // every subsequent session. 188 + let liveScript; 189 + try { 190 + liveScript = fs.readFileSync(livePath, 'utf-8'); 191 + } catch (err) { 192 + res.writeHead(500, { 'Content-Type': 'text/plain' }); 193 + res.end('Error reading live-browser.js: ' + err.message); 194 + return; 195 + } 196 + const body = 197 + `window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` + 198 + `window.__IMPECCABLE_PORT__ = ${state.port};\n` + 199 + liveScript; 200 + res.writeHead(200, { 201 + 'Content-Type': 'application/javascript', 202 + 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', 203 + 'Pragma': 'no-cache', 204 + }); 205 + res.end(body); 206 + return; 207 + } 208 + if (p === '/detect.js' || p === '/') { 209 + if (!detectScript) { res.writeHead(404); res.end('Not available'); return; } 210 + res.writeHead(200, { 'Content-Type': 'application/javascript' }); 211 + res.end(detectScript); 212 + return; 213 + } 214 + 215 + // --- Vendored modern-screenshot (UMD build) --- 216 + // Lazy-loaded by live.js when the user clicks Go; exposes 217 + // window.modernScreenshot.domToBlob(...) for capture. 218 + if (p === '/modern-screenshot.js') { 219 + const vendorPath = path.join(__dirname, 'modern-screenshot.umd.js'); 220 + try { 221 + res.writeHead(200, { 222 + 'Content-Type': 'application/javascript', 223 + 'Cache-Control': 'public, max-age=31536000, immutable', 224 + }); 225 + res.end(fs.readFileSync(vendorPath)); 226 + } catch { 227 + res.writeHead(404); res.end('Vendor script not found'); 228 + } 229 + return; 230 + } 231 + 232 + // --- Annotation upload (browser → server, raw PNG body) --- 233 + // Client generates the eventId, POSTs the PNG, then POSTs the generate 234 + // event with screenshotPath already set. Keeps bytes out of the SSE/poll 235 + // bridge and preserves the "one shot from the user's POV" UX. 236 + if (p === '/annotation' && req.method === 'POST') { 237 + const token = url.searchParams.get('token'); 238 + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } 239 + const eventId = url.searchParams.get('eventId'); 240 + if (!eventId || !/^[A-Za-z0-9_-]{1,64}$/.test(eventId)) { 241 + res.writeHead(400, { 'Content-Type': 'application/json' }); 242 + res.end(JSON.stringify({ error: 'Invalid eventId' })); 243 + return; 244 + } 245 + if ((req.headers['content-type'] || '').toLowerCase() !== 'image/png') { 246 + res.writeHead(415, { 'Content-Type': 'application/json' }); 247 + res.end(JSON.stringify({ error: 'Content-Type must be image/png' })); 248 + return; 249 + } 250 + if (!state.sessionDir) { 251 + res.writeHead(500, { 'Content-Type': 'application/json' }); 252 + res.end(JSON.stringify({ error: 'Session dir unavailable' })); 253 + return; 254 + } 255 + const chunks = []; 256 + let total = 0; 257 + let aborted = false; 258 + req.on('data', (c) => { 259 + if (aborted) return; 260 + total += c.length; 261 + if (total > MAX_ANNOTATION_BYTES) { 262 + aborted = true; 263 + res.writeHead(413, { 'Content-Type': 'application/json' }); 264 + res.end(JSON.stringify({ error: 'Payload too large' })); 265 + req.destroy(); 266 + return; 267 + } 268 + chunks.push(c); 269 + }); 270 + req.on('end', () => { 271 + if (aborted) return; 272 + const absPath = path.join(state.sessionDir, eventId + '.png'); 273 + try { 274 + fs.writeFileSync(absPath, Buffer.concat(chunks)); 275 + } catch (err) { 276 + res.writeHead(500, { 'Content-Type': 'application/json' }); 277 + res.end(JSON.stringify({ error: 'Write failed: ' + err.message })); 278 + return; 279 + } 280 + res.writeHead(200, { 'Content-Type': 'application/json' }); 281 + res.end(JSON.stringify({ ok: true, path: absPath })); 282 + }); 283 + req.on('error', () => { 284 + if (!aborted) { 285 + res.writeHead(500, { 'Content-Type': 'application/json' }); 286 + res.end(JSON.stringify({ error: 'Upload failed' })); 287 + } 288 + }); 289 + return; 290 + } 291 + 292 + // --- Health --- 293 + if (p === '/health') { 294 + res.writeHead(200, { 'Content-Type': 'application/json' }); 295 + res.end(JSON.stringify({ 296 + status: 'ok', port: state.port, mode: 'variant', 297 + hasProjectContext: hasProjectContext(), 298 + connectedClients: state.sseClients.size, 299 + })); 300 + return; 301 + } 302 + 303 + // --- Design system (unified v2 response) + raw --- 304 + // /design-system.json returns both parsed DESIGN.md and DESIGN.json 305 + // sidecar when present. Panel merges them: 306 + // { present, parsed, sidecar, hasMd, hasSidecar, 307 + // mdNewerThanJson, parseError?, sidecarError? } 308 + // - parsed: output of parseDesignMd (frontmatter 309 + // + six canonical sections) when DESIGN.md exists. 310 + // - sidecar: DESIGN.json contents when present. 311 + // Expected shape: schemaVersion 2, carrying 312 + // extensions + components + narrative. 313 + // /design-system/raw returns DESIGN.md markdown verbatim 314 + if (p === '/design-system.json' || p === '/design-system/raw') { 315 + const token = url.searchParams.get('token'); 316 + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } 317 + 318 + const mdPath = path.join(process.cwd(), 'DESIGN.md'); 319 + const jsonPath = path.join(process.cwd(), 'DESIGN.json'); 320 + const mdStat = statOrNull(mdPath); 321 + const jsonStat = statOrNull(jsonPath); 322 + 323 + if (p === '/design-system/raw') { 324 + if (!mdStat) { res.writeHead(404); res.end('Not found'); return; } 325 + res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' }); 326 + res.end(fs.readFileSync(mdPath, 'utf-8')); 327 + return; 328 + } 329 + 330 + if (!mdStat && !jsonStat) { 331 + res.writeHead(404, { 'Content-Type': 'application/json' }); 332 + res.end(JSON.stringify({ present: false })); 333 + return; 334 + } 335 + 336 + const response = { 337 + present: true, 338 + hasMd: !!mdStat, 339 + hasSidecar: !!jsonStat, 340 + mdNewerThanJson: !!(mdStat && jsonStat && mdStat.mtimeMs > jsonStat.mtimeMs + 1000), 341 + }; 342 + 343 + if (mdStat) { 344 + try { 345 + response.parsed = parseDesignMd(fs.readFileSync(mdPath, 'utf-8')); 346 + } catch (err) { 347 + response.parseError = err.message; 348 + } 349 + } 350 + 351 + if (jsonStat) { 352 + try { 353 + response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); 354 + } catch (err) { 355 + response.sidecarError = 'Failed to parse DESIGN.json: ' + err.message; 356 + } 357 + } 358 + 359 + res.writeHead(200, { 'Content-Type': 'application/json' }); 360 + res.end(JSON.stringify(response)); 361 + return; 362 + } 363 + 364 + // --- Source file (no-HMR fallback) --- 365 + if (p === '/source') { 366 + const token = url.searchParams.get('token'); 367 + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } 368 + const filePath = url.searchParams.get('path'); 369 + if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; } 370 + const absPath = path.resolve(process.cwd(), filePath); 371 + if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; } 372 + let content; 373 + try { content = fs.readFileSync(absPath, 'utf-8'); } 374 + catch { res.writeHead(404); res.end('File not found'); return; } 375 + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); 376 + res.end(content); 377 + return; 378 + } 379 + 380 + // --- SSE: server→browser push (replaces WebSocket) --- 381 + if (p === '/events' && req.method === 'GET') { 382 + const token = url.searchParams.get('token'); 383 + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } 384 + res.writeHead(200, { 385 + 'Content-Type': 'text/event-stream', 386 + 'Cache-Control': 'no-cache', 387 + 'Connection': 'keep-alive', 388 + }); 389 + res.write('data: ' + JSON.stringify({ 390 + type: 'connected', 391 + hasProjectContext: hasProjectContext(), 392 + }) + '\n\n'); 393 + 394 + state.sseClients.add(res); 395 + clearTimeout(state.exitTimer); 396 + 397 + // Keepalive: SSE comment every 30s prevents silent connection drops. 398 + const heartbeat = setInterval(() => { 399 + try { res.write(': keepalive\n\n'); } catch { clearInterval(heartbeat); } 400 + }, SSE_HEARTBEAT_INTERVAL); 401 + 402 + req.on('close', () => { 403 + clearInterval(heartbeat); 404 + state.sseClients.delete(res); 405 + if (state.sseClients.size === 0) { 406 + clearTimeout(state.exitTimer); 407 + state.exitTimer = setTimeout(() => { 408 + if (state.sseClients.size === 0) enqueueEvent({ type: 'exit' }); 409 + }, 8000); 410 + } 411 + }); 412 + return; 413 + } 414 + 415 + // --- Browser→server events (replaces WebSocket messages) --- 416 + if (p === '/events' && req.method === 'POST') { 417 + let body = ''; 418 + req.on('data', (c) => { body += c; }); 419 + req.on('end', () => { 420 + let msg; 421 + try { msg = JSON.parse(body); } catch { 422 + res.writeHead(400, { 'Content-Type': 'application/json' }); 423 + res.end(JSON.stringify({ error: 'Invalid JSON' })); 424 + return; 425 + } 426 + if (msg.token !== state.token) { 427 + res.writeHead(401, { 'Content-Type': 'application/json' }); 428 + res.end(JSON.stringify({ error: 'Unauthorized' })); 429 + return; 430 + } 431 + const error = validateEvent(msg); 432 + if (error) { 433 + res.writeHead(400, { 'Content-Type': 'application/json' }); 434 + res.end(JSON.stringify({ error })); 435 + return; 436 + } 437 + enqueueEvent(msg); 438 + res.writeHead(200, { 'Content-Type': 'application/json' }); 439 + res.end(JSON.stringify({ ok: true })); 440 + }); 441 + return; 442 + } 443 + 444 + // --- Stop --- 445 + if (p === '/stop') { 446 + const token = url.searchParams.get('token'); 447 + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } 448 + res.writeHead(200, { 'Content-Type': 'text/plain' }); 449 + res.end('stopping'); 450 + shutdown(); 451 + return; 452 + } 453 + 454 + // --- Agent poll --- 455 + if (p === '/poll' && req.method === 'GET') { 456 + handlePollGet(req, res, url); 457 + return; 458 + } 459 + if (p === '/poll' && req.method === 'POST') { 460 + handlePollPost(req, res); 461 + return; 462 + } 463 + 464 + res.writeHead(404); res.end('Not found'); 465 + }; 466 + } 467 + 468 + // --------------------------------------------------------------------------- 469 + // Agent poll endpoints (unchanged from WS version) 470 + // --------------------------------------------------------------------------- 471 + 472 + function handlePollGet(req, res, url) { 473 + const token = url.searchParams.get('token'); 474 + if (token !== state.token) { 475 + res.writeHead(401, { 'Content-Type': 'application/json' }); 476 + res.end(JSON.stringify({ error: 'Unauthorized' })); 477 + return; 478 + } 479 + const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10); 480 + if (state.pendingEvents.length > 0) { 481 + res.writeHead(200, { 'Content-Type': 'application/json' }); 482 + res.end(JSON.stringify(state.pendingEvents.shift())); 483 + return; 484 + } 485 + const timer = setTimeout(() => { 486 + const idx = state.pendingPolls.indexOf(resolve); 487 + if (idx !== -1) state.pendingPolls.splice(idx, 1); 488 + res.writeHead(200, { 'Content-Type': 'application/json' }); 489 + res.end(JSON.stringify({ type: 'timeout' })); 490 + }, timeout); 491 + function resolve(event) { 492 + clearTimeout(timer); 493 + res.writeHead(200, { 'Content-Type': 'application/json' }); 494 + res.end(JSON.stringify(event)); 495 + } 496 + state.pendingPolls.push(resolve); 497 + req.on('close', () => { 498 + clearTimeout(timer); 499 + const idx = state.pendingPolls.indexOf(resolve); 500 + if (idx !== -1) state.pendingPolls.splice(idx, 1); 501 + }); 502 + } 503 + 504 + function handlePollPost(req, res) { 505 + let body = ''; 506 + req.on('data', (c) => { body += c; }); 507 + req.on('end', () => { 508 + let msg; 509 + try { msg = JSON.parse(body); } catch { 510 + res.writeHead(400, { 'Content-Type': 'application/json' }); 511 + res.end(JSON.stringify({ error: 'Invalid JSON' })); 512 + return; 513 + } 514 + if (msg.token !== state.token) { 515 + res.writeHead(401, { 'Content-Type': 'application/json' }); 516 + res.end(JSON.stringify({ error: 'Unauthorized' })); 517 + return; 518 + } 519 + // Forward the reply to the browser via SSE 520 + broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data }); 521 + res.writeHead(200, { 'Content-Type': 'application/json' }); 522 + res.end(JSON.stringify({ ok: true })); 523 + }); 524 + } 525 + 526 + // --------------------------------------------------------------------------- 527 + // Lifecycle 528 + // --------------------------------------------------------------------------- 529 + 530 + let httpServer = null; 531 + 532 + function shutdown() { 533 + try { fs.unlinkSync(LIVE_PID_FILE); } catch {} 534 + if (state.sessionDir) { 535 + try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} 536 + } 537 + for (const res of state.sseClients) { try { res.end(); } catch {} } 538 + state.sseClients.clear(); 539 + for (const resolve of state.pendingPolls) resolve({ type: 'exit' }); 540 + state.pendingPolls.length = 0; 541 + if (httpServer) httpServer.close(); 542 + process.exit(0); 543 + } 544 + 545 + // --------------------------------------------------------------------------- 546 + // Main 547 + // --------------------------------------------------------------------------- 548 + 549 + const args = process.argv.slice(2); 550 + 551 + if (args.includes('--help') || args.includes('-h')) { 552 + console.log(`Usage: node live-server.mjs [options] 553 + 554 + Start the live variant mode server (zero dependencies). 555 + 556 + Commands: 557 + (default) Start the server (foreground) 558 + stop Stop the server and remove the injected live.js script tag 559 + stop --keep-inject Stop the server only (leave the script tag in the HTML entry) 560 + 561 + Options: 562 + --background Start detached, print connection JSON to stdout, then exit 563 + --port=PORT Use a specific port (default: auto-detect starting at 8400) 564 + --keep-inject Only with stop: skip live-inject.mjs --remove 565 + --help Show this help 566 + 567 + Endpoints: 568 + /live.js Browser script (element picker + variant cycling) 569 + /detect.js Detection overlay (backwards compatible) 570 + /modern-screenshot.js Vendored modern-screenshot UMD build (lazy-loaded by live.js) 571 + /annotation POST raw image/png to stage a variant screenshot 572 + /events SSE stream (server→browser) + POST (browser→server) 573 + /poll Long-poll for agent CLI 574 + /source Raw source file reader (no-HMR fallback) 575 + /health Health check`); 576 + process.exit(0); 577 + } 578 + 579 + if (args.includes('stop')) { 580 + const keepInject = args.includes('--keep-inject'); 581 + try { 582 + const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); 583 + const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`); 584 + if (res.ok) console.log(`Stopped live server on port ${info.port}.`); 585 + } catch { 586 + console.log('No running live server found.'); 587 + } 588 + if (!keepInject) { 589 + const injectPath = path.join(__dirname, 'live-inject.mjs'); 590 + try { 591 + const out = execFileSync(process.execPath, [injectPath, '--remove'], { 592 + encoding: 'utf-8', 593 + cwd: process.cwd(), 594 + }); 595 + const line = out.trim().split('\n').filter(Boolean).pop(); 596 + if (line) { 597 + try { 598 + const j = JSON.parse(line); 599 + if (j.removed === true) { 600 + console.log(`Removed live script tag from ${j.file}.`); 601 + } 602 + } catch { 603 + /* ignore non-JSON lines */ 604 + } 605 + } 606 + } catch (err) { 607 + const detail = err.stderr?.toString?.().trim?.() 608 + || err.stdout?.toString?.().trim?.() 609 + || err.message 610 + || String(err); 611 + console.warn(`Note: could not remove live script tag (${detail.split('\n')[0]})`); 612 + } 613 + } 614 + process.exit(0); 615 + } 616 + 617 + // --background: spawn a detached child server, wait for it to be ready, 618 + // print the connection JSON, then exit. This keeps the startup command 619 + // simple (no shell backgrounding or chained commands). 620 + if (args.includes('--background')) { 621 + const childArgs = args.filter(a => a !== '--background'); 622 + const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...childArgs], { 623 + detached: true, 624 + stdio: 'ignore', 625 + cwd: process.cwd(), 626 + }); 627 + child.unref(); 628 + 629 + // Poll for the PID file (the child writes it once the HTTP server is listening). 630 + const deadline = Date.now() + 10_000; 631 + while (Date.now() < deadline) { 632 + try { 633 + const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); 634 + if (info.pid !== process.pid) { 635 + // Output JSON so the agent can read port + token from stdout. 636 + console.log(JSON.stringify(info)); 637 + process.exit(0); 638 + } 639 + } catch { /* not ready yet */ } 640 + await new Promise(r => setTimeout(r, 200)); 641 + } 642 + console.error('Timed out waiting for live server to start.'); 643 + process.exit(1); 644 + } 645 + 646 + // Check for existing session 647 + try { 648 + const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); 649 + try { process.kill(existing.pid, 0); 650 + console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`); 651 + console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop'); 652 + process.exit(1); 653 + } catch { fs.unlinkSync(LIVE_PID_FILE); } 654 + } catch {} 655 + 656 + state.token = randomUUID(); 657 + const portArg = args.find(a => a.startsWith('--port=')); 658 + state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort(); 659 + // Annotation screenshots live in the project root so the agent's Read tool 660 + // doesn't trip a per-file permission prompt. Sessioned by token so concurrent 661 + // projects (or quick restarts) don't collide. 662 + const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); 663 + fs.mkdirSync(annotRoot, { recursive: true }); 664 + state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); 665 + 666 + const { detectScript, livePath } = loadBrowserScripts(); 667 + httpServer = http.createServer(createRequestHandler({ detectScript, livePath })); 668 + 669 + httpServer.listen(state.port, '127.0.0.1', () => { 670 + fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token })); 671 + const url = `http://localhost:${state.port}`; 672 + console.log(`\nImpeccable live server running on ${url}`); 673 + console.log(`Token: ${state.token}\n`); 674 + console.log(`Inject: <script src="${url}/live.js"><\/script>`); 675 + console.log(`Stop: node ${path.basename(fileURLToPath(import.meta.url))} stop`); 676 + }); 677 + 678 + process.on('SIGINT', shutdown); 679 + process.on('SIGTERM', shutdown);
+395
.agents/skills/impeccable/scripts/live-wrap.mjs
··· 1 + /** 2 + * CLI helper: find an element in source and wrap it in a variant container. 3 + * 4 + * Usage: 5 + * npx impeccable wrap --id SESSION_ID --count N --query "hero-combined-left" [--file path] 6 + * 7 + * Searches project files for the element matching the query (class name, ID, or 8 + * text snippet), wraps it with the variant scaffolding, and prints the file path 9 + * + line range where the agent should insert variant HTML. 10 + * 11 + * This replaces 3-4 agent tool calls (grep + read + edit) with a single CLI call. 12 + */ 13 + 14 + import fs from 'node:fs'; 15 + import path from 'node:path'; 16 + import { isGeneratedFile } from './is-generated.mjs'; 17 + 18 + const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; 19 + 20 + export async function wrapCli() { 21 + const args = process.argv.slice(2); 22 + 23 + if (args.includes('--help') || args.includes('-h')) { 24 + console.log(`Usage: impeccable wrap [options] 25 + 26 + Find an element in source and wrap it in a variant container. 27 + 28 + Required: 29 + --id ID Session ID for the variant wrapper 30 + --count N Number of expected variants (1-8) 31 + 32 + Element identification (at least one required): 33 + --element-id ID HTML id attribute of the element 34 + --classes A,B,C Comma-separated CSS class names 35 + --tag TAG Tag name (div, section, etc.) 36 + --query TEXT Fallback: raw text to search for 37 + 38 + Optional: 39 + --file PATH Source file to search in (skips auto-detection) 40 + --help Show this help message 41 + 42 + Output (JSON): 43 + { file, startLine, endLine, insertLine, commentSyntax } 44 + 45 + The agent should insert variant HTML at insertLine.`); 46 + process.exit(0); 47 + } 48 + 49 + const id = argVal(args, '--id'); 50 + const count = parseInt(argVal(args, '--count') || '3'); 51 + const elementId = argVal(args, '--element-id'); 52 + const classes = argVal(args, '--classes'); 53 + const tag = argVal(args, '--tag'); 54 + const query = argVal(args, '--query'); 55 + const filePath = argVal(args, '--file'); 56 + 57 + if (!id) { console.error('Missing --id'); process.exit(1); } 58 + if (!elementId && !classes && !query) { 59 + console.error('Need at least one of: --element-id, --classes, --query'); 60 + process.exit(1); 61 + } 62 + 63 + // Build search queries in priority order (most specific first) 64 + const queries = buildSearchQueries(elementId, classes, tag, query); 65 + 66 + const genOpts = { cwd: process.cwd() }; 67 + 68 + // Find the source file. Generated files are excluded from auto-search so we 69 + // don't silently write variants into a file the next build will wipe. 70 + let targetFile = filePath; 71 + let matchedQuery = null; 72 + if (!targetFile) { 73 + for (const q of queries) { 74 + targetFile = findFileWithQuery(q, process.cwd(), genOpts); 75 + if (targetFile) { matchedQuery = q; break; } 76 + } 77 + if (!targetFile) { 78 + // Nothing in source. Did the element show up in a generated file? That 79 + // tells the agent "fall back to the agent-driven flow" vs "element just 80 + // doesn't exist in this project." 81 + let generatedHit = null; 82 + for (const q of queries) { 83 + generatedHit = findFileWithQuery(q, process.cwd(), { ...genOpts, includeGenerated: true }); 84 + if (generatedHit) break; 85 + } 86 + if (generatedHit) { 87 + console.error(JSON.stringify({ 88 + error: 'element_not_in_source', 89 + fallback: 'agent-driven', 90 + generatedMatch: path.relative(process.cwd(), generatedHit), 91 + hint: 'Element found only in a generated file. See "Handle fallback" in live.md.', 92 + })); 93 + } else { 94 + console.error(JSON.stringify({ 95 + error: 'element_not_found', 96 + fallback: 'agent-driven', 97 + hint: 'Element not found in any project file. It may be runtime-injected (JS component, etc.). See "Handle fallback" in live.md.', 98 + })); 99 + } 100 + process.exit(1); 101 + } 102 + } else { 103 + if (isGeneratedFile(targetFile, genOpts)) { 104 + console.error(JSON.stringify({ 105 + error: 'file_is_generated', 106 + fallback: 'agent-driven', 107 + file: path.relative(process.cwd(), path.resolve(process.cwd(), targetFile)), 108 + hint: 'Explicit --file points at a generated file. Writing here gets wiped by the next build. See "Handle fallback" in live.md.', 109 + })); 110 + process.exit(1); 111 + } 112 + matchedQuery = queries[0]; 113 + } 114 + 115 + const content = fs.readFileSync(targetFile, 'utf-8'); 116 + const lines = content.split('\n'); 117 + 118 + // Find the element, trying each query in priority order. 119 + // Pass tag hint so findElement can reject matches inside wrong element types 120 + // and walk backward to the real opener on multi-line JSX tags. 121 + let match = null; 122 + for (const q of queries) { 123 + match = findElement(lines, q, tag); 124 + if (match) break; 125 + } 126 + if (!match) { 127 + console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') })); 128 + process.exit(1); 129 + } 130 + 131 + const { startLine, endLine } = match; 132 + const commentSyntax = detectCommentSyntax(targetFile); 133 + const isJsx = commentSyntax.open === '{/*'; 134 + const indent = lines[startLine].match(/^(\s*)/)[1]; 135 + 136 + // Extract the original element 137 + const originalLines = lines.slice(startLine, endLine + 1); 138 + const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n'); 139 + 140 + // Wrapper attributes differ by syntax. HTML allows plain string attrs; 141 + // JSX requires object-literal style and parses string attrs as HTML (which 142 + // either type-errors or renders a literal CSS string). 143 + const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"'; 144 + 145 + // Build the wrapper 146 + const wrapperLines = [ 147 + indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close, 148 + indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>', 149 + indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close, 150 + indent + ' <div data-impeccable-variant="original">', 151 + originalIndented, 152 + indent + ' </div>', 153 + indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close, 154 + indent + '</div>', 155 + indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close, 156 + ]; 157 + 158 + // Replace the original element with the wrapper 159 + const newLines = [ 160 + ...lines.slice(0, startLine), 161 + ...wrapperLines, 162 + ...lines.slice(endLine + 1), 163 + ]; 164 + fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8'); 165 + 166 + // Calculate insert line (the "insert below this line" comment) 167 + const insertLine = startLine + 6; // 0-indexed in the new file 168 + 169 + console.log(JSON.stringify({ 170 + file: path.relative(process.cwd(), targetFile), 171 + startLine: startLine + 1, // 1-indexed for the agent 172 + endLine: startLine + wrapperLines.length, // 1-indexed 173 + insertLine: insertLine + 1, // 1-indexed: where variants go 174 + commentSyntax: commentSyntax, 175 + originalLineCount: originalLines.length, 176 + })); 177 + } 178 + 179 + // --------------------------------------------------------------------------- 180 + // Helpers 181 + // --------------------------------------------------------------------------- 182 + 183 + function argVal(args, flag) { 184 + const idx = args.indexOf(flag); 185 + return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null; 186 + } 187 + 188 + /** 189 + * Build search query strings in priority order (most specific first). 190 + * ID is most reliable, then specific class combos, then single classes, then raw query. 191 + */ 192 + function buildSearchQueries(elementId, classes, tag, query) { 193 + const queries = []; 194 + 195 + // 1. ID is the most specific 196 + if (elementId) { 197 + queries.push('id="' + elementId + '"'); 198 + } 199 + 200 + // 2. Full class attribute match (for elements with distinctive multi-class combos). 201 + // Emit both class="..." (HTML) and className="..." (React/JSX) so whichever 202 + // convention the file uses will match. 203 + if (classes) { 204 + const classList = classes.split(',').map(c => c.trim()).filter(Boolean); 205 + if (classList.length > 1) { 206 + const joined = classList.join(' '); 207 + const sorted = [...classList].sort((a, b) => b.length - a.length); 208 + queries.push('class="' + joined + '"'); 209 + queries.push('className="' + joined + '"'); 210 + queries.push(sorted[0]); // most distinctive single class, fallback 211 + } else if (classList.length === 1) { 212 + queries.push(classList[0]); 213 + } 214 + } 215 + 216 + // 3. Tag + class combo (e.g., <section class="hero">). 217 + // Same dual-emit for JSX compatibility. 218 + if (tag && classes) { 219 + const firstClass = classes.split(',')[0].trim(); 220 + queries.push('<' + tag + ' class="' + firstClass); 221 + queries.push('<' + tag + ' className="' + firstClass); 222 + } 223 + 224 + // 4. Raw fallback query 225 + if (query) { 226 + queries.push(query); 227 + } 228 + 229 + return queries; 230 + } 231 + 232 + function detectCommentSyntax(filePath) { 233 + const ext = path.extname(filePath).toLowerCase(); 234 + if (ext === '.jsx' || ext === '.tsx') { 235 + return { open: '{/*', close: '*/}' }; 236 + } 237 + // HTML, Vue, Svelte, Astro all use HTML comments 238 + return { open: '<!--', close: '-->' }; 239 + } 240 + 241 + /** 242 + * Search project files for the query string (class name, ID, etc.) 243 + * Returns the first matching file path, or null. 244 + */ 245 + function findFileWithQuery(query, cwd, genOpts = {}) { 246 + const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.']; 247 + const seen = new Set(); 248 + 249 + for (const dir of searchDirs) { 250 + const absDir = path.join(cwd, dir); 251 + if (!fs.existsSync(absDir)) continue; 252 + const result = searchDir(absDir, query, seen, 0, genOpts); 253 + if (result) return result; 254 + } 255 + return null; 256 + } 257 + 258 + function searchDir(dir, query, seen, depth, genOpts) { 259 + if (depth > 5) return null; // don't go too deep 260 + const realDir = fs.realpathSync(dir); 261 + if (seen.has(realDir)) return null; 262 + seen.add(realDir); 263 + 264 + let entries; 265 + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } 266 + catch { return null; } 267 + 268 + // Check files first 269 + for (const entry of entries) { 270 + if (!entry.isFile()) continue; 271 + const ext = path.extname(entry.name).toLowerCase(); 272 + if (!EXTENSIONS.includes(ext)) continue; 273 + 274 + const filePath = path.join(dir, entry.name); 275 + if (!genOpts.includeGenerated && isGeneratedFile(filePath, genOpts)) continue; 276 + try { 277 + const content = fs.readFileSync(filePath, 'utf-8'); 278 + if (content.includes(query)) return filePath; 279 + } catch { /* skip unreadable files */ } 280 + } 281 + 282 + // Then recurse into directories. Always skip node_modules and .git (never 283 + // project content). dist/build/out are left to the isGeneratedFile guard so 284 + // the includeGenerated second-pass can still find the element there and 285 + // report `generatedMatch`. 286 + for (const entry of entries) { 287 + if (!entry.isDirectory()) continue; 288 + if (entry.name === 'node_modules' || entry.name === '.git') continue; 289 + const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts); 290 + if (result) return result; 291 + } 292 + 293 + return null; 294 + } 295 + 296 + /** 297 + * Regex that matches a tag opener on a line. Allows the tag name to be 298 + * followed by whitespace, `>`, `/`, or end-of-line so that multi-line JSX 299 + * openers (e.g. `<section\n className="..."\n>`) are recognised. 300 + */ 301 + const OPENER_RE = /<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/; 302 + 303 + /** 304 + * Find the element's start and end line in the file. 305 + * 306 + * `query` is a class name, attribute fragment (`class="..."`, `className="..."`, 307 + * `id="..."`), or a raw text snippet. Because a query can appear on a 308 + * continuation line of a multi-line tag (e.g. the `className="..."` row of a 309 + * `<section\n className="..."\n>` JSX tag), we walk backward from the match 310 + * line to find the actual tag opener. When `tag` is provided, opener candidates 311 + * must match that tag name. 312 + */ 313 + function findElement(lines, query, tag = null) { 314 + // Iterate all matches — the first substring hit isn't always the right one. 315 + for (let i = 0; i < lines.length; i++) { 316 + if (!lines[i].includes(query)) continue; 317 + 318 + const stripped = lines[i].trim(); 319 + if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue; 320 + // Skip lines already inside a variant wrapper 321 + if (lines[i].includes('data-impeccable-variant')) continue; 322 + 323 + const openerLine = findOpenerLine(lines, i, tag); 324 + if (openerLine === -1) continue; 325 + 326 + const endLine = findClosingLine(lines, openerLine); 327 + return { startLine: openerLine, endLine }; 328 + } 329 + 330 + return null; 331 + } 332 + 333 + /** 334 + * Resolve a match line to the real tag opener. If the match line itself opens 335 + * a tag, return it. Otherwise walk up to 10 lines backward looking for the 336 + * first tag opener. If `tag` is specified, the opener must match that tag 337 + * name; an opener with a different tag name aborts the backward walk for this 338 + * match (we don't jump across element boundaries). 339 + * 340 + * Returns the line index of the opener, or -1 if none can be resolved. 341 + */ 342 + function findOpenerLine(lines, matchLine, tag) { 343 + const self = lines[matchLine].match(OPENER_RE); 344 + if (self) { 345 + if (!tag || self[1] === tag) return matchLine; 346 + return -1; 347 + } 348 + const MAX_BACKWALK = 10; 349 + for (let i = matchLine - 1; i >= Math.max(0, matchLine - MAX_BACKWALK); i--) { 350 + const opener = lines[i].match(OPENER_RE); 351 + if (!opener) continue; 352 + if (!tag || opener[1] === tag) return i; 353 + // Different tag name than requested — abort; we're inside a non-target opener. 354 + return -1; 355 + } 356 + return -1; 357 + } 358 + 359 + /** 360 + * Starting from a line with an opening tag, find the line with the matching 361 + * closing tag by counting tag nesting depth. 362 + */ 363 + function findClosingLine(lines, start) { 364 + const openMatch = lines[start].match(OPENER_RE); 365 + if (!openMatch) return start; // caller passed a non-opener; nothing to span 366 + 367 + const tagName = openMatch[1]; 368 + let depth = 0; 369 + const openRe = new RegExp('<' + tagName + '(?=[\\s/>]|$)', 'g'); 370 + const selfCloseRe = new RegExp('<' + tagName + '[^>]*/>', 'g'); 371 + const closeRe = new RegExp('</' + tagName + '\\s*>', 'g'); 372 + 373 + for (let i = start; i < lines.length; i++) { 374 + const line = lines[i]; 375 + const opens = (line.match(openRe) || []).length; 376 + const selfCloses = (line.match(selfCloseRe) || []).length; 377 + const closes = (line.match(closeRe) || []).length; 378 + 379 + depth += opens - selfCloses - closes; 380 + 381 + if (depth <= 0) return i; 382 + } 383 + 384 + // If we can't find the close, return a reasonable guess 385 + return Math.min(start + 50, lines.length - 1); 386 + } 387 + 388 + // Auto-execute when run directly (node live-wrap.mjs ...) 389 + const _running = process.argv[1]; 390 + if (_running?.endsWith('live-wrap.mjs') || _running?.endsWith('live-wrap.mjs/')) { 391 + wrapCli(); 392 + } 393 + 394 + // Test exports (used by tests/live-wrap.test.mjs) 395 + export { buildSearchQueries, findElement, findClosingLine, detectCommentSyntax };
+247
.agents/skills/impeccable/scripts/live.mjs
··· 1 + /** 2 + * CLI entry point: prepare everything needed to enter the live variant poll loop. 3 + * 4 + * Does (all in one command): 5 + * 1. Check config.json (returns config_missing if first-ever run) 6 + * 2. Start the live server in the background (or reuse a running one) 7 + * 3. Inject the browser script tag into the project's entry file 8 + * 4. Read .impeccable.md for design context (if present) 9 + * 5. Print a single JSON blob with everything the agent needs 10 + * 11 + * After this, the agent's only remaining steps are: 12 + * - 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 13 + * - Enter the poll loop: `node live-poll.mjs` 14 + * 15 + * Usage: 16 + * node live.mjs # Prepare everything, print JSON, exit 17 + * node live.mjs --help 18 + */ 19 + 20 + import { execSync } from 'node:child_process'; 21 + import fs from 'node:fs'; 22 + import path from 'node:path'; 23 + import { fileURLToPath } from 'node:url'; 24 + import { loadContext } from './load-context.mjs'; 25 + import { resolveFiles } from './live-inject.mjs'; 26 + 27 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 28 + const PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); 29 + 30 + async function liveCli() { 31 + const args = process.argv.slice(2); 32 + 33 + if (args.includes('--help') || args.includes('-h')) { 34 + console.log(`Usage: node live.mjs 35 + 36 + Prepare everything for live variant mode in a single command: 37 + - Checks scripts/config.json (required, created once per project) 38 + - Starts (or reuses) the live server in the background 39 + - Injects the browser script tag 40 + - Reads .impeccable.md for design context 41 + 42 + On success, prints a JSON blob with: 43 + { ok, serverPort, serverToken, pageFile, hasContext, context } 44 + 45 + On config_missing, prints: 46 + { ok: false, error: "config_missing", configPath, hint } 47 + 48 + The agent should then: 49 + 1. If config_missing, create the config and re-run this script 50 + 2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) 51 + 3. Enter the poll loop: node live-poll.mjs`); 52 + process.exit(0); 53 + } 54 + 55 + // 1. Check config (fail fast if missing — no point starting anything else) 56 + const checkOut = runScript('live-inject.mjs', ['--check']); 57 + const checkResult = safeParse(checkOut); 58 + if (!checkResult || !checkResult.ok) { 59 + console.log(JSON.stringify(checkResult || { ok: false, error: 'check_failed', raw: checkOut })); 60 + process.exit(0); 61 + } 62 + 63 + // 2. Start server (or reuse existing) 64 + const serverInfo = ensureServerRunning(); 65 + if (!serverInfo) { 66 + console.log(JSON.stringify({ ok: false, error: 'server_start_failed' })); 67 + process.exit(1); 68 + } 69 + 70 + // 3. Inject the script tag at the current port 71 + const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)]); 72 + const injectResult = safeParse(injectOut); 73 + if (!injectResult || !injectResult.ok) { 74 + console.log(JSON.stringify({ 75 + ok: false, 76 + error: 'inject_failed', 77 + detail: injectResult || injectOut, 78 + serverPort: serverInfo.port, 79 + })); 80 + process.exit(1); 81 + } 82 + 83 + // 4. Load PRODUCT.md + DESIGN.md context (auto-migrates legacy .impeccable.md) 84 + const ctx = loadContext(process.cwd()); 85 + 86 + // 5. Compute drift-heal: compare resolved inject targets against the 87 + // project's HTML files. Orphans are HTML files not covered by config. 88 + // Warning only — the agent decides whether to act. 89 + const resolvedFiles = resolveFiles(process.cwd(), checkResult.config); 90 + const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config); 91 + 92 + // 6. Emit everything the agent needs 93 + console.log(JSON.stringify({ 94 + ok: true, 95 + serverPort: serverInfo.port, 96 + serverToken: serverInfo.token, 97 + pageFiles: resolvedFiles, 98 + configDrift: drift, 99 + hasProduct: ctx.hasProduct, 100 + product: ctx.product, 101 + productPath: ctx.productPath, 102 + hasDesign: ctx.hasDesign, 103 + design: ctx.design, 104 + designPath: ctx.designPath, 105 + migrated: ctx.migrated, 106 + }, null, 2)); 107 + } 108 + 109 + /** 110 + * Drift-heal scan. Walks the project for HTML files under common 111 + * page-source directories (public/, src/, app/, pages/) and reports any 112 + * that aren't covered by the resolved inject targets. This is purely 113 + * advisory — the agent can ignore it, or suggest the user add the 114 + * orphans to config.files. 115 + * 116 + * Skipped if config.files already contains at least one glob pattern 117 + * covering everything in practice (signaled by the orphan count being 0). 118 + */ 119 + function scanForDrift(rootDir, resolvedFiles, config) { 120 + const SCAN_ROOTS = ['public', 'src', 'app', 'pages']; 121 + const IGNORE_DIRS = new Set([ 122 + 'node_modules', '.git', '.next', '.nuxt', '.svelte-kit', '.astro', 123 + '.turbo', '.vercel', '.cache', 'coverage', 'dist', 'build', 124 + ]); 125 + 126 + const resolvedSet = new Set(resolvedFiles.map((f) => f.split(path.sep).join('/'))); 127 + 128 + // Files matching the user's `exclude` globs are intentional omissions, 129 + // not drift. Compile them to regexes so the orphan list stays signal. 130 + const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) 131 + .map((p) => globToRegex(p)); 132 + const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); 133 + 134 + const orphans = []; 135 + 136 + const walk = (dir, relBase) => { 137 + let entries; 138 + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } 139 + catch { return; } 140 + for (const e of entries) { 141 + const rel = relBase ? `${relBase}/${e.name}` : e.name; 142 + if (e.isDirectory()) { 143 + if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue; 144 + walk(path.join(dir, e.name), rel); 145 + } else if (e.isFile() && e.name.endsWith('.html')) { 146 + if (resolvedSet.has(rel)) continue; 147 + if (isUserExcluded(rel)) continue; 148 + orphans.push(rel); 149 + } 150 + } 151 + }; 152 + 153 + for (const root of SCAN_ROOTS) { 154 + const abs = path.join(rootDir, root); 155 + if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) { 156 + walk(abs, root); 157 + } 158 + } 159 + 160 + if (orphans.length === 0) return null; 161 + const capped = orphans.slice(0, 20); 162 + return { 163 + orphans: capped, 164 + orphanCount: orphans.length, 165 + hint: `${orphans.length} HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like "public/**/*.html".`, 166 + }; 167 + } 168 + 169 + /** 170 + * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here 171 + * to avoid a circular import (live-inject.mjs already imports nothing 172 + * from live.mjs). The two must stay in sync. 173 + */ 174 + function globToRegex(pattern) { 175 + let re = ''; 176 + let i = 0; 177 + while (i < pattern.length) { 178 + const c = pattern[i]; 179 + if (c === '*') { 180 + if (pattern[i + 1] === '*') { 181 + if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } 182 + else { re += '.*'; i += 2; } 183 + } else { 184 + re += '[^/]*'; 185 + i += 1; 186 + } 187 + } else if (c === '?') { 188 + re += '[^/]'; 189 + i += 1; 190 + } else if (/[.+^${}()|[\]\\]/.test(c)) { 191 + re += '\\' + c; 192 + i += 1; 193 + } else { 194 + re += c; 195 + i += 1; 196 + } 197 + } 198 + return new RegExp('^' + re + '$'); 199 + } 200 + 201 + // --------------------------------------------------------------------------- 202 + // Helpers 203 + // --------------------------------------------------------------------------- 204 + 205 + function runScript(name, args) { 206 + const scriptPath = path.join(__dirname, name); 207 + const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`; 208 + try { 209 + return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 }); 210 + } catch (err) { 211 + // execSync throws on non-zero exit; return stdout if any 212 + return err.stdout || err.message || ''; 213 + } 214 + } 215 + 216 + function safeParse(out) { 217 + try { return JSON.parse(String(out).trim()); } catch { return null; } 218 + } 219 + 220 + /** 221 + * Return { pid, port, token } for the running live server, starting one if needed. 222 + */ 223 + function ensureServerRunning() { 224 + // Try to reuse an existing server 225 + try { 226 + const existing = JSON.parse(fs.readFileSync(PID_FILE, 'utf-8')); 227 + if (existing && existing.pid) { 228 + try { 229 + process.kill(existing.pid, 0); // throws if dead 230 + return existing; 231 + } catch { /* stale PID file — the server script will clean it up */ } 232 + } 233 + } catch { /* no PID file */ } 234 + 235 + // Start a new server 236 + const out = runScript('live-server.mjs', ['--background']); 237 + return safeParse(out); 238 + } 239 + 240 + // --------------------------------------------------------------------------- 241 + // Auto-execute 242 + // --------------------------------------------------------------------------- 243 + 244 + const _running = process.argv[1]; 245 + if (_running?.endsWith('live.mjs') || _running?.endsWith('live.mjs/')) { 246 + liveCli(); 247 + }
+93
.agents/skills/impeccable/scripts/load-context.mjs
··· 1 + /** 2 + * Shared context loader for every impeccable command that needs to know 3 + * "who is this for" and "what does this look like". 4 + * 5 + * Input: project root (process.cwd()). 6 + * 7 + * Output (JSON to stdout): 8 + * { 9 + * hasProduct: boolean, // PRODUCT.md found (or auto-migrated) 10 + * product: string | null, // PRODUCT.md contents 11 + * productPath: string | null, // relative path 12 + * hasDesign: boolean, // DESIGN.md found 13 + * design: string | null, // DESIGN.md contents 14 + * designPath: string | null, 15 + * migrated: boolean, // true if we auto-renamed .impeccable.md -> PRODUCT.md 16 + * } 17 + * 18 + * Filename matching is case-insensitive for PRODUCT.md and DESIGN.md. The 19 + * Google DESIGN.md convention is uppercase at repo root; Kiro-style and 20 + * lowercase variants are also matched so users don't get punished for case. 21 + */ 22 + 23 + import fs from 'node:fs'; 24 + import path from 'node:path'; 25 + 26 + const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md']; 27 + const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md']; 28 + const LEGACY_NAMES = ['.impeccable.md']; 29 + 30 + export function loadContext(cwd = process.cwd()) { 31 + let migrated = false; 32 + 33 + // 1. Look for PRODUCT.md (case-insensitive) 34 + let productPath = firstExisting(cwd, PRODUCT_NAMES); 35 + 36 + // 2. Legacy: if no PRODUCT.md but .impeccable.md exists, rename in place 37 + if (!productPath) { 38 + const legacyPath = firstExisting(cwd, LEGACY_NAMES); 39 + if (legacyPath) { 40 + const newPath = path.join(cwd, 'PRODUCT.md'); 41 + try { 42 + fs.renameSync(legacyPath, newPath); 43 + productPath = newPath; 44 + migrated = true; 45 + } catch { 46 + // Rename failed (permissions, etc.) — fall back to reading legacy in place 47 + productPath = legacyPath; 48 + } 49 + } 50 + } 51 + 52 + // 3. DESIGN.md (case-insensitive) 53 + const designPath = firstExisting(cwd, DESIGN_NAMES); 54 + 55 + const product = productPath ? safeRead(productPath) : null; 56 + const design = designPath ? safeRead(designPath) : null; 57 + 58 + return { 59 + hasProduct: !!product, 60 + product, 61 + productPath: productPath ? path.relative(cwd, productPath) : null, 62 + hasDesign: !!design, 63 + design, 64 + designPath: designPath ? path.relative(cwd, designPath) : null, 65 + migrated, 66 + }; 67 + } 68 + 69 + function firstExisting(cwd, names) { 70 + for (const name of names) { 71 + const abs = path.join(cwd, name); 72 + if (fs.existsSync(abs)) return abs; 73 + } 74 + return null; 75 + } 76 + 77 + function safeRead(p) { 78 + try { return fs.readFileSync(p, 'utf-8'); } catch { return null; } 79 + } 80 + 81 + // --------------------------------------------------------------------------- 82 + // CLI mode — print the context as JSON 83 + // --------------------------------------------------------------------------- 84 + 85 + function cli() { 86 + const result = loadContext(process.cwd()); 87 + console.log(JSON.stringify(result, null, 2)); 88 + } 89 + 90 + const _running = process.argv[1]; 91 + if (_running?.endsWith('load-context.mjs') || _running?.endsWith('load-context.mjs/')) { 92 + cli(); 93 + }
+14
.agents/skills/impeccable/scripts/modern-screenshot.umd.js
··· 1 + (function(y,v){typeof exports=="object"&&typeof module!="undefined"?v(exports):typeof define=="function"&&define.amd?define(["exports"],v):(y=typeof globalThis!="undefined"?globalThis:y||self,v(y.modernScreenshot={}))})(this,function(y){"use strict";var rr=Object.defineProperty,nr=Object.defineProperties;var or=Object.getOwnPropertyDescriptors;var Z=Object.getOwnPropertySymbols;var xe=Object.prototype.hasOwnProperty,Me=Object.prototype.propertyIsEnumerable;var Oe=Math.pow,Le=(y,v,N)=>v in y?rr(y,v,{enumerable:!0,configurable:!0,writable:!0,value:N}):y[v]=N,D=(y,v)=>{for(var N in v||(v={}))xe.call(v,N)&&Le(y,N,v[N]);if(Z)for(var N of Z(v))Me.call(v,N)&&Le(y,N,v[N]);return y},M=(y,v)=>nr(y,or(v));var je=(y,v)=>{var N={};for(var R in y)xe.call(y,R)&&v.indexOf(R)<0&&(N[R]=y[R]);if(y!=null&&Z)for(var R of Z(y))v.indexOf(R)<0&&Me.call(y,R)&&(N[R]=y[R]);return N};var C=(y,v,N)=>new Promise((R,O)=>{var X=P=>{try{q(N.next(P))}catch(W){O(W)}},j=P=>{try{q(N.throw(P))}catch(W){O(W)}},q=P=>P.done?R(P.value):Promise.resolve(P.value).then(X,j);q((N=N.apply(y,v)).next())});var Be;function v(e,t){return e[13]=1,e[14]=t>>8,e[15]=t&255,e[16]=t>>8,e[17]=t&255,e}const N=112,R=72,O=89,X=115;let j;function q(){const e=new Int32Array(256);for(let t=0;t<256;t++){let r=t;for(let n=0;n<8;n++)r=r&1?3988292384^r>>>1:r>>>1;e[t]=r}return e}function P(e){let t=-1;j||(j=q());for(let r=0;r<e.length;r++)t=j[(t^e[r])&255]^t>>>8;return t^-1}function W(e){const t=e.length-1;for(let r=t;r>=4;r--)if(e[r-4]===9&&e[r-3]===N&&e[r-2]===R&&e[r-1]===O&&e[r]===X)return r-3;return 0}function ae(e,t,r=!1){const n=new Uint8Array(13);t*=39.3701,n[0]=N,n[1]=R,n[2]=O,n[3]=X,n[4]=t>>>24,n[5]=t>>>16,n[6]=t>>>8,n[7]=t&255,n[8]=n[4],n[9]=n[5],n[10]=n[6],n[11]=n[7],n[12]=1;const i=P(n),a=new Uint8Array(4);if(a[0]=i>>>24,a[1]=i>>>16,a[2]=i>>>8,a[3]=i&255,r){const s=W(e);return e.set(n,s),e.set(a,s+13),e}else{const s=new Uint8Array(4);s[0]=0,s[1]=0,s[2]=0,s[3]=9;const o=new Uint8Array(54);return o.set(e,0),o.set(s,33),o.set(n,37),o.set(a,50),o}}const qe="AAlwSFlz",We="AAAJcEhZ",He="AAAACXBI";function Ve(e){let t=e.indexOf(qe);return t===-1&&(t=e.indexOf(We)),t===-1&&(t=e.indexOf(He)),t}const se="[modern-screenshot]",U=typeof window!="undefined",ze=U&&"Worker"in window,ie=U&&"atob"in window,Xe=U&&"btoa"in window,ee=U?(Be=window.navigator)==null?void 0:Be.userAgent:"",le=ee.includes("Chrome"),G=ee.includes("AppleWebKit")&&!le,te=ee.includes("Firefox"),Ge=e=>e&&"__CONTEXT__"in e,Ye=e=>e.constructor.name==="CSSFontFaceRule",Je=e=>e.constructor.name==="CSSImportRule",Ke=e=>e.constructor.name==="CSSLayerBlockRule",I=e=>e.nodeType===1,H=e=>typeof e.className=="object",ce=e=>e.tagName==="image",Qe=e=>e.tagName==="use",V=e=>I(e)&&typeof e.style!="undefined"&&!H(e),Ze=e=>e.nodeType===8,et=e=>e.nodeType===3,$=e=>e.tagName==="IMG",Y=e=>e.tagName==="VIDEO",tt=e=>e.tagName==="CANVAS",rt=e=>e.tagName==="TEXTAREA",nt=e=>e.tagName==="INPUT",ot=e=>e.tagName==="STYLE",at=e=>e.tagName==="SCRIPT",st=e=>e.tagName==="SELECT",it=e=>e.tagName==="SLOT",lt=e=>e.tagName==="IFRAME",ct=(...e)=>console.warn(se,...e);function ut(e){var r;const t=(r=e==null?void 0:e.createElement)==null?void 0:r.call(e,"canvas");return t&&(t.height=t.width=1),!!t&&"toDataURL"in t&&!!t.toDataURL("image/webp").includes("image/webp")}const re=e=>e.startsWith("data:");function ue(e,t){if(e.match(/^[a-z]+:\/\//i))return e;if(U&&e.match(/^\/\//))return window.location.protocol+e;if(e.match(/^[a-z]+:/i)||!U)return e;const r=J().implementation.createHTMLDocument(),n=r.createElement("base"),i=r.createElement("a");return r.head.appendChild(n),r.body.appendChild(i),t&&(n.href=t),i.href=e,i.href}function J(e){var t;return(t=e&&I(e)?e==null?void 0:e.ownerDocument:e)!=null?t:window.document}const K="http://www.w3.org/2000/svg";function fe(e,t,r){const n=J(r).createElementNS(K,"svg");return n.setAttributeNS(null,"width",e.toString()),n.setAttributeNS(null,"height",t.toString()),n.setAttributeNS(null,"viewBox",`0 0 ${e} ${t}`),n}function de(e,t){let r=new XMLSerializer().serializeToString(e);return t&&(r=r.replace(/[\u0000-\u0008\v\f\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/gu,"")),`data:image/svg+xml;charset=utf-8,${encodeURIComponent(r)}`}function ft(e,t="image/png",r=1){return C(this,null,function*(){try{return yield new Promise((n,i)=>{e.toBlob(a=>{a?n(a):i(new Error("Blob is null"))},t,r)})}catch(n){if(ie)return dt(e.toDataURL(t,r));throw n}})}function dt(e){var o,c;const[t,r]=e.split(","),n=(c=(o=t.match(/data:(.+);/))==null?void 0:o[1])!=null?c:void 0,i=window.atob(r),a=i.length,s=new Uint8Array(a);for(let u=0;u<a;u+=1)s[u]=i.charCodeAt(u);return new Blob([s],{type:n})}function ge(e,t){return new Promise((r,n)=>{const i=new FileReader;i.onload=()=>r(i.result),i.onerror=()=>n(i.error),i.onabort=()=>n(new Error(`Failed read blob to ${t}`)),t==="dataUrl"?i.readAsDataURL(e):t==="arrayBuffer"&&i.readAsArrayBuffer(e)})}const gt=e=>ge(e,"dataUrl"),mt=e=>ge(e,"arrayBuffer");function _(e,t){const r=J(t).createElement("img");return r.decoding="sync",r.loading="eager",r.src=e,r}function L(e,t){return new Promise(r=>{const{timeout:n,ownerDocument:i,onError:a,onWarn:s}=t!=null?t:{},o=typeof e=="string"?_(e,J(i)):e;let c=null,u=null;function l(){r(o),c&&clearTimeout(c),u==null||u()}if(n&&(c=setTimeout(l,n)),Y(o)){const d=o.currentSrc||o.src;if(!d)return o.poster?L(o.poster,t).then(r):l();if(o.readyState>=2)return l();const m=l,f=h=>{s==null||s("Failed video load",d,h),a==null||a(h),l()};u=()=>{o.removeEventListener("loadeddata",m),o.removeEventListener("error",f)},o.addEventListener("loadeddata",m,{once:!0}),o.addEventListener("error",f,{once:!0})}else{const d=ce(o)?o.href.baseVal:o.currentSrc||o.src;if(!d)return l();const m=()=>C(this,null,function*(){if($(o)&&"decode"in o)try{yield o.decode()}catch(h){s==null||s("Failed to decode image, trying to render anyway",o.dataset.originalSrc||d,h)}l()}),f=h=>{s==null||s("Failed image load",o.dataset.originalSrc||d,h),l()};if($(o)&&o.complete)return m();u=()=>{o.removeEventListener("load",m),o.removeEventListener("error",f)},o.addEventListener("load",m,{once:!0}),o.addEventListener("error",f,{once:!0})}})}function me(e,t){return C(this,null,function*(){V(e)&&($(e)||Y(e)?yield L(e,t):yield Promise.all(["img","video"].flatMap(r=>Array.from(e.querySelectorAll(r)).map(n=>L(n,t)))))})}const he=function(){let t=0;const r=()=>`0000${(Math.random()*Oe(36,4)<<0).toString(36)}`.slice(-4);return()=>(t+=1,`u${r()}${t}`)}();function we(e){return e==null?void 0:e.split(",").map(t=>t.trim().replace(/"|'/g,"").toLowerCase()).filter(Boolean)}let pe=0;function ht(e){const t=`${se}[#${pe}]`;return pe++,{time:r=>e&&console.time(`${t} ${r}`),timeEnd:r=>e&&console.timeEnd(`${t} ${r}`),warn:(...r)=>e&&ct(...r)}}function wt(e){return{cache:e?"no-cache":"force-cache"}}function k(e,t){return C(this,null,function*(){return Ge(e)?e:ye(e,M(D({},t),{autoDestruct:!0}))})}function ye(e,t){return C(this,null,function*(){var f,h,g,p,E;const{scale:r=1,workerUrl:n,workerNumber:i=1}=t||{},a=!!(t!=null&&t.debug),s=(f=t==null?void 0:t.features)!=null?f:!0,o=(h=e.ownerDocument)!=null?h:U?window.document:void 0,c=(p=(g=e.ownerDocument)==null?void 0:g.defaultView)!=null?p:U?window:void 0,u=new Map,l=M(D({width:0,height:0,quality:1,type:"image/png",scale:r,backgroundColor:null,style:null,filter:null,maximumCanvasSize:0,timeout:3e4,progress:null,debug:a,fetch:D({requestInit:wt((E=t==null?void 0:t.fetch)==null?void 0:E.bypassingCache),placeholderImage:"data:image/png;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",bypassingCache:!1},t==null?void 0:t.fetch),fetchFn:null,font:{},drawImageInterval:100,workerUrl:null,workerNumber:i,onCloneEachNode:null,onCloneNode:null,onEmbedNode:null,onCreateForeignObjectSvg:null,includeStyleProperties:null,autoDestruct:!1},t),{__CONTEXT__:!0,log:ht(a),node:e,ownerDocument:o,ownerWindow:c,dpi:r===1?null:96*r,svgStyleElement:be(o),svgDefsElement:o==null?void 0:o.createElementNS(K,"defs"),svgStyles:new Map,defaultComputedStyles:new Map,workers:[...Array.from({length:ze&&n&&i?i:0})].map(()=>{try{const b=new Worker(n);return b.onmessage=w=>C(this,null,function*(){var A,F,B,$e;const{url:S,result:T}=w.data;T?(F=(A=u.get(S))==null?void 0:A.resolve)==null||F.call(A,T):($e=(B=u.get(S))==null?void 0:B.reject)==null||$e.call(B,new Error(`Error receiving message from worker: ${S}`))}),b.onmessageerror=w=>{var T,A;const{url:S}=w.data;(A=(T=u.get(S))==null?void 0:T.reject)==null||A.call(T,new Error(`Error receiving message from worker: ${S}`))},b}catch(b){return l.log.warn("Failed to new Worker",b),null}}).filter(Boolean),fontFamilies:new Map,fontCssTexts:new Map,acceptOfImage:`${[ut(o)&&"image/webp","image/svg+xml","image/*","*/*"].filter(Boolean).join(",")};q=0.8`,requests:u,drawImageCount:0,tasks:[],features:s,isEnable:b=>{var w,S;return b==="restoreScrollPosition"?typeof s=="boolean"?!1:(w=s[b])!=null?w:!1:typeof s=="boolean"?s:(S=s[b])!=null?S:!0},shadowRoots:[]});l.log.time("wait until load"),yield me(e,{timeout:l.timeout,onWarn:l.log.warn}),l.log.timeEnd("wait until load");const{width:d,height:m}=pt(e,l);return l.width=d,l.height=m,l})}function be(e){if(!e)return;const t=e.createElement("style"),r=t.ownerDocument.createTextNode(` 2 + .______background-clip--text { 3 + background-clip: text; 4 + -webkit-background-clip: text; 5 + } 6 + `);return t.appendChild(r),t}function pt(e,t){let{width:r,height:n}=t;if(I(e)&&(!r||!n)){const i=e.getBoundingClientRect();r=r||i.width||Number(e.getAttribute("width"))||0,n=n||i.height||Number(e.getAttribute("height"))||0}return{width:r,height:n}}function yt(e,t){return C(this,null,function*(){const{log:r,timeout:n,drawImageCount:i,drawImageInterval:a}=t;r.time("image to canvas");const s=yield L(e,{timeout:n,onWarn:t.log.warn}),{canvas:o,context2d:c}=bt(e.ownerDocument,t),u=()=>{try{c==null||c.drawImage(s,0,0,o.width,o.height)}catch(l){t.log.warn("Failed to drawImage",l)}};if(u(),t.isEnable("fixSvgXmlDecode"))for(let l=0;l<i;l++)yield new Promise(d=>{setTimeout(()=>{c==null||c.clearRect(0,0,o.width,o.height),u(),d()},l+a)});return t.drawImageCount=0,r.timeEnd("image to canvas"),o})}function bt(e,t){const{width:r,height:n,scale:i,backgroundColor:a,maximumCanvasSize:s}=t,o=e.createElement("canvas");o.width=Math.floor(r*i),o.height=Math.floor(n*i),o.style.width=`${r}px`,o.style.height=`${n}px`,s&&(o.width>s||o.height>s)&&(o.width>s&&o.height>s?o.width>o.height?(o.height*=s/o.width,o.width=s):(o.width*=s/o.height,o.height=s):o.width>s?(o.height*=s/o.width,o.width=s):(o.width*=s/o.height,o.height=s));const c=o.getContext("2d");return c&&a&&(c.fillStyle=a,c.fillRect(0,0,o.width,o.height)),{canvas:o,context2d:c}}function Se(e,t){if(e.ownerDocument)try{const a=e.toDataURL();if(a!=="data:,")return _(a,e.ownerDocument)}catch(a){t.log.warn("Failed to clone canvas",a)}const r=e.cloneNode(!1),n=e.getContext("2d"),i=r.getContext("2d");try{return n&&i&&i.putImageData(n.getImageData(0,0,e.width,e.height),0,0),r}catch(a){t.log.warn("Failed to clone canvas",a)}return r}function St(e,t){var r;try{if((r=e==null?void 0:e.contentDocument)!=null&&r.documentElement)return ne(e.contentDocument.documentElement,t)}catch(n){t.log.warn("Failed to clone iframe",n)}return e.cloneNode(!1)}function Et(e){const t=e.cloneNode(!1);return e.currentSrc&&e.currentSrc!==e.src&&(t.src=e.currentSrc,t.srcset=""),t.loading==="lazy"&&(t.loading="eager"),t}function Ct(e,t){return C(this,null,function*(){if(e.ownerDocument&&!e.currentSrc&&e.poster)return _(e.poster,e.ownerDocument);const r=e.cloneNode(!1);r.crossOrigin="anonymous",e.currentSrc&&e.currentSrc!==e.src&&(r.src=e.currentSrc);const n=r.ownerDocument;if(n){let i=!0;if(yield L(r,{onError:()=>i=!1,onWarn:t.log.warn}),!i)return e.poster?_(e.poster,e.ownerDocument):r;r.currentTime=e.currentTime,yield new Promise(s=>{r.addEventListener("seeked",s,{once:!0})});const a=n.createElement("canvas");a.width=e.offsetWidth,a.height=e.offsetHeight;try{const s=a.getContext("2d");s&&s.drawImage(r,0,0,a.width,a.height)}catch(s){return t.log.warn("Failed to clone video",s),e.poster?_(e.poster,e.ownerDocument):r}return Se(a,t)}return r})}function Tt(e,t){return tt(e)?Se(e,t):lt(e)?St(e,t):$(e)?Et(e):Y(e)?Ct(e,t):e.cloneNode(!1)}function vt(e){let t=e.sandbox;if(!t){const{ownerDocument:r}=e;try{r&&(t=r.createElement("iframe"),t.id=`__SANDBOX__${he()}`,t.width="0",t.height="0",t.style.visibility="hidden",t.style.position="fixed",r.body.appendChild(t),t.srcdoc='<!DOCTYPE html><meta charset="UTF-8"><title></title><body>',e.sandbox=t)}catch(n){e.log.warn("Failed to getSandBox",n)}}return t}const At=["width","height","-webkit-text-fill-color"],Nt=["stroke","fill"];function Ee(e,t,r){const{defaultComputedStyles:n}=r,i=e.nodeName.toLowerCase(),a=H(e)&&i!=="svg",s=a?Nt.map(g=>[g,e.getAttribute(g)]).filter(([,g])=>g!==null):[],o=[a&&"svg",i,s.map((g,p)=>`${g}=${p}`).join(","),t].filter(Boolean).join(":");if(n.has(o))return n.get(o);const c=vt(r),u=c==null?void 0:c.contentWindow;if(!u)return new Map;const l=u==null?void 0:u.document;let d,m;a?(d=l.createElementNS(K,"svg"),m=d.ownerDocument.createElementNS(d.namespaceURI,i),s.forEach(([g,p])=>{m.setAttributeNS(null,g,p)}),d.appendChild(m)):d=m=l.createElement(i),m.textContent=" ",l.body.appendChild(d);const f=u.getComputedStyle(m,t),h=new Map;for(let g=f.length,p=0;p<g;p++){const E=f.item(p);At.includes(E)||h.set(E,f.getPropertyValue(E))}return l.body.removeChild(d),n.set(o,h),h}function Ce(e,t,r){var o;const n=new Map,i=[],a=new Map;if(r)for(const c of r)s(c);else for(let c=e.length,u=0;u<c;u++){const l=e.item(u);s(l)}for(let c=i.length,u=0;u<c;u++)(o=a.get(i[u]))==null||o.forEach((l,d)=>n.set(d,l));function s(c){const u=e.getPropertyValue(c),l=e.getPropertyPriority(c),d=c.lastIndexOf("-"),m=d>-1?c.substring(0,d):void 0;if(m){let f=a.get(m);f||(f=new Map,a.set(m,f)),f.set(c,[u,l])}t.get(c)===u&&!l||(m?i.push(m):n.set(c,[u,l]))}return n}function Rt(e,t,r,n){var d,m,f,h;const{ownerWindow:i,includeStyleProperties:a,currentParentNodeStyle:s}=n,o=t.style,c=i.getComputedStyle(e),u=Ee(e,null,n);s==null||s.forEach((g,p)=>{u.delete(p)});const l=Ce(c,u,a);l.delete("transition-property"),l.delete("all"),l.delete("d"),l.delete("content"),r&&(l.delete("position"),l.delete("margin-top"),l.delete("margin-right"),l.delete("margin-bottom"),l.delete("margin-left"),l.delete("margin-block-start"),l.delete("margin-block-end"),l.delete("margin-inline-start"),l.delete("margin-inline-end"),l.set("box-sizing",["border-box",""])),((d=l.get("background-clip"))==null?void 0:d[0])==="text"&&t.classList.add("______background-clip--text"),le&&(l.has("font-kerning")||l.set("font-kerning",["normal",""]),(((m=l.get("overflow-x"))==null?void 0:m[0])==="hidden"||((f=l.get("overflow-y"))==null?void 0:f[0])==="hidden")&&((h=l.get("text-overflow"))==null?void 0:h[0])==="ellipsis"&&e.scrollWidth===e.clientWidth&&l.set("text-overflow",["clip",""]));for(let g=o.length,p=0;p<g;p++)o.removeProperty(o.item(p));return l.forEach(([g,p],E)=>{o.setProperty(E,g,p)}),l}function It(e,t){(rt(e)||nt(e)||st(e))&&t.setAttribute("value",e.value)}const kt=["::before","::after"],Dt=["::-webkit-scrollbar","::-webkit-scrollbar-button","::-webkit-scrollbar-thumb","::-webkit-scrollbar-track","::-webkit-scrollbar-track-piece","::-webkit-scrollbar-corner","::-webkit-resizer"];function Pt(e,t,r,n,i){const{ownerWindow:a,svgStyleElement:s,svgStyles:o,currentNodeStyle:c}=n;if(!s||!a)return;function u(l){var w;const d=a.getComputedStyle(e,l);let m=d.getPropertyValue("content");if(!m||m==="none")return;i==null||i(m),m=m.replace(/(')|(")|(counter\(.+\))/g,"");const f=[he()],h=Ee(e,l,n);c==null||c.forEach((S,T)=>{h.delete(T)});const g=Ce(d,h,n.includeStyleProperties);g.delete("content"),g.delete("-webkit-locale"),((w=g.get("background-clip"))==null?void 0:w[0])==="text"&&t.classList.add("______background-clip--text");const p=[`content: '${m}';`];if(g.forEach(([S,T],A)=>{p.push(`${A}: ${S}${T?" !important":""};`)}),p.length===1)return;try{t.className=[t.className,...f].join(" ")}catch(S){n.log.warn("Failed to copyPseudoClass",S);return}const E=p.join(` 7 + `);let b=o.get(E);b||(b=[],o.set(E,b)),b.push(`.${f[0]}${l}`)}kt.forEach(u),r&&Dt.forEach(u)}const Te=new Set(["symbol"]);function ve(e,t,r,n,i){return C(this,null,function*(){if(I(r)&&(ot(r)||at(r))||n.filter&&!n.filter(r))return;Te.has(t.nodeName)||Te.has(r.nodeName)?n.currentParentNodeStyle=void 0:n.currentParentNodeStyle=n.currentNodeStyle;const a=yield ne(r,n,!1,i);n.isEnable("restoreScrollPosition")&&Ut(e,a),t.appendChild(a)})}function Ae(e,t,r,n){return C(this,null,function*(){var a;let i=e.firstChild;I(e)&&e.shadowRoot&&(i=(a=e.shadowRoot)==null?void 0:a.firstChild,r.shadowRoots.push(e.shadowRoot));for(let s=i;s;s=s.nextSibling)if(!Ze(s))if(I(s)&&it(s)&&typeof s.assignedNodes=="function"){const o=s.assignedNodes();for(let c=0;c<o.length;c++)yield ve(e,t,o[c],r,n)}else yield ve(e,t,s,r,n)})}function Ut(e,t){if(!V(e)||!V(t))return;const{scrollTop:r,scrollLeft:n}=e;if(!r&&!n)return;const{transform:i}=t.style,a=new DOMMatrix(i),{a:s,b:o,c,d:u}=a;a.a=1,a.b=0,a.c=0,a.d=1,a.translateSelf(-n,-r),a.a=s,a.b=o,a.c=c,a.d=u,t.style.transform=a.toString()}function _t(e,t){const{backgroundColor:r,width:n,height:i,style:a}=t,s=e.style;if(r&&s.setProperty("background-color",r,"important"),n&&s.setProperty("width",`${n}px`,"important"),i&&s.setProperty("height",`${i}px`,"important"),a)for(const o in a)s[o]=a[o]}const Ft=/^[\w-:]+$/;function ne(e,t,r=!1,n){return C(this,null,function*(){var u,l,d,m;const{ownerDocument:i,ownerWindow:a,fontFamilies:s,onCloneEachNode:o}=t;if(i&&et(e))return n&&/\S/.test(e.data)&&n(e.data),i.createTextNode(e.data);if(i&&a&&I(e)&&(V(e)||H(e))){const f=yield Tt(e,t);if(t.isEnable("removeAbnormalAttributes")){const w=f.getAttributeNames();for(let S=w.length,T=0;T<S;T++){const A=w[T];Ft.test(A)||f.removeAttribute(A)}}const h=t.currentNodeStyle=Rt(e,f,r,t);r&&_t(f,t);let g=!1;if(t.isEnable("copyScrollbar")){const w=[(u=h.get("overflow-x"))==null?void 0:u[0],(l=h.get("overflow-y"))==null?void 0:l[0]];g=w.includes("scroll")||(w.includes("auto")||w.includes("overlay"))&&(e.scrollHeight>e.clientHeight||e.scrollWidth>e.clientWidth)}const p=(d=h.get("text-transform"))==null?void 0:d[0],E=we((m=h.get("font-family"))==null?void 0:m[0]),b=E?w=>{p==="uppercase"?w=w.toUpperCase():p==="lowercase"?w=w.toLowerCase():p==="capitalize"&&(w=w[0].toUpperCase()+w.substring(1)),E.forEach(S=>{let T=s.get(S);T||s.set(S,T=new Set),w.split("").forEach(A=>T.add(A))})}:void 0;return Pt(e,f,g,t,b),It(e,f),Y(e)||(yield Ae(e,f,t,b)),yield o==null?void 0:o(f),f}const c=e.cloneNode(!1);return yield Ae(e,c,t),yield o==null?void 0:o(c),c})}function Ne(e){if(e.ownerDocument=void 0,e.ownerWindow=void 0,e.svgStyleElement=void 0,e.svgDefsElement=void 0,e.svgStyles.clear(),e.defaultComputedStyles.clear(),e.sandbox){try{e.sandbox.remove()}catch(t){e.log.warn("Failed to destroyContext",t)}e.sandbox=void 0}e.workers=[],e.fontFamilies.clear(),e.fontCssTexts.clear(),e.requests.clear(),e.tasks=[],e.shadowRoots=[]}function Bt(e){const o=e,{url:t,timeout:r,responseType:n}=o,i=je(o,["url","timeout","responseType"]),a=new AbortController,s=r?setTimeout(()=>a.abort(),r):void 0;return fetch(t,D({signal:a.signal},i)).then(c=>{if(!c.ok)throw new Error("Failed fetch, not 2xx response",{cause:c});switch(n){case"arrayBuffer":return c.arrayBuffer();case"dataUrl":return c.blob().then(gt);case"text":default:return c.text()}}).finally(()=>clearTimeout(s))}function z(e,t){const{url:r,requestType:n="text",responseType:i="text",imageDom:a}=t;let s=r;const{timeout:o,acceptOfImage:c,requests:u,fetchFn:l,fetch:{requestInit:d,bypassingCache:m,placeholderImage:f},font:h,workers:g,fontFamilies:p}=e;n==="image"&&(G||te)&&e.drawImageCount++;let E=u.get(r);if(!E){m&&m instanceof RegExp&&m.test(s)&&(s+=(/\?/.test(s)?"&":"?")+new Date().getTime());const b=n.startsWith("font")&&h&&h.minify,w=new Set;b&&n.split(";")[1].split(",").forEach(F=>{p.has(F)&&p.get(F).forEach(B=>w.add(B))});const S=b&&w.size,T=D({url:s,timeout:o,responseType:S?"arrayBuffer":i,headers:n==="image"?{accept:c}:void 0},d);E={type:n,resolve:void 0,reject:void 0,response:null},E.response=C(this,null,function*(){if(l&&n==="image"){const A=yield l(r);if(A)return A}return!G&&r.startsWith("http")&&g.length?new Promise((A,F)=>{g[u.size&g.length-1].postMessage(D({rawUrl:r},T)),E.resolve=A,E.reject=F}):Bt(T)}).catch(A=>{if(u.delete(r),n==="image"&&f)return e.log.warn("Failed to fetch image base64, trying to use placeholder image",s),typeof f=="string"?f:f(a);throw A}),u.set(r,E)}return E.response}function Re(e,t,r,n){return C(this,null,function*(){if(!Ie(e))return e;for(const[i,a]of $t(e,t))try{const s=yield z(r,{url:a,requestType:n?"image":"text",responseType:"dataUrl"});e=e.replace(Lt(i),`$1${s}$3`)}catch(s){r.log.warn("Failed to fetch css data url",i,s)}return e})}function Ie(e){return/url\((['"]?)([^'"]+?)\1\)/.test(e)}const ke=/url\((['"]?)([^'"]+?)\1\)/g;function $t(e,t){const r=[];return e.replace(ke,(n,i,a)=>(r.push([a,ue(a,t)]),n)),r.filter(([n])=>!re(n))}function Lt(e){const t=e.replace(/([.*+?^${}()|\[\]\/\\])/g,"\\$1");return new RegExp(`(url\\(['"]?)(${t})(['"]?\\))`,"g")}const xt=["background-image","border-image-source","-webkit-border-image","-webkit-mask-image","list-style-image"];function Mt(e,t){return xt.map(r=>{const n=e.getPropertyValue(r);return!n||n==="none"?null:((G||te)&&t.drawImageCount++,Re(n,null,t,!0).then(i=>{!i||n===i||e.setProperty(r,i,e.getPropertyPriority(r))}))}).filter(Boolean)}function Ot(e,t){if($(e)){const r=e.currentSrc||e.src;if(!re(r))return[z(t,{url:r,imageDom:e,requestType:"image",responseType:"dataUrl"}).then(n=>{n&&(e.srcset="",e.dataset.originalSrc=r,e.src=n||"")})];(G||te)&&t.drawImageCount++}else if(H(e)&&!re(e.href.baseVal)){const r=e.href.baseVal;return[z(t,{url:r,imageDom:e,requestType:"image",responseType:"dataUrl"}).then(n=>{n&&(e.dataset.originalSrc=r,e.href.baseVal=n||"")})]}return[]}function jt(e,t){var o;const{ownerDocument:r,svgDefsElement:n}=t,i=(o=e.getAttribute("href"))!=null?o:e.getAttribute("xlink:href");if(!i)return[];const[a,s]=i.split("#");if(s){const c=`#${s}`,u=t.shadowRoots.reduce((l,d)=>l!=null?l:d.querySelector(`svg ${c}`),r==null?void 0:r.querySelector(`svg ${c}`));if(a&&e.setAttribute("href",c),n!=null&&n.querySelector(c))return[];if(u)return n==null||n.appendChild(u.cloneNode(!0)),[];if(a)return[z(t,{url:a,responseType:"text"}).then(l=>{n==null||n.insertAdjacentHTML("beforeend",l)})]}return[]}function De(e,t){const{tasks:r}=t;I(e)&&(($(e)||ce(e))&&r.push(...Ot(e,t)),Qe(e)&&r.push(...jt(e,t))),V(e)&&r.push(...Mt(e.style,t)),e.childNodes.forEach(n=>{De(n,t)})}function qt(e,t){return C(this,null,function*(){const{ownerDocument:r,svgStyleElement:n,fontFamilies:i,fontCssTexts:a,tasks:s,font:o}=t;if(!(!r||!n||!i.size))if(o&&o.cssText){const c=Ue(o.cssText,t);n.appendChild(r.createTextNode(`${c} 8 + `))}else{const c=Array.from(r.styleSheets).filter(f=>{try{return"cssRules"in f&&!!f.cssRules.length}catch(h){return t.log.warn(`Error while reading CSS rules from ${f.href}`,h),!1}}),u=r.implementation.createHTMLDocument(""),l=u.createElement("style");u.head.appendChild(l);const d=l.sheet;yield Promise.all(c.flatMap(f=>Array.from(f.cssRules).map(h=>C(this,null,function*(){if(Je(h)){const g=h.href;let p="";try{p=yield z(t,{url:g,requestType:"text",responseType:"text"})}catch(b){t.log.warn(`Error fetch remote css import from ${g}`,b)}const E=p.replace(ke,(b,w,S)=>b.replace(S,ue(S,g)));for(const b of Ht(E))try{d.insertRule(b,d.cssRules.length)}catch(w){t.log.warn("Error inserting rule from remote css import",{rule:b,error:w})}}})))),d.cssRules.length&&c.push(d);const m=[];c.forEach(f=>{oe(f.cssRules,m)}),m.filter(f=>{var h;return Ye(f)&&Ie(f.style.getPropertyValue("src"))&&((h=we(f.style.getPropertyValue("font-family")))==null?void 0:h.some(g=>i.has(g)))}).forEach(f=>{const h=f,g=a.get(h.cssText);g?n.appendChild(r.createTextNode(`${g} 9 + `)):s.push(Re(h.cssText,h.parentStyleSheet?h.parentStyleSheet.href:null,t).then(p=>{p=Ue(p,t),a.set(h.cssText,p),n.appendChild(r.createTextNode(`${p} 10 + `))}))})}})}const Wt=/(\/\*[\s\S]*?\*\/)/g,Pe=/((@.*?keyframes [\s\S]*?){([\s\S]*?}\s*?)})/gi;function Ht(e){if(e==null)return[];const t=[];let r=e.replace(Wt,"");for(;;){const a=Pe.exec(r);if(!a)break;t.push(a[0])}r=r.replace(Pe,"");const n=/@import[\s\S]*?url\([^)]*\)[\s\S]*?;/gi,i=new RegExp("((\\s*?(?:\\/\\*[\\s\\S]*?\\*\\/)?\\s*?@media[\\s\\S]*?){([\\s\\S]*?)}\\s*?})|(([\\s\\S]*?){([\\s\\S]*?)})","gi");for(;;){let a=n.exec(r);if(a)i.lastIndex=n.lastIndex;else if(a=i.exec(r),a)n.lastIndex=i.lastIndex;else break;t.push(a[0])}return t}const Vt=/url\([^)]+\)\s*format\((["']?)([^"']+)\1\)/g,zt=/src:\s*(?:url\([^)]+\)\s*format\([^)]+\)[,;]\s*)+/g;function Ue(e,t){const{font:r}=t,n=r?r==null?void 0:r.preferredFormat:void 0;return n?e.replace(zt,i=>{for(;;){const[a,,s]=Vt.exec(i)||[];if(!s)return"";if(s===n)return`src: ${a};`}}):e}function oe(e,t=[]){for(const r of Array.from(e))Ke(r)?t.push(...oe(r.cssRules)):"cssRules"in r?oe(r.cssRules,t):t.push(r);return t}const Xt=/\bx?link:?href\s*=\s*["'](?!data:)[^"']+["']/i;function Gt(e){return Xt.test(e.innerHTML)}function _e(e,t){return C(this,null,function*(){const r=yield k(e,t);if(I(r.node)&&H(r.node)&&!Gt(r.node))return r.node;const{ownerDocument:n,log:i,tasks:a,svgStyleElement:s,svgDefsElement:o,svgStyles:c,font:u,progress:l,autoDestruct:d,onCloneNode:m,onEmbedNode:f,onCreateForeignObjectSvg:h}=r;i.time("clone node");const g=yield ne(r.node,r,!0);if(s&&n){let S="";c.forEach((T,A)=>{S+=`${T.join(`, 11 + `)} { 12 + ${A} 13 + } 14 + `}),s.appendChild(n.createTextNode(S))}i.timeEnd("clone node"),yield m==null?void 0:m(g),u!==!1&&I(g)&&(i.time("embed web font"),yield qt(g,r),i.timeEnd("embed web font")),i.time("embed node"),De(g,r);const p=a.length;let E=0;const b=()=>C(this,null,function*(){for(;;){const S=a.pop();if(!S)break;try{yield S}catch(T){r.log.warn("Failed to run task",T)}l==null||l(++E,p)}});l==null||l(E,p),yield Promise.all([...Array.from({length:4})].map(b)),i.timeEnd("embed node"),yield f==null?void 0:f(g);const w=Yt(g,r);return o&&w.insertBefore(o,w.children[0]),s&&w.insertBefore(s,w.children[0]),d&&Ne(r),yield h==null?void 0:h(w),w})}function Yt(e,t){const{width:r,height:n}=t,i=fe(r,n,e.ownerDocument),a=i.ownerDocument.createElementNS(i.namespaceURI,"foreignObject");return a.setAttributeNS(null,"x","0%"),a.setAttributeNS(null,"y","0%"),a.setAttributeNS(null,"width","100%"),a.setAttributeNS(null,"height","100%"),a.append(e),i.appendChild(a),i}function Q(e,t){return C(this,null,function*(){var s;const r=yield k(e,t),n=yield _e(r),i=de(n,r.isEnable("removeControlCharacter"));r.autoDestruct||(r.svgStyleElement=be(r.ownerDocument),r.svgDefsElement=(s=r.ownerDocument)==null?void 0:s.createElementNS(K,"defs"),r.svgStyles.clear());const a=_(i,n.ownerDocument);return yield yt(a,r)})}function Jt(e,t){return C(this,null,function*(){const r=yield k(e,t),{log:n,type:i,quality:a,dpi:s}=r,o=yield Q(r);n.time("canvas to blob");const c=yield ft(o,i,a);if(["image/png","image/jpeg"].includes(i)&&s){const u=yield mt(c.slice(0,33));let l=new Uint8Array(u);return i==="image/png"?l=ae(l,s):i==="image/jpeg"&&(l=v(l,s)),n.timeEnd("canvas to blob"),new Blob([l,c.slice(33)],{type:i})}return n.timeEnd("canvas to blob"),c})}function x(e,t){return C(this,null,function*(){const r=yield k(e,t),{log:n,quality:i,type:a,dpi:s}=r,o=yield Q(r);n.time("canvas to data url");let c=o.toDataURL(a,i);if(["image/png","image/jpeg"].includes(a)&&s&&ie&&Xe){const[u,l]=c.split(",");let d=0,m=!1;if(a==="image/png"){const w=Ve(l);w>=0?(d=Math.ceil((w+28)/3)*4,m=!0):d=33/3*4}else a==="image/jpeg"&&(d=18/3*4);const f=l.substring(0,d),h=l.substring(d),g=window.atob(f),p=new Uint8Array(g.length);for(let w=0;w<p.length;w++)p[w]=g.charCodeAt(w);const E=a==="image/png"?ae(p,s,m):v(p,s),b=window.btoa(String.fromCharCode(...E));c=[u,",",b,h].join("")}return n.timeEnd("canvas to data url"),c})}function Fe(e,t){return C(this,null,function*(){const r=yield k(e,t),{width:n,height:i,ownerDocument:a}=r,s=yield x(r),o=fe(n,i,a),c=o.ownerDocument.createElementNS(o.namespaceURI,"image");return c.setAttributeNS(null,"href",s),c.setAttributeNS(null,"height","100%"),c.setAttributeNS(null,"width","100%"),o.appendChild(c),de(o,r.isEnable("removeControlCharacter"))})}function Kt(e,t){return C(this,null,function*(){const r=yield k(e,t),{ownerDocument:n,width:i,height:a,scale:s,type:o}=r,c=o==="image/svg+xml"?yield Fe(r):yield x(r),u=_(c,n);return u.width=Math.floor(i*s),u.height=Math.floor(a*s),u.style.width=`${i}px`,u.style.height=`${a}px`,u})}function Qt(e,t){return C(this,null,function*(){return x(yield k(e,M(D({},t),{type:"image/jpeg"})))})}function Zt(e,t){return C(this,null,function*(){const r=yield k(e,t),n=yield Q(r);return n.getContext("2d").getImageData(0,0,n.width,n.height).data})}function er(e,t){return C(this,null,function*(){return x(yield k(e,M(D({},t),{type:"image/png"})))})}function tr(e,t){return C(this,null,function*(){return x(yield k(e,M(D({},t),{type:"image/webp"})))})}y.createContext=ye,y.destroyContext=Ne,y.domToBlob=Jt,y.domToCanvas=Q,y.domToDataUrl=x,y.domToForeignObjectSvg=_e,y.domToImage=Kt,y.domToJpeg=Qt,y.domToPixel=Zt,y.domToPng=er,y.domToSvg=Fe,y.domToWebp=tr,y.loadMedia=L,y.waitUntilLoad=me,Object.defineProperty(y,Symbol.toStringTag,{value:"Module"})});
+214
.agents/skills/impeccable/scripts/pin.mjs
··· 1 + #!/usr/bin/env node 2 + /** 3 + * Pin/unpin sub-commands as standalone skill shortcuts. 4 + * 5 + * Usage: 6 + * node <scripts_path>/pin.mjs pin <command> 7 + * node <scripts_path>/pin.mjs unpin <command> 8 + * 9 + * `pin audit` creates a lightweight /audit skill that redirects to /impeccable audit. 10 + * `unpin audit` removes that shortcut. 11 + * 12 + * The script discovers harness directories (.claude/skills, .cursor/skills, etc.) 13 + * in the project root and creates/removes the pin in all of them. 14 + */ 15 + 16 + import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync } from 'node:fs'; 17 + import { join, resolve, dirname } from 'node:path'; 18 + import { fileURLToPath } from 'node:url'; 19 + 20 + const __dirname = dirname(fileURLToPath(import.meta.url)); 21 + 22 + // All known harness directories 23 + const HARNESS_DIRS = [ 24 + '.claude', '.cursor', '.gemini', '.codex', '.agents', 25 + '.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev', 26 + ]; 27 + 28 + // Valid sub-command names 29 + const VALID_COMMANDS = [ 30 + 'craft', 'teach', 'extract', 'document', 'shape', 31 + 'critique', 'audit', 32 + 'polish', 'bolder', 'quieter', 'distill', 'harden', 'onboard', 'live', 33 + 'animate', 'colorize', 'typeset', 'layout', 'delight', 'overdrive', 34 + 'clarify', 'adapt', 'optimize', 35 + ]; 36 + 37 + // Marker to identify pinned skills (so unpin doesn't delete user skills) 38 + const PIN_MARKER = '<!-- impeccable-pinned-skill -->'; 39 + 40 + /** 41 + * Walk up from startDir to find a project root. 42 + */ 43 + function findProjectRoot(startDir = process.cwd()) { 44 + let dir = resolve(startDir); 45 + while (dir !== '/') { 46 + if ( 47 + existsSync(join(dir, 'package.json')) || 48 + existsSync(join(dir, '.git')) || 49 + existsSync(join(dir, 'skills-lock.json')) 50 + ) { 51 + return dir; 52 + } 53 + const parent = resolve(dir, '..'); 54 + if (parent === dir) break; 55 + dir = parent; 56 + } 57 + return resolve(startDir); 58 + } 59 + 60 + /** 61 + * Find harness skill directories that have an impeccable skill installed. 62 + */ 63 + function findHarnessDirs(projectRoot) { 64 + const dirs = []; 65 + for (const harness of HARNESS_DIRS) { 66 + const skillsDir = join(projectRoot, harness, 'skills'); 67 + // Only pin in harness dirs that already have impeccable installed 68 + const impeccableDir = join(skillsDir, 'impeccable'); 69 + if (existsSync(impeccableDir) || existsSync(join(skillsDir, 'i-impeccable'))) { 70 + dirs.push(skillsDir); 71 + } 72 + } 73 + return dirs; 74 + } 75 + 76 + /** 77 + * Load command metadata (descriptions for pinned skills). 78 + */ 79 + function loadCommandMetadata() { 80 + const metadataPath = join(__dirname, 'command-metadata.json'); 81 + if (existsSync(metadataPath)) { 82 + return JSON.parse(readFileSync(metadataPath, 'utf-8')); 83 + } 84 + return {}; 85 + } 86 + 87 + /** 88 + * Generate a pinned skill's SKILL.md content. 89 + */ 90 + function generatePinnedSkill(command, metadata) { 91 + const desc = metadata[command]?.description || `Shortcut for /impeccable ${command}.`; 92 + const hint = metadata[command]?.argumentHint || '[target]'; 93 + 94 + return `--- 95 + name: ${command} 96 + description: "${desc}" 97 + argument-hint: "${hint}" 98 + user-invocable: true 99 + --- 100 + 101 + ${PIN_MARKER} 102 + 103 + This is a pinned shortcut for \`{{command_prefix}}impeccable ${command}\`. 104 + 105 + Invoke {{command_prefix}}impeccable ${command}, passing along any arguments provided here, and follow its instructions. 106 + `; 107 + } 108 + 109 + /** 110 + * Pin a command: create shortcut skill in all harness dirs. 111 + */ 112 + function pin(command, projectRoot) { 113 + const metadata = loadCommandMetadata(); 114 + const harnessDirs = findHarnessDirs(projectRoot); 115 + 116 + if (harnessDirs.length === 0) { 117 + console.log('No harness directories with impeccable installed found.'); 118 + return false; 119 + } 120 + 121 + const content = generatePinnedSkill(command, metadata); 122 + let created = 0; 123 + 124 + for (const skillsDir of harnessDirs) { 125 + // Check if skill already exists (and isn't a pin) 126 + const skillDir = join(skillsDir, command); 127 + if (existsSync(skillDir)) { 128 + const existingMd = join(skillDir, 'SKILL.md'); 129 + if (existsSync(existingMd)) { 130 + const existing = readFileSync(existingMd, 'utf-8'); 131 + if (!existing.includes(PIN_MARKER)) { 132 + console.log(` SKIP: ${skillDir} (non-pinned skill already exists)`); 133 + continue; 134 + } 135 + } 136 + } 137 + 138 + mkdirSync(skillDir, { recursive: true }); 139 + writeFileSync(join(skillDir, 'SKILL.md'), content, 'utf-8'); 140 + console.log(` + ${skillDir}`); 141 + created++; 142 + } 143 + 144 + if (created > 0) { 145 + console.log(`\nPinned '${command}' as a standalone shortcut in ${created} location(s).`); 146 + console.log(`You can now use /${command} directly.`); 147 + } 148 + 149 + return created > 0; 150 + } 151 + 152 + /** 153 + * Unpin a command: remove shortcut skill from all harness dirs. 154 + */ 155 + function unpin(command, projectRoot) { 156 + const harnessDirs = findHarnessDirs(projectRoot); 157 + let removed = 0; 158 + 159 + for (const skillsDir of harnessDirs) { 160 + const skillDir = join(skillsDir, command); 161 + if (!existsSync(skillDir)) continue; 162 + 163 + const skillMd = join(skillDir, 'SKILL.md'); 164 + if (!existsSync(skillMd)) continue; 165 + 166 + // Safety: only remove if it's a pinned skill 167 + const content = readFileSync(skillMd, 'utf-8'); 168 + if (!content.includes(PIN_MARKER)) { 169 + console.log(` SKIP: ${skillDir} (not a pinned skill)`); 170 + continue; 171 + } 172 + 173 + rmSync(skillDir, { recursive: true, force: true }); 174 + console.log(` - ${skillDir}`); 175 + removed++; 176 + } 177 + 178 + if (removed > 0) { 179 + console.log(`\nUnpinned '${command}' from ${removed} location(s).`); 180 + console.log(`Use /impeccable ${command} to access it.`); 181 + } else { 182 + console.log(`No pinned '${command}' shortcut found.`); 183 + } 184 + 185 + return removed > 0; 186 + } 187 + 188 + // --- CLI --- 189 + const [,, action, command] = process.argv; 190 + 191 + if (!action || !command) { 192 + console.log('Usage: node pin.mjs <pin|unpin> <command>'); 193 + console.log(`\nAvailable commands: ${VALID_COMMANDS.join(', ')}`); 194 + process.exit(1); 195 + } 196 + 197 + if (action !== 'pin' && action !== 'unpin') { 198 + console.error(`Unknown action: ${action}. Use 'pin' or 'unpin'.`); 199 + process.exit(1); 200 + } 201 + 202 + if (!VALID_COMMANDS.includes(command)) { 203 + console.error(`Unknown command: ${command}`); 204 + console.error(`Available commands: ${VALID_COMMANDS.join(', ')}`); 205 + process.exit(1); 206 + } 207 + 208 + const root = findProjectRoot(); 209 + 210 + if (action === 'pin') { 211 + pin(command, root); 212 + } else { 213 + unpin(command, root); 214 + }
+26 -11
.agents/skills/layout/SKILL.md .agents/skills/impeccable/reference/layout.md
··· 1 - --- 2 - name: layout 3 - 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." 4 - version: 2.1.1 5 - user-invocable: true 6 - argument-hint: "[target]" 1 + Assess and improve layout and spacing that feels monotonous, crowded, or structurally weak — turning generic arrangements into intentional, rhythmic compositions. 2 + 7 3 --- 8 4 9 - Assess and improve layout and spacing that feels monotonous, crowded, or structurally weak — turning generic arrangements into intentional, rhythmic compositions. 5 + ## Register 10 6 11 - ## MANDATORY PREPARATION 7 + Brand: asymmetric compositions, fluid spacing with `clamp()`, intentional grid-breaking for emphasis. Rhythm through contrast — tight groupings paired with generous separations. 12 8 13 - 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. 9 + Product: predictable grids, consistent densities, familiar navigation patterns. Responsive behavior is structural (collapse sidebar, responsive table), not fluid typography. Consistency IS an affordance. 14 10 15 11 --- 16 12 ··· 47 43 48 44 ## Plan Layout Improvements 49 45 50 - Consult the [spatial design reference](reference/spatial-design.md) from the impeccable skill for detailed guidance on grids, rhythm, and container queries. 46 + Consult the [spatial design reference](spatial-design.md) for detailed guidance on grids, rhythm, and container queries. 51 47 52 48 Create a systematic plan: 53 49 ··· 103 99 - 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. 104 100 105 101 **NEVER**: 106 - 107 102 - Use arbitrary spacing values outside your scale 108 103 - Make all spacing equal — variety creates hierarchy 109 104 - Wrap everything in cards — not everything needs a container ··· 124 119 - **Responsiveness**: Does the layout adapt gracefully across screen sizes? 125 120 126 121 Remember: Space is the most underused design tool. A layout with the right rhythm and hierarchy can make even simple content feel polished and intentional. 122 + 123 + ## Live-mode signature params 124 + 125 + 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. 126 + 127 + ```json 128 + {"id":"density","kind":"range","min":0.6,"max":1.4,"step":0.05,"default":1,"label":"Density"} 129 + ``` 130 + 131 + 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. 132 + 133 + ```json 134 + {"id":"structure","kind":"steps","default":"grid","label":"Structure","options":[ 135 + {"value":"stacked","label":"Stacked"}, 136 + {"value":"grid","label":"Grid"}, 137 + {"value":"bento","label":"Bento"} 138 + ]} 139 + ``` 140 + 141 + See `reference/live.md` for the full params contract.
+11 -41
.agents/skills/optimize/SKILL.md .agents/skills/impeccable/reference/optimize.md
··· 1 - --- 2 - name: optimize 3 - 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." 4 - version: 2.1.1 5 - user-invocable: true 6 - argument-hint: "[target]" 7 - --- 8 - 9 1 Identify and fix performance issues to create faster, smoother user experiences. 10 2 11 3 ## Assess Performance Issues ··· 34 26 ### Loading Performance 35 27 36 28 **Optimize Images**: 37 - 38 29 - Use modern formats (WebP, AVIF) 39 30 - Proper sizing (don't load 3000px image for 300px display) 40 31 - Lazy loading for below-fold images ··· 43 34 - Use CDN for faster delivery 44 35 45 36 ```html 46 - <img 37 + <img 47 38 src="hero.webp" 48 39 srcset="hero-400.webp 400w, hero-800.webp 800w, hero-1200.webp 1200w" 49 40 sizes="(max-width: 400px) 400px, (max-width: 800px) 800px, 1200px" ··· 53 44 ``` 54 45 55 46 **Reduce JavaScript Bundle**: 56 - 57 47 - Code splitting (route-based, component-based) 58 48 - Tree shaking (remove unused code) 59 49 - Remove unused dependencies ··· 62 52 63 53 ```javascript 64 54 // Lazy load heavy component 65 - const HeavyChart = lazy(() => import("./HeavyChart")); 55 + const HeavyChart = lazy(() => import('./HeavyChart')); 66 56 ``` 67 57 68 58 **Optimize CSS**: 69 - 70 59 - Remove unused CSS 71 60 - Critical CSS inline, rest async 72 61 - Minimize CSS files 73 62 - Use CSS containment for independent regions 74 63 75 64 **Optimize Fonts**: 76 - 77 65 - Use `font-display: swap` or `optional` 78 66 - Subset fonts (only characters you need) 79 67 - Preload critical fonts ··· 82 70 83 71 ```css 84 72 @font-face { 85 - font-family: "CustomFont"; 86 - src: url("/fonts/custom.woff2") format("woff2"); 73 + font-family: 'CustomFont'; 74 + src: url('/fonts/custom.woff2') format('woff2'); 87 75 font-display: swap; /* Show fallback immediately */ 88 76 unicode-range: U+0020-007F; /* Basic Latin only */ 89 77 } 90 78 ``` 91 79 92 80 **Optimize Loading Strategy**: 93 - 94 81 - Critical resources first (async/defer non-critical) 95 82 - Preload critical assets 96 83 - Prefetch likely next pages ··· 100 87 ### Rendering Performance 101 88 102 89 **Avoid Layout Thrashing**: 103 - 104 90 ```javascript 105 91 // ❌ Bad: Alternating reads and writes (causes reflows) 106 - elements.forEach((el) => { 92 + elements.forEach(el => { 107 93 const height = el.offsetHeight; // Read (forces layout) 108 94 el.style.height = height * 2; // Write 109 95 }); 110 96 111 97 // ✅ Good: Batch reads, then batch writes 112 - const heights = elements.map((el) => el.offsetHeight); // All reads 98 + const heights = elements.map(el => el.offsetHeight); // All reads 113 99 elements.forEach((el, i) => { 114 100 el.style.height = heights[i] * 2; // All writes 115 101 }); 116 102 ``` 117 103 118 104 **Optimize Rendering**: 119 - 120 105 - Use CSS `contain` property for independent regions 121 106 - Minimize DOM depth (flatter is faster) 122 107 - Reduce DOM size (fewer elements) ··· 124 109 - Virtual scrolling for very long lists (react-window, react-virtualized) 125 110 126 111 **Reduce Paint & Composite**: 127 - 128 - - Use `transform` and `opacity` for animations (GPU-accelerated) 129 - - Avoid animating layout properties (width, height, top, left) 112 + - Use `transform` and `opacity` for reliable movement, but allow blur, filters, masks, clip paths, shadows, and color shifts when they create meaningful polish 113 + - Avoid casual animation of layout-driving properties (`width`, `height`, `top`, `left`, margins) 130 114 - Use `will-change` sparingly for known expensive operations 131 - - Minimize paint areas (smaller is faster) 115 + - Bound expensive paint areas for blur/filter/shadow effects (smaller and isolated is faster) 132 116 133 117 ### Animation Performance 134 118 135 119 **GPU Acceleration**: 136 - 137 120 ```css 138 121 /* ✅ GPU-accelerated (fast) */ 139 122 .animated { ··· 149 132 ``` 150 133 151 134 **Smooth 60fps**: 152 - 153 135 - Target 16ms per frame (60fps) 154 136 - Use `requestAnimationFrame` for JS animations 155 137 - Debounce/throttle scroll handlers ··· 157 139 - Avoid long-running JavaScript during animations 158 140 159 141 **Intersection Observer**: 160 - 161 142 ```javascript 162 143 // Efficiently detect when elements enter viewport 163 144 const observer = new IntersectionObserver((entries) => { 164 - entries.forEach((entry) => { 145 + entries.forEach(entry => { 165 146 if (entry.isIntersecting) { 166 147 // Element is visible, lazy load or animate 167 148 } ··· 172 153 ### React/Framework Optimization 173 154 174 155 **React-specific**: 175 - 176 156 - Use `memo()` for expensive components 177 157 - `useMemo()` and `useCallback()` for expensive computations 178 158 - Virtualize long lists ··· 181 161 - Use React DevTools Profiler 182 162 183 163 **Framework-agnostic**: 184 - 185 164 - Minimize re-renders 186 165 - Debounce expensive operations 187 166 - Memoize computed values ··· 190 169 ### Network Optimization 191 170 192 171 **Reduce Requests**: 193 - 194 172 - Combine small files 195 173 - Use SVG sprites for icons 196 174 - Inline small critical assets 197 175 - Remove unused third-party scripts 198 176 199 177 **Optimize APIs**: 200 - 201 178 - Use pagination (don't load everything) 202 179 - GraphQL to request only needed fields 203 180 - Response compression (gzip, brotli) ··· 205 182 - CDN for static assets 206 183 207 184 **Optimize for Slow Connections**: 208 - 209 185 - Adaptive loading based on connection (navigator.connection) 210 186 - Optimistic UI updates 211 187 - Request prioritization ··· 214 190 ## Core Web Vitals Optimization 215 191 216 192 ### Largest Contentful Paint (LCP < 2.5s) 217 - 218 193 - Optimize hero images 219 194 - Inline critical CSS 220 195 - Preload key resources ··· 222 197 - Server-side rendering 223 198 224 199 ### First Input Delay (FID < 100ms) / INP (< 200ms) 225 - 226 200 - Break up long tasks 227 201 - Defer non-critical JavaScript 228 202 - Use web workers for heavy computation 229 203 - Reduce JavaScript execution time 230 204 231 205 ### Cumulative Layout Shift (CLS < 0.1) 232 - 233 206 - Set dimensions on images and videos 234 207 - Don't inject content above existing content 235 208 - Use `aspect-ratio` CSS property ··· 246 219 ## Performance Monitoring 247 220 248 221 **Tools to use**: 249 - 250 222 - Chrome DevTools (Lighthouse, Performance panel) 251 223 - WebPageTest 252 224 - Core Web Vitals (Chrome UX Report) ··· 254 226 - Performance monitoring (Sentry, DataDog, New Relic) 255 227 256 228 **Key metrics**: 257 - 258 229 - LCP, FID/INP, CLS (Core Web Vitals) 259 230 - Time to Interactive (TTI) 260 231 - First Contentful Paint (FCP) ··· 265 236 **IMPORTANT**: Measure on real devices with real network conditions. Desktop Chrome with fast connection isn't representative. 266 237 267 238 **NEVER**: 268 - 269 239 - Optimize without measuring (premature optimization) 270 240 - Sacrifice accessibility for performance 271 241 - Break functionality while optimizing ··· 283 253 - **Different devices**: Test on low-end Android, not just flagship iPhone 284 254 - **Slow connections**: Throttle to 3G, test experience 285 255 - **No regressions**: Ensure functionality still works 286 - - **User perception**: Does it _feel_ faster? 256 + - **User perception**: Does it *feel* faster? 287 257 288 258 Remember: Performance is a feature. Fast experiences feel more responsive, more polished, more professional. Optimize systematically, measure ruthlessly, and prioritize user-perceived performance.
+11 -40
.agents/skills/overdrive/SKILL.md .agents/skills/impeccable/reference/overdrive.md
··· 1 - --- 2 - name: overdrive 3 - 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." 4 - version: 2.1.1 5 - user-invocable: true 6 - argument-hint: "[target]" 7 - --- 8 - 9 1 Start your response with: 10 2 11 3 ``` ··· 13 5 》》》 Entering overdrive mode... 14 6 ``` 15 7 16 - 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. 17 - 18 - ## MANDATORY PREPARATION 19 - 20 - 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. 8 + 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. 21 9 22 - **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. 10 + **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. 23 11 24 12 ### Propose Before Building 25 13 26 - This skill has the highest potential to misfire. Do NOT jump straight into implementation. You MUST: 14 + This command has the highest potential to misfire. Do NOT jump straight into implementation. You MUST: 27 15 28 - 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. 29 - 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). 16 + 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. 17 + 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). 30 18 3. Only proceed with the direction the user confirms. 31 19 32 20 Skipping this step risks building something embarrassing that needs to be thrown away. 33 21 34 22 ### Iterate with Browser Automation 35 23 36 - 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. 24 + 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. 37 25 38 26 --- 39 27 ··· 42 30 The 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"?** 43 31 44 32 ### For visual/marketing surfaces 45 - 46 33 Pages, 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. 47 34 48 35 ### For functional UI 49 - 50 36 Tables, 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. 51 37 52 38 ### For performance-critical UI 53 - 54 39 The "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. 55 40 56 41 ### For data-heavy interfaces 57 - 58 42 Charts 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. 59 43 60 44 **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. ··· 64 48 Organized by what you're trying to achieve, not by technology name. 65 49 66 50 ### Make transitions feel cinematic 67 - 68 51 - **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. 69 52 - **`@starting-style`** (all browsers) — animate elements from `display: none` to visible with CSS only, including entry keyframes 70 53 - **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. 71 54 72 55 ### Tie animation to scroll position 73 - 74 56 - **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) 75 57 76 58 ### Render beyond CSS 77 - 78 59 - **WebGL** (all browsers) — shader effects, post-processing, particle systems. Libraries: Three.js, OGL (lightweight), regl. Use for effects CSS can't express. 79 60 - **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. 80 61 - **Canvas 2D / OffscreenCanvas** — custom rendering, pixel manipulation, or moving heavy rendering off the main thread entirely via Web Workers + OffscreenCanvas. 81 62 - **SVG filter chains** — displacement maps, turbulence, morphology for organic distortion effects. CSS-animatable. 82 63 83 64 ### Make data feel alive 84 - 85 65 - **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. 86 66 - **GPU-accelerated charts** — Canvas or WebGL-rendered data visualization for datasets too large for SVG/DOM. Libraries: deck.gl, regl-based custom renderers. 87 67 - **Animated data transitions** — morph between chart states rather than replacing. D3's `transition()` or View Transitions for DOM-based charts. 88 68 89 69 ### Animate complex properties 90 - 91 70 - **`@property`** (all browsers) — register custom CSS properties with types, enabling animation of gradients, colors, and complex values that CSS can't normally interpolate. 92 71 - **Web Animations API** (all browsers) — JavaScript-driven animations with the performance of CSS. Composable, cancellable, reversible. The foundation for complex choreography. 93 72 94 73 ### Push performance boundaries 95 - 96 74 - **Web Workers** — move computation off the main thread. Heavy data processing, image manipulation, search indexing — anything that would cause jank. 97 75 - **OffscreenCanvas** — render in a Worker thread. The main thread stays free while complex visuals render in the background. 98 76 - **WASM** — near-native performance for computation-heavy features. Image processing, physics simulations, codecs. 99 77 100 78 ### Interact with the device 101 - 102 79 - **Web Audio API** — spatial audio, audio-reactive visualizations, sonic feedback. Requires user gesture to start. 103 80 - **Device APIs** — orientation, ambient light, geolocation. Use sparingly and always with user permission. 104 81 105 - **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. 82 + **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. 106 83 107 84 ## Implement with Discipline 108 85 ··· 112 89 113 90 ```css 114 91 @supports (animation-timeline: scroll()) { 115 - .hero { 116 - animation-timeline: scroll(); 117 - } 92 + .hero { animation-timeline: scroll(); } 118 93 } 119 94 ``` 120 95 121 96 ```javascript 122 - if ("gpu" in navigator) { 123 - /* WebGPU */ 124 - } else if (canvas.getContext("webgl2")) { 125 - /* WebGL2 fallback */ 126 - } 97 + if ('gpu' in navigator) { /* WebGPU */ } 98 + else if (canvas.getContext('webgl2')) { /* WebGL2 fallback */ } 127 99 /* CSS-only fallback must still look good */ 128 100 ``` 129 101 ··· 140 112 The 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. 141 113 142 114 **NEVER**: 143 - 144 115 - Ignore `prefers-reduced-motion` — this is an accessibility requirement, not a suggestion 145 116 - Ship effects that cause jank on mid-range devices 146 117 - Use bleeding-edge APIs without a functional fallback 147 118 - Add sound without explicit user opt-in 148 - - Use technical ambition to mask weak design fundamentals — fix those first with other skills 119 + - Use technical ambition to mask weak design fundamentals; fix those first with other commands 149 120 - Layer multiple competing extraordinary moments — focus creates impact, excess creates noise 150 121 151 122 ## Verify the Result
+30 -24
.agents/skills/polish/SKILL.md .agents/skills/impeccable/reference/polish.md
··· 1 - --- 2 - name: polish 3 - 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." 4 - version: 2.1.1 5 - user-invocable: true 6 - argument-hint: "[target]" 7 - --- 8 - 9 - ## MANDATORY PREPARATION 10 - 11 - 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). 12 - 13 - --- 1 + > **Additional context needed**: quality bar (MVP vs flagship). 14 2 15 3 Perform a meticulous final pass to catch all the small details that separate good work from great work. The difference between shipped and polished. 16 4 17 5 ## Design System Discovery 18 6 19 - Before polishing, understand the system you are polishing toward: 7 + 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. 20 8 21 - 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. 22 - 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? 23 - 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. 9 + 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. 10 + 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)? 11 + 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. 24 12 25 - If a design system exists, polish should align the feature with it. If none exists, polish against the conventions visible in the codebase. 13 + 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.** 26 14 27 15 ## Pre-Polish Assessment 28 16 29 - Understand the current state and goals: 17 + Understand the current state and goals before touching anything: 30 18 31 19 1. **Review completeness**: 32 20 - Is it functionally complete? ··· 34 22 - What's the quality bar? (MVP vs flagship feature?) 35 23 - When does it ship? (How much time for polish?) 36 24 37 - 2. **Identify polish areas**: 25 + 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. 26 + 27 + 3. **Identify polish areas**: 38 28 - Visual inconsistencies 39 29 - Spacing and alignment issues 40 30 - Interaction state gaps 41 31 - Copy inconsistencies 42 32 - Edge cases and error states 43 33 - Loading and transition smoothness 34 + - Information architecture and flow drift (does this feature reveal complexity the way neighboring features do?) 35 + 36 + 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. 44 37 45 38 **CRITICAL**: Polish is the last step, not the first. Don't polish work that's not functionally complete. 46 39 ··· 57 50 - **Grid adherence**: Elements snap to baseline grid 58 51 59 52 **Check**: 60 - 61 53 - Enable grid overlay and verify alignment 62 54 - Check spacing with browser inspector 63 55 - Test at multiple viewport sizes 64 56 - Look for elements that "feel" off 65 57 58 + ### Information Architecture & Flow 59 + 60 + Visual polish on a misshapen flow is wasted work. Match the *shape* of the experience to the system, not just the surface. 61 + 62 + - **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. 63 + - **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. 64 + - **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. 65 + - **Empty, loading, and arrival transitions**: How content arrives, updates, and leaves matches how it does in adjacent features. 66 + - **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. 67 + 66 68 ### Typography Refinement 67 69 68 70 - **Hierarchy consistency**: Same elements use same sizes/weights throughout ··· 102 104 103 105 - **Smooth transitions**: All state changes animated appropriately (150-300ms) 104 106 - **Consistent easing**: Use ease-out-quart/quint/expo for natural deceleration. Never bounce or elastic—they feel dated. 105 - - **No jank**: 60fps animations, only animate transform and opacity 107 + - **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 106 108 - **Appropriate motion**: Motion serves purpose, not decoration 107 109 - **Reduced motion**: Respects `prefers-reduced-motion` 108 110 ··· 171 173 172 174 Go through systematically: 173 175 176 + - [ ] Aligned to the design system (drift named and resolved by root cause) 177 + - [ ] Information architecture and flow shape match neighboring features 174 178 - [ ] Visual alignment perfect at all breakpoints 175 179 - [ ] Spacing uses design tokens consistently 176 180 - [ ] Typography hierarchy consistent ··· 195 199 **IMPORTANT**: Polish is about details. Zoom in. Squint at it. Use it yourself. The little things add up. 196 200 197 201 **NEVER**: 198 - 199 202 - Polish before it's functionally complete 203 + - Polish without aligning to the design system — that's decoration on drift 204 + - Guess at design system principles instead of asking when something is ambiguous 200 205 - Spend hours on polish if it ships in 30 minutes (triage) 201 206 - Introduce bugs while polishing (test thoroughly) 202 - - Ignore systematic issues (if spacing is off everywhere, fix the system) 207 + - Ignore systematic issues (if spacing is off everywhere, fix the system, not just one screen) 203 208 - Perfect one thing while leaving others rough (consistent quality level) 204 209 - Create new one-off components when design system equivalents exist 205 210 - Hard-code values that should use design tokens 211 + - Introduce new patterns or flows that diverge from established ones 206 212 207 213 ## Final Verification 208 214
+6 -16
.agents/skills/quieter/SKILL.md .agents/skills/impeccable/reference/quieter.md
··· 1 - --- 2 - name: quieter 3 - 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." 4 - version: 2.1.1 5 - user-invocable: true 6 - argument-hint: "[target]" 1 + Reduce visual intensity in designs that are too bold, aggressive, or overstimulating, creating a more refined and approachable aesthetic without losing effectiveness. 2 + 7 3 --- 8 4 9 - Reduce visual intensity in designs that are too bold, aggressive, or overstimulating, creating a more refined and approachable aesthetic without losing effectiveness. 5 + ## Register 10 6 11 - ## MANDATORY PREPARATION 7 + Brand: "quieter" means more restrained palette, more whitespace, more typographic air. Drama is reduced, not eliminated — the POV stays intact. 12 8 13 - 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. 9 + Product: "quieter" means reducing visual noise — fewer background accents, flatter cards, less color, less motion. The tool should disappear more completely into the task. 14 10 15 11 --- 16 12 ··· 32 28 - What's working? (Don't throw away good ideas) 33 29 - What's the core message? (Preserve what matters) 34 30 35 - If any of these are unclear from the codebase, ask the user directly to clarify what you cannot infer. 31 + 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. 36 32 37 33 **CRITICAL**: "Quieter" doesn't mean boring or generic. It means refined, sophisticated, and easier on the eyes. Think luxury, not laziness. 38 34 ··· 52 48 Systematically reduce intensity across these dimensions: 53 49 54 50 ### Color Refinement 55 - 56 51 - **Reduce saturation**: Shift from fully saturated to 70-85% saturation 57 52 - **Soften palette**: Replace bright colors with muted, sophisticated tones 58 53 - **Reduce color variety**: Use fewer colors more thoughtfully ··· 62 57 - **Never gray on color**: If you have gray text on a colored background, use a darker shade of that color or transparency instead 63 58 64 59 ### Visual Weight Reduction 65 - 66 60 - **Typography**: Reduce font weights (900 → 600, 700 → 500), decrease sizes where appropriate 67 61 - **Hierarchy through subtlety**: Use weight, size, and space instead of color and boldness 68 62 - **White space**: Increase breathing room, reduce density 69 63 - **Borders & lines**: Reduce thickness, decrease opacity, or remove entirely 70 64 71 65 ### Simplification 72 - 73 66 - **Remove decorative elements**: Gradients, shadows, patterns, textures that don't serve purpose 74 67 - **Simplify shapes**: Reduce border radius extremes, simplify custom shapes 75 68 - **Reduce layering**: Flatten visual hierarchy where possible 76 69 - **Clean up effects**: Reduce or remove blur effects, glows, multiple shadows 77 70 78 71 ### Motion Reduction 79 - 80 72 - **Reduce animation intensity**: Shorter distances (10-20px instead of 40px), gentler easing 81 73 - **Remove decorative animations**: Keep functional motion, remove flourishes 82 74 - **Subtle micro-interactions**: Replace dramatic effects with gentle feedback ··· 84 76 - **Remove animations entirely** if they're not serving a clear purpose 85 77 86 78 ### Composition Refinement 87 - 88 79 - **Reduce scale jumps**: Smaller contrast between sizes creates calmer feeling 89 80 - **Align to grid**: Bring rogue elements back into systematic alignment 90 81 - **Even out spacing**: Replace extreme spacing variations with consistent rhythm 91 82 92 83 **NEVER**: 93 - 94 84 - Make everything the same size/weight (hierarchy still matters) 95 85 - Remove all color (quiet ≠ grayscale) 96 86 - Eliminate all personality (maintain character through refinement)
-101
.agents/skills/shape/SKILL.md
··· 1 - --- 2 - name: shape 3 - 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." 4 - version: 2.1.1 5 - user-invocable: true 6 - argument-hint: "[feature to shape]" 7 - --- 8 - 9 - ## MANDATORY PREPARATION 10 - 11 - 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. 12 - 13 - --- 14 - 15 - 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. 16 - 17 - **Scope**: Design planning only. This skill does NOT write code. It produces the thinking that makes code good. 18 - 19 - **Output**: A design brief that can be handed off to /impeccable craft, /impeccable, or any other implementation skill. 20 - 21 - ## Philosophy 22 - 23 - 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. 24 - 25 - ## Phase 1: Discovery Interview 26 - 27 - **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. 28 - 29 - 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. 30 - 31 - ### Purpose & Context 32 - 33 - - What is this feature for? What problem does it solve? 34 - - Who specifically will use it? (Not "users"; be specific: role, context, frequency) 35 - - What does success look like? How will you know this feature is working? 36 - - What's the user's state of mind when they reach this feature? (Rushed? Exploring? Anxious? Focused?) 37 - 38 - ### Content & Data 39 - 40 - - What content or data does this feature display or collect? 41 - - What are the realistic ranges? (Minimum, typical, maximum, e.g., 0 items, 5 items, 500 items) 42 - - What are the edge cases? (Empty state, error state, first-time use, power user) 43 - - Is any content dynamic? What changes and how often? 44 - 45 - ### Design Goals 46 - 47 - - What's the single most important thing a user should do or understand here? 48 - - What should this feel like? (Fast/efficient? Calm/trustworthy? Fun/playful? Premium/refined?) 49 - - Are there existing patterns in the product this should be consistent with? 50 - - Are there specific examples (inside or outside the product) that capture what you're going for? 51 - 52 - ### Constraints 53 - 54 - - Are there technical constraints? (Framework, performance budget, browser support) 55 - - Are there content constraints? (Localization, dynamic text length, user-generated content) 56 - - Mobile/responsive requirements? 57 - - Accessibility requirements beyond WCAG AA? 58 - 59 - ### Anti-Goals 60 - 61 - - What should this NOT be? What would be a wrong direction? 62 - - What's the biggest risk of getting this wrong? 63 - 64 - ## Phase 2: Design Brief 65 - 66 - After the interview, synthesize everything into a structured design brief. Present it to the user for confirmation before considering this skill complete. 67 - 68 - ### Brief Structure 69 - 70 - **1. Feature Summary** (2-3 sentences) 71 - What this is, who it's for, what it needs to accomplish. 72 - 73 - **2. Primary User Action** 74 - The single most important thing a user should do or understand here. 75 - 76 - **3. Design Direction** 77 - 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. 78 - 79 - **4. Layout Strategy** 80 - High-level spatial approach: what gets emphasis, what's secondary, how information flows. Describe the visual hierarchy and rhythm, not specific CSS. 81 - 82 - **5. Key States** 83 - List every state the feature needs: default, empty, loading, error, success, edge cases. For each, note what the user needs to see and feel. 84 - 85 - **6. Interaction Model** 86 - 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? 87 - 88 - **7. Content Requirements** 89 - What copy, labels, empty state messages, error messages, and microcopy are needed. Note any dynamic content and its realistic ranges. 90 - 91 - **8. Recommended References** 92 - 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). 93 - 94 - **9. Open Questions** 95 - Anything unresolved that the implementer should resolve during build. 96 - 97 - --- 98 - 99 - 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. 100 - 101 - 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.)
+18 -13
.agents/skills/typeset/SKILL.md .agents/skills/impeccable/reference/typeset.md
··· 1 - --- 2 - name: typeset 3 - 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." 4 - version: 2.1.1 5 - user-invocable: true 6 - argument-hint: "[target]" 1 + Assess and improve typography that feels generic, inconsistent, or poorly structured — turning default-looking text into intentional, well-crafted type. 2 + 7 3 --- 8 4 9 - Assess and improve typography that feels generic, inconsistent, or poorly structured — turning default-looking text into intentional, well-crafted type. 5 + ## Register 10 6 11 - ## MANDATORY PREPARATION 7 + 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. 12 8 13 - 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. 9 + 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. 14 10 15 11 --- 16 12 ··· 47 43 48 44 ## Plan Typography Improvements 49 45 50 - Consult the [typography reference](reference/typography.md) from the impeccable skill for detailed guidance on scales, pairing, and loading strategies. 46 + Consult the [typography reference](typography.md) for detailed guidance on scales, pairing, and loading strategies. 51 47 52 48 Create a systematic plan: 53 49 ··· 61 57 ### Font Selection 62 58 63 59 If fonts need replacing: 64 - 65 60 - Choose fonts that reflect the brand personality 66 61 - Pair with genuine contrast (serif + sans, geometric + humanist) — or use a single family in multiple weights 67 62 - Ensure web font loading doesn't cause layout shift (`font-display: swap`, metric-matched fallbacks) ··· 69 64 ### Establish Hierarchy 70 65 71 66 Build a clear type scale: 72 - 73 67 - **5 sizes cover most needs**: caption, secondary, body, subheading, heading 74 68 - **Use a consistent ratio** between levels (1.25, 1.333, or 1.5) 75 69 - **Combine dimensions**: Size + weight + color + space for strong hierarchy — don't rely on size alone ··· 97 91 - Load only the weights you actually use (each weight adds to page load) 98 92 99 93 **NEVER**: 100 - 101 94 - Use more than 2-3 font families 102 95 - Pick sizes arbitrarily — commit to a scale 103 96 - Set body text below 16px ··· 117 110 - **Accessibility**: Does text meet WCAG contrast ratios? Is it zoomable to 200%? 118 111 119 112 Remember: Typography is the foundation of interface design — it carries the majority of information. Getting it right is the highest-leverage improvement you can make. 113 + 114 + ## Live-mode signature params 115 + 116 + 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. 117 + 118 + ```json 119 + {"id":"scale","kind":"range","min":0.85,"max":1.3,"step":0.05,"default":1,"label":"Scale"} 120 + ``` 121 + 122 + 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. 123 + 124 + See `reference/live.md` for the full params contract.
.impeccable.md PRODUCT.md
-95
skills-lock.json
··· 1 - { 2 - "version": 1, 3 - "skills": { 4 - "adapt": { 5 - "source": "pbakaus/impeccable", 6 - "sourceType": "github", 7 - "computedHash": "65085998d643c3b64ff323ca74d76496b47484046b030730c00c03a0208bf410" 8 - }, 9 - "agent-browser": { 10 - "source": "vercel-labs/agent-browser", 11 - "sourceType": "github", 12 - "computedHash": "c19a532a3777698b80b35270939a1dc559da451381971b3e1e78174d1ab54863" 13 - }, 14 - "animate": { 15 - "source": "pbakaus/impeccable", 16 - "sourceType": "github", 17 - "computedHash": "354414a934957f9d54a71468abe81888304d35b47a4fe8e27fff08f60b457d53" 18 - }, 19 - "audit": { 20 - "source": "pbakaus/impeccable", 21 - "sourceType": "github", 22 - "computedHash": "115a00d21d91d52123115af789948cb22bd604de0084837b5533f5e4af3c4055" 23 - }, 24 - "bolder": { 25 - "source": "pbakaus/impeccable", 26 - "sourceType": "github", 27 - "computedHash": "1787478962577c9d5bde26e89e47c1654aa9c8f3c47c316a026731db0000a92e" 28 - }, 29 - "clarify": { 30 - "source": "pbakaus/impeccable", 31 - "sourceType": "github", 32 - "computedHash": "42877142fcc51dcc6be696349aa7df8887eb29510e2a5868beac913f5f84de31" 33 - }, 34 - "colorize": { 35 - "source": "pbakaus/impeccable", 36 - "sourceType": "github", 37 - "computedHash": "4793ac377b2bc1a5831d9634ce882373350cc5d097fd631d192155266204ceb8" 38 - }, 39 - "critique": { 40 - "source": "pbakaus/impeccable", 41 - "sourceType": "github", 42 - "computedHash": "977f6fc3aa1002ec095f649e1b7c4fa52ee08a447f19229062810a9323c5c342" 43 - }, 44 - "delight": { 45 - "source": "pbakaus/impeccable", 46 - "sourceType": "github", 47 - "computedHash": "1f6f4adee0b964e8344c0baef46b4e303ab3ba20932907bcbb1444ba7b3273b2" 48 - }, 49 - "distill": { 50 - "source": "pbakaus/impeccable", 51 - "sourceType": "github", 52 - "computedHash": "669a671f855be8773e4c2936e54d4243fbd1d273f87633158544efe3dbc4d825" 53 - }, 54 - "impeccable": { 55 - "source": "pbakaus/impeccable", 56 - "sourceType": "github", 57 - "computedHash": "a26b3dd377c4572b1c34c68f82ab9a1cb806f21c7157060327fe60e9b6d2e277" 58 - }, 59 - "layout": { 60 - "source": "pbakaus/impeccable", 61 - "sourceType": "github", 62 - "computedHash": "6b5fd49587616ca1e594e9c6b11bc1cef5eb0b2d137c307de54a8a650770f4ce" 63 - }, 64 - "optimize": { 65 - "source": "pbakaus/impeccable", 66 - "sourceType": "github", 67 - "computedHash": "979ffc76a4345c081fdf89078b7107c216d70696e027b23e8e24972dd4e457b2" 68 - }, 69 - "overdrive": { 70 - "source": "pbakaus/impeccable", 71 - "sourceType": "github", 72 - "computedHash": "30e2909c14b9e860b580e63f9bbe10cc1dee8de0be9b03013218c3ef8d9ede78" 73 - }, 74 - "polish": { 75 - "source": "pbakaus/impeccable", 76 - "sourceType": "github", 77 - "computedHash": "c294f2570dcc5b8865d17f0dc5ef339a700c868f1b20fa9994297f64ace2d4de" 78 - }, 79 - "quieter": { 80 - "source": "pbakaus/impeccable", 81 - "sourceType": "github", 82 - "computedHash": "2c20b12bb4ece445d45fc63bda020aecf78b3cdf6d593beb59c273f658293130" 83 - }, 84 - "shape": { 85 - "source": "pbakaus/impeccable", 86 - "sourceType": "github", 87 - "computedHash": "8edad33758f2461f51afdb51741bfcc4cc57d84ea42e2751a84be2c6a2d48f69" 88 - }, 89 - "typeset": { 90 - "source": "pbakaus/impeccable", 91 - "sourceType": "github", 92 - "computedHash": "1cd76e560d1ba25b0bd5f7adfa2957a9f653a3ad0b031d467221154fcd1e0423" 93 - } 94 - } 95 - }