diff --git a/.agents/skills/adapt/SKILL.md b/.agents/skills/adapt/SKILL.md deleted file mode 100644 index 21a424162..000000000 --- a/.agents/skills/adapt/SKILL.md +++ /dev/null @@ -1,199 +0,0 @@ ---- -name: adapt -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. -version: 2.1.1 -user-invocable: true -argument-hint: "[target] [context (mobile, tablet, print...)]" ---- - -Adapt existing designs to work effectively across different contexts - different screen sizes, devices, platforms, or use cases. - -## MANDATORY PREPARATION - -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. - ---- - -## Assess Adaptation Challenge - -Understand what needs adaptation and why: - -1. **Identify the source context**: - - What was it designed for originally? (Desktop web? Mobile app?) - - What assumptions were made? (Large screen? Mouse input? Fast connection?) - - What works well in current context? - -2. **Understand target context**: - - **Device**: Mobile, tablet, desktop, TV, watch, print? - - **Input method**: Touch, mouse, keyboard, voice, gamepad? - - **Screen constraints**: Size, resolution, orientation? - - **Connection**: Fast wifi, slow 3G, offline? - - **Usage context**: On-the-go vs desk, quick glance vs focused reading? - - **User expectations**: What do users expect on this platform? - -3. **Identify adaptation challenges**: - - What won't fit? (Content, navigation, features) - - What won't work? (Hover states on touch, tiny touch targets) - - What's inappropriate? (Desktop patterns on mobile, mobile patterns on desktop) - -**CRITICAL**: Adaptation is not just scaling - it's rethinking the experience for the new context. - -## Plan Adaptation Strategy - -Create context-appropriate strategy: - -### Mobile Adaptation (Desktop → Mobile) - -**Layout Strategy**: -- Single column instead of multi-column -- Vertical stacking instead of side-by-side -- Full-width components instead of fixed widths -- Bottom navigation instead of top/side navigation - -**Interaction Strategy**: -- Touch targets 44x44px minimum (not hover-dependent) -- Swipe gestures where appropriate (lists, carousels) -- Bottom sheets instead of dropdowns -- Thumbs-first design (controls within thumb reach) -- Larger tap areas with more spacing - -**Content Strategy**: -- Progressive disclosure (don't show everything at once) -- Prioritize primary content (secondary content in tabs/accordions) -- Shorter text (more concise) -- Larger text (16px minimum) - -**Navigation Strategy**: -- Hamburger menu or bottom navigation -- Reduce navigation complexity -- Sticky headers for context -- Back button in navigation flow - -### Tablet Adaptation (Hybrid Approach) - -**Layout Strategy**: -- Two-column layouts (not single or three-column) -- Side panels for secondary content -- Master-detail views (list + detail) -- Adaptive based on orientation (portrait vs landscape) - -**Interaction Strategy**: -- Support both touch and pointer -- Touch targets 44x44px but allow denser layouts than phone -- Side navigation drawers -- Multi-column forms where appropriate - -### Desktop Adaptation (Mobile → Desktop) - -**Layout Strategy**: -- Multi-column layouts (use horizontal space) -- Side navigation always visible -- Multiple information panels simultaneously -- Fixed widths with max-width constraints (don't stretch to 4K) - -**Interaction Strategy**: -- Hover states for additional information -- Keyboard shortcuts -- Right-click context menus -- Drag and drop where helpful -- Multi-select with Shift/Cmd - -**Content Strategy**: -- Show more information upfront (less progressive disclosure) -- Data tables with many columns -- Richer visualizations -- More detailed descriptions - -### Print Adaptation (Screen → Print) - -**Layout Strategy**: -- Page breaks at logical points -- Remove navigation, footer, interactive elements -- Black and white (or limited color) -- Proper margins for binding - -**Content Strategy**: -- Expand shortened content (show full URLs, hidden sections) -- Add page numbers, headers, footers -- Include metadata (print date, page title) -- Convert charts to print-friendly versions - -### Email Adaptation (Web → Email) - -**Layout Strategy**: -- Narrow width (600px max) -- Single column only -- Inline CSS (no external stylesheets) -- Table-based layouts (for email client compatibility) - -**Interaction Strategy**: -- Large, obvious CTAs (buttons not text links) -- No hover states (not reliable) -- Deep links to web app for complex interactions - -## Implement Adaptations - -Apply changes systematically: - -### Responsive Breakpoints - -Choose appropriate breakpoints: -- Mobile: 320px-767px -- Tablet: 768px-1023px -- Desktop: 1024px+ -- Or content-driven breakpoints (where design breaks) - -### Layout Adaptation Techniques - -- **CSS Grid/Flexbox**: Reflow layouts automatically -- **Container Queries**: Adapt based on container, not viewport -- **`clamp()`**: Fluid sizing between min and max -- **Media queries**: Different styles for different contexts -- **Display properties**: Show/hide elements per context - -### Touch Adaptation - -- Increase touch target sizes (44x44px minimum) -- Add more spacing between interactive elements -- Remove hover-dependent interactions -- Add touch feedback (ripples, highlights) -- Consider thumb zones (easier to reach bottom than top) - -### Content Adaptation - -- Use `display: none` sparingly (still downloads) -- Progressive enhancement (core content first, enhancements on larger screens) -- Lazy loading for off-screen content -- Responsive images (`srcset`, `picture` element) - -### Navigation Adaptation - -- Transform complex nav to hamburger/drawer on mobile -- Bottom nav bar for mobile apps -- Persistent side navigation on desktop -- Breadcrumbs on smaller screens for context - -**IMPORTANT**: Test on real devices, not just browser DevTools. Device emulation is helpful but not perfect. - -**NEVER**: -- Hide core functionality on mobile (if it matters, make it work) -- Assume desktop = powerful device (consider accessibility, older machines) -- Use different information architecture across contexts (confusing) -- Break user expectations for platform (mobile users expect mobile patterns) -- Forget landscape orientation on mobile/tablet -- Use generic breakpoints blindly (use content-driven breakpoints) -- Ignore touch on desktop (many desktop devices have touch) - -## Verify Adaptations - -Test thoroughly across contexts: - -- **Real devices**: Test on actual phones, tablets, desktops -- **Different orientations**: Portrait and landscape -- **Different browsers**: Safari, Chrome, Firefox, Edge -- **Different OS**: iOS, Android, Windows, macOS -- **Different input methods**: Touch, mouse, keyboard -- **Edge cases**: Very small screens (320px), very large screens (4K) -- **Slow connections**: Test on throttled network - -Remember: You're a cross-platform design expert. Make experiences that feel native to each context while maintaining brand and functionality consistency. Adapt intentionally, test thoroughly. \ No newline at end of file diff --git a/.agents/skills/animate/SKILL.md b/.agents/skills/animate/SKILL.md deleted file mode 100644 index 89933bfb5..000000000 --- a/.agents/skills/animate/SKILL.md +++ /dev/null @@ -1,175 +0,0 @@ ---- -name: animate -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. -version: 2.1.1 -user-invocable: true -argument-hint: "[target]" ---- - -Analyze a feature and strategically add animations and micro-interactions that enhance understanding, provide feedback, and create delight. - -## MANDATORY PREPARATION - -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. - ---- - -## Assess Animation Opportunities - -Analyze where motion would improve the experience: - -1. **Identify static areas**: - - **Missing feedback**: Actions without visual acknowledgment (button clicks, form submission, etc.) - - **Jarring transitions**: Instant state changes that feel abrupt (show/hide, page loads, route changes) - - **Unclear relationships**: Spatial or hierarchical relationships that aren't obvious - - **Lack of delight**: Functional but joyless interactions - - **Missed guidance**: Opportunities to direct attention or explain behavior - -2. **Understand the context**: - - What's the personality? (Playful vs serious, energetic vs calm) - - What's the performance budget? (Mobile-first? Complex page?) - - Who's the audience? (Motion-sensitive users? Power users who want speed?) - - What matters most? (One hero animation vs many micro-interactions?) - -If any of these are unclear from the codebase, ask the user directly to clarify what you cannot infer. - -**CRITICAL**: Respect `prefers-reduced-motion`. Always provide non-animated alternatives for users who need them. - -## Plan Animation Strategy - -Create a purposeful animation plan: - -- **Hero moment**: What's the ONE signature animation? (Page load? Hero section? Key interaction?) -- **Feedback layer**: Which interactions need acknowledgment? -- **Transition layer**: Which state changes need smoothing? -- **Delight layer**: Where can we surprise and delight? - -**IMPORTANT**: One well-orchestrated experience beats scattered animations everywhere. Focus on high-impact moments. - -## Implement Animations - -Add motion systematically across these categories: - -### Entrance Animations -- **Page load choreography**: Stagger element reveals (100-150ms delays), fade + slide combinations -- **Hero section**: Dramatic entrance for primary content (scale, parallax, or creative effects) -- **Content reveals**: Scroll-triggered animations using intersection observer -- **Modal/drawer entry**: Smooth slide + fade, backdrop fade, focus management - -### Micro-interactions -- **Button feedback**: - - Hover: Subtle scale (1.02-1.05), color shift, shadow increase - - Click: Quick scale down then up (0.95 → 1), ripple effect - - Loading: Spinner or pulse state -- **Form interactions**: - - Input focus: Border color transition, slight scale or glow - - Validation: Shake on error, check mark on success, smooth color transitions -- **Toggle switches**: Smooth slide + color transition (200-300ms) -- **Checkboxes/radio**: Check mark animation, ripple effect -- **Like/favorite**: Scale + rotation, particle effects, color transition - -### State Transitions -- **Show/hide**: Fade + slide (not instant), appropriate timing (200-300ms) -- **Expand/collapse**: Height transition with overflow handling, icon rotation -- **Loading states**: Skeleton screen fades, spinner animations, progress bars -- **Success/error**: Color transitions, icon animations, gentle scale pulse -- **Enable/disable**: Opacity transitions, cursor changes - -### Navigation & Flow -- **Page transitions**: Crossfade between routes, shared element transitions -- **Tab switching**: Slide indicator, content fade/slide -- **Carousel/slider**: Smooth transforms, snap points, momentum -- **Scroll effects**: Parallax layers, sticky headers with state changes, scroll progress indicators - -### Feedback & Guidance -- **Hover hints**: Tooltip fade-ins, cursor changes, element highlights -- **Drag & drop**: Lift effect (shadow + scale), drop zone highlights, smooth repositioning -- **Copy/paste**: Brief highlight flash on paste, "copied" confirmation -- **Focus flow**: Highlight path through form or workflow - -### Delight Moments -- **Empty states**: Subtle floating animations on illustrations -- **Completed actions**: Confetti, check mark flourish, success celebrations -- **Easter eggs**: Hidden interactions for discovery -- **Contextual animation**: Weather effects, time-of-day themes, seasonal touches - -## Technical Implementation - -Use appropriate techniques for each animation: - -### Timing & Easing - -**Durations by purpose:** -- **100-150ms**: Instant feedback (button press, toggle) -- **200-300ms**: State changes (hover, menu open) -- **300-500ms**: Layout changes (accordion, modal) -- **500-800ms**: Entrance animations (page load) - -**Easing curves (use these, not CSS defaults):** -```css -/* Recommended - natural deceleration */ ---ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1); /* Smooth, refined */ ---ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1); /* Slightly snappier */ ---ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); /* Confident, decisive */ - -/* AVOID - feel dated and tacky */ -/* bounce: cubic-bezier(0.34, 1.56, 0.64, 1); */ -/* elastic: cubic-bezier(0.68, -0.6, 0.32, 1.6); */ -``` - -**Exit animations are faster than entrances.** Use ~75% of enter duration. - -### CSS Animations -```css -/* Prefer for simple, declarative animations */ -- transitions for state changes -- @keyframes for complex sequences -- transform + opacity only (GPU-accelerated) -``` - -### JavaScript Animation -```javascript -/* Use for complex, interactive animations */ -- Web Animations API for programmatic control -- Framer Motion for React -- GSAP for complex sequences -``` - -### Performance -- **GPU acceleration**: Use `transform` and `opacity`, avoid layout properties -- **will-change**: Add sparingly for known expensive animations -- **Reduce paint**: Minimize repaints, use `contain` where appropriate -- **Monitor FPS**: Ensure 60fps on target devices - -### Accessibility -```css -@media (prefers-reduced-motion: reduce) { - * { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; - } -} -``` - -**NEVER**: -- Use bounce or elastic easing curves—they feel dated and draw attention to the animation itself -- Animate layout properties (width, height, top, left)—use transform instead -- Use durations over 500ms for feedback—it feels laggy -- Animate without purpose—every animation needs a reason -- Ignore `prefers-reduced-motion`—this is an accessibility violation -- Animate everything—animation fatigue makes interfaces feel exhausting -- Block interaction during animations unless intentional - -## Verify Quality - -Test animations thoroughly: - -- **Smooth at 60fps**: No jank on target devices -- **Feels natural**: Easing curves feel organic, not robotic -- **Appropriate timing**: Not too fast (jarring) or too slow (laggy) -- **Reduced motion works**: Animations disabled or simplified appropriately -- **Doesn't block**: Users can interact during/after animations -- **Adds value**: Makes interface clearer or more delightful - -Remember: Motion should enhance understanding and provide feedback, not just add decoration. Animate with purpose, respect performance constraints, and always consider accessibility. Great animation is invisible - it just makes everything feel right. \ No newline at end of file diff --git a/.agents/skills/audit/SKILL.md b/.agents/skills/audit/SKILL.md deleted file mode 100644 index ea30301c1..000000000 --- a/.agents/skills/audit/SKILL.md +++ /dev/null @@ -1,148 +0,0 @@ ---- -name: audit -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. -version: 2.1.1 -user-invocable: true -argument-hint: "[area (feature, page, component...)]" ---- - -## MANDATORY PREPARATION - -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. - ---- - -Run systematic **technical** quality checks and generate a comprehensive report. Don't fix issues — document them for other commands to address. - -This is a code-level audit, not a design critique. Check what's measurable and verifiable in the implementation. - -## Diagnostic Scan - -Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the criteria below. - -### 1. Accessibility (A11y) - -**Check for**: -- **Contrast issues**: Text contrast ratios < 4.5:1 (or 7:1 for AAA) -- **Missing ARIA**: Interactive elements without proper roles, labels, or states -- **Keyboard navigation**: Missing focus indicators, illogical tab order, keyboard traps -- **Semantic HTML**: Improper heading hierarchy, missing landmarks, divs instead of buttons -- **Alt text**: Missing or poor image descriptions -- **Form issues**: Inputs without labels, poor error messaging, missing required indicators - -**Score 0-4**: 0=Inaccessible (fails WCAG A), 1=Major gaps (few ARIA labels, no keyboard nav), 2=Partial (some a11y effort, significant gaps), 3=Good (WCAG AA mostly met, minor gaps), 4=Excellent (WCAG AA fully met, approaches AAA) - -### 2. Performance - -**Check for**: -- **Layout thrashing**: Reading/writing layout properties in loops -- **Expensive animations**: Animating layout properties (width, height, top, left) instead of transform/opacity -- **Missing optimization**: Images without lazy loading, unoptimized assets, missing will-change -- **Bundle size**: Unnecessary imports, unused dependencies -- **Render performance**: Unnecessary re-renders, missing memoization - -**Score 0-4**: 0=Severe issues (layout thrash, unoptimized everything), 1=Major problems (no lazy loading, expensive animations), 2=Partial (some optimization, gaps remain), 3=Good (mostly optimized, minor improvements possible), 4=Excellent (fast, lean, well-optimized) - -### 3. Theming - -**Check for**: -- **Hard-coded colors**: Colors not using design tokens -- **Broken dark mode**: Missing dark mode variants, poor contrast in dark theme -- **Inconsistent tokens**: Using wrong tokens, mixing token types -- **Theme switching issues**: Values that don't update on theme change - -**Score 0-4**: 0=No theming (hard-coded everything), 1=Minimal tokens (mostly hard-coded), 2=Partial (tokens exist but inconsistently used), 3=Good (tokens used, minor hard-coded values), 4=Excellent (full token system, dark mode works perfectly) - -### 4. Responsive Design - -**Check for**: -- **Fixed widths**: Hard-coded widths that break on mobile -- **Touch targets**: Interactive elements < 44x44px -- **Horizontal scroll**: Content overflow on narrow viewports -- **Text scaling**: Layouts that break when text size increases -- **Missing breakpoints**: No mobile/tablet variants - -**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets) - -### 5. Anti-Patterns (CRITICAL) - -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). - -**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) - -## Generate Report - -### Audit Health Score - -| # | Dimension | Score | Key Finding | -|---|-----------|-------|-------------| -| 1 | Accessibility | ? | [most critical a11y issue or "--"] | -| 2 | Performance | ? | | -| 3 | Responsive Design | ? | | -| 4 | Theming | ? | | -| 5 | Anti-Patterns | ? | | -| **Total** | | **??/20** | **[Rating band]** | - -**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) - -### Anti-Patterns Verdict -**Start here.** Pass/fail: Does this look AI-generated? List specific tells. Be brutally honest. - -### Executive Summary -- Audit Health Score: **??/20** ([rating band]) -- Total issues found (count by severity: P0/P1/P2/P3) -- Top 3-5 critical issues -- Recommended next steps - -### Detailed Findings by Severity - -Tag every issue with **P0-P3 severity**: -- **P0 Blocking**: Prevents task completion — fix immediately -- **P1 Major**: Significant difficulty or WCAG AA violation — fix before release -- **P2 Minor**: Annoyance, workaround exists — fix in next pass -- **P3 Polish**: Nice-to-fix, no real user impact — fix if time permits - -For each issue, document: -- **[P?] Issue name** -- **Location**: Component, file, line -- **Category**: Accessibility / Performance / Theming / Responsive / Anti-Pattern -- **Impact**: How it affects users -- **WCAG/Standard**: Which standard it violates (if applicable) -- **Recommendation**: How to fix it -- **Suggested command**: Which command to use (prefer: /animate, /quieter, /shape, /optimize, /adapt, /clarify, /layout, /distill, /delight, /audit, /harden, /polish, /bolder, /typeset, /critique, /colorize, /overdrive) - -### Patterns & Systemic Issues - -Identify recurring problems that indicate systemic gaps rather than one-off mistakes: -- "Hard-coded colors appear in 15+ components, should use design tokens" -- "Touch targets consistently too small (<44px) throughout mobile experience" - -### Positive Findings - -Note what's working well — good practices to maintain and replicate. - -## Recommended Actions - -List recommended commands in priority order (P0 first, then P1, then P2): - -1. **[P?] `/command-name`** — Brief description (specific context from audit findings) -2. **[P?] `/command-name`** — Brief description (specific context) - -**Rules**: Only recommend commands from: /animate, /quieter, /shape, /optimize, /adapt, /clarify, /layout, /distill, /delight, /audit, /harden, /polish, /bolder, /typeset, /critique, /colorize, /overdrive. Map findings to the most appropriate command. End with `/polish` as the final step if any fixes were recommended. - -After presenting the summary, tell the user: - -> You can ask me to run these one at a time, all at once, or in any order you prefer. -> -> Re-run `/audit` after fixes to see your score improve. - -**IMPORTANT**: Be thorough but actionable. Too many P3 issues creates noise. Focus on what actually matters. - -**NEVER**: -- Report issues without explaining impact (why does this matter?) -- Provide generic recommendations (be specific and actionable) -- Skip positive findings (celebrate what works) -- Forget to prioritize (everything can't be P0) -- Report false positives without verification - -Remember: You're a technical quality auditor. Document systematically, prioritize ruthlessly, cite specific code locations, and provide clear paths to improvement. \ No newline at end of file diff --git a/.agents/skills/bolder/SKILL.md b/.agents/skills/bolder/SKILL.md deleted file mode 100644 index e80f55ed1..000000000 --- a/.agents/skills/bolder/SKILL.md +++ /dev/null @@ -1,117 +0,0 @@ ---- -name: bolder -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. -version: 2.1.1 -user-invocable: true -argument-hint: "[target]" ---- - -Increase visual impact and personality in designs that are too safe, generic, or visually underwhelming, creating more engaging and memorable experiences. - -## MANDATORY PREPARATION - -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. - ---- - -## Assess Current State - -Analyze what makes the design feel too safe or boring: - -1. **Identify weakness sources**: - - **Generic choices**: System fonts, basic colors, standard layouts - - **Timid scale**: Everything is medium-sized with no drama - - **Low contrast**: Everything has similar visual weight - - **Static**: No motion, no energy, no life - - **Predictable**: Standard patterns with no surprises - - **Flat hierarchy**: Nothing stands out or commands attention - -2. **Understand the context**: - - What's the brand personality? (How far can we push?) - - What's the purpose? (Marketing can be bolder than financial dashboards) - - Who's the audience? (What will resonate?) - - What are the constraints? (Brand guidelines, accessibility, performance) - -If any of these are unclear from the codebase, ask the user directly to clarify what you cannot infer. - -**CRITICAL**: "Bolder" doesn't mean chaotic or garish. It means distinctive, memorable, and confident. Think intentional drama, not random chaos. - -**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." - -## Plan Amplification - -Create a strategy to increase impact while maintaining coherence: - -- **Focal point**: What should be the hero moment? (Pick ONE, make it amazing) -- **Personality direction**: Maximalist chaos? Elegant drama? Playful energy? Dark moody? Choose a lane. -- **Risk budget**: How experimental can we be? Push boundaries within constraints. -- **Hierarchy amplification**: Make big things BIGGER, small things smaller (increase contrast) - -**IMPORTANT**: Bold design must still be usable. Impact without function is just decoration. - -## Amplify the Design - -Systematically increase impact across these dimensions: - -### Typography Amplification -- **Replace generic fonts**: Swap system fonts for distinctive choices (see impeccable skill for inspiration) -- **Extreme scale**: Create dramatic size jumps (3x-5x differences, not 1.5x) -- **Weight contrast**: Pair 900 weights with 200 weights, not 600 with 400 -- **Unexpected choices**: Variable fonts, display fonts for headlines, condensed/extended widths, monospace as intentional accent (not as lazy "dev tool" default) - -### Color Intensification -- **Increase saturation**: Shift to more vibrant, energetic colors (but not neon) -- **Bold palette**: Introduce unexpected color combinations—avoid the purple-blue gradient AI slop -- **Dominant color strategy**: Let one bold color own 60% of the design -- **Sharp accents**: High-contrast accent colors that pop -- **Tinted neutrals**: Replace pure grays with tinted grays that harmonize with your palette -- **Rich gradients**: Intentional multi-stop gradients (not generic purple-to-blue) - -### Spatial Drama -- **Extreme scale jumps**: Make important elements 3-5x larger than surroundings -- **Break the grid**: Let hero elements escape containers and cross boundaries -- **Asymmetric layouts**: Replace centered, balanced layouts with tension-filled asymmetry -- **Generous space**: Use white space dramatically (100-200px gaps, not 20-40px) -- **Overlap**: Layer elements intentionally for depth - -### Visual Effects -- **Dramatic shadows**: Large, soft shadows for elevation (but not generic drop shadows on rounded rectangles) -- **Background treatments**: Mesh patterns, noise textures, geometric patterns, intentional gradients (not purple-to-blue) -- **Texture & depth**: Grain, halftone, duotone, layered elements—NOT glassmorphism (it's overused AI slop) -- **Borders & frames**: Thick borders, decorative frames, custom shapes (not rounded rectangles with colored border on one side) -- **Custom elements**: Illustrative elements, custom icons, decorative details that reinforce brand - -### Motion & Animation -- **Entrance choreography**: Staggered, dramatic page load animations with 50-100ms delays -- **Scroll effects**: Parallax, reveal animations, scroll-triggered sequences -- **Micro-interactions**: Satisfying hover effects, click feedback, state changes -- **Transitions**: Smooth, noticeable transitions using ease-out-quart/quint/expo (not bounce or elastic—they cheapen the effect) - -### Composition Boldness -- **Hero moments**: Create clear focal points with dramatic treatment -- **Diagonal flows**: Escape horizontal/vertical rigidity with diagonal arrangements -- **Full-bleed elements**: Use full viewport width/height for impact -- **Unexpected proportions**: Golden ratio? Throw it out. Try 70/30, 80/20 splits - -**NEVER**: -- Add effects randomly without purpose (chaos ≠ bold) -- Sacrifice readability for aesthetics (body text must be readable) -- Make everything bold (then nothing is bold - need contrast) -- Ignore accessibility (bold design must still meet WCAG standards) -- Overwhelm with motion (animation fatigue is real) -- Copy trendy aesthetics blindly (bold means distinctive, not derivative) - -## Verify Quality - -Ensure amplification maintains usability and coherence: - -- **NOT AI slop**: Does this look like every other AI-generated "bold" design? If yes, start over. -- **Still functional**: Can users accomplish tasks without distraction? -- **Coherent**: Does everything feel intentional and unified? -- **Memorable**: Will users remember this experience? -- **Performant**: Do all these effects run smoothly? -- **Accessible**: Does it still meet accessibility standards? - -**The test**: If you showed this to someone and said "AI made this bolder," would they believe you immediately? If yes, you've failed. Bold means distinctive, not "more AI effects." - -Remember: Bold design is confident design. It takes risks, makes statements, and creates memorable experiences. But bold without strategy is just loud. Be intentional, be dramatic, be unforgettable. \ No newline at end of file diff --git a/.agents/skills/clarify/SKILL.md b/.agents/skills/clarify/SKILL.md deleted file mode 100644 index f0013b2cf..000000000 --- a/.agents/skills/clarify/SKILL.md +++ /dev/null @@ -1,183 +0,0 @@ ---- -name: clarify -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. -version: 2.1.1 -user-invocable: true -argument-hint: "[target]" ---- - -Identify and improve unclear, confusing, or poorly written interface text to make the product easier to understand and use. - -## MANDATORY PREPARATION - -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. - ---- - -## Assess Current Copy - -Identify what makes the text unclear or ineffective: - -1. **Find clarity problems**: - - **Jargon**: Technical terms users won't understand - - **Ambiguity**: Multiple interpretations possible - - **Passive voice**: "Your file has been uploaded" vs "We uploaded your file" - - **Length**: Too wordy or too terse - - **Assumptions**: Assuming user knowledge they don't have - - **Missing context**: Users don't know what to do or why - - **Tone mismatch**: Too formal, too casual, or inappropriate for situation - -2. **Understand the context**: - - Who's the audience? (Technical? General? First-time users?) - - What's the user's mental state? (Stressed during error? Confident during success?) - - What's the action? (What do we want users to do?) - - What's the constraint? (Character limits? Space limitations?) - -**CRITICAL**: Clear copy helps users succeed. Unclear copy creates frustration, errors, and support tickets. - -## Plan Copy Improvements - -Create a strategy for clearer communication: - -- **Primary message**: What's the ONE thing users need to know? -- **Action needed**: What should users do next (if anything)? -- **Tone**: How should this feel? (Helpful? Apologetic? Encouraging?) -- **Constraints**: Length limits, brand voice, localization considerations - -**IMPORTANT**: Good UX writing is invisible. Users should understand immediately without noticing the words. - -## Improve Copy Systematically - -Refine text across these common areas: - -### Error Messages -**Bad**: "Error 403: Forbidden" -**Good**: "You don't have permission to view this page. Contact your admin for access." - -**Bad**: "Invalid input" -**Good**: "Email addresses need an @ symbol. Try: name@example.com" - -**Principles**: -- Explain what went wrong in plain language -- Suggest how to fix it -- Don't blame the user -- Include examples when helpful -- Link to help/support if applicable - -### Form Labels & Instructions -**Bad**: "DOB (MM/DD/YYYY)" -**Good**: "Date of birth" (with placeholder showing format) - -**Bad**: "Enter value here" -**Good**: "Your email address" or "Company name" - -**Principles**: -- Use clear, specific labels (not generic placeholders) -- Show format expectations with examples -- Explain why you're asking (when not obvious) -- Put instructions before the field, not after -- Keep required field indicators clear - -### Button & CTA Text -**Bad**: "Click here" | "Submit" | "OK" -**Good**: "Create account" | "Save changes" | "Got it, thanks" - -**Principles**: -- Describe the action specifically -- Use active voice (verb + noun) -- Match user's mental model -- Be specific ("Save" is better than "OK") - -### Help Text & Tooltips -**Bad**: "This is the username field" -**Good**: "Choose a username. You can change this later in Settings." - -**Principles**: -- Add value (don't just repeat the label) -- Answer the implicit question ("What is this?" or "Why do you need this?") -- Keep it brief but complete -- Link to detailed docs if needed - -### Empty States -**Bad**: "No items" -**Good**: "No projects yet. Create your first project to get started." - -**Principles**: -- Explain why it's empty (if not obvious) -- Show next action clearly -- Make it welcoming, not dead-end - -### Success Messages -**Bad**: "Success" -**Good**: "Settings saved! Your changes will take effect immediately." - -**Principles**: -- Confirm what happened -- Explain what happens next (if relevant) -- Be brief but complete -- Match the user's emotional moment (celebrate big wins) - -### Loading States -**Bad**: "Loading..." (for 30+ seconds) -**Good**: "Analyzing your data... this usually takes 30-60 seconds" - -**Principles**: -- Set expectations (how long?) -- Explain what's happening (when it's not obvious) -- Show progress when possible -- Offer escape hatch if appropriate ("Cancel") - -### Confirmation Dialogs -**Bad**: "Are you sure?" -**Good**: "Delete 'Project Alpha'? This can't be undone." - -**Principles**: -- State the specific action -- Explain consequences (especially for destructive actions) -- Use clear button labels ("Delete project" not "Yes") -- Don't overuse confirmations (only for risky actions) - -### Navigation & Wayfinding -**Bad**: Generic labels like "Items" | "Things" | "Stuff" -**Good**: Specific labels like "Your projects" | "Team members" | "Settings" - -**Principles**: -- Be specific and descriptive -- Use language users understand (not internal jargon) -- Make hierarchy clear -- Consider information scent (breadcrumbs, current location) - -## Apply Clarity Principles - -Every piece of copy should follow these rules: - -1. **Be specific**: "Enter email" not "Enter value" -2. **Be concise**: Cut unnecessary words (but don't sacrifice clarity) -3. **Be active**: "Save changes" not "Changes will be saved" -4. **Be human**: "Oops, something went wrong" not "System error encountered" -5. **Be helpful**: Tell users what to do, not just what happened -6. **Be consistent**: Use same terms throughout (don't vary for variety) - -**NEVER**: -- Use jargon without explanation -- Blame users ("You made an error" → "This field is required") -- Be vague ("Something went wrong" without explanation) -- Use passive voice unnecessarily -- Write overly long explanations (be concise) -- Use humor for errors (be empathetic instead) -- Assume technical knowledge -- Vary terminology (pick one term and stick with it) -- Repeat information (headers restating intros, redundant explanations) -- Use placeholders as the only labels (they disappear when users type) - -## Verify Improvements - -Test that copy improvements work: - -- **Comprehension**: Can users understand without context? -- **Actionability**: Do users know what to do next? -- **Brevity**: Is it as short as possible while remaining clear? -- **Consistency**: Does it match terminology elsewhere? -- **Tone**: Is it appropriate for the situation? - -Remember: You're a clarity expert with excellent communication skills. Write like you're explaining to a smart friend who's unfamiliar with the product. Be clear, be helpful, be human. \ No newline at end of file diff --git a/.agents/skills/delight/SKILL.md b/.agents/skills/delight/SKILL.md deleted file mode 100644 index fedebff9c..000000000 --- a/.agents/skills/delight/SKILL.md +++ /dev/null @@ -1,304 +0,0 @@ ---- -name: delight -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. -version: 2.1.1 -user-invocable: true -argument-hint: "[target]" ---- - -Identify opportunities to add moments of joy, personality, and unexpected polish that transform functional interfaces into delightful experiences. - -## MANDATORY PREPARATION - -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). - ---- - -## Assess Delight Opportunities - -Identify where delight would enhance (not distract from) the experience: - -1. **Find natural delight moments**: - - **Success states**: Completed actions (save, send, publish) - - **Empty states**: First-time experiences, onboarding - - **Loading states**: Waiting periods that could be entertaining - - **Achievements**: Milestones, streaks, completions - - **Interactions**: Hover states, clicks, drags - - **Errors**: Softening frustrating moments - - **Easter eggs**: Hidden discoveries for curious users - -2. **Understand the context**: - - What's the brand personality? (Playful? Professional? Quirky? Elegant?) - - Who's the audience? (Tech-savvy? Creative? Corporate?) - - What's the emotional context? (Accomplishment? Exploration? Frustration?) - - What's appropriate? (Banking app ≠ gaming app) - -3. **Define delight strategy**: - - **Subtle sophistication**: Refined micro-interactions (luxury brands) - - **Playful personality**: Whimsical illustrations and copy (consumer apps) - - **Helpful surprises**: Anticipating needs before users ask (productivity tools) - - **Sensory richness**: Satisfying sounds, smooth animations (creative tools) - -If any of these are unclear from the codebase, ask the user directly to clarify what you cannot infer. - -**CRITICAL**: Delight should enhance usability, never obscure it. If users notice the delight more than accomplishing their goal, you've gone too far. - -## Delight Principles - -Follow these guidelines: - -### Delight Amplifies, Never Blocks -- Delight moments should be quick (< 1 second) -- Never delay core functionality for delight -- Make delight skippable or subtle -- Respect user's time and task focus - -### Surprise and Discovery -- Hide delightful details for users to discover -- Reward exploration and curiosity -- Don't announce every delight moment -- Let users share discoveries with others - -### Appropriate to Context -- Match delight to emotional moment (celebrate success, empathize with errors) -- Respect the user's state (don't be playful during critical errors) -- Match brand personality and audience expectations -- Cultural sensitivity (what's delightful varies by culture) - -### Compound Over Time -- Delight should remain fresh with repeated use -- Vary responses (not same animation every time) -- Reveal deeper layers with continued use -- Build anticipation through patterns - -## Delight Techniques - -Add personality and joy through these methods: - -### Micro-interactions & Animation - -**Button delight**: -```css -/* Satisfying button press */ -.button { - transition: transform 0.1s, box-shadow 0.1s; -} -.button:active { - transform: translateY(2px); - box-shadow: 0 2px 4px rgba(0,0,0,0.2); -} - -/* Ripple effect on click */ -/* Smooth lift on hover */ -.button:hover { - transform: translateY(-2px); - transition: transform 0.2s cubic-bezier(0.25, 1, 0.5, 1); /* ease-out-quart */ -} -``` - -**Loading delight**: -- Playful loading animations (not just spinners) -- Personality in loading messages (write product-specific ones, not generic AI filler) -- Progress indication with encouraging messages -- Skeleton screens with subtle animations - -**Success animations**: -- Checkmark draw animation -- Confetti burst for major achievements -- Gentle scale + fade for confirmation -- Satisfying sound effects (subtle) - -**Hover surprises**: -- Icons that animate on hover -- Color shifts or glow effects -- Tooltip reveals with personality -- Cursor changes (custom cursors for branded experiences) - -### Personality in Copy - -**Playful error messages**: -``` -"Error 404" -"This page is playing hide and seek. (And winning)" - -"Connection failed" -"Looks like the internet took a coffee break. Want to retry?" -``` - -**Encouraging empty states**: -``` -"No projects" -"Your canvas awaits. Create something amazing." - -"No messages" -"Inbox zero! You're crushing it today." -``` - -**Playful labels & tooltips**: -``` -"Delete" -"Send to void" (for playful brand) - -"Help" -"Rescue me" (tooltip) -``` - -**IMPORTANT**: Match copy personality to brand. Banks shouldn't be wacky, but they can be warm. - -### Illustrations & Visual Personality - -**Custom illustrations**: -- Empty state illustrations (not stock icons) -- Error state illustrations (friendly monsters, quirky characters) -- Loading state illustrations (animated characters) -- Success state illustrations (celebrations) - -**Icon personality**: -- Custom icon set matching brand personality -- Animated icons (subtle motion on hover/click) -- Illustrative icons (more detailed than generic) -- Consistent style across all icons - -**Background effects**: -- Subtle particle effects -- Gradient mesh backgrounds -- Geometric patterns -- Parallax depth -- Time-of-day themes (morning vs night) - -### Satisfying Interactions - -**Drag and drop delight**: -- Lift effect on drag (shadow, scale) -- Snap animation when dropped -- Satisfying placement sound -- Undo toast ("Dropped in wrong place? [Undo]") - -**Toggle switches**: -- Smooth slide with spring physics -- Color transition -- Haptic feedback on mobile -- Optional sound effect - -**Progress & achievements**: -- Streak counters with celebratory milestones -- Progress bars that "celebrate" at 100% -- Badge unlocks with animation -- Playful stats ("You're on fire! 5 days in a row") - -**Form interactions**: -- Input fields that animate on focus -- Checkboxes with a satisfying scale pulse when checked -- Success state that celebrates valid input -- Auto-grow textareas - -### Sound Design - -**Subtle audio cues** (when appropriate): -- Notification sounds (distinctive but not annoying) -- Success sounds (satisfying "ding") -- Error sounds (empathetic, not harsh) -- Typing sounds for chat/messaging -- Ambient background audio (very subtle) - -**IMPORTANT**: -- Respect system sound settings -- Provide mute option -- Keep volumes quiet (subtle cues, not alarms) -- Don't play on every interaction (sound fatigue is real) - -### Easter Eggs & Hidden Delights - -**Discovery rewards**: -- Konami code unlocks special theme -- Hidden keyboard shortcuts (Cmd+K for special features) -- Hover reveals on logos or illustrations -- Alt text jokes on images (for screen reader users too!) -- Console messages for developers ("Like what you see? We're hiring!") - -**Seasonal touches**: -- Holiday themes (subtle, tasteful) -- Seasonal color shifts -- Weather-based variations -- Time-based changes (dark at night, light during day) - -**Contextual personality**: -- Different messages based on time of day -- Responses to specific user actions -- Randomized variations (not same every time) -- Progressive reveals with continued use - -### Loading & Waiting States - -**Make waiting engaging**: -- Interesting loading messages that rotate -- Progress bars with personality -- Mini-games during long loads -- Fun facts or tips while waiting -- Countdown with encouraging messages - -``` -Loading messages — write ones specific to your product, not generic AI filler: -- "Crunching your latest numbers..." -- "Syncing with your team's changes..." -- "Preparing your dashboard..." -- "Checking for updates since yesterday..." -``` - -**WARNING**: Avoid cliched loading messages like "Herding pixels", "Teaching robots to dance", "Consulting the magic 8-ball", "Counting backwards from infinity". These are AI-slop copy — instantly recognizable as machine-generated. Write messages that are specific to what your product actually does. - -### Celebration Moments - -**Success celebrations**: -- Confetti for major milestones -- Animated checkmarks for completions -- Progress bar celebrations at 100% -- "Achievement unlocked" style notifications -- Personalized messages ("You published your 10th article!") - -**Milestone recognition**: -- First-time actions get special treatment -- Streak tracking and celebration -- Progress toward goals -- Anniversary celebrations - -## Implementation Patterns - -**Animation libraries**: -- Framer Motion (React) -- GSAP (universal) -- Lottie (After Effects animations) -- Canvas confetti (party effects) - -**Sound libraries**: -- Howler.js (audio management) -- Use-sound (React hook) - -**Physics libraries**: -- React Spring (spring physics) -- Popmotion (animation primitives) - -**IMPORTANT**: File size matters. Compress images, optimize animations, lazy load delight features. - -**NEVER**: -- Delay core functionality for delight -- Force users through delightful moments (make skippable) -- Use delight to hide poor UX -- Overdo it (less is more) -- Ignore accessibility (animate responsibly, provide alternatives) -- Make every interaction delightful (special moments should be special) -- Sacrifice performance for delight -- Be inappropriate for context (read the room) - -## Verify Delight Quality - -Test that delight actually delights: - -- **User reactions**: Do users smile? Share screenshots? -- **Doesn't annoy**: Still pleasant after 100th time? -- **Doesn't block**: Can users opt out or skip? -- **Performant**: No jank, no slowdown -- **Appropriate**: Matches brand and context -- **Accessible**: Works with reduced motion, screen readers - -Remember: Delight is the difference between a tool and an experience. Add personality, surprise users positively, and create moments worth sharing. But always respect usability - delight should enhance, never obstruct. \ No newline at end of file diff --git a/.agents/skills/distill/SKILL.md b/.agents/skills/distill/SKILL.md deleted file mode 100644 index f3f721c99..000000000 --- a/.agents/skills/distill/SKILL.md +++ /dev/null @@ -1,122 +0,0 @@ ---- -name: distill -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. -version: 2.1.1 -user-invocable: true -argument-hint: "[target]" ---- - -Remove unnecessary complexity from designs, revealing the essential elements and creating clarity through ruthless simplification. - -## MANDATORY PREPARATION - -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. - ---- - -## Assess Current State - -Analyze what makes the design feel complex or cluttered: - -1. **Identify complexity sources**: - - **Too many elements**: Competing buttons, redundant information, visual clutter - - **Excessive variation**: Too many colors, fonts, sizes, styles without purpose - - **Information overload**: Everything visible at once, no progressive disclosure - - **Visual noise**: Unnecessary borders, shadows, backgrounds, decorations - - **Confusing hierarchy**: Unclear what matters most - - **Feature creep**: Too many options, actions, or paths forward - -2. **Find the essence**: - - What's the primary user goal? (There should be ONE) - - What's actually necessary vs nice-to-have? - - What can be removed, hidden, or combined? - - What's the 20% that delivers 80% of value? - -If any of these are unclear from the codebase, ask the user directly to clarify what you cannot infer. - -**CRITICAL**: Simplicity is not about removing features - it's about removing obstacles between users and their goals. Every element should justify its existence. - -## Plan Simplification - -Create a ruthless editing strategy: - -- **Core purpose**: What's the ONE thing this should accomplish? -- **Essential elements**: What's truly necessary to achieve that purpose? -- **Progressive disclosure**: What can be hidden until needed? -- **Consolidation opportunities**: What can be combined or integrated? - -**IMPORTANT**: Simplification is hard. It requires saying no to good ideas to make room for great execution. Be ruthless. - -## Simplify the Design - -Systematically remove complexity across these dimensions: - -### Information Architecture -- **Reduce scope**: Remove secondary actions, optional features, redundant information -- **Progressive disclosure**: Hide complexity behind clear entry points (accordions, modals, step-through flows) -- **Combine related actions**: Merge similar buttons, consolidate forms, group related content -- **Clear hierarchy**: ONE primary action, few secondary actions, everything else tertiary or hidden -- **Remove redundancy**: If it's said elsewhere, don't repeat it here - -### Visual Simplification -- **Reduce color palette**: Use 1-2 colors plus neutrals, not 5-7 colors -- **Limit typography**: One font family, 3-4 sizes maximum, 2-3 weights -- **Remove decorations**: Eliminate borders, shadows, backgrounds that don't serve hierarchy or function -- **Flatten structure**: Reduce nesting, remove unnecessary containers—never nest cards inside cards -- **Remove unnecessary cards**: Cards aren't needed for basic layout; use spacing and alignment instead -- **Consistent spacing**: Use one spacing scale, remove arbitrary gaps - -### Layout Simplification -- **Linear flow**: Replace complex grids with simple vertical flow where possible -- **Remove sidebars**: Move secondary content inline or hide it -- **Full-width**: Use available space generously instead of complex multi-column layouts -- **Consistent alignment**: Pick left or center, stick with it -- **Generous white space**: Let content breathe, don't pack everything tight - -### Interaction Simplification -- **Reduce choices**: Fewer buttons, fewer options, clearer path forward (paradox of choice is real) -- **Smart defaults**: Make common choices automatic, only ask when necessary -- **Inline actions**: Replace modal flows with inline editing where possible -- **Remove steps**: Can signup be one step instead of three? Can checkout be simplified? -- **Clear CTAs**: ONE obvious next step, not five competing actions - -### Content Simplification -- **Shorter copy**: Cut every sentence in half, then do it again -- **Active voice**: "Save changes" not "Changes will be saved" -- **Remove jargon**: Plain language always wins -- **Scannable structure**: Short paragraphs, bullet points, clear headings -- **Essential information only**: Remove marketing fluff, legalese, hedging -- **Remove redundant copy**: No headers restating intros, no repeated explanations, say it once - -### Code Simplification -- **Remove unused code**: Dead CSS, unused components, orphaned files -- **Flatten component trees**: Reduce nesting depth -- **Consolidate styles**: Merge similar styles, use utilities consistently -- **Reduce variants**: Does that component need 12 variations, or can 3 cover 90% of cases? - -**NEVER**: -- Remove necessary functionality (simplicity ≠ feature-less) -- Sacrifice accessibility for simplicity (clear labels and ARIA still required) -- Make things so simple they're unclear (mystery ≠ minimalism) -- Remove information users need to make decisions -- Eliminate hierarchy completely (some things should stand out) -- Oversimplify complex domains (match complexity to actual task complexity) - -## Verify Simplification - -Ensure simplification improves usability: - -- **Faster task completion**: Can users accomplish goals more quickly? -- **Reduced cognitive load**: Is it easier to understand what to do? -- **Still complete**: Are all necessary features still accessible? -- **Clearer hierarchy**: Is it obvious what matters most? -- **Better performance**: Does simpler design load faster? - -## Document Removed Complexity - -If you removed features or options: -- Document why they were removed -- Consider if they need alternative access points -- Note any user feedback to monitor - -Remember: You have great taste and judgment. Simplification is an act of confidence - knowing what to keep and courage to remove the rest. As Antoine de Saint-Exupéry said: "Perfection is achieved not when there is nothing more to add, but when there is nothing left to take away." \ No newline at end of file diff --git a/.agents/skills/harden/SKILL.md b/.agents/skills/harden/SKILL.md deleted file mode 100644 index 31b996fa8..000000000 --- a/.agents/skills/harden/SKILL.md +++ /dev/null @@ -1,389 +0,0 @@ ---- -name: harden -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. -version: 2.1.1 -user-invocable: true -argument-hint: "[target]" ---- - -Strengthen interfaces against edge cases, errors, internationalization issues, and real-world usage scenarios that break idealized designs. - -## Assess Hardening Needs - -Identify weaknesses and edge cases: - -1. **Test with extreme inputs**: - - Very long text (names, descriptions, titles) - - Very short text (empty, single character) - - Special characters (emoji, RTL text, accents) - - Large numbers (millions, billions) - - Many items (1000+ list items, 50+ options) - - No data (empty states) - -2. **Test error scenarios**: - - Network failures (offline, slow, timeout) - - API errors (400, 401, 403, 404, 500) - - Validation errors - - Permission errors - - Rate limiting - - Concurrent operations - -3. **Test internationalization**: - - Long translations (German is often 30% longer than English) - - RTL languages (Arabic, Hebrew) - - Character sets (Chinese, Japanese, Korean, emoji) - - Date/time formats - - Number formats (1,000 vs 1.000) - - Currency symbols - -**CRITICAL**: Designs that only work with perfect data aren't production-ready. Harden against reality. - -## Hardening Dimensions - -Systematically improve resilience: - -### Text Overflow & Wrapping - -**Long text handling**: -```css -/* Single line with ellipsis */ -.truncate { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* Multi-line with clamp */ -.line-clamp { - display: -webkit-box; - -webkit-line-clamp: 3; - -webkit-box-orient: vertical; - overflow: hidden; -} - -/* Allow wrapping */ -.wrap { - word-wrap: break-word; - overflow-wrap: break-word; - hyphens: auto; -} -``` - -**Flex/Grid overflow**: -```css -/* Prevent flex items from overflowing */ -.flex-item { - min-width: 0; /* Allow shrinking below content size */ - overflow: hidden; -} - -/* Prevent grid items from overflowing */ -.grid-item { - min-width: 0; - min-height: 0; -} -``` - -**Responsive text sizing**: -- Use `clamp()` for fluid typography -- Set minimum readable sizes (14px on mobile) -- Test text scaling (zoom to 200%) -- Ensure containers expand with text - -### Internationalization (i18n) - -**Text expansion**: -- Add 30-40% space budget for translations -- Use flexbox/grid that adapts to content -- Test with longest language (usually German) -- Avoid fixed widths on text containers - -```jsx -// ❌ Bad: Assumes short English text - - -// ✅ Good: Adapts to content - -``` - -**RTL (Right-to-Left) support**: -```css -/* Use logical properties */ -margin-inline-start: 1rem; /* Not margin-left */ -padding-inline: 1rem; /* Not padding-left/right */ -border-inline-end: 1px solid; /* Not border-right */ - -/* Or use dir attribute */ -[dir="rtl"] .arrow { transform: scaleX(-1); } -``` - -**Character set support**: -- Use UTF-8 encoding everywhere -- Test with Chinese/Japanese/Korean (CJK) characters -- Test with emoji (they can be 2-4 bytes) -- Handle different scripts (Latin, Cyrillic, Arabic, etc.) - -**Date/Time formatting**: -```javascript -// ✅ Use Intl API for proper formatting -new Intl.DateTimeFormat('en-US').format(date); // 1/15/2024 -new Intl.DateTimeFormat('de-DE').format(date); // 15.1.2024 - -new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD' -}).format(1234.56); // $1,234.56 -``` - -**Pluralization**: -```javascript -// ❌ Bad: Assumes English pluralization -`${count} item${count !== 1 ? 's' : ''}` - -// ✅ Good: Use proper i18n library -t('items', { count }) // Handles complex plural rules -``` - -### Error Handling - -**Network errors**: -- Show clear error messages -- Provide retry button -- Explain what happened -- Offer offline mode (if applicable) -- Handle timeout scenarios - -```jsx -// Error states with recovery -{error && ( - -

Failed to load data. {error.message}

- -
-)} -``` - -**Form validation errors**: -- Inline errors near fields -- Clear, specific messages -- Suggest corrections -- Don't block submission unnecessarily -- Preserve user input on error - -**API errors**: -- Handle each status code appropriately - - 400: Show validation errors - - 401: Redirect to login - - 403: Show permission error - - 404: Show not found state - - 429: Show rate limit message - - 500: Show generic error, offer support - -**Graceful degradation**: -- Core functionality works without JavaScript -- Images have alt text -- Progressive enhancement -- Fallbacks for unsupported features - -### Edge Cases & Boundary Conditions - -**Empty states**: -- No items in list -- No search results -- No notifications -- No data to display -- Provide clear next action - -**Loading states**: -- Initial load -- Pagination load -- Refresh -- Show what's loading ("Loading your projects...") -- Time estimates for long operations - -**Large datasets**: -- Pagination or virtual scrolling -- Search/filter capabilities -- Performance optimization -- Don't load all 10,000 items at once - -**Concurrent operations**: -- Prevent double-submission (disable button while loading) -- Handle race conditions -- Optimistic updates with rollback -- Conflict resolution - -**Permission states**: -- No permission to view -- No permission to edit -- Read-only mode -- Clear explanation of why - -**Browser compatibility**: -- Polyfills for modern features -- Fallbacks for unsupported CSS -- Feature detection (not browser detection) -- Test in target browsers - -### Onboarding & First-Run Experience - -Production-ready features work for first-time users, not just power users. Design the paths that get new users to value: - -**Empty states**: Every zero-data screen needs: -- What will appear here (description or illustration) -- Why it matters to the user -- Clear CTA to create the first item or start from a template -- Visual interest (not just blank space with "No items yet") - -Empty state types to handle: -- **First use**: emphasize value, provide templates -- **User cleared**: light touch, easy to recreate -- **No results**: suggest a different query, offer to clear filters -- **No permissions**: explain why, how to get access - -**First-run experience**: Get users to their "aha moment" as quickly as possible. -- Show, don't tell -- working examples over descriptions -- Progressive disclosure -- teach one thing at a time, not everything upfront -- Make onboarding optional -- let experienced users skip -- Provide smart defaults so required setup is minimal - -**Feature discovery**: Teach features when users need them, not upfront. -- Contextual tooltips at point of use (brief, dismissable, one-time) -- Badges or indicators on new or unused features -- Celebrate activation events quietly (a toast, not a modal) - -**NEVER**: -- Force long onboarding before users can touch the product -- Show the same tooltip repeatedly (track and respect dismissals) -- Block the entire UI during a guided tour -- Create separate tutorial modes disconnected from the real product -- Design empty states that just say "No items" with no next action - -### Input Validation & Sanitization - -**Client-side validation**: -- Required fields -- Format validation (email, phone, URL) -- Length limits -- Pattern matching -- Custom validation rules - -**Server-side validation** (always): -- Never trust client-side only -- Validate and sanitize all inputs -- Protect against injection attacks -- Rate limiting - -**Constraint handling**: -```html - - - - Letters and numbers only, up to 100 characters - -``` - -### Accessibility Resilience - -**Keyboard navigation**: -- All functionality accessible via keyboard -- Logical tab order -- Focus management in modals -- Skip links for long content - -**Screen reader support**: -- Proper ARIA labels -- Announce dynamic changes (live regions) -- Descriptive alt text -- Semantic HTML - -**Motion sensitivity**: -```css -@media (prefers-reduced-motion: reduce) { - * { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; - } -} -``` - -**High contrast mode**: -- Test in Windows high contrast mode -- Don't rely only on color -- Provide alternative visual cues - -### Performance Resilience - -**Slow connections**: -- Progressive image loading -- Skeleton screens -- Optimistic UI updates -- Offline support (service workers) - -**Memory leaks**: -- Clean up event listeners -- Cancel subscriptions -- Clear timers/intervals -- Abort pending requests on unmount - -**Throttling & Debouncing**: -```javascript -// Debounce search input -const debouncedSearch = debounce(handleSearch, 300); - -// Throttle scroll handler -const throttledScroll = throttle(handleScroll, 100); -``` - -## Testing Strategies - -**Manual testing**: -- Test with extreme data (very long, very short, empty) -- Test in different languages -- Test offline -- Test slow connection (throttle to 3G) -- Test with screen reader -- Test keyboard-only navigation -- Test on old browsers - -**Automated testing**: -- Unit tests for edge cases -- Integration tests for error scenarios -- E2E tests for critical paths -- Visual regression tests -- Accessibility tests (axe, WAVE) - -**IMPORTANT**: Hardening is about expecting the unexpected. Real users will do things you never imagined. - -**NEVER**: -- Assume perfect input (validate everything) -- Ignore internationalization (design for global) -- Leave error messages generic ("Error occurred") -- Forget offline scenarios -- Trust client-side validation alone -- Use fixed widths for text -- Assume English-length text -- Block entire interface when one component errors - -## Verify Hardening - -Test thoroughly with edge cases: - -- **Long text**: Try names with 100+ characters -- **Emoji**: Use emoji in all text fields -- **RTL**: Test with Arabic or Hebrew -- **CJK**: Test with Chinese/Japanese/Korean -- **Network issues**: Disable internet, throttle connection -- **Large datasets**: Test with 1000+ items -- **Concurrent actions**: Click submit 10 times rapidly -- **Errors**: Force API errors, test all error states -- **Empty**: Remove all data, test empty states - -Remember: You're hardening for production reality, not demo perfection. Expect users to input weird data, lose connection mid-flow, and use your product in unexpected ways. Build resilience into every component. \ No newline at end of file diff --git a/.agents/skills/impeccable/SKILL.md b/.agents/skills/impeccable/SKILL.md index bc4574d35..576199435 100644 --- a/.agents/skills/impeccable/SKILL.md +++ b/.agents/skills/impeccable/SKILL.md @@ -1,365 +1,152 @@ --- name: impeccable -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. -version: 2.1.1 -user-invocable: true -argument-hint: "[craft|teach|extract]" -license: Apache 2.0. Based on Anthropic's frontend-design skill. See NOTICE.md for attribution. +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. --- - -BEFORE doing any design work, run this one-time maintenance step. Tell the user: +Designs and iterates production-grade frontend interfaces. Real working code, committed design choices, exceptional craft. -> **Impeccable was updated.** A few skills were renamed or merged in this version (e.g. `/arrange` is now `/layout`, `/normalize` was folded into `/polish`). I'll clean up the old skill files so they don't clutter your project. This only runs once after an update. +## Setup (non-optional) -Then run: +Two steps before any design work. Both are required. Skipping either produces generic output that ignores the project. + +### 1. Context gathering + +Two files at the project root, case-insensitive: + +- **PRODUCT.md** — required. Users, brand, tone, anti-references, strategic principles. +- **DESIGN.md** — optional, strongly recommended. Colors, typography, elevation, components. + +Load both in one call: ```bash -node .agents/skills/impeccable/scripts/cleanup-deprecated.mjs +node .agents/skills/impeccable/scripts/load-context.mjs ``` -If the script removed files, briefly confirm what was cleaned up. If it found nothing, skip any output and move on. +Consume the full JSON output. Never pipe through `head`, `tail`, `grep`, or `jq`. -After running the script, delete this entire section (from `` through `` inclusive) from THIS file so it does not run again until the next update. Save the file. - +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. -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. +`$impeccable live` already warms context via `live.mjs` — if you've run `live.mjs`, don't also run `load-context.mjs` this session. -## Context Gathering Protocol +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. -Design skills produce generic output without project context. You MUST have confirmed design context before doing any design work. +If DESIGN.md is missing: nudge once per session (*"Run `$impeccable document` for more on-brand output"*), then proceed. -**Required context** (every design skill needs at minimum): -- **Target audience**: Who uses this product and in what context? -- **Use cases**: What jobs are they trying to get done? -- **Brand personality/tone**: How should the interface feel? +### 2. Register -Individual skills may require additional context. Check the skill's preparation section for specifics. +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). -**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. +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. -**Gathering order:** -1. **Check current instructions (instant)**: If your loaded instructions already contain a **Design Context** section, proceed immediately. -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. -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. +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. ---- +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. -## Design Direction +## Shared design laws -Commit to a BOLD aesthetic direction: -- **Purpose**: What problem does this interface solve? Who uses it? -- **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. -- **Constraints**: Technical requirements (framework, performance, accessibility). -- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember? +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. -**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work. The key is intentionality, not intensity. +### Color -Then implement working code that is: -- Production-grade and functional -- Visually striking and memorable -- Cohesive with a clear aesthetic point-of-view -- Meticulously refined in every detail +- Use OKLCH. Reduce chroma as lightness approaches 0 or 100 — high chroma at extremes looks garish. +- Never use `#000` or `#fff`. Tint every neutral toward the brand hue (chroma 0.005–0.01 is enough). +- Pick a **color strategy** before picking colors. Four steps on the commitment axis: + - **Restrained** — tinted neutrals + one accent ≤10%. Product default; brand minimalism. + - **Committed** — one saturated color carries 30–60% of the surface. Brand default for identity-driven pages. + - **Full palette** — 3–4 named roles, each used deliberately. Brand campaigns; product data viz. + - **Drenched** — the surface IS the color. Brand heroes, campaign pages. +- 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. -## Frontend Aesthetics Guidelines +### Theme + +Dark vs. light is never a default. Not dark "because tools look cool dark." Not light "to be safe." + +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. + +"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. ### Typography -→ *Consult [typography reference](reference/typography.md) for OpenType features, web font loading, and the deeper material on scales.* -Choose fonts that are beautiful, unique, and interesting. Pair a distinctive display font with a refined body font. +- Cap body line length at 65–75ch. +- Hierarchy through scale + weight contrast (≥1.25 ratio between steps). Avoid flat scales. - -Always apply these — do not consult a reference, just do them: +### Layout -- 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). -- 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. -- 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. -- Cap line length at ~65-75ch. Body text wider than that is fatiguing. - - - -DO THIS BEFORE TYPING ANY FONT NAME. - -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: - -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. - -Step 2. List the 3 fonts you would normally reach for given those words. Write them down. They are most likely from this list: - - -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 - - -Reject every font that appears in the reflex_fonts_to_reject list. They are your training-data defaults and they create monoculture across projects. - -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. - -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. - - - -DO use a modular type scale with fluid sizing (clamp) on headings. -DO vary font weights and sizes to create clear visual hierarchy. -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. - -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. -DO NOT use monospace typography as lazy shorthand for "technical/developer" vibes. -DO NOT put large icons with rounded corners above every heading. They rarely add value and make sites look templated. -DO NOT use only one font family for the entire page. Pair a distinctive display font with a refined body font. -DO NOT use a flat type hierarchy where sizes are too close together. Aim for at least a 1.25 ratio between steps. -DO NOT set long body passages in uppercase. Reserve all-caps for short labels and headings. - - -### Color & Theme -→ *Consult [color reference](reference/color-and-contrast.md) for the deeper material on contrast, accessibility, and palette construction.* - -Commit to a cohesive palette. Dominant colors with sharp accents outperform timid, evenly-distributed palettes. - - -Always apply these — do not consult a reference, just do them: - -- 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. -- 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. -- 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. - - - -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? - -- A perp DEX consumed during fast trading sessions → dark -- A hospital portal consumed by anxious patients on phones late at night → light -- A children's reading app → light -- A vintage motorcycle forum where users sit in their garage at 9pm → dark -- An observability dashboard for SREs in a dark office → dark -- A wedding planning checklist for couples on a Sunday morning → light -- A music player app for headphone listening at night → dark -- A food magazine homepage browsed during a coffee break → light - -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. - - - -DO use modern CSS color functions (oklch, color-mix, light-dark) for perceptually uniform, maintainable palettes. -DO tint your neutrals toward your brand hue. Even a subtle hint creates subconscious cohesion. - -DO NOT use gray text on colored backgrounds; it looks washed out. Use a shade of the background color instead. -DO NOT use pure black (#000) or pure white (#fff). Always tint; pure black/white never appears in nature. -DO NOT use the AI color palette: cyan-on-dark, purple-to-blue gradients, neon accents on dark backgrounds. -DO NOT use gradient text for impact — see below for the strict definition. Solid colors only for text. -DO NOT default to dark mode with glowing accents. It looks "cool" without requiring actual design decisions. -DO NOT default to light mode "to be safe" either. The point is to choose, not to retreat to a safe option. - - -### Layout & Space -→ *Consult [spatial reference](reference/spatial-design.md) for the deeper material on grids, container queries, and optical adjustments.* - -Create visual rhythm through varied spacing, not the same padding everywhere. Embrace asymmetry and unexpected compositions. Break the grid intentionally for emphasis. - - -Always apply these — do not consult a reference, just do them: - -- 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. -- Use `gap` instead of margins for sibling spacing. It eliminates margin collapse and the cleanup hacks that come with it. -- 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. -- Self-adjusting grid pattern: `grid-template-columns: repeat(auto-fit, minmax(280px, 1fr))` is the breakpoint-free responsive grid for card-style content. -- 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. - - - -DO create visual rhythm through varied spacing: tight groupings, generous separations. -DO use fluid spacing with clamp() that breathes on larger screens. -DO use asymmetry and unexpected compositions; break the grid intentionally for emphasis. - -DO NOT wrap everything in cards. Not everything needs a container. -DO NOT nest cards inside cards. Visual noise; flatten the hierarchy. -DO NOT use identical card grids (same-sized cards with icon + heading + text, repeated endlessly). -DO NOT use the hero metric layout template (big number, small label, supporting stats, gradient accent). -DO NOT center everything. Left-aligned text with asymmetric layouts feels more designed. -DO NOT use the same spacing everywhere. Without rhythm, layouts feel monotonous. -DO NOT let body text wrap beyond ~80 characters per line. Add a max-width like 65–75ch so the eye can track easily. - - -### Visual Details - - -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. - -BAN 1: Side-stripe borders on cards/list items/callouts/alerts - - PATTERN: `border-left:` or `border-right:` with width greater than 1px - - INCLUDES: hard-coded colors AND CSS variables - - FORBIDDEN: `border-left: 3px solid red`, `border-left: 4px solid #ff0000`, `border-left: 4px solid var(--color-warning)`, `border-left: 5px solid oklch(...)`, etc. - - 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." - - 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. - -BAN 2: Gradient text - - PATTERN: `background-clip: text` (or `-webkit-background-clip: text`) combined with a gradient background - - FORBIDDEN: any combination that makes text fill come from a `linear-gradient`, `radial-gradient`, or `conic-gradient` - - WHY: gradient text is decorative rather than meaningful and is one of the top three AI design tells - - REWRITE: use a single solid color for text. If you want emphasis, use weight or size, not gradient fill. - - -DO: Use intentional, purposeful decorative elements that reinforce brand. -DO NOT: Use border-left or border-right greater than 1px as a colored accent stripe on cards, list items, callouts, or alerts. See above for the strict CSS pattern. -DO NOT: Use glassmorphism everywhere (blur effects, glass cards, glow borders used decoratively rather than purposefully). -DO NOT: Use sparklines as decoration. Tiny charts that look sophisticated but convey nothing meaningful. -DO NOT: Use rounded rectangles with generic drop shadows. Safe, forgettable, could be any AI output. -DO NOT: Use modals unless there's truly no better alternative. Modals are lazy. +- Vary spacing for rhythm. Same padding everywhere is monotony. +- Cards are the lazy answer. Use them only when they're truly the best affordance. Nested cards are always wrong. +- Don't wrap everything in a container. Most things don't need one. ### Motion -→ *Consult [motion reference](reference/motion-design.md) for timing, easing, and reduced motion.* -Focus on high-impact moments: one well-orchestrated page load with staggered reveals creates more delight than scattered micro-interactions. +- Don't animate CSS layout properties. +- Ease out with exponential curves (ease-out-quart / quint / expo). No bounce, no elastic. -**DO**: Use motion to convey state changes: entrances, exits, feedback -**DO**: Use exponential easing (ease-out-quart/quint/expo) for natural deceleration -**DO**: For height animations, use grid-template-rows transitions instead of animating height directly -**DON'T**: Animate layout properties (width, height, padding, margin). Use transform and opacity only -**DON'T**: Use bounce or elastic easing. They feel dated and tacky; real objects decelerate smoothly +### Absolute bans -### Interaction -→ *Consult [interaction reference](reference/interaction-design.md) for forms, focus, and loading patterns.* +Match-and-refuse. If you're about to write any of these, rewrite the element with different structure. -Make interactions feel fast. Use optimistic UI: update immediately, sync later. +- **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. +- **Gradient text.** `background-clip: text` combined with a gradient background. Decorative, never meaningful. Use a single solid color. Emphasis via weight or size. +- **Glassmorphism as default.** Blurs and glass cards used decoratively. Rare and purposeful, or nothing. +- **The hero-metric template.** Big number, small label, supporting stats, gradient accent. SaaS cliché. +- **Identical card grids.** Same-sized cards with icon + heading + text, repeated endlessly. +- **Modal as first thought.** Modals are usually laziness. Exhaust inline / progressive alternatives first. -**DO**: Use progressive disclosure. Start simple, reveal sophistication through interaction (basic options first, advanced behind expandable sections; hover states that reveal secondary actions) -**DO**: Design empty states that teach the interface, not just say "nothing here" -**DO**: Make every interactive surface feel intentional and responsive -**DON'T**: Repeat the same information (redundant headers, intros that restate the heading) -**DON'T**: Make every button primary. Use ghost buttons, text links, secondary styles; hierarchy matters +### Copy -### Responsive -→ *Consult [responsive reference](reference/responsive-design.md) for mobile-first, fluid design, and container queries.* +- Every word earns its place. No restated headings, no intros that repeat the title. +- **No em dashes.** Use commas, colons, semicolons, periods, or parentheses. Also not `--`. -**DO**: Use container queries (@container) for component-level responsiveness -**DO**: Adapt the interface for different contexts, not just shrink it -**DON'T**: Hide critical functionality on mobile. Adapt the interface, don't amputate it +### The AI slop test -### UX Writing -→ *Consult [ux-writing reference](reference/ux-writing.md) for labels, errors, and empty states.* +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. -**DO**: Make every word earn its place -**DON'T**: Repeat information users can already see +**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. ---- +## Commands -## The AI Slop Test +| Command | Category | Description | Reference | +|---|---|---|---| +| `craft [feature]` | Build | Shape, then build a feature end-to-end | [reference/craft.md](reference/craft.md) | +| `shape [feature]` | Build | Plan UX/UI before writing code | [reference/shape.md](reference/shape.md) | +| `teach` | Build | Set up PRODUCT.md and DESIGN.md context | [reference/teach.md](reference/teach.md) | +| `document` | Build | Generate DESIGN.md from existing project code | [reference/document.md](reference/document.md) | +| `extract [target]` | Build | Pull reusable tokens and components into design system | [reference/extract.md](reference/extract.md) | +| `critique [target]` | Evaluate | UX design review with heuristic scoring | [reference/critique.md](reference/critique.md) | +| `audit [target]` | Evaluate | Technical quality checks (a11y, perf, responsive) | [reference/audit.md](reference/audit.md) | +| `polish [target]` | Refine | Final quality pass before shipping | [reference/polish.md](reference/polish.md) | +| `bolder [target]` | Refine | Amplify safe or bland designs | [reference/bolder.md](reference/bolder.md) | +| `quieter [target]` | Refine | Tone down aggressive or overstimulating designs | [reference/quieter.md](reference/quieter.md) | +| `distill [target]` | Refine | Strip to essence, remove complexity | [reference/distill.md](reference/distill.md) | +| `harden [target]` | Refine | Production-ready: errors, i18n, edge cases | [reference/harden.md](reference/harden.md) | +| `onboard [target]` | Refine | Design first-run flows, empty states, activation | [reference/onboard.md](reference/onboard.md) | +| `animate [target]` | Enhance | Add purposeful animations and motion | [reference/animate.md](reference/animate.md) | +| `colorize [target]` | Enhance | Add strategic color to monochromatic UIs | [reference/colorize.md](reference/colorize.md) | +| `typeset [target]` | Enhance | Improve typography hierarchy and fonts | [reference/typeset.md](reference/typeset.md) | +| `layout [target]` | Enhance | Fix spacing, rhythm, and visual hierarchy | [reference/layout.md](reference/layout.md) | +| `delight [target]` | Enhance | Add personality and memorable touches | [reference/delight.md](reference/delight.md) | +| `overdrive [target]` | Enhance | Push past conventional limits | [reference/overdrive.md](reference/overdrive.md) | +| `clarify [target]` | Fix | Improve UX copy, labels, and error messages | [reference/clarify.md](reference/clarify.md) | +| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) | +| `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) | +| `live` | Iterate | Visual variant mode: pick elements in the browser, generate alternatives | [reference/live.md](reference/live.md) | -**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. +Plus two management commands — `pin ` and `unpin `, detailed below. -A distinctive interface should make someone ask "how was this made?" not "which AI made this?" +### Routing rules -Review the DON'T guidelines above. They are the fingerprints of AI-generated work from 2024-2025. +1. **No argument** — render the table above as the user-facing command menu, grouped by category. Ask what they'd like to do. +2. **First word matches a command** — load its reference file and follow its instructions. Everything after the command name is the target. +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. ---- +Setup (context gathering, register) is already loaded by then; sub-commands don't re-invoke `$impeccable`. -## Implementation Principles +## Pin / Unpin -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. +**Pin** creates a standalone shortcut so `$` invokes `$impeccable ` directly. **Unpin** removes it. The script writes to every harness directory present in the project. -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. - -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. - ---- - -## Craft Mode - -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. - ---- - -## Teach Mode - -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. - -### Step 1: Explore the Codebase - -Before asking questions, thoroughly scan the project to discover what you can: - -- **README and docs**: Project purpose, target audience, any stated goals -- **Package.json / config files**: Tech stack, dependencies, existing design libraries -- **Existing components**: Current design patterns, spacing, typography in use -- **Brand assets**: Logos, favicons, color values already defined -- **Design tokens / CSS variables**: Existing color palettes, font stacks, spacing scales -- **Any style guides or brand documentation** - -Note what you've learned and what remains unclear. - -### Step 2: Ask UX-Focused Questions - -ask the user directly to clarify what you cannot infer. Focus only on what you couldn't infer from the codebase: - -#### Users & Purpose -- Who uses this? What's their context when using it? -- What job are they trying to get done? -- What emotions should the interface evoke? (confidence, delight, calm, urgency, etc.) - -#### Brand & Personality -- How would you describe the brand personality in 3 words? -- Any reference sites or apps that capture the right feel? What specifically about them? -- What should this explicitly NOT look like? Any anti-references? - -#### Aesthetic Preferences -- Any strong preferences for visual direction? (minimal, bold, elegant, playful, technical, organic, etc.) -- Light mode, dark mode, or both? -- Any colors that must be used or avoided? - -#### Accessibility & Inclusion -- Specific accessibility requirements? (WCAG level, known user needs) -- Considerations for reduced motion, color blindness, or other accommodations? - -Skip questions where the answer is already clear from the codebase exploration. - -### Step 3: Write Design Context - -Synthesize your findings and the user's answers into a `## Design Context` section: - -```markdown -## Design Context - -### Users -[Who they are, their context, the job to be done] - -### Brand Personality -[Voice, tone, 3-word personality, emotional goals] - -### Aesthetic Direction -[Visual tone, references, anti-references, theme] - -### Design Principles -[3-5 principles derived from the conversation that should guide all design decisions] +```bash +node .agents/skills/impeccable/scripts/pin.mjs ``` -Write this section to `.impeccable.md` in the project root. If the file already exists, update the Design Context section in place. - -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. - -Confirm completion and summarize the key design principles that will now guide all future work. - ---- - -## Extract Mode - -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. \ No newline at end of file +Valid `` is any command from the table above. Report the script's result concisely — confirm the new shortcut on success, relay stderr verbatim on error. \ No newline at end of file diff --git a/.agents/skills/impeccable/agents/openai.yaml b/.agents/skills/impeccable/agents/openai.yaml new file mode 100644 index 000000000..ee6cae772 --- /dev/null +++ b/.agents/skills/impeccable/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: Impeccable + short_description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify,... + default_prompt: Use Impeccable to redesign, critique, audit, or polish this frontend. \ No newline at end of file diff --git a/.gemini/skills/adapt/SKILL.md b/.agents/skills/impeccable/reference/adapt.md similarity index 90% rename from .gemini/skills/adapt/SKILL.md rename to .agents/skills/impeccable/reference/adapt.md index 35b00e3f9..249653d4c 100644 --- a/.gemini/skills/adapt/SKILL.md +++ b/.agents/skills/impeccable/reference/adapt.md @@ -1,14 +1,7 @@ ---- -name: adapt -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. -version: 2.1.1 ---- +> **Additional context needed**: target platforms/devices and usage contexts. Adapt existing designs to work effectively across different contexts - different screen sizes, devices, platforms, or use cases. -## MANDATORY PREPARATION - -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. --- @@ -194,4 +187,4 @@ Test thoroughly across contexts: - **Edge cases**: Very small screens (320px), very large screens (4K) - **Slow connections**: Test on throttled network -Remember: You're a cross-platform design expert. Make experiences that feel native to each context while maintaining brand and functionality consistency. Adapt intentionally, test thoroughly. \ No newline at end of file +Remember: You're a cross-platform design expert. Make experiences that feel native to each context while maintaining brand and functionality consistency. Adapt intentionally, test thoroughly. diff --git a/.pi/skills/animate/SKILL.md b/.agents/skills/impeccable/reference/animate.md similarity index 91% rename from .pi/skills/animate/SKILL.md rename to .agents/skills/impeccable/reference/animate.md index 02294bc19..187f44c31 100644 --- a/.pi/skills/animate/SKILL.md +++ b/.agents/skills/impeccable/reference/animate.md @@ -1,14 +1,14 @@ ---- -name: animate -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. -version: 2.1.1 ---- +> **Additional context needed**: performance constraints. Analyze a feature and strategically add animations and micro-interactions that enhance understanding, provide feedback, and create delight. -## MANDATORY PREPARATION +--- -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. +## Register + +Brand: orchestrated page-load sequences, staggered reveals, scroll-driven animation. Motion is part of the voice; one well-rehearsed entrance beats scattered micro-interactions. + +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. --- @@ -170,4 +170,4 @@ Test animations thoroughly: - **Doesn't block**: Users can interact during/after animations - **Adds value**: Makes interface clearer or more delightful -Remember: Motion should enhance understanding and provide feedback, not just add decoration. Animate with purpose, respect performance constraints, and always consider accessibility. Great animation is invisible - it just makes everything feel right. \ No newline at end of file +Remember: Motion should enhance understanding and provide feedback, not just add decoration. Animate with purpose, respect performance constraints, and always consider accessibility. Great animation is invisible - it just makes everything feel right. diff --git a/.codex/skills/audit/SKILL.md b/.agents/skills/impeccable/reference/audit.md similarity index 79% rename from .codex/skills/audit/SKILL.md rename to .agents/skills/impeccable/reference/audit.md index 84a55b188..fa480e345 100644 --- a/.codex/skills/audit/SKILL.md +++ b/.agents/skills/impeccable/reference/audit.md @@ -1,16 +1,3 @@ ---- -name: audit -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. -version: 2.1.1 -argument-hint: "[area (feature, page, component...)]" ---- - -## MANDATORY PREPARATION - -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. - ---- - Run systematic **technical** quality checks and generate a comprehensive report. Don't fix issues — document them for other commands to address. This is a code-level audit, not a design critique. Check what's measurable and verifiable in the implementation. @@ -65,7 +52,7 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the ### 5. Anti-Patterns (CRITICAL) -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). +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). **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) @@ -108,7 +95,7 @@ For each issue, document: - **Impact**: How it affects users - **WCAG/Standard**: Which standard it violates (if applicable) - **Recommendation**: How to fix it -- **Suggested command**: Which command to use (prefer: $animate, $quieter, $shape, $optimize, $adapt, $clarify, $layout, $distill, $delight, $audit, $harden, $polish, $bolder, $typeset, $critique, $colorize, $overdrive) +- **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) ### Patterns & Systemic Issues @@ -127,13 +114,13 @@ List recommended commands in priority order (P0 first, then P1, then P2): 1. **[P?] `$command-name`** — Brief description (specific context from audit findings) 2. **[P?] `$command-name`** — Brief description (specific context) -**Rules**: Only recommend commands from: $animate, $quieter, $shape, $optimize, $adapt, $clarify, $layout, $distill, $delight, $audit, $harden, $polish, $bolder, $typeset, $critique, $colorize, $overdrive. Map findings to the most appropriate command. End with `$polish` as the final step if any fixes were recommended. +**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. After presenting the summary, tell the user: > You can ask me to run these one at a time, all at once, or in any order you prefer. > -> Re-run `$audit` after fixes to see your score improve. +> Re-run `$impeccable audit` after fixes to see your score improve. **IMPORTANT**: Be thorough but actionable. Too many P3 issues creates noise. Focus on what actually matters. @@ -144,4 +131,4 @@ After presenting the summary, tell the user: - Forget to prioritize (everything can't be P0) - Report false positives without verification -Remember: You're a technical quality auditor. Document systematically, prioritize ruthlessly, cite specific code locations, and provide clear paths to improvement. \ No newline at end of file +Remember: You're a technical quality auditor. Document systematically, prioritize ruthlessly, cite specific code locations, and provide clear paths to improvement. diff --git a/.pi/skills/bolder/SKILL.md b/.agents/skills/impeccable/reference/bolder.md similarity index 88% rename from .pi/skills/bolder/SKILL.md rename to .agents/skills/impeccable/reference/bolder.md index e276b4d0b..fa05e8ea6 100644 --- a/.pi/skills/bolder/SKILL.md +++ b/.agents/skills/impeccable/reference/bolder.md @@ -1,14 +1,12 @@ ---- -name: bolder -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. -version: 2.1.1 ---- - Increase visual impact and personality in designs that are too safe, generic, or visually underwhelming, creating more engaging and memorable experiences. -## MANDATORY PREPARATION +--- -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. +## Register + +Brand: "bolder" means distinctive. Extreme scale, unexpected color, typographic risk, committed POV. + +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. --- @@ -34,7 +32,7 @@ If any of these are unclear from the codebase, ask the user directly to clarify **CRITICAL**: "Bolder" doesn't mean chaotic or garish. It means distinctive, memorable, and confident. Think intentional drama, not random chaos. -**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." +**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." ## Plan Amplification @@ -52,7 +50,7 @@ Create a strategy to increase impact while maintaining coherence: Systematically increase impact across these dimensions: ### Typography Amplification -- **Replace generic fonts**: Swap system fonts for distinctive choices (see impeccable skill for inspiration) +- **Replace generic fonts**: Swap system fonts for distinctive choices (see the parent skill's typography guidelines and [typography.md](typography.md) for inspiration) - **Extreme scale**: Create dramatic size jumps (3x-5x differences, not 1.5x) - **Weight contrast**: Pair 900 weights with 200 weights, not 600 with 400 - **Unexpected choices**: Variable fonts, display fonts for headlines, condensed/extended widths, monospace as intentional accent (not as lazy "dev tool" default) @@ -112,4 +110,4 @@ Ensure amplification maintains usability and coherence: **The test**: If you showed this to someone and said "AI made this bolder," would they believe you immediately? If yes, you've failed. Bold means distinctive, not "more AI effects." -Remember: Bold design is confident design. It takes risks, makes statements, and creates memorable experiences. But bold without strategy is just loud. Be intentional, be dramatic, be unforgettable. \ No newline at end of file +Remember: Bold design is confident design. It takes risks, makes statements, and creates memorable experiences. But bold without strategy is just loud. Be intentional, be dramatic, be unforgettable. diff --git a/.agents/skills/impeccable/reference/brand.md b/.agents/skills/impeccable/reference/brand.md new file mode 100644 index 000000000..2f615ac43 --- /dev/null +++ b/.agents/skills/impeccable/reference/brand.md @@ -0,0 +1,104 @@ +# Brand register + +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. + +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. + +## The brand slop test + +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?" + +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. + +**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. + +## Typography + +### Font selection procedure + +Every project. Never skip. + +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. +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. +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." +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. + +### Reflex-reject list + +Training-data defaults. Ban list — look further: + +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 + +### Pairing and voice + +Distinctive + refined is the goal — the specific shape depends on the brand: + +- **Editorial / long-form / luxury**: display serif + sans body (a magazine shape). +- **Tech / dev tools / fintech**: one committed sans, usually; custom-tight tracking, strong weight contrast inside a single family. +- **Consumer / food / travel**: warmer pairings, often a humanist sans plus a script or display serif. +- **Creative studios / agencies**: rule-breaking welcome — mono-only, or display-only, or custom-drawn type as voice. + +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. + +Vary across projects. If the last brief was a serif-display landing page, this one isn't. + +### Scale + +Modular scale, fluid `clamp()` for headings, ≥1.25 ratio between steps. Flat scales (1.1× apart) read as uncommitted. + +Light text on dark backgrounds: add 0.05–0.1 to line-height. Light type reads as lighter weight and needs more breathing room. + +## Color + +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. + +- 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. +- Palette IS voice. A calm brand and a restless brand should not share palette mechanics. +- When the strategy is Committed or Drenched, the color is load-bearing. Don't hedge with neutrals around the edges — commit. +- Don't converge across projects. If the last brand surface was restrained-on-cream, this one is not. + +## Layout + +- Asymmetric compositions are one option. Break the grid intentionally for emphasis. +- Fluid spacing with `clamp()` that breathes on larger viewports. Vary for rhythm — generous separations, tight groupings. +- 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. +- 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. +- When cards ARE the right affordance, use `grid-template-columns: repeat(auto-fit, minmax(280px, 1fr))` — breakpoint-free responsiveness. + +## Imagery + +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. + +**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. + +- **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 `
` placeholders. +- **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". +- **One decisive photo beats five mediocre ones.** Hero imagery should commit to a mood; padding with more stock doesn't rescue an indecisive one. +- **Alt text is part of the voice.** "Coastal fettuccine, hand-cut, served on the terrace" beats "pasta dish". + +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. + +## Motion + +- 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. +- For collapsing/expanding sections, transition `grid-template-rows` rather than `height`. + +## Brand bans (on top of the shared absolute bans) + +- Monospace as lazy shorthand for "technical / developer." If the brand isn't technical, mono reads as costume. +- Large rounded-corner icons above every heading. Screams template. +- Single-family pages that picked the family by reflex, not voice. (A single family chosen deliberately is fine.) +- All-caps body copy. Reserve caps for short labels and headings. +- Timid palettes and average layouts. Safe = invisible. +- Zero imagery on a brief that implies imagery (restaurant, hotel, food, travel, fashion, photography, hobbyist). Colored blocks where a hero photo belongs. +- 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. + +## Brand permissions + +Brand can afford things product can't. Take them. + +- Ambitious first-load motion. Reveals, scroll-triggered transitions, typographic choreography. +- Single-purpose viewports. One dominant idea per fold, long scroll, deliberate pacing. +- Typographic risk. Enormous display type, unexpected italic cuts, mixed cases, hand-drawn headlines, a single oversize word as a hero. +- Unexpected color strategies. Palette IS voice — a calm brand and a restless brand should not share palette mechanics. +- Art direction per section. Different sections can have different visual worlds if the narrative demands it. Consistency of voice beats consistency of treatment. diff --git a/.pi/skills/clarify/SKILL.md b/.agents/skills/impeccable/reference/clarify.md similarity index 89% rename from .pi/skills/clarify/SKILL.md rename to .agents/skills/impeccable/reference/clarify.md index 468541090..dc116e745 100644 --- a/.pi/skills/clarify/SKILL.md +++ b/.agents/skills/impeccable/reference/clarify.md @@ -1,14 +1,7 @@ ---- -name: clarify -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. -version: 2.1.1 ---- +> **Additional context needed**: audience technical level and users' mental state in context. Identify and improve unclear, confusing, or poorly written interface text to make the product easier to understand and use. -## MANDATORY PREPARATION - -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. --- @@ -178,4 +171,4 @@ Test that copy improvements work: - **Consistency**: Does it match terminology elsewhere? - **Tone**: Is it appropriate for the situation? -Remember: You're a clarity expert with excellent communication skills. Write like you're explaining to a smart friend who's unfamiliar with the product. Be clear, be helpful, be human. \ No newline at end of file +Remember: You're a clarity expert with excellent communication skills. Write like you're explaining to a smart friend who's unfamiliar with the product. Be clear, be helpful, be human. diff --git a/.agents/skills/critique/reference/cognitive-load.md b/.agents/skills/impeccable/reference/cognitive-load.md similarity index 100% rename from .agents/skills/critique/reference/cognitive-load.md rename to .agents/skills/impeccable/reference/cognitive-load.md diff --git a/.agents/skills/colorize/SKILL.md b/.agents/skills/impeccable/reference/colorize.md similarity index 75% rename from .agents/skills/colorize/SKILL.md rename to .agents/skills/impeccable/reference/colorize.md index 76075804f..176185e69 100644 --- a/.agents/skills/colorize/SKILL.md +++ b/.agents/skills/impeccable/reference/colorize.md @@ -1,16 +1,14 @@ ---- -name: colorize -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. -version: 2.1.1 -user-invocable: true -argument-hint: "[target]" ---- +> **Additional context needed**: existing brand colors. Strategically introduce color to designs that are too monochromatic, gray, or lacking in visual warmth and personality. -## MANDATORY PREPARATION +--- -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. +## Register + +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. + +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. --- @@ -83,10 +81,13 @@ Add color systematically across these dimensions: - **Comparison**: Color coding for different datasets or timeframes ### Borders & Accents -- **Accent borders**: Add colored left/top borders to cards or sections +- **Hairline borders**: 1px colored borders on full perimeter (not side-stripes — see the absolute ban on `border-left/right > 1px`) - **Underlines**: Color underlines for emphasis or active states - **Dividers**: Subtle colored dividers instead of gray lines - **Focus rings**: Colored focus indicators matching brand +- **Surface tints**: A 4-8% background wash of the accent color instead of a stripe + +**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. ### Typography Color - **Colored headings**: Use brand colors for section headings (maintain contrast) @@ -140,4 +141,14 @@ Test that colorization improves the experience: - **Still accessible**: Do all color combinations meet WCAG standards? - **Not overwhelming**: Is color balanced and purposeful? -Remember: Color is emotional and powerful. Use it to create warmth, guide attention, communicate meaning, and express personality. But restraint and strategy matter more than saturation and variety. Be colorful, but be intentional. \ No newline at end of file +Remember: Color is emotional and powerful. Use it to create warmth, guide attention, communicate meaning, and express personality. But restraint and strategy matter more than saturation and variety. Be colorful, but be intentional. + +## Live-mode signature params + +When invoked from live mode, each variant MUST declare a `color-amount` param so the user can dial between a restrained accent and a drenched surface without regeneration. Author the variant's CSS against `var(--p-color-amount, 0.5)` — typically as the alpha multiplier on backgrounds, or as a scaling factor on the chroma axis in an OKLCH expression. 0 = neutral/monochrome, 1 = full saturation / dominant coverage. + +```json +{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"} +``` + +Layer 1-2 variant-specific params on top: palette selection (`steps` with named options), temperature warmth, or tint vs. true color. See `reference/live.md` for the full params contract. diff --git a/.agents/skills/impeccable/reference/craft.md b/.agents/skills/impeccable/reference/craft.md index 8cddbc9db..45249f46c 100644 --- a/.agents/skills/impeccable/reference/craft.md +++ b/.agents/skills/impeccable/reference/craft.md @@ -4,11 +4,11 @@ Build a feature with impeccable UX and UI quality through a structured process: ## Step 1: Shape the Design -Run /shape, passing along whatever feature description the user provided. +Run $impeccable shape, passing along whatever feature description the user provided. 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. -If the user has already run /shape and has a confirmed design brief, skip this step and use the existing brief. +If the user has already run $impeccable shape and has a confirmed design brief, skip this step and use the existing brief. ## Step 2: Load References @@ -24,7 +24,61 @@ Then add references based on the brief's needs: - Responsive requirements? Consult [responsive-design.md](responsive-design.md) - Heavy on copy, labels, or errors? Consult [ux-writing.md](ux-writing.md) -## Step 3: Build +## Step 3: North Star Mock (Capability-Gated) + +Before implementation, generate a small set of high-fidelity visual comps when all of these are true: + +- The work is **net-new** or visually open-ended enough that composition exploration will improve the build. +- The brief's scope is **mid-fi, high-fi, or production-ready**. +- 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. + +When those conditions are met, this step is the default for **both brand and product work**. + +### Purpose + +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. + +### What to generate + +Generate **1 to 3** high-fidelity north-star comps based on the confirmed brief. + +- For brand work, push visual identity, composition, and mood aggressively. +- For product work, still push hierarchy, topology, density, and tone, but keep the comps grounded in realistic product structure and states. + +The comps must be genuinely different in primary visual direction, not just color variants. + +### After generation + +Choose a direction with the user, or if the user delegated the decision, pick the strongest one and explain why. + +Before moving to implementation, summarize: + +- What to carry into code +- What **not** to literalize from the mock + +Treat the mock as a **north star**, not a screenshot to trace. Do **not** let it override the confirmed brief. + +## Step 4: Asset Extraction (Optional) + +If the chosen direction includes image-native visual ingredients that would materially improve the implementation, generate them as separate assets before building. + +Good candidates: + +- stickers +- badges +- seals +- tickets +- graphic labels +- textures +- abstract objects +- decorative marks +- non-semantic scene elements + +Do **not** export assets for core UI text, navigation, body copy, or any structure that should stay semantic and editable in code. + +Usually **1 to 5** extracted assets is enough. If the design can be built cleanly in HTML/CSS/SVG, prefer that over raster assets. + +## Step 5: Build Implement the feature following the design brief. Work in this order: @@ -40,9 +94,11 @@ Implement the feature following the design brief. Work in this order: - Test with real (or realistic) data at every step, not placeholder text - Check each state as you build it, not all at the end - If you discover a design question, stop and ask rather than guessing -- Every visual choice should trace back to something in the design brief +- Every visual choice should trace back to something in the design brief or the chosen north-star direction +- Keep text semantic, layout real, and interactions accessible. Do not turn the mock into a pile of rasterized UI +- If assets were extracted, use them intentionally. They support the build; they do not replace interface structure -## Step 4: Visual Iteration +## Step 6: Visual Iteration **This step is critical.** Do not stop after the first implementation pass. @@ -59,12 +115,12 @@ Iterate through these checks visually: 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." -## Step 5: Present +## Step 7: Present Present the result to the user: - Show the feature in its primary state - Walk through the key states (empty, error, responsive) -- Explain design decisions that connect back to the design brief +- Explain design decisions that connect back to the design brief and, when used, the chosen north-star mock - Ask: "What's working? What isn't?" Iterate based on feedback. Good design is rarely right on the first pass. diff --git a/.codex/skills/critique/SKILL.md b/.agents/skills/impeccable/reference/critique.md similarity index 78% rename from .codex/skills/critique/SKILL.md rename to .agents/skills/impeccable/reference/critique.md index 185049999..78fa241ce 100644 --- a/.codex/skills/critique/SKILL.md +++ b/.agents/skills/impeccable/reference/critique.md @@ -1,23 +1,12 @@ ---- -name: critique -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. -version: 2.1.1 -argument-hint: "[area (feature, page, component...)]" ---- +> **Additional context needed**: what the interface is trying to accomplish. -## STEPS +### Gather Assessments -### Step 1: Preparation +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. -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. +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. -### Step 2: Gather Assessments - -Launch two independent assessments. **Neither must see the other's output** to avoid bias. - -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. - -If sub-agents are not available in the current environment, complete each assessment sequentially, writing findings to internal notes before proceeding. +Fall back to sequential in-head work only if the environment genuinely cannot spawn sub-agents. **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. @@ -29,11 +18,11 @@ document.title = '[LLM] ' + document.title; ``` Think like a design director. Evaluate: -**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? +**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? **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). -**Cognitive Load** (consult [cognitive-load](reference/cognitive-load.md)): +**Cognitive Load** (consult [cognitive-load](cognitive-load.md)): - Run the 8-item cognitive load checklist. Report failure count: 0-1 = low (good), 2-3 = moderate, 4+ = critical. - Count visible options at each decision point. If >4, flag it. - Check for progressive disclosure: is complexity revealed only when needed? @@ -43,7 +32,7 @@ Think like a design director. Evaluate: - **Peak-end rule**: Is the most intense moment positive? Does the experience end well? - **Emotional valleys**: Check for anxiety spikes at high-stakes moments (payment, delete, commit). Are there design interventions (progress indicators, reassurance copy, undo options)? -**Nielsen's Heuristics** (consult [heuristics-scoring](reference/heuristics-scoring.md)): +**Nielsen's Heuristics** (consult [heuristics-scoring](heuristics-scoring.md)): Score each of the 10 heuristics 0-4. This scoring will be presented in the report. 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. @@ -63,7 +52,7 @@ npx impeccable --json [--fast] [target] - For 500+ files, narrow scope or ask the user - Exit code 0 = clean, 2 = findings -**Browser visualization** (when browser automation tools are available AND the target is a viewable page): +**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). 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. @@ -93,14 +82,14 @@ For multi-view targets, inject on 3-5 representative pages. If injection fails, Return: CLI findings (JSON), browser console findings (if applicable), and any false positives noted. -### Step 3: Generate Combined Critique Report +### Generate Combined Critique Report 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. Structure your feedback as a design director would: #### Design Health Score -> *Consult [heuristics-scoring](reference/heuristics-scoring.md)* +> *Consult [heuristics-scoring](heuristics-scoring.md)* Present the Nielsen's 10 heuristics scores as a table: @@ -139,14 +128,14 @@ Highlight 2-3 things done well. Be specific about why they work. #### Priority Issues The 3-5 most impactful design problems, ordered by importance. -For each issue, tag with **P0-P3 severity** (consult [heuristics-scoring](reference/heuristics-scoring.md) for severity definitions): +For each issue, tag with **P0-P3 severity** (consult [heuristics-scoring](heuristics-scoring.md) for severity definitions): - **[P?] What**: Name the problem clearly - **Why it matters**: How this hurts users or undermines goals - **Fix**: What to do about it (be concrete) -- **Suggested command**: Which command could address this (from: $animate, $quieter, $shape, $optimize, $adapt, $clarify, $layout, $distill, $delight, $audit, $harden, $polish, $bolder, $typeset, $critique, $colorize, $overdrive) +- **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) #### Persona Red Flags -> *Consult [personas](reference/personas.md)* +> *Consult [personas](personas.md)* 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. @@ -175,7 +164,7 @@ Provocative questions that might unlock better solutions: - Prioritize ruthlessly. If everything is important, nothing is. - Don't soften criticism. Developers need honest feedback to ship great design. -### Step 4: Ask the User +### Ask the User **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. @@ -183,7 +172,7 @@ Ask questions along these lines (adapt to the specific findings; do NOT ask gene 1. **Priority direction**: Based on the issues found, ask which category matters most to the user right now. For example: "I found problems with visual hierarchy, color usage, and information overload. Which area should we tackle first?" Offer the top 2-3 issue categories as options. -2. **Design intent**: If the critique found a tonal mismatch, ask whether it was intentional. For example: "The interface feels clinical and corporate. Is that the intended tone, or should it feel warmer$bolder/more playful?" Offer 2-3 tonal directions as options based on what would fix the issues found. +2. **Design intent**: If the critique found a tonal mismatch, ask whether it was intentional. For example: "The interface feels clinical and corporate. Is that the intended tone, or should it feel warmer/bolder/more playful?" Offer 2-3 tonal directions as options based on what would fix the issues found. 3. **Scope**: Ask how much the user wants to take on. For example: "I found N issues. Want to address everything, or focus on the top 3?" Offer scope options like "Top 3 only", "All issues", "Critical issues only". @@ -193,11 +182,11 @@ Ask questions along these lines (adapt to the specific findings; do NOT ask gene - Every question must reference specific findings from the report. Never ask generic "who is your audience?" questions. - Keep it to 2-4 questions maximum. Respect the user's time. - Offer concrete options, not open-ended prompts. -- If findings are straightforward (e.g., only 1-2 clear issues), skip questions and go directly to Step 5. +- If findings are straightforward (e.g., only 1-2 clear issues), skip questions and go directly to Recommended Actions. -### Step 5: Recommended Actions +### Recommended Actions -**After receiving the user's answers**, present a prioritized action summary reflecting the user's priorities and scope from Step 4. +**After receiving the user's answers**, present a prioritized action summary reflecting the user's priorities and scope from Ask the User. #### Action Summary @@ -208,17 +197,17 @@ List recommended commands in priority order, based on the user's answers: ... **Rules for recommendations**: -- Only recommend commands from: $animate, $quieter, $shape, $optimize, $adapt, $clarify, $layout, $distill, $delight, $audit, $harden, $polish, $bolder, $typeset, $critique, $colorize, $overdrive +- 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 - Order by the user's stated priorities first, then by impact - Each item's description should carry enough context that the command knows what to focus on - Map each Priority Issue to the appropriate command - Skip commands that would address zero issues - If the user chose a limited scope, only include items within that scope - If the user marked areas as off-limits, exclude commands that would touch those areas -- End with `$polish` as the final step if any fixes were recommended +- End with `$impeccable polish` as the final step if any fixes were recommended After presenting the summary, tell the user: > You can ask me to run these one at a time, all at once, or in any order you prefer. > -> Re-run `$critique` after fixes to see your score improve. \ No newline at end of file +> Re-run `$impeccable critique` after fixes to see your score improve. diff --git a/.kiro/skills/delight/SKILL.md b/.agents/skills/impeccable/reference/delight.md similarity index 92% rename from .kiro/skills/delight/SKILL.md rename to .agents/skills/impeccable/reference/delight.md index f323738dd..5112bef3c 100644 --- a/.kiro/skills/delight/SKILL.md +++ b/.agents/skills/impeccable/reference/delight.md @@ -1,14 +1,14 @@ ---- -name: delight -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. -version: 2.1.1 ---- +> **Additional context needed**: what's appropriate for the domain (playful vs professional vs quirky vs elegant). Identify opportunities to add moments of joy, personality, and unexpected polish that transform functional interfaces into delightful experiences. -## MANDATORY PREPARATION +--- -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). +## Register + +Brand: delight can be distributed — copy voice, section transitions, discovery rewards, seasonal touches, personality across the whole surface. + +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. --- @@ -299,4 +299,4 @@ Test that delight actually delights: - **Appropriate**: Matches brand and context - **Accessible**: Works with reduced motion, screen readers -Remember: Delight is the difference between a tool and an experience. Add personality, surprise users positively, and create moments worth sharing. But always respect usability - delight should enhance, never obstruct. \ No newline at end of file +Remember: Delight is the difference between a tool and an experience. Add personality, surprise users positively, and create moments worth sharing. But always respect usability - delight should enhance, never obstruct. diff --git a/.gemini/skills/distill/SKILL.md b/.agents/skills/impeccable/reference/distill.md similarity index 91% rename from .gemini/skills/distill/SKILL.md rename to .agents/skills/impeccable/reference/distill.md index e462d1c27..4f47dc0b4 100644 --- a/.gemini/skills/distill/SKILL.md +++ b/.agents/skills/impeccable/reference/distill.md @@ -1,14 +1,5 @@ ---- -name: distill -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. -version: 2.1.1 ---- - Remove unnecessary complexity from designs, revealing the essential elements and creating clarity through ruthless simplification. -## MANDATORY PREPARATION - -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. --- @@ -117,4 +108,4 @@ If you removed features or options: - Consider if they need alternative access points - Note any user feedback to monitor -Remember: You have great taste and judgment. Simplification is an act of confidence - knowing what to keep and courage to remove the rest. As Antoine de Saint-Exupéry said: "Perfection is achieved not when there is nothing more to add, but when there is nothing left to take away." \ No newline at end of file +Remember: You have great taste and judgment. Simplification is an act of confidence - knowing what to keep and courage to remove the rest. As Antoine de Saint-Exupéry said: "Perfection is achieved not when there is nothing more to add, but when there is nothing left to take away." diff --git a/.agents/skills/impeccable/reference/document.md b/.agents/skills/impeccable/reference/document.md new file mode 100644 index 000000000..572b7d879 --- /dev/null +++ b/.agents/skills/impeccable/reference/document.md @@ -0,0 +1,427 @@ +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. + +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.). + +## The frontmatter: token schema + +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. + +```yaml +--- +name: +description: +colors: + primary: "#b8422e" + neutral-bg: "#faf7f2" + # ...one entry per extracted color; key = descriptive slug +typography: + display: + fontFamily: "Cormorant Garamond, Georgia, serif" + fontSize: "clamp(2.5rem, 7vw, 4.5rem)" + fontWeight: 300 + lineHeight: 1 + letterSpacing: "normal" + body: + # ... +rounded: + sm: "4px" + md: "8px" +spacing: + sm: "8px" + md: "16px" +components: + button-primary: + backgroundColor: "{colors.primary}" + textColor: "{colors.neutral-bg}" + rounded: "{rounded.sm}" + padding: "16px 48px" + button-primary-hover: + backgroundColor: "{colors.primary-deep}" +--- +``` + +Rules that matter: + +- **Token refs** use `{path.to.token}` (e.g. `{colors.primary}`, `{rounded.md}`). Components may reference primitives; primitives may not reference each other. +- **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. +- **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). +- **Scale keys are open-ended.** Use whatever names the project already uses (`warm-ash-cream`, `surface-container-low`). Don't rename to Material defaults. +- **Variants are naming convention, not schema.** `button-primary` / `button-primary-hover` / `button-primary-active` as sibling keys. + +## The markdown body: six sections (exact order) + +1. `## Overview` +2. `## Colors` +3. `## Typography` +4. `## Elevation` +5. `## Components` +6. `## Do's and Don'ts` + +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. + +## When to run + +- The user just ran `$impeccable teach` and needs the visual side documented. +- The skill noticed no `DESIGN.md` exists and nudged the user to create one. +- An existing `DESIGN.md` is stale (the design has drifted). +- Before a large redesign, to capture the current state as a reference. + +If a `DESIGN.md` already exists, **do not silently overwrite it**. Show the user the existing file and ask the user directly to clarify what you cannot infer. whether to refresh, overwrite, or merge. + +## Two paths + +- **Scan mode** (default): the project has design tokens, components, or rendered output. Extract, then confirm descriptive language. Use when there's code to analyze. +- **Seed mode**: the project is pre-implementation (fresh teach, nothing built yet). Interview for five high-level answers, write a minimal DESIGN.md marked ``. Re-run in scan mode once there's code. + +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. + +## Scan mode (approach C: auto-extract, then confirm descriptive language) + +### Step 1: Find the design assets + +Search the codebase in priority order: + +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. +2. **Tailwind config** — if `tailwind.config.{js,ts,mjs}` exists, read the `theme.extend` block for colors, fontFamily, spacing, borderRadius, boxShadow. +3. **CSS-in-JS theme files** — styled-components, emotion, vanilla-extract, stitches: look for `theme.ts`, `tokens.ts`, or equivalent. +4. **Design token files** — `tokens.json`, `design-tokens.json`, Style Dictionary output, W3C token community group format. +5. **Component library** — scan the main button, card, input, navigation, dialog components. Note their variant APIs and default styles. +6. **Global stylesheet** — the root CSS file usually has the base typography and color assignments. +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. + +### Step 2: Auto-extract what can be auto-extracted + +Build a structured draft from the discovered tokens. For each token class: + +- **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. +- **Typography**: Map observed sizes and weights to the Material hierarchy (display / headline / title / body / label). Note font-family stacks and the scale ratio. +- **Elevation**: Catalogue the shadow vocabulary. If the project is flat and uses tonal layering instead, that's a valid answer — state it explicitly. +- **Components**: For each common component (button, card, input, chip, list item, tooltip, nav), extract shape (radius), color assignment, hover/focus treatment, internal padding. +- **Spacing + layout**: Fold into Overview or relevant Components. The spec does NOT have a Layout section. + +### Step 2b: Stage the frontmatter + +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. + +- **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. +- **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`). +- **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). +- **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. + +Skip anything the project doesn't have. Empty scale keys or fabricated tokens pollute the spec. + +### Step 3: Ask the user for qualitative language + +The following require creative input that cannot be auto-extracted. Group them into one `AskUserQuestion` interaction: + +- **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. +- **Overview voice**: mood adjectives, aesthetic philosophy in 2-3 sentences, anti-references (what the system should not feel like). +- **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. +- **Elevation philosophy**: flat/layered/lifted. If shadows exist, is their role ambient or structural? +- **Component philosophy**: the feel of buttons, cards, inputs in one phrase ("tactile and confident" vs. "refined and restrained"). + +Quote a line from PRODUCT.md when possible so the user sees their own strategic language carry forward. + +### Step 4: Write DESIGN.md + +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. + +```markdown +--- +name: [Project Title] +description: [one-line tagline] +colors: + # ... staged frontmatter from Step 2b +--- + +# Design System: [Project Title] + +## 1. Overview + +**Creative North Star: "[Named metaphor in quotes]"** + +[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.] + +## 2. Colors + +[Describe the palette character in one sentence.] + +### Primary +- **[Descriptive Name]** (#HEX / oklch(...)): [Where and why this color is used. Be specific about context, not just role.] + +### Secondary (optional — omit if the project has only one accent) +- **[Descriptive Name]** (#HEX): [Role.] + +### Tertiary (optional) +- **[Descriptive Name]** (#HEX): [Role.] + +### Neutral +- **[Descriptive Name]** (#HEX): [Text / background / border / divider role.] +- [...] + +### Named Rules (optional, powerful) +**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."] + +## 3. Typography + +**Display Font:** [Family] (with [fallback]) +**Body Font:** [Family] (with [fallback]) +**Label/Mono Font:** [Family, if distinct] + +**Character:** [1-2 sentence personality description of the pairing.] + +### Hierarchy +- **Display** ([weight], [size/clamp], [line-height]): [Purpose — where it appears.] +- **Headline** ([weight], [size], [line-height]): [Purpose.] +- **Title** ([weight], [size], [line-height]): [Purpose.] +- **Body** ([weight], [size], [line-height]): [Purpose. Include max line length like 65–75ch if relevant.] +- **Label** ([weight], [size], [letter-spacing], [case if uppercase]): [Purpose.] + +### Named Rules (optional) +**The [Rule Name] Rule.** [Short doctrine about type use.] + +## 4. Elevation + +[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.] + +### Shadow Vocabulary (if applicable) +- **[Role name]** (`box-shadow: [exact value]`): [When to use it.] +- [...] + +### Named Rules (optional) +**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)."] + +## 5. Components + +For each component, lead with a short character line, then specify shape, color assignment, states, and any distinctive behavior. + +### Buttons +- **Shape:** [radius described, exact value in parens] +- **Primary:** [color assignment + padding, in semantic + exact terms] +- **Hover / Focus:** [transitions, treatments] +- **Secondary / Ghost / Tertiary (if applicable):** [brief description] + +### Chips (if used) +- **Style:** [background, text color, border treatment] +- **State:** [selected / unselected, filter / action variants] + +### Cards / Containers +- **Corner Style:** [radius] +- **Background:** [colors used] +- **Shadow Strategy:** [reference Elevation section] +- **Border:** [if any] +- **Internal Padding:** [scale] + +### Inputs / Fields +- **Style:** [stroke, background, radius] +- **Focus:** [treatment — glow, border shift, etc.] +- **Error / Disabled:** [if applicable] + +### Navigation +- **Style, typography, default/hover/active states, mobile treatment.** + +### [Signature Component] (optional — if the project has a distinctive custom component worth documenting) +[Description.] + +## 6. Do's and Don'ts + +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. + +### Do: +- **Do** [specific prescription with exact values / named rule]. +- **Do** [...] + +### Don't: +- **Don't** [specific prohibition — e.g. "use border-left greater than 1px as a colored stripe"]. +- **Don't** [...] +- **Don't** [...] +``` + +### Step 4b: Write DESIGN.json sidecar (extensions only) + +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. + +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. + +#### Schema + +```json +{ + "schemaVersion": 2, + "generatedAt": "ISO-8601 string", + "title": "Design System: [Project Title]", + "extensions": { + "colorMeta": { + "primary": { "role": "primary", "displayName": "Editorial Magenta", "canonical": "oklch(60% 0.25 350)", "tonalRamp": ["...", "...", "..."] }, + "warm-ash-cream": { "role": "neutral", "displayName": "Warm Ash Cream", "canonical": "oklch(96% 0.005 350)", "tonalRamp": ["...", "...", "..."] } + }, + "typographyMeta": { + "display": { "displayName": "Display", "purpose": "Hero headlines only." } + }, + "shadows": [ + { "name": "ambient-low", "value": "0 4px 24px rgba(0,0,0,0.12)", "purpose": "Diffuse hover glow under accent elements." } + ], + "motion": [ + { "name": "ease-standard", "value": "cubic-bezier(0.4, 0, 0.2, 1)", "purpose": "Default easing for state transitions." } + ], + "breakpoints": [ + { "name": "sm", "value": "640px" } + ] + }, + "components": [ + { + "name": "Primary Button", + "kind": "button | input | nav | chip | card | custom", + "refersTo": "button-primary", + "description": "One-line what and when.", + "html": "", + "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); }" + } + ], + "narrative": { + "northStar": "The Editorial Sanctuary", + "overview": "2-3 paragraphs of the philosophy — pulled from DESIGN.md Overview section.", + "keyCharacteristics": ["...", "..."], + "rules": [{ "name": "The One Voice Rule", "body": "...", "section": "colors|typography|elevation" }], + "dos": ["Do use ..."], + "donts": ["Don't use ..."] + } +} +``` + +**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.`, `typographyMeta.`). Components still carry full HTML/CSS because Stitch's 8-prop set can't hold them. + +#### Component translation rules + +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. + +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. +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. +3. **Icons.** Inline as SVG. Do not reference Lucide/Heroicons packages, icon fonts, or ``. A typical icon is 16-24px; copy the SVG path data directly. +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. +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. +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. + +#### What to include + +Aim for a tight set of **5-10 components** that best represent the visual system: + +- **Canonical primitives (always include if the project has them):** button (each variant as a separate component entry), input/text field, navigation, chip/tag, card. +- **Signature components (include if distinctive):** hero CTA, featured card, filter pill, any custom pattern the user mentioned as important in PRODUCT.md. +- **Skip the rest.** Utility components, form building blocks, wrapper layouts — not worth documenting unless visually distinctive. + +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. + +#### Tonal ramps + +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. + +#### Narrative mapping + +Pull directly from the DESIGN.md you just wrote: + +- `narrative.northStar` → the `**Creative North Star: "..."**` line from Overview +- `narrative.overview` → the philosophy paragraphs from Overview +- `narrative.keyCharacteristics` → the bulleted `**Key Characteristics:**` list +- `narrative.rules` → every `**The [Name] Rule.** [body]` across all sections, tagged with `section` +- `narrative.dos` / `narrative.donts` → the bullet lists from Do's and Don'ts verbatim + +Do not reword. The panel shows these as secondary collapsible context; the same voice that's in the Markdown carries through. + +### Step 5: Confirm, refine, and refresh session cache + +1. Show the user the full DESIGN.md you wrote. Briefly highlight the non-obvious creative choices (descriptive color names, atmosphere language, named rules). +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. +3. Offer to refine any section: "Want me to revise a section, add component patterns I missed, or adjust the atmosphere language?" +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. + +## Seed mode + +For projects with no visual system to extract yet. Produces a minimal scaffold, not a full spec. + +### Step 1: Confirm seed mode + +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?" + +If the user prefers to skip, stop. No file. + +### Step 2: Five questions + +Group into one `AskUserQuestion` interaction. Options must be concrete. + +1. **Color strategy.** Pick one: + - Restrained — tinted neutrals + one accent ≤10% + - Committed — one saturated color carries 30–60% of the surface + - Full palette — 3–4 named color roles, each deliberate + - Drenched — the surface IS the color + + Then: one hue family or anchor reference ("deep teal", "mustard", "Klim #ff4500 orange"). + +2. **Typography direction.** Pick one (specific fonts come later): + - Serif display + sans body + - Single sans (warm / technical / geometric / humanist — pick a feel) + - Display + mono + - Mono-forward + - Editorial script + sans + +3. **Motion energy.** Pick one: + - Restrained — state changes only + - Responsive — feedback + transitions, no choreography + - Choreographed — orchestrated entrances, scroll-driven sequences + +4. **Three named references.** Brands, products, printed objects. Not adjectives. + +5. **One anti-reference.** What it should NOT feel like. Also named. + +### Step 3: Write seed DESIGN.md + +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. + +Lead the file with: + +```markdown + +``` + +Per-section guidance in seed mode: + +- **Overview**: Creative North Star and philosophy phrased from the answers (color strategy + motion energy + references). Reference the user's anti-reference directly. +- **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]`. +- **Typography**: the direction the user picked (e.g. "Serif display + sans body"). No font names yet — `[font pairing to be chosen at implementation]`. +- **Elevation**: inferred from motion energy. Restrained/Responsive → flat by default; Choreographed → layered. One sentence. +- **Components**: omit entirely — no components exist yet. +- **Do's and Don'ts**: carry PRODUCT.md's anti-references directly plus the anti-reference named in Q5. + +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. + +### Step 4: Confirm and refresh session cache + +1. Show the seed DESIGN.md. Call out that it is a seed (the marker is the literal commitment). +2. Tell the user: "Re-run `$impeccable document` once you have some code. That pass will extract real tokens and generate the sidecar." +3. Run `node .agents/skills/impeccable/scripts/load-context.mjs` once so the seed lands in conversation for the rest of the session. + +## Style guidelines + +- **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. +- **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. +- **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). +- **Descriptive > technical**: "Gently curved edges (8px radius)" > "rounded-lg". Include the technical value in parens, lead with the description. +- **Functional > decorative**: for each token, explain WHERE and WHY it's used, not just WHAT it is. +- **Exact values in parens**: hex codes, px/rem values, font weights — always the number in parens alongside the description. +- **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. +- **Be forceful**. The voice of a design director. "Prohibited", "forbidden", "never", "always" — not "consider", "might", "prefer". Match PRODUCT.md's tone. +- **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. +- **Reference PRODUCT.md**. The anti-references section of PRODUCT.md should directly inform the Do's and Don'ts section here. Quote or paraphrase. +- **Group colors by role**, not by hex-order or hue-order. Primary / Secondary / Tertiary / Neutral is the spec ordering. + +## Pitfalls + +- Don't paste raw CSS class names. Translate to descriptive language. +- Don't extract every token. Stop at what's actually reused — one-offs pollute the system. +- Don't invent components that don't exist. If the project only has buttons and cards, only document those. +- Don't overwrite an existing DESIGN.md without asking. +- Don't duplicate content from PRODUCT.md. DESIGN.md is strictly visual. +- 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. +- Don't rename sections even slightly. "Colors" not "Color Palette & Roles". "Typography" not "Typography Rules". Tooling parsing depends on exact headers. +- 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. +- 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`. diff --git a/.pi/skills/harden/SKILL.md b/.agents/skills/impeccable/reference/harden.md similarity index 81% rename from .pi/skills/harden/SKILL.md rename to .agents/skills/impeccable/reference/harden.md index 78eaa9881..a27c669a0 100644 --- a/.pi/skills/harden/SKILL.md +++ b/.agents/skills/impeccable/reference/harden.md @@ -1,9 +1,3 @@ ---- -name: harden -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. -version: 2.1.1 ---- - Strengthen interfaces against edge cases, errors, internationalization issues, and real-world usage scenarios that break idealized designs. ## Assess Hardening Needs @@ -223,40 +217,6 @@ t('items', { count }) // Handles complex plural rules - Feature detection (not browser detection) - Test in target browsers -### Onboarding & First-Run Experience - -Production-ready features work for first-time users, not just power users. Design the paths that get new users to value: - -**Empty states**: Every zero-data screen needs: -- What will appear here (description or illustration) -- Why it matters to the user -- Clear CTA to create the first item or start from a template -- Visual interest (not just blank space with "No items yet") - -Empty state types to handle: -- **First use**: emphasize value, provide templates -- **User cleared**: light touch, easy to recreate -- **No results**: suggest a different query, offer to clear filters -- **No permissions**: explain why, how to get access - -**First-run experience**: Get users to their "aha moment" as quickly as possible. -- Show, don't tell -- working examples over descriptions -- Progressive disclosure -- teach one thing at a time, not everything upfront -- Make onboarding optional -- let experienced users skip -- Provide smart defaults so required setup is minimal - -**Feature discovery**: Teach features when users need them, not upfront. -- Contextual tooltips at point of use (brief, dismissable, one-time) -- Badges or indicators on new or unused features -- Celebrate activation events quietly (a toast, not a modal) - -**NEVER**: -- Force long onboarding before users can touch the product -- Show the same tooltip repeatedly (track and respect dismissals) -- Block the entire UI during a guided tour -- Create separate tutorial modes disconnected from the real product -- Design empty states that just say "No items" with no next action - ### Input Validation & Sanitization **Client-side validation**: @@ -384,4 +344,4 @@ Test thoroughly with edge cases: - **Errors**: Force API errors, test all error states - **Empty**: Remove all data, test empty states -Remember: You're hardening for production reality, not demo perfection. Expect users to input weird data, lose connection mid-flow, and use your product in unexpected ways. Build resilience into every component. \ No newline at end of file +Remember: You're hardening for production reality, not demo perfection. Expect users to input weird data, lose connection mid-flow, and use your product in unexpected ways. Build resilience into every component. diff --git a/.agents/skills/critique/reference/heuristics-scoring.md b/.agents/skills/impeccable/reference/heuristics-scoring.md similarity index 100% rename from .agents/skills/critique/reference/heuristics-scoring.md rename to .agents/skills/impeccable/reference/heuristics-scoring.md diff --git a/source/skills/layout/SKILL.md b/.agents/skills/impeccable/reference/layout.md similarity index 82% rename from source/skills/layout/SKILL.md rename to .agents/skills/impeccable/reference/layout.md index 747fb66be..5e8022081 100644 --- a/source/skills/layout/SKILL.md +++ b/.agents/skills/impeccable/reference/layout.md @@ -1,15 +1,12 @@ ---- -name: layout -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." -argument-hint: "[target]" -user-invocable: true ---- - Assess and improve layout and spacing that feels monotonous, crowded, or structurally weak — turning generic arrangements into intentional, rhythmic compositions. -## MANDATORY PREPARATION +--- -Invoke {{command_prefix}}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 {{command_prefix}}impeccable teach first. +## Register + +Brand: asymmetric compositions, fluid spacing with `clamp()`, intentional grid-breaking for emphasis. Rhythm through contrast — tight groupings paired with generous separations. + +Product: predictable grids, consistent densities, familiar navigation patterns. Responsive behavior is structural (collapse sidebar, responsive table), not fluid typography. Consistency IS an affordance. --- @@ -46,7 +43,7 @@ Analyze what's weak about the current spatial design: ## Plan Layout Improvements -Consult the [spatial design reference](reference/spatial-design.md) from the impeccable skill for detailed guidance on grids, rhythm, and container queries. +Consult the [spatial design reference](spatial-design.md) for detailed guidance on grids, rhythm, and container queries. Create a systematic plan: @@ -122,3 +119,23 @@ Create a systematic plan: - **Responsiveness**: Does the layout adapt gracefully across screen sizes? Remember: Space is the most underused design tool. A layout with the right rhythm and hierarchy can make even simple content feel polished and intentional. + +## Live-mode signature params + +Each variant MUST declare a `density` param. Drive all spacing tokens in the variant's scoped CSS through `calc(var(--p-density, 1) * )` — paddings, gaps, column widths. Users slide from airy to packed and see layout re-breathe with no regeneration. + +```json +{"id":"density","kind":"range","min":0.6,"max":1.4,"step":0.05,"default":1,"label":"Density"} +``` + +For variants whose topology genuinely changes (stacked vs. side-by-side, grid vs. bento), use a `steps` param whose scoped CSS branches via `:scope[data-p-structure="X"]`. One structure param + one density param is a powerful combo; resist adding a third. + +```json +{"id":"structure","kind":"steps","default":"grid","label":"Structure","options":[ + {"value":"stacked","label":"Stacked"}, + {"value":"grid","label":"Grid"}, + {"value":"bento","label":"Bento"} +]} +``` + +See `reference/live.md` for the full params contract. diff --git a/.agents/skills/impeccable/reference/live.md b/.agents/skills/impeccable/reference/live.md new file mode 100644 index 000000000..dedb9df54 --- /dev/null +++ b/.agents/skills/impeccable/reference/live.md @@ -0,0 +1,513 @@ +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. + +## Prerequisites + +A running dev server with hot module replacement (Vite, Next.js, Bun, etc.), OR a static HTML file open in the browser. + +## The contract (read once) + +Execute in order. No step skipped, no step reordered. + +1. `live.mjs` — boot. +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. +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=`. +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. +5. On `accept` / `discard` — the poll script already cleaned up; just poll again. +6. On `exit` — run the cleanup at the bottom. + +Harness policy: +- **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. +- **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. +- **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. +- **Other harnesses**: foreground unless you know stdout reliably returns to this session. + +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. + +## Start + +```bash +node .agents/skills/impeccable/scripts/live.mjs +``` + +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. + +`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). + +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. + +## Poll loop + +``` +LOOP: + node .agents/skills/impeccable/scripts/live-poll.mjs # default long timeout; no --timeout= + Read JSON; dispatch on "type" + + "generate" → Handle Generate; reply done; LOOP + "accept" → Handle Accept; LOOP + "discard" → Handle Discard; LOOP + "prefetch" → Handle Prefetch; LOOP + "timeout" → LOOP + "exit" → break → Cleanup +``` + +## Handle `generate` + +Event: `{id, action, freeformPrompt?, count, pageUrl, element, screenshotPath?, comments?, strokes?}`. + +Speed matters — the user is watching a spinner. Minimize tool calls by using the `wrap` helper and writing all variants in a single edit. + +### 1. Read the screenshot (if present) + +`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. + +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. + +`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). + +Reading annotations precisely: + +- **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. +- **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. +- **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." +- **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. + +### 2. Wrap the element + +```bash +node .agents/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" +``` + +Flag mapping — keep them separate, don't collapse into `--query`: + +- `--element-id` ← `event.element.id` +- `--classes` ← `event.element.classes` joined with commas +- `--tag` ← `event.element.tagName` + +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. + +Output on success: `{ file, insertLine, commentSyntax }`. + +**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: + +- `{ error: "file_is_generated", file, hint }` — user-supplied `--file` points at a generated file. +- `{ error: "element_not_in_source", generatedMatch, hint }` — element exists only in a generated file (the next build would wipe any edits). +- `{ error: "element_not_found", hint }` — element isn't in any project file; likely runtime-injected (JS component, data-driven render). + +All three carry `fallback: "agent-driven"`. Follow **Handle fallback** below. + +### 3. Load the action's reference + +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. + +Any other `event.action` (`bolder`, `quieter`, `distill`, `polish`, `typeset`, `colorize`, `layout`, `adapt`, `animate`, `delight`, `overdrive`): Read `reference/.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. + +### 4. Plan three genuinely distinct directions + +Before writing a single line of code, name each variant. + +**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: + +- *Broadsheet masthead with rule-divided columns* (think NYT print edition) +- *Klim Type Foundry specimen page* (dense, technical, catalog-driven) +- *Japanese print-poster minimalism with a single oversize glyph* +- *Bloomberg Terminal status bar* +- *Condé Nast Traveler feature layout* + +Then commit each variant to a different **primary axis** of difference: + +1. **Hierarchy** — which element commands the eye? +2. **Layout topology** — stacked / side-by-side / grid / asymmetric / overlay +3. **Typographic system** — pairing, scale ratio, case/weight strategy +4. **Color strategy** — Restrained / Committed / Full palette / Drenched +5. **Density** — minimal / comfortable / dense +6. **Structural decomposition** — merge, split, progressive disclosure + +Three variants → three DIFFERENT primary axes, not three riffs on color. + +**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. + +**The squint test (before writing code).** Write the three one-sentence descriptions side by side: + +> V1: Broadsheet masthead, ruled columns, 24px ink on cream. +> V2: Enormous italic title, catalog spec rows, heavy monospace data. +> V3: Card-framed poster with one oversize glyph, magenta veil. + +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. + +**For action-specific invocations**, each variant must vary along the dimension the action names: + +- `bolder` — amplify a different dimension per variant (scale / saturation / structural change). Not three "slightly bigger" variants. +- `quieter` — pull back a different dimension (color / ornament / spacing). +- `distill` — remove a different class of excess (visual noise / redundant content / nested structure). +- `polish` — target a different refinement axis (rhythm / hierarchy / micro-details like corner radii, focus states, optical kerning). +- `typeset` — different type pairing AND different scale ratio each. Not three riffs on one pairing. +- `colorize` — different hue family each (not shades of one hue). Vary chroma and contrast strategy. +- `layout` — different structural arrangement (stacked / side-by-side / grid / asymmetric). Not spacing tweaks. +- `adapt` — different target context per variant (mobile-first / tablet / desktop / print or low-data). Don't make three mobile layouts. +- `animate` — different motion vocabulary (cascade stagger / clip wipe / scale-and-focus / morph / parallax). Not three staggered fades. +- `delight` — different flavor of personality (unexpected micro-interaction / typographic surprise / illustrated accent / sonic-or-haptic moment / easter-egg interaction). +- `overdrive` — different convention broken (scale / structure / motion / input model / state transitions). Skip `overdrive.md`'s "propose and ask" step — live mode is non-interactive. + +### 5. Apply the freeform prompt (if present) + +`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. + +### 6. Write all variants in a single edit + +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`). + +Write CSS + all variants in ONE edit at the `insertLine` reported by `wrap`. Colocate scoped CSS as a ` +
+ +
+
+ +
+
+ +
+``` + +**Each variant div contains exactly one top-level element — the full replacement for the original.** Use the same tag as the original (e.g. `
` if the user picked a `
`). 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. + +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 `'); + if (paramValues && Object.keys(paramValues).length > 0) { + // Preserve the user's knob positions for the carbonize-cleanup agent + // to bake into the final CSS when it collapses scoped rules. + replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close); + } + replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close); + } + + // Keep the `@scope ([data-impeccable-variant="N"])` selectors in the + // carbonize CSS block working visually by re-wrapping the accepted content + // in a data-impeccable-variant="N" div with `display: contents` (so layout + // isn't affected). The carbonize agent strips this attribute + wrapper when + // it moves the CSS to a proper stylesheet. + if (cssContent) { + replacement.push(indent + '
'); + replacement.push(...restored); + replacement.push(indent + '
'); + } else { + replacement.push(...restored); + } + + const newLines = [ + ...lines.slice(0, block.start), + ...replacement, + ...lines.slice(block.end + 1), + ]; + fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8'); + + return { carbonize: needsCarbonize }; +} + +// --------------------------------------------------------------------------- +// Parsing helpers +// --------------------------------------------------------------------------- + +/** + * Find the start/end marker lines for a session. + * Returns { start, end } (0-indexed line numbers) or null. + */ +function findMarkerBlock(id, lines) { + let start = -1; + let end = -1; + const startPattern = 'impeccable-variants-start ' + id; + const endPattern = 'impeccable-variants-end ' + id; + + for (let i = 0; i < lines.length; i++) { + if (start === -1 && lines[i].includes(startPattern)) start = i; + if (lines[i].includes(endPattern)) { end = i; break; } + } + + return (start !== -1 && end !== -1) ? { start, end } : null; +} + +/** + * Join wrapper lines into a single string with `` to close on) + * - Same-line `` blocks + * - Multi-line `` blocks + */ +function stripStyleAndJoin(lines, block) { + const out = []; + let inStyle = false; + for (let i = block.start; i <= block.end; i++) { + let line = lines[i]; + + if (!inStyle) { + // Strip any complete . + const closeIdx = line.search(/<\/style\s*>/); + if (closeIdx !== -1) { + inStyle = false; + out.push(line.slice(closeIdx).replace(/<\/style\s*>/, '')); + } + // else: skip line entirely + } + } + return out.join('\n'); +} + +/** + * Find the inner content of `` inside `text`, + * handling nested same-tag elements via depth counting. `attrMatch` is a + * regex source fragment that must appear inside the opener tag. + * Returns the inner string (may be empty), or null if not found. + */ +function extractInnerByAttr(text, attrMatch) { + const openerRe = new RegExp('<([A-Za-z][A-Za-z0-9]*)\\b[^>]*' + attrMatch + '[^>]*>'); + const openMatch = text.match(openerRe); + if (!openMatch) return null; + + const tagName = openMatch[1]; + const innerStart = openMatch.index + openMatch[0].length; + + // Match any opener or closer of this tag name after innerStart. + // (Does not match self-closing , which doesn't contribute to depth.) + const tagRe = new RegExp('<(?:/)?' + tagName + '\\b[^>]*>', 'g'); + tagRe.lastIndex = innerStart; + + let depth = 1; + let m; + while ((m = tagRe.exec(text))) { + const isClose = m[0].startsWith('$/.test(m[0]); + if (isClose) { + depth--; + if (depth === 0) return text.slice(innerStart, m.index); + } else if (!isSelfClose) { + depth++; + } + } + return null; +} + +/** + * Extract the original element content from within the variant wrapper. + * Returns an array of lines. + */ +function extractOriginal(lines, block) { + const text = stripStyleAndJoin(lines, block); + const inner = extractInnerByAttr(text, 'data-impeccable-variant="original"'); + if (inner === null) return []; + return inner.split('\n'); +} + +/** + * Extract a specific variant's inner content (stripping the wrapper div). + * Returns an array of lines, or null if not found. + */ +function extractVariant(lines, block, variantNum) { + const text = stripStyleAndJoin(lines, block); + const inner = extractInnerByAttr(text, 'data-impeccable-variant="' + variantNum + '"'); + if (inner === null) return null; + const result = inner.split('\n'); + // Collapse a lone empty leading/trailing line (common after string splice). + while (result.length > 1 && result[0].trim() === '') result.shift(); + while (result.length > 1 && result[result.length - 1].trim() === '') result.pop(); + return result.length > 0 ? result : null; +} + +/** + * Extract the colocated ` — return the inner content. + * 3. Multi-line: `` on a later line — return + * the lines between them. + */ +function extractCss(lines, block, id) { + const styleAttr = 'data-impeccable-css="' + id + '"'; + let inStyle = false; + const content = []; + + for (let i = block.start; i <= block.end; i++) { + const line = lines[i]; + + if (!inStyle && line.includes(styleAttr)) { + // Self-closing: nothing to carbonize. + if (/]*\/\s*>/.test(line)) return null; + // Same-line open + close: extract inner text. + const sameLine = line.match(/]*>([\s\S]*?)<\/style\s*>/); + if (sameLine) { + const inner = sameLine[1]; + return inner.length > 0 ? inner.split('\n') : null; + } + inStyle = true; + continue; // skip the ')) break; + content.push(line); + } + } + + return content.length > 0 ? content : null; +} + +/** + * De-indent content that was indented by live-wrap.mjs. + * The wrap script adds `indent + ' '` (4 extra spaces) to each line. + * We restore to just `indent` level. + */ +function deindentContent(contentLines, baseIndent) { + // Find the minimum indentation in the content to determine how much was added + let minIndent = Infinity; + for (const line of contentLines) { + if (line.trim() === '') continue; + const leadingSpaces = line.match(/^(\s*)/)[1].length; + minIndent = Math.min(minIndent, leadingSpaces); + } + if (minIndent === Infinity) minIndent = 0; + + // Strip the extra indentation and re-add base indent + return contentLines.map(line => { + if (line.trim() === '') return ''; + return baseIndent + line.slice(minIndent); + }); +} + +function detectCommentSyntax(filePath) { + const ext = path.extname(filePath).toLowerCase(); + if (ext === '.jsx' || ext === '.tsx') { + return { open: '{/*', close: '*/}' }; + } + return { open: '' }; +} + +// --------------------------------------------------------------------------- +// File search (find the file containing session markers) +// --------------------------------------------------------------------------- + +function findSessionFile(id, cwd) { + const marker = 'impeccable-variants-start ' + id; + const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.']; + const seen = new Set(); + + for (const dir of searchDirs) { + const absDir = path.join(cwd, dir); + if (!fs.existsSync(absDir)) continue; + const result = searchDir(absDir, marker, seen, 0); + if (result) { + const content = fs.readFileSync(result, 'utf-8'); + return { file: result, content, lines: content.split('\n') }; + } + } + return null; +} + +function searchDir(dir, query, seen, depth) { + if (depth > 5) return null; + let realDir; + try { realDir = fs.realpathSync(dir); } catch { return null; } + if (seen.has(realDir)) return null; + seen.add(realDir); + + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { return null; } + + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue; + const filePath = path.join(dir, entry.name); + try { + const content = fs.readFileSync(filePath, 'utf-8'); + if (content.includes(query)) return filePath; + } catch { /* skip */ } + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (['node_modules', '.git', 'dist', 'build'].includes(entry.name)) continue; + const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1); + if (result) return result; + } + + return null; +} + +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- + +function argVal(args, flag) { + const idx = args.indexOf(flag); + return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null; +} + +// Auto-execute when run directly +const _running = process.argv[1]; +if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs/')) { + acceptCli(); +} + +export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax }; diff --git a/.agents/skills/impeccable/scripts/live-browser.js b/.agents/skills/impeccable/scripts/live-browser.js new file mode 100644 index 000000000..65e779628 --- /dev/null +++ b/.agents/skills/impeccable/scripts/live-browser.js @@ -0,0 +1,4590 @@ +/** + * Impeccable Live Variant Mode — Browser Script + * + * Injected into the user's page via \n' + + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' + ); +} + +function insertTag(content, config, port) { + const block = buildTagBlock(config.commentSyntax, port); + // insertBefore: match the LAST occurrence. Anchors like `` naturally + // belong at the end, and the same literal can appear earlier in code blocks + // within rendered documentation pages. + if (config.insertBefore) { + const idx = content.lastIndexOf(config.insertBefore); + if (idx === -1) return content; + return content.slice(0, idx) + block + content.slice(idx); + } + // insertAfter: match the FIRST occurrence — typical anchors like `` or + // `` open near the top of the document. + const idx = content.indexOf(config.insertAfter); + if (idx === -1) return content; + const after = idx + config.insertAfter.length; + // Preserve a single trailing newline if the anchor didn't end with one + const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; + return prefix + block + content.slice(prefix.length); +} + +/** + * Remove the live script block. Matches either HTML or JSX comment markers + * regardless of config (so stale tags from a wrong config can still be cleaned). + * + * Indent-preserving: captures any whitespace immediately preceding the opener + * marker and re-emits it in place of the removed block. `insertTag` inserted + * the block *after* the original line's indent and *before* the anchor (e.g. + * ``), which moved the indent onto the opener line and left the anchor + * unindented. Replacing the whole block (plus its trailing newline) with just + * the captured indent hands the indent back to the anchor that follows. + */ +function removeTag(content, _syntax) { + const patterns = [ + /([ \t]*)[\s\S]*?[ \t]*\n/, + /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}[ \t]*\n/, + ]; + for (const pat of patterns) { + const next = content.replace(pat, '$1'); + if (next !== content) return next; + } + return content; +} + +// --------------------------------------------------------------------------- +// Auto-execute +// --------------------------------------------------------------------------- + +const _running = process.argv[1]; +if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) { + injectCli(); +} + +export { insertTag, removeTag, validateConfig, buildTagBlock }; diff --git a/.agents/skills/impeccable/scripts/live-poll.mjs b/.agents/skills/impeccable/scripts/live-poll.mjs new file mode 100644 index 000000000..5cece1a43 --- /dev/null +++ b/.agents/skills/impeccable/scripts/live-poll.mjs @@ -0,0 +1,187 @@ +/** + * CLI client for the live variant mode poll/reply protocol. + * + * Usage: + * npx impeccable poll # Block until browser event, print JSON + * npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly + * npx impeccable poll --reply done # Reply "done" to event + * npx impeccable poll --reply error "msg" # Reply with error + */ + +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { fileURLToPath } from 'node:url'; + +// Node's built-in fetch (undici under the hood) enforces a 300s headers +// timeout that can't be lowered per-request. We cap each request below +// that ceiling and loop in `pollOnce` to synthesize a long poll without +// depending on the standalone undici package. +const PER_REQUEST_TIMEOUT_MS = 270_000; + +const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); + +function readServerInfo() { + try { + return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + } catch { + console.error('No running live server found. Start one with: npx impeccable live'); + process.exit(1); + } +} + +export async function pollCli() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log(`Usage: impeccable poll [options] + +Wait for a browser event from the live variant server, or reply to one. + +Modes: + poll Block until a browser event arrives, print JSON + poll --reply done Reply "done" to event + poll --reply error "msg" Reply with an error message + +Options: + --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 + --help Show this help message`); + process.exit(0); + } + + const info = readServerInfo(); + const base = `http://localhost:${info.port}`; + + // Reply mode: npx impeccable poll --reply [--file path] [message] + const replyIdx = args.indexOf('--reply'); + if (replyIdx !== -1) { + const id = args[replyIdx + 1]; + const status = args[replyIdx + 2] || 'done'; + const fileIdx = args.indexOf('--file'); + const filePath = fileIdx !== -1 && fileIdx + 1 < args.length ? args[fileIdx + 1] : undefined; + // Message is any remaining positional arg that isn't a flag + const message = args.find((a, i) => i > replyIdx + 2 && !a.startsWith('--') && i !== fileIdx + 1) || undefined; + + if (!id) { + console.error('Usage: npx impeccable poll --reply [--file path] [message]'); + process.exit(1); + } + + try { + const res = await fetch(`${base}/poll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: info.token, + id, + type: status, + message, + file: filePath, + }), + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + console.error(`Reply failed (${res.status}):`, body.error || res.statusText); + process.exit(1); + } + + // Success — silent exit (agent doesn't need output for replies) + } catch (err) { + if (err.cause?.code === 'ECONNREFUSED') { + console.error('Live server not running. Start one with: npx impeccable live'); + } else { + console.error('Reply failed:', err.message); + } + process.exit(1); + } + return; + } + + // Poll mode: block until browser event. Default 10 min. Node's built-in + // fetch enforces a 300s headers timeout, so we loop in slices under that + // ceiling and keep re-polling until we get a real event or the user's + // total timeout runs out. + const timeoutArg = args.find(a => a.startsWith('--timeout=')); + const totalTimeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600000; + + const deadline = Date.now() + totalTimeout; + let event; + try { + while (true) { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + event = { type: 'timeout' }; + break; + } + const slice = Math.min(remaining, PER_REQUEST_TIMEOUT_MS); + const res = await fetch(`${base}/poll?token=${info.token}&timeout=${slice}`); + + if (res.status === 401) { + console.error('Authentication failed. The server token may have changed.'); + console.error('Try restarting: npx impeccable live stop && npx impeccable live'); + process.exit(1); + } + + if (!res.ok) { + console.error(`Poll failed: ${res.status} ${res.statusText}`); + process.exit(1); + } + + const next = await res.json(); + // Server-side timeout means no browser event arrived in this slice. + // Loop and re-poll until we get a real event or we hit the user's + // total deadline. + if (next?.type === 'timeout' && Date.now() < deadline) continue; + event = next; + break; + } + + // Auto-handle accept/discard via deterministic script + if (event.type === 'accept' || event.type === 'discard') { + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const acceptScript = path.join(__dirname, 'live-accept.mjs'); + const scriptArgs = event.type === 'discard' + ? ['--id', event.id, '--discard'] + : ['--id', event.id, '--variant', event.variantId]; + if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) { + // Pass through a JSON blob; the shell-safe wrap uses single quotes because + // values are finite {id, number|string|boolean} pairs from a validated payload. + scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`); + } + try { + const out = execSync( + `node "${acceptScript}" ${scriptArgs.join(' ')}`, + { encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 } + ); + event._acceptResult = JSON.parse(out.trim()); + } catch (err) { + event._acceptResult = { handled: false, error: err.message }; + } + } + + // Second signal path: stderr banner in case the agent parses stdout + // JSON but skips nested fields. One line is enough — the full checklist + // is in reference/live.md. + if (event._acceptResult?.carbonize === true) { + process.stderr.write('\n⚠ Carbonize cleanup REQUIRED before next poll. See reference/live.md "Required after accept".\n\n'); + } + + // Print the event as JSON — the agent reads this from stdout + console.log(JSON.stringify(event)); + } catch (err) { + if (err.cause?.code === 'ECONNREFUSED') { + console.error('Live server not running. Start one with: npx impeccable live'); + } else { + console.error('Poll failed:', err.message); + } + process.exit(1); + } +} + +// Auto-execute when run directly +const _running = process.argv[1]; +if (_running?.endsWith('live-poll.mjs') || _running?.endsWith('live-poll.mjs/')) { + pollCli(); +} diff --git a/.agents/skills/impeccable/scripts/live-server.mjs b/.agents/skills/impeccable/scripts/live-server.mjs new file mode 100644 index 000000000..3d608e3c0 --- /dev/null +++ b/.agents/skills/impeccable/scripts/live-server.mjs @@ -0,0 +1,679 @@ +#!/usr/bin/env node +/** + * Live variant mode server (self-contained, zero dependencies). + * + * Serves the browser script (/live.js), the detection overlay (/detect.js), + * uses Server-Sent Events (SSE) for server→browser push, and HTTP POST for + * browser→server events. Agent communicates via HTTP long-poll (/poll). + * + * Usage: + * node /live-server.mjs # start + * node /live-server.mjs stop # stop + remove injected live.js tag + * node /live-server.mjs stop --keep-inject # stop only + * node /live-server.mjs --help + */ + +import http from 'node:http'; +import { randomUUID } from 'node:crypto'; +import { spawn, execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import net from 'node:net'; +import { fileURLToPath } from 'node:url'; +import { parseDesignMd } from './design-parser.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +// PID file in the project root so both the server and agent can find it +// predictably (os.tmpdir() varies across platforms). +const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway +const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s + +// --------------------------------------------------------------------------- +// Port detection +// --------------------------------------------------------------------------- + +async function findOpenPort(start = 8400) { + return new Promise((resolve) => { + const srv = net.createServer(); + srv.listen(start, '127.0.0.1', () => { + const port = srv.address().port; + srv.close(() => resolve(port)); + }); + srv.on('error', () => resolve(findOpenPort(start + 1))); + }); +} + +// --------------------------------------------------------------------------- +// Session state +// --------------------------------------------------------------------------- + +const state = { + token: null, + port: null, + sseClients: new Set(), // SSE response objects (server→browser push) + pendingEvents: [], // browser events waiting for agent poll + pendingPolls: [], // agent poll callbacks waiting for browser events + exitTimer: null, + sessionDir: null, // per-session tmp dir for annotation screenshots +}; + +// Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; +// cap at 10 MB to guard against runaway writes from a misbehaving client. +const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024; + +function enqueueEvent(event) { + if (state.pendingPolls.length > 0) { + state.pendingPolls.shift()(event); + } else { + state.pendingEvents.push(event); + } +} + +/** Push a message to all connected SSE clients. */ +function broadcast(msg) { + const data = 'data: ' + JSON.stringify(msg) + '\n\n'; + for (const res of state.sseClients) { + try { res.write(data); } catch { /* client gone */ } + } +} + +// --------------------------------------------------------------------------- +// Load scripts +// --------------------------------------------------------------------------- + +function loadBrowserScripts() { + // Detection script: look relative to the skill scripts dir, then fall back + // to the npm package location (src/detect-antipatterns-browser.js). + // This one IS cached — detect.js rarely changes during a session. + const detectPaths = [ + path.join(__dirname, '..', '..', '..', '..', 'src', 'detect-antipatterns-browser.js'), + path.join(process.cwd(), 'node_modules', 'impeccable', 'src', 'detect-antipatterns-browser.js'), + ]; + let detectScript = ''; + for (const p of detectPaths) { + try { detectScript = fs.readFileSync(p, 'utf-8'); break; } catch { /* try next */ } + } + + // live-browser.js: DO NOT cache. Return the path so the /live.js handler + // can re-read on every request. Editing the browser script during iteration + // should land on the next tab reload, not require a server restart. + const livePath = path.join(__dirname, 'live-browser.js'); + if (!fs.existsSync(livePath)) { + process.stderr.write('Error: live-browser.js not found at ' + livePath + '\n'); + process.exit(1); + } + + return { detectScript, livePath }; +} + +function hasProjectContext() { + // PRODUCT.md carries brand voice / anti-references — that's what determines + // whether variants are brand-aware. DESIGN.md (visual tokens) is a separate + // concern, surfaced by the design panel's own empty state. Legacy + // .impeccable.md is auto-migrated to PRODUCT.md by load-context.mjs. + try { + fs.accessSync(path.join(process.cwd(), 'PRODUCT.md'), fs.constants.R_OK); + return true; + } catch { return false; } +} + +function statOrNull(filePath) { + try { return fs.statSync(filePath); } catch { return null; } +} + +// --------------------------------------------------------------------------- +// Validation (inline — no external import needed for self-contained script) +// --------------------------------------------------------------------------- + +const VISUAL_ACTIONS = [ + 'impeccable', 'bolder', 'quieter', 'distill', 'polish', 'typeset', + 'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive', +]; + +function validateEvent(msg) { + if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message'; + switch (msg.type) { + case 'generate': + if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id'; + if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action'; + if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8'; + if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context'; + // Optional annotation fields (all-or-nothing: if any present, all must be well-formed). + if (msg.screenshotPath !== undefined && typeof msg.screenshotPath !== 'string') return 'generate: screenshotPath must be string'; + if (msg.comments !== undefined && !Array.isArray(msg.comments)) return 'generate: comments must be array'; + if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array'; + return null; + case 'accept': + if (!msg.id) return 'accept: missing id'; + if (!msg.variantId) return 'accept: missing variantId'; + if (msg.paramValues !== undefined) { + if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) { + return 'accept: paramValues must be an object'; + } + } + return null; + case 'discard': + return msg.id ? null : 'discard: missing id'; + case 'exit': + return null; + case 'prefetch': + if (!msg.pageUrl || typeof msg.pageUrl !== 'string') return 'prefetch: missing pageUrl'; + return null; + default: + return 'Unknown event type: ' + msg.type; + } +} + +// --------------------------------------------------------------------------- +// HTTP request handler +// --------------------------------------------------------------------------- + +function createRequestHandler({ detectScript, livePath }) { + return (req, res) => { + const url = new URL(req.url, `http://localhost:${state.port}`); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } + + const p = url.pathname; + + // --- Scripts --- + if (p === '/live.js') { + // Re-read from disk each request so edits to live-browser.js land on + // the next tab reload. No-store headers prevent browser caching across + // sessions — during iteration, a cached old script silently breaks + // every subsequent session. + let liveScript; + try { + liveScript = fs.readFileSync(livePath, 'utf-8'); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end('Error reading live-browser.js: ' + err.message); + return; + } + const body = + `window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` + + `window.__IMPECCABLE_PORT__ = ${state.port};\n` + + liveScript; + res.writeHead(200, { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', + 'Pragma': 'no-cache', + }); + res.end(body); + return; + } + if (p === '/detect.js' || p === '/') { + if (!detectScript) { res.writeHead(404); res.end('Not available'); return; } + res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.end(detectScript); + return; + } + + // --- Vendored modern-screenshot (UMD build) --- + // Lazy-loaded by live.js when the user clicks Go; exposes + // window.modernScreenshot.domToBlob(...) for capture. + if (p === '/modern-screenshot.js') { + const vendorPath = path.join(__dirname, 'modern-screenshot.umd.js'); + try { + res.writeHead(200, { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'public, max-age=31536000, immutable', + }); + res.end(fs.readFileSync(vendorPath)); + } catch { + res.writeHead(404); res.end('Vendor script not found'); + } + return; + } + + // --- Annotation upload (browser → server, raw PNG body) --- + // Client generates the eventId, POSTs the PNG, then POSTs the generate + // event with screenshotPath already set. Keeps bytes out of the SSE/poll + // bridge and preserves the "one shot from the user's POV" UX. + if (p === '/annotation' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const eventId = url.searchParams.get('eventId'); + if (!eventId || !/^[A-Za-z0-9_-]{1,64}$/.test(eventId)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid eventId' })); + return; + } + if ((req.headers['content-type'] || '').toLowerCase() !== 'image/png') { + res.writeHead(415, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Content-Type must be image/png' })); + return; + } + if (!state.sessionDir) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Session dir unavailable' })); + return; + } + const chunks = []; + let total = 0; + let aborted = false; + req.on('data', (c) => { + if (aborted) return; + total += c.length; + if (total > MAX_ANNOTATION_BYTES) { + aborted = true; + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Payload too large' })); + req.destroy(); + return; + } + chunks.push(c); + }); + req.on('end', () => { + if (aborted) return; + const absPath = path.join(state.sessionDir, eventId + '.png'); + try { + fs.writeFileSync(absPath, Buffer.concat(chunks)); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Write failed: ' + err.message })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, path: absPath })); + }); + req.on('error', () => { + if (!aborted) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Upload failed' })); + } + }); + return; + } + + // --- Health --- + if (p === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'ok', port: state.port, mode: 'variant', + hasProjectContext: hasProjectContext(), + connectedClients: state.sseClients.size, + })); + return; + } + + // --- Design system (unified v2 response) + raw --- + // /design-system.json returns both parsed DESIGN.md and DESIGN.json + // sidecar when present. Panel merges them: + // { present, parsed, sidecar, hasMd, hasSidecar, + // mdNewerThanJson, parseError?, sidecarError? } + // - parsed: output of parseDesignMd (frontmatter + // + six canonical sections) when DESIGN.md exists. + // - sidecar: DESIGN.json contents when present. + // Expected shape: schemaVersion 2, carrying + // extensions + components + narrative. + // /design-system/raw returns DESIGN.md markdown verbatim + if (p === '/design-system.json' || p === '/design-system/raw') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + + const mdPath = path.join(process.cwd(), 'DESIGN.md'); + const jsonPath = path.join(process.cwd(), 'DESIGN.json'); + const mdStat = statOrNull(mdPath); + const jsonStat = statOrNull(jsonPath); + + if (p === '/design-system/raw') { + if (!mdStat) { res.writeHead(404); res.end('Not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' }); + res.end(fs.readFileSync(mdPath, 'utf-8')); + return; + } + + if (!mdStat && !jsonStat) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ present: false })); + return; + } + + const response = { + present: true, + hasMd: !!mdStat, + hasSidecar: !!jsonStat, + mdNewerThanJson: !!(mdStat && jsonStat && mdStat.mtimeMs > jsonStat.mtimeMs + 1000), + }; + + if (mdStat) { + try { + response.parsed = parseDesignMd(fs.readFileSync(mdPath, 'utf-8')); + } catch (err) { + response.parseError = err.message; + } + } + + if (jsonStat) { + try { + response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); + } catch (err) { + response.sidecarError = 'Failed to parse DESIGN.json: ' + err.message; + } + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(response)); + return; + } + + // --- Source file (no-HMR fallback) --- + if (p === '/source') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const filePath = url.searchParams.get('path'); + if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; } + const absPath = path.resolve(process.cwd(), filePath); + if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; } + let content; + try { content = fs.readFileSync(absPath, 'utf-8'); } + catch { res.writeHead(404); res.end('File not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(content); + return; + } + + // --- SSE: server→browser push (replaces WebSocket) --- + if (p === '/events' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }); + res.write('data: ' + JSON.stringify({ + type: 'connected', + hasProjectContext: hasProjectContext(), + }) + '\n\n'); + + state.sseClients.add(res); + clearTimeout(state.exitTimer); + + // Keepalive: SSE comment every 30s prevents silent connection drops. + const heartbeat = setInterval(() => { + try { res.write(': keepalive\n\n'); } catch { clearInterval(heartbeat); } + }, SSE_HEARTBEAT_INTERVAL); + + req.on('close', () => { + clearInterval(heartbeat); + state.sseClients.delete(res); + if (state.sseClients.size === 0) { + clearTimeout(state.exitTimer); + state.exitTimer = setTimeout(() => { + if (state.sseClients.size === 0) enqueueEvent({ type: 'exit' }); + }, 8000); + } + }); + return; + } + + // --- Browser→server events (replaces WebSocket messages) --- + if (p === '/events' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (msg.token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + const error = validateEvent(msg); + if (error) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error })); + return; + } + enqueueEvent(msg); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); + return; + } + + // --- Stop --- + if (p === '/stop') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('stopping'); + shutdown(); + return; + } + + // --- Agent poll --- + if (p === '/poll' && req.method === 'GET') { + handlePollGet(req, res, url); + return; + } + if (p === '/poll' && req.method === 'POST') { + handlePollPost(req, res); + return; + } + + res.writeHead(404); res.end('Not found'); + }; +} + +// --------------------------------------------------------------------------- +// Agent poll endpoints (unchanged from WS version) +// --------------------------------------------------------------------------- + +function handlePollGet(req, res, url) { + const token = url.searchParams.get('token'); + if (token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10); + if (state.pendingEvents.length > 0) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(state.pendingEvents.shift())); + return; + } + const timer = setTimeout(() => { + const idx = state.pendingPolls.indexOf(resolve); + if (idx !== -1) state.pendingPolls.splice(idx, 1); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ type: 'timeout' })); + }, timeout); + function resolve(event) { + clearTimeout(timer); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(event)); + } + state.pendingPolls.push(resolve); + req.on('close', () => { + clearTimeout(timer); + const idx = state.pendingPolls.indexOf(resolve); + if (idx !== -1) state.pendingPolls.splice(idx, 1); + }); +} + +function handlePollPost(req, res) { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (msg.token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + // Forward the reply to the browser via SSE + broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); +} + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +let httpServer = null; + +function shutdown() { + try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + if (state.sessionDir) { + try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} + } + for (const res of state.sseClients) { try { res.end(); } catch {} } + state.sseClients.clear(); + for (const resolve of state.pendingPolls) resolve({ type: 'exit' }); + state.pendingPolls.length = 0; + if (httpServer) httpServer.close(); + process.exit(0); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +const args = process.argv.slice(2); + +if (args.includes('--help') || args.includes('-h')) { + console.log(`Usage: node live-server.mjs [options] + +Start the live variant mode server (zero dependencies). + +Commands: + (default) Start the server (foreground) + stop Stop the server and remove the injected live.js script tag + stop --keep-inject Stop the server only (leave the script tag in the HTML entry) + +Options: + --background Start detached, print connection JSON to stdout, then exit + --port=PORT Use a specific port (default: auto-detect starting at 8400) + --keep-inject Only with stop: skip live-inject.mjs --remove + --help Show this help + +Endpoints: + /live.js Browser script (element picker + variant cycling) + /detect.js Detection overlay (backwards compatible) + /modern-screenshot.js Vendored modern-screenshot UMD build (lazy-loaded by live.js) + /annotation POST raw image/png to stage a variant screenshot + /events SSE stream (server→browser) + POST (browser→server) + /poll Long-poll for agent CLI + /source Raw source file reader (no-HMR fallback) + /health Health check`); + process.exit(0); +} + +if (args.includes('stop')) { + const keepInject = args.includes('--keep-inject'); + try { + const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`); + if (res.ok) console.log(`Stopped live server on port ${info.port}.`); + } catch { + console.log('No running live server found.'); + } + if (!keepInject) { + const injectPath = path.join(__dirname, 'live-inject.mjs'); + try { + const out = execFileSync(process.execPath, [injectPath, '--remove'], { + encoding: 'utf-8', + cwd: process.cwd(), + }); + const line = out.trim().split('\n').filter(Boolean).pop(); + if (line) { + try { + const j = JSON.parse(line); + if (j.removed === true) { + console.log(`Removed live script tag from ${j.file}.`); + } + } catch { + /* ignore non-JSON lines */ + } + } + } catch (err) { + const detail = err.stderr?.toString?.().trim?.() + || err.stdout?.toString?.().trim?.() + || err.message + || String(err); + console.warn(`Note: could not remove live script tag (${detail.split('\n')[0]})`); + } + } + process.exit(0); +} + +// --background: spawn a detached child server, wait for it to be ready, +// print the connection JSON, then exit. This keeps the startup command +// simple (no shell backgrounding or chained commands). +if (args.includes('--background')) { + const childArgs = args.filter(a => a !== '--background'); + const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...childArgs], { + detached: true, + stdio: 'ignore', + cwd: process.cwd(), + }); + child.unref(); + + // Poll for the PID file (the child writes it once the HTTP server is listening). + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + try { + const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + if (info.pid !== process.pid) { + // Output JSON so the agent can read port + token from stdout. + console.log(JSON.stringify(info)); + process.exit(0); + } + } catch { /* not ready yet */ } + await new Promise(r => setTimeout(r, 200)); + } + console.error('Timed out waiting for live server to start.'); + process.exit(1); +} + +// Check for existing session +try { + const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + try { process.kill(existing.pid, 0); + console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`); + console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop'); + process.exit(1); + } catch { fs.unlinkSync(LIVE_PID_FILE); } +} catch {} + +state.token = randomUUID(); +const portArg = args.find(a => a.startsWith('--port=')); +state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort(); +// Annotation screenshots live in the project root so the agent's Read tool +// doesn't trip a per-file permission prompt. Sessioned by token so concurrent +// projects (or quick restarts) don't collide. +const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); +fs.mkdirSync(annotRoot, { recursive: true }); +state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); + +const { detectScript, livePath } = loadBrowserScripts(); +httpServer = http.createServer(createRequestHandler({ detectScript, livePath })); + +httpServer.listen(state.port, '127.0.0.1', () => { + fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token })); + const url = `http://localhost:${state.port}`; + console.log(`\nImpeccable live server running on ${url}`); + console.log(`Token: ${state.token}\n`); + console.log(`Inject: \n' + + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' + ); +} + +function insertTag(content, config, port) { + const block = buildTagBlock(config.commentSyntax, port); + // insertBefore: match the LAST occurrence. Anchors like `` naturally + // belong at the end, and the same literal can appear earlier in code blocks + // within rendered documentation pages. + if (config.insertBefore) { + const idx = content.lastIndexOf(config.insertBefore); + if (idx === -1) return content; + return content.slice(0, idx) + block + content.slice(idx); + } + // insertAfter: match the FIRST occurrence — typical anchors like `` or + // `` open near the top of the document. + const idx = content.indexOf(config.insertAfter); + if (idx === -1) return content; + const after = idx + config.insertAfter.length; + // Preserve a single trailing newline if the anchor didn't end with one + const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; + return prefix + block + content.slice(prefix.length); +} + +/** + * Remove the live script block. Matches either HTML or JSX comment markers + * regardless of config (so stale tags from a wrong config can still be cleaned). + * + * Indent-preserving: captures any whitespace immediately preceding the opener + * marker and re-emits it in place of the removed block. `insertTag` inserted + * the block *after* the original line's indent and *before* the anchor (e.g. + * ``), which moved the indent onto the opener line and left the anchor + * unindented. Replacing the whole block (plus its trailing newline) with just + * the captured indent hands the indent back to the anchor that follows. + */ +function removeTag(content, _syntax) { + const patterns = [ + /([ \t]*)[\s\S]*?[ \t]*\n/, + /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}[ \t]*\n/, + ]; + for (const pat of patterns) { + const next = content.replace(pat, '$1'); + if (next !== content) return next; + } + return content; +} + +// --------------------------------------------------------------------------- +// Auto-execute +// --------------------------------------------------------------------------- + +const _running = process.argv[1]; +if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) { + injectCli(); +} + +export { insertTag, removeTag, validateConfig, buildTagBlock }; diff --git a/.claude/skills/impeccable/scripts/live-poll.mjs b/.claude/skills/impeccable/scripts/live-poll.mjs new file mode 100644 index 000000000..5cece1a43 --- /dev/null +++ b/.claude/skills/impeccable/scripts/live-poll.mjs @@ -0,0 +1,187 @@ +/** + * CLI client for the live variant mode poll/reply protocol. + * + * Usage: + * npx impeccable poll # Block until browser event, print JSON + * npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly + * npx impeccable poll --reply done # Reply "done" to event + * npx impeccable poll --reply error "msg" # Reply with error + */ + +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { fileURLToPath } from 'node:url'; + +// Node's built-in fetch (undici under the hood) enforces a 300s headers +// timeout that can't be lowered per-request. We cap each request below +// that ceiling and loop in `pollOnce` to synthesize a long poll without +// depending on the standalone undici package. +const PER_REQUEST_TIMEOUT_MS = 270_000; + +const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); + +function readServerInfo() { + try { + return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + } catch { + console.error('No running live server found. Start one with: npx impeccable live'); + process.exit(1); + } +} + +export async function pollCli() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log(`Usage: impeccable poll [options] + +Wait for a browser event from the live variant server, or reply to one. + +Modes: + poll Block until a browser event arrives, print JSON + poll --reply done Reply "done" to event + poll --reply error "msg" Reply with an error message + +Options: + --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 + --help Show this help message`); + process.exit(0); + } + + const info = readServerInfo(); + const base = `http://localhost:${info.port}`; + + // Reply mode: npx impeccable poll --reply [--file path] [message] + const replyIdx = args.indexOf('--reply'); + if (replyIdx !== -1) { + const id = args[replyIdx + 1]; + const status = args[replyIdx + 2] || 'done'; + const fileIdx = args.indexOf('--file'); + const filePath = fileIdx !== -1 && fileIdx + 1 < args.length ? args[fileIdx + 1] : undefined; + // Message is any remaining positional arg that isn't a flag + const message = args.find((a, i) => i > replyIdx + 2 && !a.startsWith('--') && i !== fileIdx + 1) || undefined; + + if (!id) { + console.error('Usage: npx impeccable poll --reply [--file path] [message]'); + process.exit(1); + } + + try { + const res = await fetch(`${base}/poll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: info.token, + id, + type: status, + message, + file: filePath, + }), + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + console.error(`Reply failed (${res.status}):`, body.error || res.statusText); + process.exit(1); + } + + // Success — silent exit (agent doesn't need output for replies) + } catch (err) { + if (err.cause?.code === 'ECONNREFUSED') { + console.error('Live server not running. Start one with: npx impeccable live'); + } else { + console.error('Reply failed:', err.message); + } + process.exit(1); + } + return; + } + + // Poll mode: block until browser event. Default 10 min. Node's built-in + // fetch enforces a 300s headers timeout, so we loop in slices under that + // ceiling and keep re-polling until we get a real event or the user's + // total timeout runs out. + const timeoutArg = args.find(a => a.startsWith('--timeout=')); + const totalTimeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600000; + + const deadline = Date.now() + totalTimeout; + let event; + try { + while (true) { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + event = { type: 'timeout' }; + break; + } + const slice = Math.min(remaining, PER_REQUEST_TIMEOUT_MS); + const res = await fetch(`${base}/poll?token=${info.token}&timeout=${slice}`); + + if (res.status === 401) { + console.error('Authentication failed. The server token may have changed.'); + console.error('Try restarting: npx impeccable live stop && npx impeccable live'); + process.exit(1); + } + + if (!res.ok) { + console.error(`Poll failed: ${res.status} ${res.statusText}`); + process.exit(1); + } + + const next = await res.json(); + // Server-side timeout means no browser event arrived in this slice. + // Loop and re-poll until we get a real event or we hit the user's + // total deadline. + if (next?.type === 'timeout' && Date.now() < deadline) continue; + event = next; + break; + } + + // Auto-handle accept/discard via deterministic script + if (event.type === 'accept' || event.type === 'discard') { + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const acceptScript = path.join(__dirname, 'live-accept.mjs'); + const scriptArgs = event.type === 'discard' + ? ['--id', event.id, '--discard'] + : ['--id', event.id, '--variant', event.variantId]; + if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) { + // Pass through a JSON blob; the shell-safe wrap uses single quotes because + // values are finite {id, number|string|boolean} pairs from a validated payload. + scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`); + } + try { + const out = execSync( + `node "${acceptScript}" ${scriptArgs.join(' ')}`, + { encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 } + ); + event._acceptResult = JSON.parse(out.trim()); + } catch (err) { + event._acceptResult = { handled: false, error: err.message }; + } + } + + // Second signal path: stderr banner in case the agent parses stdout + // JSON but skips nested fields. One line is enough — the full checklist + // is in reference/live.md. + if (event._acceptResult?.carbonize === true) { + process.stderr.write('\n⚠ Carbonize cleanup REQUIRED before next poll. See reference/live.md "Required after accept".\n\n'); + } + + // Print the event as JSON — the agent reads this from stdout + console.log(JSON.stringify(event)); + } catch (err) { + if (err.cause?.code === 'ECONNREFUSED') { + console.error('Live server not running. Start one with: npx impeccable live'); + } else { + console.error('Poll failed:', err.message); + } + process.exit(1); + } +} + +// Auto-execute when run directly +const _running = process.argv[1]; +if (_running?.endsWith('live-poll.mjs') || _running?.endsWith('live-poll.mjs/')) { + pollCli(); +} diff --git a/.claude/skills/impeccable/scripts/live-server.mjs b/.claude/skills/impeccable/scripts/live-server.mjs new file mode 100644 index 000000000..3d608e3c0 --- /dev/null +++ b/.claude/skills/impeccable/scripts/live-server.mjs @@ -0,0 +1,679 @@ +#!/usr/bin/env node +/** + * Live variant mode server (self-contained, zero dependencies). + * + * Serves the browser script (/live.js), the detection overlay (/detect.js), + * uses Server-Sent Events (SSE) for server→browser push, and HTTP POST for + * browser→server events. Agent communicates via HTTP long-poll (/poll). + * + * Usage: + * node /live-server.mjs # start + * node /live-server.mjs stop # stop + remove injected live.js tag + * node /live-server.mjs stop --keep-inject # stop only + * node /live-server.mjs --help + */ + +import http from 'node:http'; +import { randomUUID } from 'node:crypto'; +import { spawn, execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import net from 'node:net'; +import { fileURLToPath } from 'node:url'; +import { parseDesignMd } from './design-parser.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +// PID file in the project root so both the server and agent can find it +// predictably (os.tmpdir() varies across platforms). +const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway +const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s + +// --------------------------------------------------------------------------- +// Port detection +// --------------------------------------------------------------------------- + +async function findOpenPort(start = 8400) { + return new Promise((resolve) => { + const srv = net.createServer(); + srv.listen(start, '127.0.0.1', () => { + const port = srv.address().port; + srv.close(() => resolve(port)); + }); + srv.on('error', () => resolve(findOpenPort(start + 1))); + }); +} + +// --------------------------------------------------------------------------- +// Session state +// --------------------------------------------------------------------------- + +const state = { + token: null, + port: null, + sseClients: new Set(), // SSE response objects (server→browser push) + pendingEvents: [], // browser events waiting for agent poll + pendingPolls: [], // agent poll callbacks waiting for browser events + exitTimer: null, + sessionDir: null, // per-session tmp dir for annotation screenshots +}; + +// Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; +// cap at 10 MB to guard against runaway writes from a misbehaving client. +const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024; + +function enqueueEvent(event) { + if (state.pendingPolls.length > 0) { + state.pendingPolls.shift()(event); + } else { + state.pendingEvents.push(event); + } +} + +/** Push a message to all connected SSE clients. */ +function broadcast(msg) { + const data = 'data: ' + JSON.stringify(msg) + '\n\n'; + for (const res of state.sseClients) { + try { res.write(data); } catch { /* client gone */ } + } +} + +// --------------------------------------------------------------------------- +// Load scripts +// --------------------------------------------------------------------------- + +function loadBrowserScripts() { + // Detection script: look relative to the skill scripts dir, then fall back + // to the npm package location (src/detect-antipatterns-browser.js). + // This one IS cached — detect.js rarely changes during a session. + const detectPaths = [ + path.join(__dirname, '..', '..', '..', '..', 'src', 'detect-antipatterns-browser.js'), + path.join(process.cwd(), 'node_modules', 'impeccable', 'src', 'detect-antipatterns-browser.js'), + ]; + let detectScript = ''; + for (const p of detectPaths) { + try { detectScript = fs.readFileSync(p, 'utf-8'); break; } catch { /* try next */ } + } + + // live-browser.js: DO NOT cache. Return the path so the /live.js handler + // can re-read on every request. Editing the browser script during iteration + // should land on the next tab reload, not require a server restart. + const livePath = path.join(__dirname, 'live-browser.js'); + if (!fs.existsSync(livePath)) { + process.stderr.write('Error: live-browser.js not found at ' + livePath + '\n'); + process.exit(1); + } + + return { detectScript, livePath }; +} + +function hasProjectContext() { + // PRODUCT.md carries brand voice / anti-references — that's what determines + // whether variants are brand-aware. DESIGN.md (visual tokens) is a separate + // concern, surfaced by the design panel's own empty state. Legacy + // .impeccable.md is auto-migrated to PRODUCT.md by load-context.mjs. + try { + fs.accessSync(path.join(process.cwd(), 'PRODUCT.md'), fs.constants.R_OK); + return true; + } catch { return false; } +} + +function statOrNull(filePath) { + try { return fs.statSync(filePath); } catch { return null; } +} + +// --------------------------------------------------------------------------- +// Validation (inline — no external import needed for self-contained script) +// --------------------------------------------------------------------------- + +const VISUAL_ACTIONS = [ + 'impeccable', 'bolder', 'quieter', 'distill', 'polish', 'typeset', + 'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive', +]; + +function validateEvent(msg) { + if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message'; + switch (msg.type) { + case 'generate': + if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id'; + if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action'; + if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8'; + if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context'; + // Optional annotation fields (all-or-nothing: if any present, all must be well-formed). + if (msg.screenshotPath !== undefined && typeof msg.screenshotPath !== 'string') return 'generate: screenshotPath must be string'; + if (msg.comments !== undefined && !Array.isArray(msg.comments)) return 'generate: comments must be array'; + if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array'; + return null; + case 'accept': + if (!msg.id) return 'accept: missing id'; + if (!msg.variantId) return 'accept: missing variantId'; + if (msg.paramValues !== undefined) { + if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) { + return 'accept: paramValues must be an object'; + } + } + return null; + case 'discard': + return msg.id ? null : 'discard: missing id'; + case 'exit': + return null; + case 'prefetch': + if (!msg.pageUrl || typeof msg.pageUrl !== 'string') return 'prefetch: missing pageUrl'; + return null; + default: + return 'Unknown event type: ' + msg.type; + } +} + +// --------------------------------------------------------------------------- +// HTTP request handler +// --------------------------------------------------------------------------- + +function createRequestHandler({ detectScript, livePath }) { + return (req, res) => { + const url = new URL(req.url, `http://localhost:${state.port}`); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } + + const p = url.pathname; + + // --- Scripts --- + if (p === '/live.js') { + // Re-read from disk each request so edits to live-browser.js land on + // the next tab reload. No-store headers prevent browser caching across + // sessions — during iteration, a cached old script silently breaks + // every subsequent session. + let liveScript; + try { + liveScript = fs.readFileSync(livePath, 'utf-8'); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end('Error reading live-browser.js: ' + err.message); + return; + } + const body = + `window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` + + `window.__IMPECCABLE_PORT__ = ${state.port};\n` + + liveScript; + res.writeHead(200, { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', + 'Pragma': 'no-cache', + }); + res.end(body); + return; + } + if (p === '/detect.js' || p === '/') { + if (!detectScript) { res.writeHead(404); res.end('Not available'); return; } + res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.end(detectScript); + return; + } + + // --- Vendored modern-screenshot (UMD build) --- + // Lazy-loaded by live.js when the user clicks Go; exposes + // window.modernScreenshot.domToBlob(...) for capture. + if (p === '/modern-screenshot.js') { + const vendorPath = path.join(__dirname, 'modern-screenshot.umd.js'); + try { + res.writeHead(200, { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'public, max-age=31536000, immutable', + }); + res.end(fs.readFileSync(vendorPath)); + } catch { + res.writeHead(404); res.end('Vendor script not found'); + } + return; + } + + // --- Annotation upload (browser → server, raw PNG body) --- + // Client generates the eventId, POSTs the PNG, then POSTs the generate + // event with screenshotPath already set. Keeps bytes out of the SSE/poll + // bridge and preserves the "one shot from the user's POV" UX. + if (p === '/annotation' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const eventId = url.searchParams.get('eventId'); + if (!eventId || !/^[A-Za-z0-9_-]{1,64}$/.test(eventId)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid eventId' })); + return; + } + if ((req.headers['content-type'] || '').toLowerCase() !== 'image/png') { + res.writeHead(415, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Content-Type must be image/png' })); + return; + } + if (!state.sessionDir) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Session dir unavailable' })); + return; + } + const chunks = []; + let total = 0; + let aborted = false; + req.on('data', (c) => { + if (aborted) return; + total += c.length; + if (total > MAX_ANNOTATION_BYTES) { + aborted = true; + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Payload too large' })); + req.destroy(); + return; + } + chunks.push(c); + }); + req.on('end', () => { + if (aborted) return; + const absPath = path.join(state.sessionDir, eventId + '.png'); + try { + fs.writeFileSync(absPath, Buffer.concat(chunks)); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Write failed: ' + err.message })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, path: absPath })); + }); + req.on('error', () => { + if (!aborted) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Upload failed' })); + } + }); + return; + } + + // --- Health --- + if (p === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'ok', port: state.port, mode: 'variant', + hasProjectContext: hasProjectContext(), + connectedClients: state.sseClients.size, + })); + return; + } + + // --- Design system (unified v2 response) + raw --- + // /design-system.json returns both parsed DESIGN.md and DESIGN.json + // sidecar when present. Panel merges them: + // { present, parsed, sidecar, hasMd, hasSidecar, + // mdNewerThanJson, parseError?, sidecarError? } + // - parsed: output of parseDesignMd (frontmatter + // + six canonical sections) when DESIGN.md exists. + // - sidecar: DESIGN.json contents when present. + // Expected shape: schemaVersion 2, carrying + // extensions + components + narrative. + // /design-system/raw returns DESIGN.md markdown verbatim + if (p === '/design-system.json' || p === '/design-system/raw') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + + const mdPath = path.join(process.cwd(), 'DESIGN.md'); + const jsonPath = path.join(process.cwd(), 'DESIGN.json'); + const mdStat = statOrNull(mdPath); + const jsonStat = statOrNull(jsonPath); + + if (p === '/design-system/raw') { + if (!mdStat) { res.writeHead(404); res.end('Not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' }); + res.end(fs.readFileSync(mdPath, 'utf-8')); + return; + } + + if (!mdStat && !jsonStat) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ present: false })); + return; + } + + const response = { + present: true, + hasMd: !!mdStat, + hasSidecar: !!jsonStat, + mdNewerThanJson: !!(mdStat && jsonStat && mdStat.mtimeMs > jsonStat.mtimeMs + 1000), + }; + + if (mdStat) { + try { + response.parsed = parseDesignMd(fs.readFileSync(mdPath, 'utf-8')); + } catch (err) { + response.parseError = err.message; + } + } + + if (jsonStat) { + try { + response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); + } catch (err) { + response.sidecarError = 'Failed to parse DESIGN.json: ' + err.message; + } + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(response)); + return; + } + + // --- Source file (no-HMR fallback) --- + if (p === '/source') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const filePath = url.searchParams.get('path'); + if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; } + const absPath = path.resolve(process.cwd(), filePath); + if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; } + let content; + try { content = fs.readFileSync(absPath, 'utf-8'); } + catch { res.writeHead(404); res.end('File not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(content); + return; + } + + // --- SSE: server→browser push (replaces WebSocket) --- + if (p === '/events' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }); + res.write('data: ' + JSON.stringify({ + type: 'connected', + hasProjectContext: hasProjectContext(), + }) + '\n\n'); + + state.sseClients.add(res); + clearTimeout(state.exitTimer); + + // Keepalive: SSE comment every 30s prevents silent connection drops. + const heartbeat = setInterval(() => { + try { res.write(': keepalive\n\n'); } catch { clearInterval(heartbeat); } + }, SSE_HEARTBEAT_INTERVAL); + + req.on('close', () => { + clearInterval(heartbeat); + state.sseClients.delete(res); + if (state.sseClients.size === 0) { + clearTimeout(state.exitTimer); + state.exitTimer = setTimeout(() => { + if (state.sseClients.size === 0) enqueueEvent({ type: 'exit' }); + }, 8000); + } + }); + return; + } + + // --- Browser→server events (replaces WebSocket messages) --- + if (p === '/events' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (msg.token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + const error = validateEvent(msg); + if (error) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error })); + return; + } + enqueueEvent(msg); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); + return; + } + + // --- Stop --- + if (p === '/stop') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('stopping'); + shutdown(); + return; + } + + // --- Agent poll --- + if (p === '/poll' && req.method === 'GET') { + handlePollGet(req, res, url); + return; + } + if (p === '/poll' && req.method === 'POST') { + handlePollPost(req, res); + return; + } + + res.writeHead(404); res.end('Not found'); + }; +} + +// --------------------------------------------------------------------------- +// Agent poll endpoints (unchanged from WS version) +// --------------------------------------------------------------------------- + +function handlePollGet(req, res, url) { + const token = url.searchParams.get('token'); + if (token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10); + if (state.pendingEvents.length > 0) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(state.pendingEvents.shift())); + return; + } + const timer = setTimeout(() => { + const idx = state.pendingPolls.indexOf(resolve); + if (idx !== -1) state.pendingPolls.splice(idx, 1); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ type: 'timeout' })); + }, timeout); + function resolve(event) { + clearTimeout(timer); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(event)); + } + state.pendingPolls.push(resolve); + req.on('close', () => { + clearTimeout(timer); + const idx = state.pendingPolls.indexOf(resolve); + if (idx !== -1) state.pendingPolls.splice(idx, 1); + }); +} + +function handlePollPost(req, res) { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (msg.token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + // Forward the reply to the browser via SSE + broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); +} + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +let httpServer = null; + +function shutdown() { + try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + if (state.sessionDir) { + try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} + } + for (const res of state.sseClients) { try { res.end(); } catch {} } + state.sseClients.clear(); + for (const resolve of state.pendingPolls) resolve({ type: 'exit' }); + state.pendingPolls.length = 0; + if (httpServer) httpServer.close(); + process.exit(0); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +const args = process.argv.slice(2); + +if (args.includes('--help') || args.includes('-h')) { + console.log(`Usage: node live-server.mjs [options] + +Start the live variant mode server (zero dependencies). + +Commands: + (default) Start the server (foreground) + stop Stop the server and remove the injected live.js script tag + stop --keep-inject Stop the server only (leave the script tag in the HTML entry) + +Options: + --background Start detached, print connection JSON to stdout, then exit + --port=PORT Use a specific port (default: auto-detect starting at 8400) + --keep-inject Only with stop: skip live-inject.mjs --remove + --help Show this help + +Endpoints: + /live.js Browser script (element picker + variant cycling) + /detect.js Detection overlay (backwards compatible) + /modern-screenshot.js Vendored modern-screenshot UMD build (lazy-loaded by live.js) + /annotation POST raw image/png to stage a variant screenshot + /events SSE stream (server→browser) + POST (browser→server) + /poll Long-poll for agent CLI + /source Raw source file reader (no-HMR fallback) + /health Health check`); + process.exit(0); +} + +if (args.includes('stop')) { + const keepInject = args.includes('--keep-inject'); + try { + const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`); + if (res.ok) console.log(`Stopped live server on port ${info.port}.`); + } catch { + console.log('No running live server found.'); + } + if (!keepInject) { + const injectPath = path.join(__dirname, 'live-inject.mjs'); + try { + const out = execFileSync(process.execPath, [injectPath, '--remove'], { + encoding: 'utf-8', + cwd: process.cwd(), + }); + const line = out.trim().split('\n').filter(Boolean).pop(); + if (line) { + try { + const j = JSON.parse(line); + if (j.removed === true) { + console.log(`Removed live script tag from ${j.file}.`); + } + } catch { + /* ignore non-JSON lines */ + } + } + } catch (err) { + const detail = err.stderr?.toString?.().trim?.() + || err.stdout?.toString?.().trim?.() + || err.message + || String(err); + console.warn(`Note: could not remove live script tag (${detail.split('\n')[0]})`); + } + } + process.exit(0); +} + +// --background: spawn a detached child server, wait for it to be ready, +// print the connection JSON, then exit. This keeps the startup command +// simple (no shell backgrounding or chained commands). +if (args.includes('--background')) { + const childArgs = args.filter(a => a !== '--background'); + const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...childArgs], { + detached: true, + stdio: 'ignore', + cwd: process.cwd(), + }); + child.unref(); + + // Poll for the PID file (the child writes it once the HTTP server is listening). + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + try { + const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + if (info.pid !== process.pid) { + // Output JSON so the agent can read port + token from stdout. + console.log(JSON.stringify(info)); + process.exit(0); + } + } catch { /* not ready yet */ } + await new Promise(r => setTimeout(r, 200)); + } + console.error('Timed out waiting for live server to start.'); + process.exit(1); +} + +// Check for existing session +try { + const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + try { process.kill(existing.pid, 0); + console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`); + console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop'); + process.exit(1); + } catch { fs.unlinkSync(LIVE_PID_FILE); } +} catch {} + +state.token = randomUUID(); +const portArg = args.find(a => a.startsWith('--port=')); +state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort(); +// Annotation screenshots live in the project root so the agent's Read tool +// doesn't trip a per-file permission prompt. Sessioned by token so concurrent +// projects (or quick restarts) don't collide. +const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); +fs.mkdirSync(annotRoot, { recursive: true }); +state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); + +const { detectScript, livePath } = loadBrowserScripts(); +httpServer = http.createServer(createRequestHandler({ detectScript, livePath })); + +httpServer.listen(state.port, '127.0.0.1', () => { + fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token })); + const url = `http://localhost:${state.port}`; + console.log(`\nImpeccable live server running on ${url}`); + console.log(`Token: ${state.token}\n`); + console.log(`Inject: \n' + + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' + ); +} + +function insertTag(content, config, port) { + const block = buildTagBlock(config.commentSyntax, port); + // insertBefore: match the LAST occurrence. Anchors like `` naturally + // belong at the end, and the same literal can appear earlier in code blocks + // within rendered documentation pages. + if (config.insertBefore) { + const idx = content.lastIndexOf(config.insertBefore); + if (idx === -1) return content; + return content.slice(0, idx) + block + content.slice(idx); + } + // insertAfter: match the FIRST occurrence — typical anchors like `` or + // `` open near the top of the document. + const idx = content.indexOf(config.insertAfter); + if (idx === -1) return content; + const after = idx + config.insertAfter.length; + // Preserve a single trailing newline if the anchor didn't end with one + const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; + return prefix + block + content.slice(prefix.length); +} + +/** + * Remove the live script block. Matches either HTML or JSX comment markers + * regardless of config (so stale tags from a wrong config can still be cleaned). + * + * Indent-preserving: captures any whitespace immediately preceding the opener + * marker and re-emits it in place of the removed block. `insertTag` inserted + * the block *after* the original line's indent and *before* the anchor (e.g. + * ``), which moved the indent onto the opener line and left the anchor + * unindented. Replacing the whole block (plus its trailing newline) with just + * the captured indent hands the indent back to the anchor that follows. + */ +function removeTag(content, _syntax) { + const patterns = [ + /([ \t]*)[\s\S]*?[ \t]*\n/, + /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}[ \t]*\n/, + ]; + for (const pat of patterns) { + const next = content.replace(pat, '$1'); + if (next !== content) return next; + } + return content; +} + +// --------------------------------------------------------------------------- +// Auto-execute +// --------------------------------------------------------------------------- + +const _running = process.argv[1]; +if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) { + injectCli(); +} + +export { insertTag, removeTag, validateConfig, buildTagBlock }; diff --git a/.cursor/skills/impeccable/scripts/live-poll.mjs b/.cursor/skills/impeccable/scripts/live-poll.mjs new file mode 100644 index 000000000..5cece1a43 --- /dev/null +++ b/.cursor/skills/impeccable/scripts/live-poll.mjs @@ -0,0 +1,187 @@ +/** + * CLI client for the live variant mode poll/reply protocol. + * + * Usage: + * npx impeccable poll # Block until browser event, print JSON + * npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly + * npx impeccable poll --reply done # Reply "done" to event + * npx impeccable poll --reply error "msg" # Reply with error + */ + +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { fileURLToPath } from 'node:url'; + +// Node's built-in fetch (undici under the hood) enforces a 300s headers +// timeout that can't be lowered per-request. We cap each request below +// that ceiling and loop in `pollOnce` to synthesize a long poll without +// depending on the standalone undici package. +const PER_REQUEST_TIMEOUT_MS = 270_000; + +const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); + +function readServerInfo() { + try { + return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + } catch { + console.error('No running live server found. Start one with: npx impeccable live'); + process.exit(1); + } +} + +export async function pollCli() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log(`Usage: impeccable poll [options] + +Wait for a browser event from the live variant server, or reply to one. + +Modes: + poll Block until a browser event arrives, print JSON + poll --reply done Reply "done" to event + poll --reply error "msg" Reply with an error message + +Options: + --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 + --help Show this help message`); + process.exit(0); + } + + const info = readServerInfo(); + const base = `http://localhost:${info.port}`; + + // Reply mode: npx impeccable poll --reply [--file path] [message] + const replyIdx = args.indexOf('--reply'); + if (replyIdx !== -1) { + const id = args[replyIdx + 1]; + const status = args[replyIdx + 2] || 'done'; + const fileIdx = args.indexOf('--file'); + const filePath = fileIdx !== -1 && fileIdx + 1 < args.length ? args[fileIdx + 1] : undefined; + // Message is any remaining positional arg that isn't a flag + const message = args.find((a, i) => i > replyIdx + 2 && !a.startsWith('--') && i !== fileIdx + 1) || undefined; + + if (!id) { + console.error('Usage: npx impeccable poll --reply [--file path] [message]'); + process.exit(1); + } + + try { + const res = await fetch(`${base}/poll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: info.token, + id, + type: status, + message, + file: filePath, + }), + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + console.error(`Reply failed (${res.status}):`, body.error || res.statusText); + process.exit(1); + } + + // Success — silent exit (agent doesn't need output for replies) + } catch (err) { + if (err.cause?.code === 'ECONNREFUSED') { + console.error('Live server not running. Start one with: npx impeccable live'); + } else { + console.error('Reply failed:', err.message); + } + process.exit(1); + } + return; + } + + // Poll mode: block until browser event. Default 10 min. Node's built-in + // fetch enforces a 300s headers timeout, so we loop in slices under that + // ceiling and keep re-polling until we get a real event or the user's + // total timeout runs out. + const timeoutArg = args.find(a => a.startsWith('--timeout=')); + const totalTimeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600000; + + const deadline = Date.now() + totalTimeout; + let event; + try { + while (true) { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + event = { type: 'timeout' }; + break; + } + const slice = Math.min(remaining, PER_REQUEST_TIMEOUT_MS); + const res = await fetch(`${base}/poll?token=${info.token}&timeout=${slice}`); + + if (res.status === 401) { + console.error('Authentication failed. The server token may have changed.'); + console.error('Try restarting: npx impeccable live stop && npx impeccable live'); + process.exit(1); + } + + if (!res.ok) { + console.error(`Poll failed: ${res.status} ${res.statusText}`); + process.exit(1); + } + + const next = await res.json(); + // Server-side timeout means no browser event arrived in this slice. + // Loop and re-poll until we get a real event or we hit the user's + // total deadline. + if (next?.type === 'timeout' && Date.now() < deadline) continue; + event = next; + break; + } + + // Auto-handle accept/discard via deterministic script + if (event.type === 'accept' || event.type === 'discard') { + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const acceptScript = path.join(__dirname, 'live-accept.mjs'); + const scriptArgs = event.type === 'discard' + ? ['--id', event.id, '--discard'] + : ['--id', event.id, '--variant', event.variantId]; + if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) { + // Pass through a JSON blob; the shell-safe wrap uses single quotes because + // values are finite {id, number|string|boolean} pairs from a validated payload. + scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`); + } + try { + const out = execSync( + `node "${acceptScript}" ${scriptArgs.join(' ')}`, + { encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 } + ); + event._acceptResult = JSON.parse(out.trim()); + } catch (err) { + event._acceptResult = { handled: false, error: err.message }; + } + } + + // Second signal path: stderr banner in case the agent parses stdout + // JSON but skips nested fields. One line is enough — the full checklist + // is in reference/live.md. + if (event._acceptResult?.carbonize === true) { + process.stderr.write('\n⚠ Carbonize cleanup REQUIRED before next poll. See reference/live.md "Required after accept".\n\n'); + } + + // Print the event as JSON — the agent reads this from stdout + console.log(JSON.stringify(event)); + } catch (err) { + if (err.cause?.code === 'ECONNREFUSED') { + console.error('Live server not running. Start one with: npx impeccable live'); + } else { + console.error('Poll failed:', err.message); + } + process.exit(1); + } +} + +// Auto-execute when run directly +const _running = process.argv[1]; +if (_running?.endsWith('live-poll.mjs') || _running?.endsWith('live-poll.mjs/')) { + pollCli(); +} diff --git a/.cursor/skills/impeccable/scripts/live-server.mjs b/.cursor/skills/impeccable/scripts/live-server.mjs new file mode 100644 index 000000000..3d608e3c0 --- /dev/null +++ b/.cursor/skills/impeccable/scripts/live-server.mjs @@ -0,0 +1,679 @@ +#!/usr/bin/env node +/** + * Live variant mode server (self-contained, zero dependencies). + * + * Serves the browser script (/live.js), the detection overlay (/detect.js), + * uses Server-Sent Events (SSE) for server→browser push, and HTTP POST for + * browser→server events. Agent communicates via HTTP long-poll (/poll). + * + * Usage: + * node /live-server.mjs # start + * node /live-server.mjs stop # stop + remove injected live.js tag + * node /live-server.mjs stop --keep-inject # stop only + * node /live-server.mjs --help + */ + +import http from 'node:http'; +import { randomUUID } from 'node:crypto'; +import { spawn, execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import net from 'node:net'; +import { fileURLToPath } from 'node:url'; +import { parseDesignMd } from './design-parser.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +// PID file in the project root so both the server and agent can find it +// predictably (os.tmpdir() varies across platforms). +const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway +const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s + +// --------------------------------------------------------------------------- +// Port detection +// --------------------------------------------------------------------------- + +async function findOpenPort(start = 8400) { + return new Promise((resolve) => { + const srv = net.createServer(); + srv.listen(start, '127.0.0.1', () => { + const port = srv.address().port; + srv.close(() => resolve(port)); + }); + srv.on('error', () => resolve(findOpenPort(start + 1))); + }); +} + +// --------------------------------------------------------------------------- +// Session state +// --------------------------------------------------------------------------- + +const state = { + token: null, + port: null, + sseClients: new Set(), // SSE response objects (server→browser push) + pendingEvents: [], // browser events waiting for agent poll + pendingPolls: [], // agent poll callbacks waiting for browser events + exitTimer: null, + sessionDir: null, // per-session tmp dir for annotation screenshots +}; + +// Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; +// cap at 10 MB to guard against runaway writes from a misbehaving client. +const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024; + +function enqueueEvent(event) { + if (state.pendingPolls.length > 0) { + state.pendingPolls.shift()(event); + } else { + state.pendingEvents.push(event); + } +} + +/** Push a message to all connected SSE clients. */ +function broadcast(msg) { + const data = 'data: ' + JSON.stringify(msg) + '\n\n'; + for (const res of state.sseClients) { + try { res.write(data); } catch { /* client gone */ } + } +} + +// --------------------------------------------------------------------------- +// Load scripts +// --------------------------------------------------------------------------- + +function loadBrowserScripts() { + // Detection script: look relative to the skill scripts dir, then fall back + // to the npm package location (src/detect-antipatterns-browser.js). + // This one IS cached — detect.js rarely changes during a session. + const detectPaths = [ + path.join(__dirname, '..', '..', '..', '..', 'src', 'detect-antipatterns-browser.js'), + path.join(process.cwd(), 'node_modules', 'impeccable', 'src', 'detect-antipatterns-browser.js'), + ]; + let detectScript = ''; + for (const p of detectPaths) { + try { detectScript = fs.readFileSync(p, 'utf-8'); break; } catch { /* try next */ } + } + + // live-browser.js: DO NOT cache. Return the path so the /live.js handler + // can re-read on every request. Editing the browser script during iteration + // should land on the next tab reload, not require a server restart. + const livePath = path.join(__dirname, 'live-browser.js'); + if (!fs.existsSync(livePath)) { + process.stderr.write('Error: live-browser.js not found at ' + livePath + '\n'); + process.exit(1); + } + + return { detectScript, livePath }; +} + +function hasProjectContext() { + // PRODUCT.md carries brand voice / anti-references — that's what determines + // whether variants are brand-aware. DESIGN.md (visual tokens) is a separate + // concern, surfaced by the design panel's own empty state. Legacy + // .impeccable.md is auto-migrated to PRODUCT.md by load-context.mjs. + try { + fs.accessSync(path.join(process.cwd(), 'PRODUCT.md'), fs.constants.R_OK); + return true; + } catch { return false; } +} + +function statOrNull(filePath) { + try { return fs.statSync(filePath); } catch { return null; } +} + +// --------------------------------------------------------------------------- +// Validation (inline — no external import needed for self-contained script) +// --------------------------------------------------------------------------- + +const VISUAL_ACTIONS = [ + 'impeccable', 'bolder', 'quieter', 'distill', 'polish', 'typeset', + 'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive', +]; + +function validateEvent(msg) { + if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message'; + switch (msg.type) { + case 'generate': + if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id'; + if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action'; + if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8'; + if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context'; + // Optional annotation fields (all-or-nothing: if any present, all must be well-formed). + if (msg.screenshotPath !== undefined && typeof msg.screenshotPath !== 'string') return 'generate: screenshotPath must be string'; + if (msg.comments !== undefined && !Array.isArray(msg.comments)) return 'generate: comments must be array'; + if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array'; + return null; + case 'accept': + if (!msg.id) return 'accept: missing id'; + if (!msg.variantId) return 'accept: missing variantId'; + if (msg.paramValues !== undefined) { + if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) { + return 'accept: paramValues must be an object'; + } + } + return null; + case 'discard': + return msg.id ? null : 'discard: missing id'; + case 'exit': + return null; + case 'prefetch': + if (!msg.pageUrl || typeof msg.pageUrl !== 'string') return 'prefetch: missing pageUrl'; + return null; + default: + return 'Unknown event type: ' + msg.type; + } +} + +// --------------------------------------------------------------------------- +// HTTP request handler +// --------------------------------------------------------------------------- + +function createRequestHandler({ detectScript, livePath }) { + return (req, res) => { + const url = new URL(req.url, `http://localhost:${state.port}`); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } + + const p = url.pathname; + + // --- Scripts --- + if (p === '/live.js') { + // Re-read from disk each request so edits to live-browser.js land on + // the next tab reload. No-store headers prevent browser caching across + // sessions — during iteration, a cached old script silently breaks + // every subsequent session. + let liveScript; + try { + liveScript = fs.readFileSync(livePath, 'utf-8'); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end('Error reading live-browser.js: ' + err.message); + return; + } + const body = + `window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` + + `window.__IMPECCABLE_PORT__ = ${state.port};\n` + + liveScript; + res.writeHead(200, { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', + 'Pragma': 'no-cache', + }); + res.end(body); + return; + } + if (p === '/detect.js' || p === '/') { + if (!detectScript) { res.writeHead(404); res.end('Not available'); return; } + res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.end(detectScript); + return; + } + + // --- Vendored modern-screenshot (UMD build) --- + // Lazy-loaded by live.js when the user clicks Go; exposes + // window.modernScreenshot.domToBlob(...) for capture. + if (p === '/modern-screenshot.js') { + const vendorPath = path.join(__dirname, 'modern-screenshot.umd.js'); + try { + res.writeHead(200, { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'public, max-age=31536000, immutable', + }); + res.end(fs.readFileSync(vendorPath)); + } catch { + res.writeHead(404); res.end('Vendor script not found'); + } + return; + } + + // --- Annotation upload (browser → server, raw PNG body) --- + // Client generates the eventId, POSTs the PNG, then POSTs the generate + // event with screenshotPath already set. Keeps bytes out of the SSE/poll + // bridge and preserves the "one shot from the user's POV" UX. + if (p === '/annotation' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const eventId = url.searchParams.get('eventId'); + if (!eventId || !/^[A-Za-z0-9_-]{1,64}$/.test(eventId)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid eventId' })); + return; + } + if ((req.headers['content-type'] || '').toLowerCase() !== 'image/png') { + res.writeHead(415, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Content-Type must be image/png' })); + return; + } + if (!state.sessionDir) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Session dir unavailable' })); + return; + } + const chunks = []; + let total = 0; + let aborted = false; + req.on('data', (c) => { + if (aborted) return; + total += c.length; + if (total > MAX_ANNOTATION_BYTES) { + aborted = true; + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Payload too large' })); + req.destroy(); + return; + } + chunks.push(c); + }); + req.on('end', () => { + if (aborted) return; + const absPath = path.join(state.sessionDir, eventId + '.png'); + try { + fs.writeFileSync(absPath, Buffer.concat(chunks)); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Write failed: ' + err.message })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, path: absPath })); + }); + req.on('error', () => { + if (!aborted) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Upload failed' })); + } + }); + return; + } + + // --- Health --- + if (p === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'ok', port: state.port, mode: 'variant', + hasProjectContext: hasProjectContext(), + connectedClients: state.sseClients.size, + })); + return; + } + + // --- Design system (unified v2 response) + raw --- + // /design-system.json returns both parsed DESIGN.md and DESIGN.json + // sidecar when present. Panel merges them: + // { present, parsed, sidecar, hasMd, hasSidecar, + // mdNewerThanJson, parseError?, sidecarError? } + // - parsed: output of parseDesignMd (frontmatter + // + six canonical sections) when DESIGN.md exists. + // - sidecar: DESIGN.json contents when present. + // Expected shape: schemaVersion 2, carrying + // extensions + components + narrative. + // /design-system/raw returns DESIGN.md markdown verbatim + if (p === '/design-system.json' || p === '/design-system/raw') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + + const mdPath = path.join(process.cwd(), 'DESIGN.md'); + const jsonPath = path.join(process.cwd(), 'DESIGN.json'); + const mdStat = statOrNull(mdPath); + const jsonStat = statOrNull(jsonPath); + + if (p === '/design-system/raw') { + if (!mdStat) { res.writeHead(404); res.end('Not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' }); + res.end(fs.readFileSync(mdPath, 'utf-8')); + return; + } + + if (!mdStat && !jsonStat) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ present: false })); + return; + } + + const response = { + present: true, + hasMd: !!mdStat, + hasSidecar: !!jsonStat, + mdNewerThanJson: !!(mdStat && jsonStat && mdStat.mtimeMs > jsonStat.mtimeMs + 1000), + }; + + if (mdStat) { + try { + response.parsed = parseDesignMd(fs.readFileSync(mdPath, 'utf-8')); + } catch (err) { + response.parseError = err.message; + } + } + + if (jsonStat) { + try { + response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); + } catch (err) { + response.sidecarError = 'Failed to parse DESIGN.json: ' + err.message; + } + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(response)); + return; + } + + // --- Source file (no-HMR fallback) --- + if (p === '/source') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const filePath = url.searchParams.get('path'); + if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; } + const absPath = path.resolve(process.cwd(), filePath); + if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; } + let content; + try { content = fs.readFileSync(absPath, 'utf-8'); } + catch { res.writeHead(404); res.end('File not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(content); + return; + } + + // --- SSE: server→browser push (replaces WebSocket) --- + if (p === '/events' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }); + res.write('data: ' + JSON.stringify({ + type: 'connected', + hasProjectContext: hasProjectContext(), + }) + '\n\n'); + + state.sseClients.add(res); + clearTimeout(state.exitTimer); + + // Keepalive: SSE comment every 30s prevents silent connection drops. + const heartbeat = setInterval(() => { + try { res.write(': keepalive\n\n'); } catch { clearInterval(heartbeat); } + }, SSE_HEARTBEAT_INTERVAL); + + req.on('close', () => { + clearInterval(heartbeat); + state.sseClients.delete(res); + if (state.sseClients.size === 0) { + clearTimeout(state.exitTimer); + state.exitTimer = setTimeout(() => { + if (state.sseClients.size === 0) enqueueEvent({ type: 'exit' }); + }, 8000); + } + }); + return; + } + + // --- Browser→server events (replaces WebSocket messages) --- + if (p === '/events' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (msg.token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + const error = validateEvent(msg); + if (error) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error })); + return; + } + enqueueEvent(msg); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); + return; + } + + // --- Stop --- + if (p === '/stop') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('stopping'); + shutdown(); + return; + } + + // --- Agent poll --- + if (p === '/poll' && req.method === 'GET') { + handlePollGet(req, res, url); + return; + } + if (p === '/poll' && req.method === 'POST') { + handlePollPost(req, res); + return; + } + + res.writeHead(404); res.end('Not found'); + }; +} + +// --------------------------------------------------------------------------- +// Agent poll endpoints (unchanged from WS version) +// --------------------------------------------------------------------------- + +function handlePollGet(req, res, url) { + const token = url.searchParams.get('token'); + if (token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10); + if (state.pendingEvents.length > 0) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(state.pendingEvents.shift())); + return; + } + const timer = setTimeout(() => { + const idx = state.pendingPolls.indexOf(resolve); + if (idx !== -1) state.pendingPolls.splice(idx, 1); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ type: 'timeout' })); + }, timeout); + function resolve(event) { + clearTimeout(timer); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(event)); + } + state.pendingPolls.push(resolve); + req.on('close', () => { + clearTimeout(timer); + const idx = state.pendingPolls.indexOf(resolve); + if (idx !== -1) state.pendingPolls.splice(idx, 1); + }); +} + +function handlePollPost(req, res) { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (msg.token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + // Forward the reply to the browser via SSE + broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); +} + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +let httpServer = null; + +function shutdown() { + try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + if (state.sessionDir) { + try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} + } + for (const res of state.sseClients) { try { res.end(); } catch {} } + state.sseClients.clear(); + for (const resolve of state.pendingPolls) resolve({ type: 'exit' }); + state.pendingPolls.length = 0; + if (httpServer) httpServer.close(); + process.exit(0); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +const args = process.argv.slice(2); + +if (args.includes('--help') || args.includes('-h')) { + console.log(`Usage: node live-server.mjs [options] + +Start the live variant mode server (zero dependencies). + +Commands: + (default) Start the server (foreground) + stop Stop the server and remove the injected live.js script tag + stop --keep-inject Stop the server only (leave the script tag in the HTML entry) + +Options: + --background Start detached, print connection JSON to stdout, then exit + --port=PORT Use a specific port (default: auto-detect starting at 8400) + --keep-inject Only with stop: skip live-inject.mjs --remove + --help Show this help + +Endpoints: + /live.js Browser script (element picker + variant cycling) + /detect.js Detection overlay (backwards compatible) + /modern-screenshot.js Vendored modern-screenshot UMD build (lazy-loaded by live.js) + /annotation POST raw image/png to stage a variant screenshot + /events SSE stream (server→browser) + POST (browser→server) + /poll Long-poll for agent CLI + /source Raw source file reader (no-HMR fallback) + /health Health check`); + process.exit(0); +} + +if (args.includes('stop')) { + const keepInject = args.includes('--keep-inject'); + try { + const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`); + if (res.ok) console.log(`Stopped live server on port ${info.port}.`); + } catch { + console.log('No running live server found.'); + } + if (!keepInject) { + const injectPath = path.join(__dirname, 'live-inject.mjs'); + try { + const out = execFileSync(process.execPath, [injectPath, '--remove'], { + encoding: 'utf-8', + cwd: process.cwd(), + }); + const line = out.trim().split('\n').filter(Boolean).pop(); + if (line) { + try { + const j = JSON.parse(line); + if (j.removed === true) { + console.log(`Removed live script tag from ${j.file}.`); + } + } catch { + /* ignore non-JSON lines */ + } + } + } catch (err) { + const detail = err.stderr?.toString?.().trim?.() + || err.stdout?.toString?.().trim?.() + || err.message + || String(err); + console.warn(`Note: could not remove live script tag (${detail.split('\n')[0]})`); + } + } + process.exit(0); +} + +// --background: spawn a detached child server, wait for it to be ready, +// print the connection JSON, then exit. This keeps the startup command +// simple (no shell backgrounding or chained commands). +if (args.includes('--background')) { + const childArgs = args.filter(a => a !== '--background'); + const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...childArgs], { + detached: true, + stdio: 'ignore', + cwd: process.cwd(), + }); + child.unref(); + + // Poll for the PID file (the child writes it once the HTTP server is listening). + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + try { + const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + if (info.pid !== process.pid) { + // Output JSON so the agent can read port + token from stdout. + console.log(JSON.stringify(info)); + process.exit(0); + } + } catch { /* not ready yet */ } + await new Promise(r => setTimeout(r, 200)); + } + console.error('Timed out waiting for live server to start.'); + process.exit(1); +} + +// Check for existing session +try { + const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + try { process.kill(existing.pid, 0); + console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`); + console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop'); + process.exit(1); + } catch { fs.unlinkSync(LIVE_PID_FILE); } +} catch {} + +state.token = randomUUID(); +const portArg = args.find(a => a.startsWith('--port=')); +state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort(); +// Annotation screenshots live in the project root so the agent's Read tool +// doesn't trip a per-file permission prompt. Sessioned by token so concurrent +// projects (or quick restarts) don't collide. +const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); +fs.mkdirSync(annotRoot, { recursive: true }); +state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); + +const { detectScript, livePath } = loadBrowserScripts(); +httpServer = http.createServer(createRequestHandler({ detectScript, livePath })); + +httpServer.listen(state.port, '127.0.0.1', () => { + fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token })); + const url = `http://localhost:${state.port}`; + console.log(`\nImpeccable live server running on ${url}`); + console.log(`Token: ${state.token}\n`); + console.log(`Inject: \n' + + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' + ); +} + +function insertTag(content, config, port) { + const block = buildTagBlock(config.commentSyntax, port); + // insertBefore: match the LAST occurrence. Anchors like `` naturally + // belong at the end, and the same literal can appear earlier in code blocks + // within rendered documentation pages. + if (config.insertBefore) { + const idx = content.lastIndexOf(config.insertBefore); + if (idx === -1) return content; + return content.slice(0, idx) + block + content.slice(idx); + } + // insertAfter: match the FIRST occurrence — typical anchors like `` or + // `` open near the top of the document. + const idx = content.indexOf(config.insertAfter); + if (idx === -1) return content; + const after = idx + config.insertAfter.length; + // Preserve a single trailing newline if the anchor didn't end with one + const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; + return prefix + block + content.slice(prefix.length); +} + +/** + * Remove the live script block. Matches either HTML or JSX comment markers + * regardless of config (so stale tags from a wrong config can still be cleaned). + * + * Indent-preserving: captures any whitespace immediately preceding the opener + * marker and re-emits it in place of the removed block. `insertTag` inserted + * the block *after* the original line's indent and *before* the anchor (e.g. + * ``), which moved the indent onto the opener line and left the anchor + * unindented. Replacing the whole block (plus its trailing newline) with just + * the captured indent hands the indent back to the anchor that follows. + */ +function removeTag(content, _syntax) { + const patterns = [ + /([ \t]*)[\s\S]*?[ \t]*\n/, + /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}[ \t]*\n/, + ]; + for (const pat of patterns) { + const next = content.replace(pat, '$1'); + if (next !== content) return next; + } + return content; +} + +// --------------------------------------------------------------------------- +// Auto-execute +// --------------------------------------------------------------------------- + +const _running = process.argv[1]; +if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) { + injectCli(); +} + +export { insertTag, removeTag, validateConfig, buildTagBlock }; diff --git a/.gemini/skills/impeccable/scripts/live-poll.mjs b/.gemini/skills/impeccable/scripts/live-poll.mjs new file mode 100644 index 000000000..5cece1a43 --- /dev/null +++ b/.gemini/skills/impeccable/scripts/live-poll.mjs @@ -0,0 +1,187 @@ +/** + * CLI client for the live variant mode poll/reply protocol. + * + * Usage: + * npx impeccable poll # Block until browser event, print JSON + * npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly + * npx impeccable poll --reply done # Reply "done" to event + * npx impeccable poll --reply error "msg" # Reply with error + */ + +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { fileURLToPath } from 'node:url'; + +// Node's built-in fetch (undici under the hood) enforces a 300s headers +// timeout that can't be lowered per-request. We cap each request below +// that ceiling and loop in `pollOnce` to synthesize a long poll without +// depending on the standalone undici package. +const PER_REQUEST_TIMEOUT_MS = 270_000; + +const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); + +function readServerInfo() { + try { + return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + } catch { + console.error('No running live server found. Start one with: npx impeccable live'); + process.exit(1); + } +} + +export async function pollCli() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log(`Usage: impeccable poll [options] + +Wait for a browser event from the live variant server, or reply to one. + +Modes: + poll Block until a browser event arrives, print JSON + poll --reply done Reply "done" to event + poll --reply error "msg" Reply with an error message + +Options: + --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 + --help Show this help message`); + process.exit(0); + } + + const info = readServerInfo(); + const base = `http://localhost:${info.port}`; + + // Reply mode: npx impeccable poll --reply [--file path] [message] + const replyIdx = args.indexOf('--reply'); + if (replyIdx !== -1) { + const id = args[replyIdx + 1]; + const status = args[replyIdx + 2] || 'done'; + const fileIdx = args.indexOf('--file'); + const filePath = fileIdx !== -1 && fileIdx + 1 < args.length ? args[fileIdx + 1] : undefined; + // Message is any remaining positional arg that isn't a flag + const message = args.find((a, i) => i > replyIdx + 2 && !a.startsWith('--') && i !== fileIdx + 1) || undefined; + + if (!id) { + console.error('Usage: npx impeccable poll --reply [--file path] [message]'); + process.exit(1); + } + + try { + const res = await fetch(`${base}/poll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: info.token, + id, + type: status, + message, + file: filePath, + }), + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + console.error(`Reply failed (${res.status}):`, body.error || res.statusText); + process.exit(1); + } + + // Success — silent exit (agent doesn't need output for replies) + } catch (err) { + if (err.cause?.code === 'ECONNREFUSED') { + console.error('Live server not running. Start one with: npx impeccable live'); + } else { + console.error('Reply failed:', err.message); + } + process.exit(1); + } + return; + } + + // Poll mode: block until browser event. Default 10 min. Node's built-in + // fetch enforces a 300s headers timeout, so we loop in slices under that + // ceiling and keep re-polling until we get a real event or the user's + // total timeout runs out. + const timeoutArg = args.find(a => a.startsWith('--timeout=')); + const totalTimeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600000; + + const deadline = Date.now() + totalTimeout; + let event; + try { + while (true) { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + event = { type: 'timeout' }; + break; + } + const slice = Math.min(remaining, PER_REQUEST_TIMEOUT_MS); + const res = await fetch(`${base}/poll?token=${info.token}&timeout=${slice}`); + + if (res.status === 401) { + console.error('Authentication failed. The server token may have changed.'); + console.error('Try restarting: npx impeccable live stop && npx impeccable live'); + process.exit(1); + } + + if (!res.ok) { + console.error(`Poll failed: ${res.status} ${res.statusText}`); + process.exit(1); + } + + const next = await res.json(); + // Server-side timeout means no browser event arrived in this slice. + // Loop and re-poll until we get a real event or we hit the user's + // total deadline. + if (next?.type === 'timeout' && Date.now() < deadline) continue; + event = next; + break; + } + + // Auto-handle accept/discard via deterministic script + if (event.type === 'accept' || event.type === 'discard') { + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const acceptScript = path.join(__dirname, 'live-accept.mjs'); + const scriptArgs = event.type === 'discard' + ? ['--id', event.id, '--discard'] + : ['--id', event.id, '--variant', event.variantId]; + if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) { + // Pass through a JSON blob; the shell-safe wrap uses single quotes because + // values are finite {id, number|string|boolean} pairs from a validated payload. + scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`); + } + try { + const out = execSync( + `node "${acceptScript}" ${scriptArgs.join(' ')}`, + { encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 } + ); + event._acceptResult = JSON.parse(out.trim()); + } catch (err) { + event._acceptResult = { handled: false, error: err.message }; + } + } + + // Second signal path: stderr banner in case the agent parses stdout + // JSON but skips nested fields. One line is enough — the full checklist + // is in reference/live.md. + if (event._acceptResult?.carbonize === true) { + process.stderr.write('\n⚠ Carbonize cleanup REQUIRED before next poll. See reference/live.md "Required after accept".\n\n'); + } + + // Print the event as JSON — the agent reads this from stdout + console.log(JSON.stringify(event)); + } catch (err) { + if (err.cause?.code === 'ECONNREFUSED') { + console.error('Live server not running. Start one with: npx impeccable live'); + } else { + console.error('Poll failed:', err.message); + } + process.exit(1); + } +} + +// Auto-execute when run directly +const _running = process.argv[1]; +if (_running?.endsWith('live-poll.mjs') || _running?.endsWith('live-poll.mjs/')) { + pollCli(); +} diff --git a/.gemini/skills/impeccable/scripts/live-server.mjs b/.gemini/skills/impeccable/scripts/live-server.mjs new file mode 100644 index 000000000..3d608e3c0 --- /dev/null +++ b/.gemini/skills/impeccable/scripts/live-server.mjs @@ -0,0 +1,679 @@ +#!/usr/bin/env node +/** + * Live variant mode server (self-contained, zero dependencies). + * + * Serves the browser script (/live.js), the detection overlay (/detect.js), + * uses Server-Sent Events (SSE) for server→browser push, and HTTP POST for + * browser→server events. Agent communicates via HTTP long-poll (/poll). + * + * Usage: + * node /live-server.mjs # start + * node /live-server.mjs stop # stop + remove injected live.js tag + * node /live-server.mjs stop --keep-inject # stop only + * node /live-server.mjs --help + */ + +import http from 'node:http'; +import { randomUUID } from 'node:crypto'; +import { spawn, execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import net from 'node:net'; +import { fileURLToPath } from 'node:url'; +import { parseDesignMd } from './design-parser.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +// PID file in the project root so both the server and agent can find it +// predictably (os.tmpdir() varies across platforms). +const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway +const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s + +// --------------------------------------------------------------------------- +// Port detection +// --------------------------------------------------------------------------- + +async function findOpenPort(start = 8400) { + return new Promise((resolve) => { + const srv = net.createServer(); + srv.listen(start, '127.0.0.1', () => { + const port = srv.address().port; + srv.close(() => resolve(port)); + }); + srv.on('error', () => resolve(findOpenPort(start + 1))); + }); +} + +// --------------------------------------------------------------------------- +// Session state +// --------------------------------------------------------------------------- + +const state = { + token: null, + port: null, + sseClients: new Set(), // SSE response objects (server→browser push) + pendingEvents: [], // browser events waiting for agent poll + pendingPolls: [], // agent poll callbacks waiting for browser events + exitTimer: null, + sessionDir: null, // per-session tmp dir for annotation screenshots +}; + +// Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; +// cap at 10 MB to guard against runaway writes from a misbehaving client. +const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024; + +function enqueueEvent(event) { + if (state.pendingPolls.length > 0) { + state.pendingPolls.shift()(event); + } else { + state.pendingEvents.push(event); + } +} + +/** Push a message to all connected SSE clients. */ +function broadcast(msg) { + const data = 'data: ' + JSON.stringify(msg) + '\n\n'; + for (const res of state.sseClients) { + try { res.write(data); } catch { /* client gone */ } + } +} + +// --------------------------------------------------------------------------- +// Load scripts +// --------------------------------------------------------------------------- + +function loadBrowserScripts() { + // Detection script: look relative to the skill scripts dir, then fall back + // to the npm package location (src/detect-antipatterns-browser.js). + // This one IS cached — detect.js rarely changes during a session. + const detectPaths = [ + path.join(__dirname, '..', '..', '..', '..', 'src', 'detect-antipatterns-browser.js'), + path.join(process.cwd(), 'node_modules', 'impeccable', 'src', 'detect-antipatterns-browser.js'), + ]; + let detectScript = ''; + for (const p of detectPaths) { + try { detectScript = fs.readFileSync(p, 'utf-8'); break; } catch { /* try next */ } + } + + // live-browser.js: DO NOT cache. Return the path so the /live.js handler + // can re-read on every request. Editing the browser script during iteration + // should land on the next tab reload, not require a server restart. + const livePath = path.join(__dirname, 'live-browser.js'); + if (!fs.existsSync(livePath)) { + process.stderr.write('Error: live-browser.js not found at ' + livePath + '\n'); + process.exit(1); + } + + return { detectScript, livePath }; +} + +function hasProjectContext() { + // PRODUCT.md carries brand voice / anti-references — that's what determines + // whether variants are brand-aware. DESIGN.md (visual tokens) is a separate + // concern, surfaced by the design panel's own empty state. Legacy + // .impeccable.md is auto-migrated to PRODUCT.md by load-context.mjs. + try { + fs.accessSync(path.join(process.cwd(), 'PRODUCT.md'), fs.constants.R_OK); + return true; + } catch { return false; } +} + +function statOrNull(filePath) { + try { return fs.statSync(filePath); } catch { return null; } +} + +// --------------------------------------------------------------------------- +// Validation (inline — no external import needed for self-contained script) +// --------------------------------------------------------------------------- + +const VISUAL_ACTIONS = [ + 'impeccable', 'bolder', 'quieter', 'distill', 'polish', 'typeset', + 'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive', +]; + +function validateEvent(msg) { + if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message'; + switch (msg.type) { + case 'generate': + if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id'; + if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action'; + if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8'; + if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context'; + // Optional annotation fields (all-or-nothing: if any present, all must be well-formed). + if (msg.screenshotPath !== undefined && typeof msg.screenshotPath !== 'string') return 'generate: screenshotPath must be string'; + if (msg.comments !== undefined && !Array.isArray(msg.comments)) return 'generate: comments must be array'; + if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array'; + return null; + case 'accept': + if (!msg.id) return 'accept: missing id'; + if (!msg.variantId) return 'accept: missing variantId'; + if (msg.paramValues !== undefined) { + if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) { + return 'accept: paramValues must be an object'; + } + } + return null; + case 'discard': + return msg.id ? null : 'discard: missing id'; + case 'exit': + return null; + case 'prefetch': + if (!msg.pageUrl || typeof msg.pageUrl !== 'string') return 'prefetch: missing pageUrl'; + return null; + default: + return 'Unknown event type: ' + msg.type; + } +} + +// --------------------------------------------------------------------------- +// HTTP request handler +// --------------------------------------------------------------------------- + +function createRequestHandler({ detectScript, livePath }) { + return (req, res) => { + const url = new URL(req.url, `http://localhost:${state.port}`); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } + + const p = url.pathname; + + // --- Scripts --- + if (p === '/live.js') { + // Re-read from disk each request so edits to live-browser.js land on + // the next tab reload. No-store headers prevent browser caching across + // sessions — during iteration, a cached old script silently breaks + // every subsequent session. + let liveScript; + try { + liveScript = fs.readFileSync(livePath, 'utf-8'); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end('Error reading live-browser.js: ' + err.message); + return; + } + const body = + `window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` + + `window.__IMPECCABLE_PORT__ = ${state.port};\n` + + liveScript; + res.writeHead(200, { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', + 'Pragma': 'no-cache', + }); + res.end(body); + return; + } + if (p === '/detect.js' || p === '/') { + if (!detectScript) { res.writeHead(404); res.end('Not available'); return; } + res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.end(detectScript); + return; + } + + // --- Vendored modern-screenshot (UMD build) --- + // Lazy-loaded by live.js when the user clicks Go; exposes + // window.modernScreenshot.domToBlob(...) for capture. + if (p === '/modern-screenshot.js') { + const vendorPath = path.join(__dirname, 'modern-screenshot.umd.js'); + try { + res.writeHead(200, { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'public, max-age=31536000, immutable', + }); + res.end(fs.readFileSync(vendorPath)); + } catch { + res.writeHead(404); res.end('Vendor script not found'); + } + return; + } + + // --- Annotation upload (browser → server, raw PNG body) --- + // Client generates the eventId, POSTs the PNG, then POSTs the generate + // event with screenshotPath already set. Keeps bytes out of the SSE/poll + // bridge and preserves the "one shot from the user's POV" UX. + if (p === '/annotation' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const eventId = url.searchParams.get('eventId'); + if (!eventId || !/^[A-Za-z0-9_-]{1,64}$/.test(eventId)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid eventId' })); + return; + } + if ((req.headers['content-type'] || '').toLowerCase() !== 'image/png') { + res.writeHead(415, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Content-Type must be image/png' })); + return; + } + if (!state.sessionDir) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Session dir unavailable' })); + return; + } + const chunks = []; + let total = 0; + let aborted = false; + req.on('data', (c) => { + if (aborted) return; + total += c.length; + if (total > MAX_ANNOTATION_BYTES) { + aborted = true; + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Payload too large' })); + req.destroy(); + return; + } + chunks.push(c); + }); + req.on('end', () => { + if (aborted) return; + const absPath = path.join(state.sessionDir, eventId + '.png'); + try { + fs.writeFileSync(absPath, Buffer.concat(chunks)); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Write failed: ' + err.message })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, path: absPath })); + }); + req.on('error', () => { + if (!aborted) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Upload failed' })); + } + }); + return; + } + + // --- Health --- + if (p === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'ok', port: state.port, mode: 'variant', + hasProjectContext: hasProjectContext(), + connectedClients: state.sseClients.size, + })); + return; + } + + // --- Design system (unified v2 response) + raw --- + // /design-system.json returns both parsed DESIGN.md and DESIGN.json + // sidecar when present. Panel merges them: + // { present, parsed, sidecar, hasMd, hasSidecar, + // mdNewerThanJson, parseError?, sidecarError? } + // - parsed: output of parseDesignMd (frontmatter + // + six canonical sections) when DESIGN.md exists. + // - sidecar: DESIGN.json contents when present. + // Expected shape: schemaVersion 2, carrying + // extensions + components + narrative. + // /design-system/raw returns DESIGN.md markdown verbatim + if (p === '/design-system.json' || p === '/design-system/raw') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + + const mdPath = path.join(process.cwd(), 'DESIGN.md'); + const jsonPath = path.join(process.cwd(), 'DESIGN.json'); + const mdStat = statOrNull(mdPath); + const jsonStat = statOrNull(jsonPath); + + if (p === '/design-system/raw') { + if (!mdStat) { res.writeHead(404); res.end('Not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' }); + res.end(fs.readFileSync(mdPath, 'utf-8')); + return; + } + + if (!mdStat && !jsonStat) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ present: false })); + return; + } + + const response = { + present: true, + hasMd: !!mdStat, + hasSidecar: !!jsonStat, + mdNewerThanJson: !!(mdStat && jsonStat && mdStat.mtimeMs > jsonStat.mtimeMs + 1000), + }; + + if (mdStat) { + try { + response.parsed = parseDesignMd(fs.readFileSync(mdPath, 'utf-8')); + } catch (err) { + response.parseError = err.message; + } + } + + if (jsonStat) { + try { + response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); + } catch (err) { + response.sidecarError = 'Failed to parse DESIGN.json: ' + err.message; + } + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(response)); + return; + } + + // --- Source file (no-HMR fallback) --- + if (p === '/source') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const filePath = url.searchParams.get('path'); + if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; } + const absPath = path.resolve(process.cwd(), filePath); + if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; } + let content; + try { content = fs.readFileSync(absPath, 'utf-8'); } + catch { res.writeHead(404); res.end('File not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(content); + return; + } + + // --- SSE: server→browser push (replaces WebSocket) --- + if (p === '/events' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }); + res.write('data: ' + JSON.stringify({ + type: 'connected', + hasProjectContext: hasProjectContext(), + }) + '\n\n'); + + state.sseClients.add(res); + clearTimeout(state.exitTimer); + + // Keepalive: SSE comment every 30s prevents silent connection drops. + const heartbeat = setInterval(() => { + try { res.write(': keepalive\n\n'); } catch { clearInterval(heartbeat); } + }, SSE_HEARTBEAT_INTERVAL); + + req.on('close', () => { + clearInterval(heartbeat); + state.sseClients.delete(res); + if (state.sseClients.size === 0) { + clearTimeout(state.exitTimer); + state.exitTimer = setTimeout(() => { + if (state.sseClients.size === 0) enqueueEvent({ type: 'exit' }); + }, 8000); + } + }); + return; + } + + // --- Browser→server events (replaces WebSocket messages) --- + if (p === '/events' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (msg.token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + const error = validateEvent(msg); + if (error) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error })); + return; + } + enqueueEvent(msg); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); + return; + } + + // --- Stop --- + if (p === '/stop') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('stopping'); + shutdown(); + return; + } + + // --- Agent poll --- + if (p === '/poll' && req.method === 'GET') { + handlePollGet(req, res, url); + return; + } + if (p === '/poll' && req.method === 'POST') { + handlePollPost(req, res); + return; + } + + res.writeHead(404); res.end('Not found'); + }; +} + +// --------------------------------------------------------------------------- +// Agent poll endpoints (unchanged from WS version) +// --------------------------------------------------------------------------- + +function handlePollGet(req, res, url) { + const token = url.searchParams.get('token'); + if (token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10); + if (state.pendingEvents.length > 0) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(state.pendingEvents.shift())); + return; + } + const timer = setTimeout(() => { + const idx = state.pendingPolls.indexOf(resolve); + if (idx !== -1) state.pendingPolls.splice(idx, 1); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ type: 'timeout' })); + }, timeout); + function resolve(event) { + clearTimeout(timer); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(event)); + } + state.pendingPolls.push(resolve); + req.on('close', () => { + clearTimeout(timer); + const idx = state.pendingPolls.indexOf(resolve); + if (idx !== -1) state.pendingPolls.splice(idx, 1); + }); +} + +function handlePollPost(req, res) { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (msg.token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + // Forward the reply to the browser via SSE + broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); +} + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +let httpServer = null; + +function shutdown() { + try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + if (state.sessionDir) { + try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} + } + for (const res of state.sseClients) { try { res.end(); } catch {} } + state.sseClients.clear(); + for (const resolve of state.pendingPolls) resolve({ type: 'exit' }); + state.pendingPolls.length = 0; + if (httpServer) httpServer.close(); + process.exit(0); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +const args = process.argv.slice(2); + +if (args.includes('--help') || args.includes('-h')) { + console.log(`Usage: node live-server.mjs [options] + +Start the live variant mode server (zero dependencies). + +Commands: + (default) Start the server (foreground) + stop Stop the server and remove the injected live.js script tag + stop --keep-inject Stop the server only (leave the script tag in the HTML entry) + +Options: + --background Start detached, print connection JSON to stdout, then exit + --port=PORT Use a specific port (default: auto-detect starting at 8400) + --keep-inject Only with stop: skip live-inject.mjs --remove + --help Show this help + +Endpoints: + /live.js Browser script (element picker + variant cycling) + /detect.js Detection overlay (backwards compatible) + /modern-screenshot.js Vendored modern-screenshot UMD build (lazy-loaded by live.js) + /annotation POST raw image/png to stage a variant screenshot + /events SSE stream (server→browser) + POST (browser→server) + /poll Long-poll for agent CLI + /source Raw source file reader (no-HMR fallback) + /health Health check`); + process.exit(0); +} + +if (args.includes('stop')) { + const keepInject = args.includes('--keep-inject'); + try { + const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`); + if (res.ok) console.log(`Stopped live server on port ${info.port}.`); + } catch { + console.log('No running live server found.'); + } + if (!keepInject) { + const injectPath = path.join(__dirname, 'live-inject.mjs'); + try { + const out = execFileSync(process.execPath, [injectPath, '--remove'], { + encoding: 'utf-8', + cwd: process.cwd(), + }); + const line = out.trim().split('\n').filter(Boolean).pop(); + if (line) { + try { + const j = JSON.parse(line); + if (j.removed === true) { + console.log(`Removed live script tag from ${j.file}.`); + } + } catch { + /* ignore non-JSON lines */ + } + } + } catch (err) { + const detail = err.stderr?.toString?.().trim?.() + || err.stdout?.toString?.().trim?.() + || err.message + || String(err); + console.warn(`Note: could not remove live script tag (${detail.split('\n')[0]})`); + } + } + process.exit(0); +} + +// --background: spawn a detached child server, wait for it to be ready, +// print the connection JSON, then exit. This keeps the startup command +// simple (no shell backgrounding or chained commands). +if (args.includes('--background')) { + const childArgs = args.filter(a => a !== '--background'); + const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...childArgs], { + detached: true, + stdio: 'ignore', + cwd: process.cwd(), + }); + child.unref(); + + // Poll for the PID file (the child writes it once the HTTP server is listening). + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + try { + const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + if (info.pid !== process.pid) { + // Output JSON so the agent can read port + token from stdout. + console.log(JSON.stringify(info)); + process.exit(0); + } + } catch { /* not ready yet */ } + await new Promise(r => setTimeout(r, 200)); + } + console.error('Timed out waiting for live server to start.'); + process.exit(1); +} + +// Check for existing session +try { + const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + try { process.kill(existing.pid, 0); + console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`); + console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop'); + process.exit(1); + } catch { fs.unlinkSync(LIVE_PID_FILE); } +} catch {} + +state.token = randomUUID(); +const portArg = args.find(a => a.startsWith('--port=')); +state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort(); +// Annotation screenshots live in the project root so the agent's Read tool +// doesn't trip a per-file permission prompt. Sessioned by token so concurrent +// projects (or quick restarts) don't collide. +const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); +fs.mkdirSync(annotRoot, { recursive: true }); +state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); + +const { detectScript, livePath } = loadBrowserScripts(); +httpServer = http.createServer(createRequestHandler({ detectScript, livePath })); + +httpServer.listen(state.port, '127.0.0.1', () => { + fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token })); + const url = `http://localhost:${state.port}`; + console.log(`\nImpeccable live server running on ${url}`); + console.log(`Token: ${state.token}\n`); + console.log(`Inject: \n' + + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' + ); +} + +function insertTag(content, config, port) { + const block = buildTagBlock(config.commentSyntax, port); + // insertBefore: match the LAST occurrence. Anchors like `` naturally + // belong at the end, and the same literal can appear earlier in code blocks + // within rendered documentation pages. + if (config.insertBefore) { + const idx = content.lastIndexOf(config.insertBefore); + if (idx === -1) return content; + return content.slice(0, idx) + block + content.slice(idx); + } + // insertAfter: match the FIRST occurrence — typical anchors like `` or + // `` open near the top of the document. + const idx = content.indexOf(config.insertAfter); + if (idx === -1) return content; + const after = idx + config.insertAfter.length; + // Preserve a single trailing newline if the anchor didn't end with one + const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; + return prefix + block + content.slice(prefix.length); +} + +/** + * Remove the live script block. Matches either HTML or JSX comment markers + * regardless of config (so stale tags from a wrong config can still be cleaned). + * + * Indent-preserving: captures any whitespace immediately preceding the opener + * marker and re-emits it in place of the removed block. `insertTag` inserted + * the block *after* the original line's indent and *before* the anchor (e.g. + * ``), which moved the indent onto the opener line and left the anchor + * unindented. Replacing the whole block (plus its trailing newline) with just + * the captured indent hands the indent back to the anchor that follows. + */ +function removeTag(content, _syntax) { + const patterns = [ + /([ \t]*)[\s\S]*?[ \t]*\n/, + /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}[ \t]*\n/, + ]; + for (const pat of patterns) { + const next = content.replace(pat, '$1'); + if (next !== content) return next; + } + return content; +} + +// --------------------------------------------------------------------------- +// Auto-execute +// --------------------------------------------------------------------------- + +const _running = process.argv[1]; +if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) { + injectCli(); +} + +export { insertTag, removeTag, validateConfig, buildTagBlock }; diff --git a/.github/skills/impeccable/scripts/live-poll.mjs b/.github/skills/impeccable/scripts/live-poll.mjs new file mode 100644 index 000000000..5cece1a43 --- /dev/null +++ b/.github/skills/impeccable/scripts/live-poll.mjs @@ -0,0 +1,187 @@ +/** + * CLI client for the live variant mode poll/reply protocol. + * + * Usage: + * npx impeccable poll # Block until browser event, print JSON + * npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly + * npx impeccable poll --reply done # Reply "done" to event + * npx impeccable poll --reply error "msg" # Reply with error + */ + +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { fileURLToPath } from 'node:url'; + +// Node's built-in fetch (undici under the hood) enforces a 300s headers +// timeout that can't be lowered per-request. We cap each request below +// that ceiling and loop in `pollOnce` to synthesize a long poll without +// depending on the standalone undici package. +const PER_REQUEST_TIMEOUT_MS = 270_000; + +const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); + +function readServerInfo() { + try { + return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + } catch { + console.error('No running live server found. Start one with: npx impeccable live'); + process.exit(1); + } +} + +export async function pollCli() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log(`Usage: impeccable poll [options] + +Wait for a browser event from the live variant server, or reply to one. + +Modes: + poll Block until a browser event arrives, print JSON + poll --reply done Reply "done" to event + poll --reply error "msg" Reply with an error message + +Options: + --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 + --help Show this help message`); + process.exit(0); + } + + const info = readServerInfo(); + const base = `http://localhost:${info.port}`; + + // Reply mode: npx impeccable poll --reply [--file path] [message] + const replyIdx = args.indexOf('--reply'); + if (replyIdx !== -1) { + const id = args[replyIdx + 1]; + const status = args[replyIdx + 2] || 'done'; + const fileIdx = args.indexOf('--file'); + const filePath = fileIdx !== -1 && fileIdx + 1 < args.length ? args[fileIdx + 1] : undefined; + // Message is any remaining positional arg that isn't a flag + const message = args.find((a, i) => i > replyIdx + 2 && !a.startsWith('--') && i !== fileIdx + 1) || undefined; + + if (!id) { + console.error('Usage: npx impeccable poll --reply [--file path] [message]'); + process.exit(1); + } + + try { + const res = await fetch(`${base}/poll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: info.token, + id, + type: status, + message, + file: filePath, + }), + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + console.error(`Reply failed (${res.status}):`, body.error || res.statusText); + process.exit(1); + } + + // Success — silent exit (agent doesn't need output for replies) + } catch (err) { + if (err.cause?.code === 'ECONNREFUSED') { + console.error('Live server not running. Start one with: npx impeccable live'); + } else { + console.error('Reply failed:', err.message); + } + process.exit(1); + } + return; + } + + // Poll mode: block until browser event. Default 10 min. Node's built-in + // fetch enforces a 300s headers timeout, so we loop in slices under that + // ceiling and keep re-polling until we get a real event or the user's + // total timeout runs out. + const timeoutArg = args.find(a => a.startsWith('--timeout=')); + const totalTimeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600000; + + const deadline = Date.now() + totalTimeout; + let event; + try { + while (true) { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + event = { type: 'timeout' }; + break; + } + const slice = Math.min(remaining, PER_REQUEST_TIMEOUT_MS); + const res = await fetch(`${base}/poll?token=${info.token}&timeout=${slice}`); + + if (res.status === 401) { + console.error('Authentication failed. The server token may have changed.'); + console.error('Try restarting: npx impeccable live stop && npx impeccable live'); + process.exit(1); + } + + if (!res.ok) { + console.error(`Poll failed: ${res.status} ${res.statusText}`); + process.exit(1); + } + + const next = await res.json(); + // Server-side timeout means no browser event arrived in this slice. + // Loop and re-poll until we get a real event or we hit the user's + // total deadline. + if (next?.type === 'timeout' && Date.now() < deadline) continue; + event = next; + break; + } + + // Auto-handle accept/discard via deterministic script + if (event.type === 'accept' || event.type === 'discard') { + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const acceptScript = path.join(__dirname, 'live-accept.mjs'); + const scriptArgs = event.type === 'discard' + ? ['--id', event.id, '--discard'] + : ['--id', event.id, '--variant', event.variantId]; + if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) { + // Pass through a JSON blob; the shell-safe wrap uses single quotes because + // values are finite {id, number|string|boolean} pairs from a validated payload. + scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`); + } + try { + const out = execSync( + `node "${acceptScript}" ${scriptArgs.join(' ')}`, + { encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 } + ); + event._acceptResult = JSON.parse(out.trim()); + } catch (err) { + event._acceptResult = { handled: false, error: err.message }; + } + } + + // Second signal path: stderr banner in case the agent parses stdout + // JSON but skips nested fields. One line is enough — the full checklist + // is in reference/live.md. + if (event._acceptResult?.carbonize === true) { + process.stderr.write('\n⚠ Carbonize cleanup REQUIRED before next poll. See reference/live.md "Required after accept".\n\n'); + } + + // Print the event as JSON — the agent reads this from stdout + console.log(JSON.stringify(event)); + } catch (err) { + if (err.cause?.code === 'ECONNREFUSED') { + console.error('Live server not running. Start one with: npx impeccable live'); + } else { + console.error('Poll failed:', err.message); + } + process.exit(1); + } +} + +// Auto-execute when run directly +const _running = process.argv[1]; +if (_running?.endsWith('live-poll.mjs') || _running?.endsWith('live-poll.mjs/')) { + pollCli(); +} diff --git a/.github/skills/impeccable/scripts/live-server.mjs b/.github/skills/impeccable/scripts/live-server.mjs new file mode 100644 index 000000000..3d608e3c0 --- /dev/null +++ b/.github/skills/impeccable/scripts/live-server.mjs @@ -0,0 +1,679 @@ +#!/usr/bin/env node +/** + * Live variant mode server (self-contained, zero dependencies). + * + * Serves the browser script (/live.js), the detection overlay (/detect.js), + * uses Server-Sent Events (SSE) for server→browser push, and HTTP POST for + * browser→server events. Agent communicates via HTTP long-poll (/poll). + * + * Usage: + * node /live-server.mjs # start + * node /live-server.mjs stop # stop + remove injected live.js tag + * node /live-server.mjs stop --keep-inject # stop only + * node /live-server.mjs --help + */ + +import http from 'node:http'; +import { randomUUID } from 'node:crypto'; +import { spawn, execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import net from 'node:net'; +import { fileURLToPath } from 'node:url'; +import { parseDesignMd } from './design-parser.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +// PID file in the project root so both the server and agent can find it +// predictably (os.tmpdir() varies across platforms). +const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway +const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s + +// --------------------------------------------------------------------------- +// Port detection +// --------------------------------------------------------------------------- + +async function findOpenPort(start = 8400) { + return new Promise((resolve) => { + const srv = net.createServer(); + srv.listen(start, '127.0.0.1', () => { + const port = srv.address().port; + srv.close(() => resolve(port)); + }); + srv.on('error', () => resolve(findOpenPort(start + 1))); + }); +} + +// --------------------------------------------------------------------------- +// Session state +// --------------------------------------------------------------------------- + +const state = { + token: null, + port: null, + sseClients: new Set(), // SSE response objects (server→browser push) + pendingEvents: [], // browser events waiting for agent poll + pendingPolls: [], // agent poll callbacks waiting for browser events + exitTimer: null, + sessionDir: null, // per-session tmp dir for annotation screenshots +}; + +// Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; +// cap at 10 MB to guard against runaway writes from a misbehaving client. +const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024; + +function enqueueEvent(event) { + if (state.pendingPolls.length > 0) { + state.pendingPolls.shift()(event); + } else { + state.pendingEvents.push(event); + } +} + +/** Push a message to all connected SSE clients. */ +function broadcast(msg) { + const data = 'data: ' + JSON.stringify(msg) + '\n\n'; + for (const res of state.sseClients) { + try { res.write(data); } catch { /* client gone */ } + } +} + +// --------------------------------------------------------------------------- +// Load scripts +// --------------------------------------------------------------------------- + +function loadBrowserScripts() { + // Detection script: look relative to the skill scripts dir, then fall back + // to the npm package location (src/detect-antipatterns-browser.js). + // This one IS cached — detect.js rarely changes during a session. + const detectPaths = [ + path.join(__dirname, '..', '..', '..', '..', 'src', 'detect-antipatterns-browser.js'), + path.join(process.cwd(), 'node_modules', 'impeccable', 'src', 'detect-antipatterns-browser.js'), + ]; + let detectScript = ''; + for (const p of detectPaths) { + try { detectScript = fs.readFileSync(p, 'utf-8'); break; } catch { /* try next */ } + } + + // live-browser.js: DO NOT cache. Return the path so the /live.js handler + // can re-read on every request. Editing the browser script during iteration + // should land on the next tab reload, not require a server restart. + const livePath = path.join(__dirname, 'live-browser.js'); + if (!fs.existsSync(livePath)) { + process.stderr.write('Error: live-browser.js not found at ' + livePath + '\n'); + process.exit(1); + } + + return { detectScript, livePath }; +} + +function hasProjectContext() { + // PRODUCT.md carries brand voice / anti-references — that's what determines + // whether variants are brand-aware. DESIGN.md (visual tokens) is a separate + // concern, surfaced by the design panel's own empty state. Legacy + // .impeccable.md is auto-migrated to PRODUCT.md by load-context.mjs. + try { + fs.accessSync(path.join(process.cwd(), 'PRODUCT.md'), fs.constants.R_OK); + return true; + } catch { return false; } +} + +function statOrNull(filePath) { + try { return fs.statSync(filePath); } catch { return null; } +} + +// --------------------------------------------------------------------------- +// Validation (inline — no external import needed for self-contained script) +// --------------------------------------------------------------------------- + +const VISUAL_ACTIONS = [ + 'impeccable', 'bolder', 'quieter', 'distill', 'polish', 'typeset', + 'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive', +]; + +function validateEvent(msg) { + if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message'; + switch (msg.type) { + case 'generate': + if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id'; + if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action'; + if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8'; + if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context'; + // Optional annotation fields (all-or-nothing: if any present, all must be well-formed). + if (msg.screenshotPath !== undefined && typeof msg.screenshotPath !== 'string') return 'generate: screenshotPath must be string'; + if (msg.comments !== undefined && !Array.isArray(msg.comments)) return 'generate: comments must be array'; + if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array'; + return null; + case 'accept': + if (!msg.id) return 'accept: missing id'; + if (!msg.variantId) return 'accept: missing variantId'; + if (msg.paramValues !== undefined) { + if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) { + return 'accept: paramValues must be an object'; + } + } + return null; + case 'discard': + return msg.id ? null : 'discard: missing id'; + case 'exit': + return null; + case 'prefetch': + if (!msg.pageUrl || typeof msg.pageUrl !== 'string') return 'prefetch: missing pageUrl'; + return null; + default: + return 'Unknown event type: ' + msg.type; + } +} + +// --------------------------------------------------------------------------- +// HTTP request handler +// --------------------------------------------------------------------------- + +function createRequestHandler({ detectScript, livePath }) { + return (req, res) => { + const url = new URL(req.url, `http://localhost:${state.port}`); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } + + const p = url.pathname; + + // --- Scripts --- + if (p === '/live.js') { + // Re-read from disk each request so edits to live-browser.js land on + // the next tab reload. No-store headers prevent browser caching across + // sessions — during iteration, a cached old script silently breaks + // every subsequent session. + let liveScript; + try { + liveScript = fs.readFileSync(livePath, 'utf-8'); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end('Error reading live-browser.js: ' + err.message); + return; + } + const body = + `window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` + + `window.__IMPECCABLE_PORT__ = ${state.port};\n` + + liveScript; + res.writeHead(200, { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', + 'Pragma': 'no-cache', + }); + res.end(body); + return; + } + if (p === '/detect.js' || p === '/') { + if (!detectScript) { res.writeHead(404); res.end('Not available'); return; } + res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.end(detectScript); + return; + } + + // --- Vendored modern-screenshot (UMD build) --- + // Lazy-loaded by live.js when the user clicks Go; exposes + // window.modernScreenshot.domToBlob(...) for capture. + if (p === '/modern-screenshot.js') { + const vendorPath = path.join(__dirname, 'modern-screenshot.umd.js'); + try { + res.writeHead(200, { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'public, max-age=31536000, immutable', + }); + res.end(fs.readFileSync(vendorPath)); + } catch { + res.writeHead(404); res.end('Vendor script not found'); + } + return; + } + + // --- Annotation upload (browser → server, raw PNG body) --- + // Client generates the eventId, POSTs the PNG, then POSTs the generate + // event with screenshotPath already set. Keeps bytes out of the SSE/poll + // bridge and preserves the "one shot from the user's POV" UX. + if (p === '/annotation' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const eventId = url.searchParams.get('eventId'); + if (!eventId || !/^[A-Za-z0-9_-]{1,64}$/.test(eventId)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid eventId' })); + return; + } + if ((req.headers['content-type'] || '').toLowerCase() !== 'image/png') { + res.writeHead(415, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Content-Type must be image/png' })); + return; + } + if (!state.sessionDir) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Session dir unavailable' })); + return; + } + const chunks = []; + let total = 0; + let aborted = false; + req.on('data', (c) => { + if (aborted) return; + total += c.length; + if (total > MAX_ANNOTATION_BYTES) { + aborted = true; + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Payload too large' })); + req.destroy(); + return; + } + chunks.push(c); + }); + req.on('end', () => { + if (aborted) return; + const absPath = path.join(state.sessionDir, eventId + '.png'); + try { + fs.writeFileSync(absPath, Buffer.concat(chunks)); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Write failed: ' + err.message })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, path: absPath })); + }); + req.on('error', () => { + if (!aborted) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Upload failed' })); + } + }); + return; + } + + // --- Health --- + if (p === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'ok', port: state.port, mode: 'variant', + hasProjectContext: hasProjectContext(), + connectedClients: state.sseClients.size, + })); + return; + } + + // --- Design system (unified v2 response) + raw --- + // /design-system.json returns both parsed DESIGN.md and DESIGN.json + // sidecar when present. Panel merges them: + // { present, parsed, sidecar, hasMd, hasSidecar, + // mdNewerThanJson, parseError?, sidecarError? } + // - parsed: output of parseDesignMd (frontmatter + // + six canonical sections) when DESIGN.md exists. + // - sidecar: DESIGN.json contents when present. + // Expected shape: schemaVersion 2, carrying + // extensions + components + narrative. + // /design-system/raw returns DESIGN.md markdown verbatim + if (p === '/design-system.json' || p === '/design-system/raw') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + + const mdPath = path.join(process.cwd(), 'DESIGN.md'); + const jsonPath = path.join(process.cwd(), 'DESIGN.json'); + const mdStat = statOrNull(mdPath); + const jsonStat = statOrNull(jsonPath); + + if (p === '/design-system/raw') { + if (!mdStat) { res.writeHead(404); res.end('Not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' }); + res.end(fs.readFileSync(mdPath, 'utf-8')); + return; + } + + if (!mdStat && !jsonStat) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ present: false })); + return; + } + + const response = { + present: true, + hasMd: !!mdStat, + hasSidecar: !!jsonStat, + mdNewerThanJson: !!(mdStat && jsonStat && mdStat.mtimeMs > jsonStat.mtimeMs + 1000), + }; + + if (mdStat) { + try { + response.parsed = parseDesignMd(fs.readFileSync(mdPath, 'utf-8')); + } catch (err) { + response.parseError = err.message; + } + } + + if (jsonStat) { + try { + response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); + } catch (err) { + response.sidecarError = 'Failed to parse DESIGN.json: ' + err.message; + } + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(response)); + return; + } + + // --- Source file (no-HMR fallback) --- + if (p === '/source') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const filePath = url.searchParams.get('path'); + if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; } + const absPath = path.resolve(process.cwd(), filePath); + if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; } + let content; + try { content = fs.readFileSync(absPath, 'utf-8'); } + catch { res.writeHead(404); res.end('File not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(content); + return; + } + + // --- SSE: server→browser push (replaces WebSocket) --- + if (p === '/events' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }); + res.write('data: ' + JSON.stringify({ + type: 'connected', + hasProjectContext: hasProjectContext(), + }) + '\n\n'); + + state.sseClients.add(res); + clearTimeout(state.exitTimer); + + // Keepalive: SSE comment every 30s prevents silent connection drops. + const heartbeat = setInterval(() => { + try { res.write(': keepalive\n\n'); } catch { clearInterval(heartbeat); } + }, SSE_HEARTBEAT_INTERVAL); + + req.on('close', () => { + clearInterval(heartbeat); + state.sseClients.delete(res); + if (state.sseClients.size === 0) { + clearTimeout(state.exitTimer); + state.exitTimer = setTimeout(() => { + if (state.sseClients.size === 0) enqueueEvent({ type: 'exit' }); + }, 8000); + } + }); + return; + } + + // --- Browser→server events (replaces WebSocket messages) --- + if (p === '/events' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (msg.token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + const error = validateEvent(msg); + if (error) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error })); + return; + } + enqueueEvent(msg); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); + return; + } + + // --- Stop --- + if (p === '/stop') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('stopping'); + shutdown(); + return; + } + + // --- Agent poll --- + if (p === '/poll' && req.method === 'GET') { + handlePollGet(req, res, url); + return; + } + if (p === '/poll' && req.method === 'POST') { + handlePollPost(req, res); + return; + } + + res.writeHead(404); res.end('Not found'); + }; +} + +// --------------------------------------------------------------------------- +// Agent poll endpoints (unchanged from WS version) +// --------------------------------------------------------------------------- + +function handlePollGet(req, res, url) { + const token = url.searchParams.get('token'); + if (token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10); + if (state.pendingEvents.length > 0) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(state.pendingEvents.shift())); + return; + } + const timer = setTimeout(() => { + const idx = state.pendingPolls.indexOf(resolve); + if (idx !== -1) state.pendingPolls.splice(idx, 1); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ type: 'timeout' })); + }, timeout); + function resolve(event) { + clearTimeout(timer); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(event)); + } + state.pendingPolls.push(resolve); + req.on('close', () => { + clearTimeout(timer); + const idx = state.pendingPolls.indexOf(resolve); + if (idx !== -1) state.pendingPolls.splice(idx, 1); + }); +} + +function handlePollPost(req, res) { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (msg.token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + // Forward the reply to the browser via SSE + broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); +} + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +let httpServer = null; + +function shutdown() { + try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + if (state.sessionDir) { + try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} + } + for (const res of state.sseClients) { try { res.end(); } catch {} } + state.sseClients.clear(); + for (const resolve of state.pendingPolls) resolve({ type: 'exit' }); + state.pendingPolls.length = 0; + if (httpServer) httpServer.close(); + process.exit(0); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +const args = process.argv.slice(2); + +if (args.includes('--help') || args.includes('-h')) { + console.log(`Usage: node live-server.mjs [options] + +Start the live variant mode server (zero dependencies). + +Commands: + (default) Start the server (foreground) + stop Stop the server and remove the injected live.js script tag + stop --keep-inject Stop the server only (leave the script tag in the HTML entry) + +Options: + --background Start detached, print connection JSON to stdout, then exit + --port=PORT Use a specific port (default: auto-detect starting at 8400) + --keep-inject Only with stop: skip live-inject.mjs --remove + --help Show this help + +Endpoints: + /live.js Browser script (element picker + variant cycling) + /detect.js Detection overlay (backwards compatible) + /modern-screenshot.js Vendored modern-screenshot UMD build (lazy-loaded by live.js) + /annotation POST raw image/png to stage a variant screenshot + /events SSE stream (server→browser) + POST (browser→server) + /poll Long-poll for agent CLI + /source Raw source file reader (no-HMR fallback) + /health Health check`); + process.exit(0); +} + +if (args.includes('stop')) { + const keepInject = args.includes('--keep-inject'); + try { + const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`); + if (res.ok) console.log(`Stopped live server on port ${info.port}.`); + } catch { + console.log('No running live server found.'); + } + if (!keepInject) { + const injectPath = path.join(__dirname, 'live-inject.mjs'); + try { + const out = execFileSync(process.execPath, [injectPath, '--remove'], { + encoding: 'utf-8', + cwd: process.cwd(), + }); + const line = out.trim().split('\n').filter(Boolean).pop(); + if (line) { + try { + const j = JSON.parse(line); + if (j.removed === true) { + console.log(`Removed live script tag from ${j.file}.`); + } + } catch { + /* ignore non-JSON lines */ + } + } + } catch (err) { + const detail = err.stderr?.toString?.().trim?.() + || err.stdout?.toString?.().trim?.() + || err.message + || String(err); + console.warn(`Note: could not remove live script tag (${detail.split('\n')[0]})`); + } + } + process.exit(0); +} + +// --background: spawn a detached child server, wait for it to be ready, +// print the connection JSON, then exit. This keeps the startup command +// simple (no shell backgrounding or chained commands). +if (args.includes('--background')) { + const childArgs = args.filter(a => a !== '--background'); + const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...childArgs], { + detached: true, + stdio: 'ignore', + cwd: process.cwd(), + }); + child.unref(); + + // Poll for the PID file (the child writes it once the HTTP server is listening). + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + try { + const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + if (info.pid !== process.pid) { + // Output JSON so the agent can read port + token from stdout. + console.log(JSON.stringify(info)); + process.exit(0); + } + } catch { /* not ready yet */ } + await new Promise(r => setTimeout(r, 200)); + } + console.error('Timed out waiting for live server to start.'); + process.exit(1); +} + +// Check for existing session +try { + const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + try { process.kill(existing.pid, 0); + console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`); + console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop'); + process.exit(1); + } catch { fs.unlinkSync(LIVE_PID_FILE); } +} catch {} + +state.token = randomUUID(); +const portArg = args.find(a => a.startsWith('--port=')); +state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort(); +// Annotation screenshots live in the project root so the agent's Read tool +// doesn't trip a per-file permission prompt. Sessioned by token so concurrent +// projects (or quick restarts) don't collide. +const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); +fs.mkdirSync(annotRoot, { recursive: true }); +state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); + +const { detectScript, livePath } = loadBrowserScripts(); +httpServer = http.createServer(createRequestHandler({ detectScript, livePath })); + +httpServer.listen(state.port, '127.0.0.1', () => { + fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token })); + const url = `http://localhost:${state.port}`; + console.log(`\nImpeccable live server running on ${url}`); + console.log(`Token: ${state.token}\n`); + console.log(`Inject: \n' + + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' + ); +} + +function insertTag(content, config, port) { + const block = buildTagBlock(config.commentSyntax, port); + // insertBefore: match the LAST occurrence. Anchors like `` naturally + // belong at the end, and the same literal can appear earlier in code blocks + // within rendered documentation pages. + if (config.insertBefore) { + const idx = content.lastIndexOf(config.insertBefore); + if (idx === -1) return content; + return content.slice(0, idx) + block + content.slice(idx); + } + // insertAfter: match the FIRST occurrence — typical anchors like `` or + // `` open near the top of the document. + const idx = content.indexOf(config.insertAfter); + if (idx === -1) return content; + const after = idx + config.insertAfter.length; + // Preserve a single trailing newline if the anchor didn't end with one + const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; + return prefix + block + content.slice(prefix.length); +} + +/** + * Remove the live script block. Matches either HTML or JSX comment markers + * regardless of config (so stale tags from a wrong config can still be cleaned). + * + * Indent-preserving: captures any whitespace immediately preceding the opener + * marker and re-emits it in place of the removed block. `insertTag` inserted + * the block *after* the original line's indent and *before* the anchor (e.g. + * ``), which moved the indent onto the opener line and left the anchor + * unindented. Replacing the whole block (plus its trailing newline) with just + * the captured indent hands the indent back to the anchor that follows. + */ +function removeTag(content, _syntax) { + const patterns = [ + /([ \t]*)[\s\S]*?[ \t]*\n/, + /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}[ \t]*\n/, + ]; + for (const pat of patterns) { + const next = content.replace(pat, '$1'); + if (next !== content) return next; + } + return content; +} + +// --------------------------------------------------------------------------- +// Auto-execute +// --------------------------------------------------------------------------- + +const _running = process.argv[1]; +if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) { + injectCli(); +} + +export { insertTag, removeTag, validateConfig, buildTagBlock }; diff --git a/.kiro/skills/impeccable/scripts/live-poll.mjs b/.kiro/skills/impeccable/scripts/live-poll.mjs new file mode 100644 index 000000000..5cece1a43 --- /dev/null +++ b/.kiro/skills/impeccable/scripts/live-poll.mjs @@ -0,0 +1,187 @@ +/** + * CLI client for the live variant mode poll/reply protocol. + * + * Usage: + * npx impeccable poll # Block until browser event, print JSON + * npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly + * npx impeccable poll --reply done # Reply "done" to event + * npx impeccable poll --reply error "msg" # Reply with error + */ + +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { fileURLToPath } from 'node:url'; + +// Node's built-in fetch (undici under the hood) enforces a 300s headers +// timeout that can't be lowered per-request. We cap each request below +// that ceiling and loop in `pollOnce` to synthesize a long poll without +// depending on the standalone undici package. +const PER_REQUEST_TIMEOUT_MS = 270_000; + +const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); + +function readServerInfo() { + try { + return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + } catch { + console.error('No running live server found. Start one with: npx impeccable live'); + process.exit(1); + } +} + +export async function pollCli() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log(`Usage: impeccable poll [options] + +Wait for a browser event from the live variant server, or reply to one. + +Modes: + poll Block until a browser event arrives, print JSON + poll --reply done Reply "done" to event + poll --reply error "msg" Reply with an error message + +Options: + --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 + --help Show this help message`); + process.exit(0); + } + + const info = readServerInfo(); + const base = `http://localhost:${info.port}`; + + // Reply mode: npx impeccable poll --reply [--file path] [message] + const replyIdx = args.indexOf('--reply'); + if (replyIdx !== -1) { + const id = args[replyIdx + 1]; + const status = args[replyIdx + 2] || 'done'; + const fileIdx = args.indexOf('--file'); + const filePath = fileIdx !== -1 && fileIdx + 1 < args.length ? args[fileIdx + 1] : undefined; + // Message is any remaining positional arg that isn't a flag + const message = args.find((a, i) => i > replyIdx + 2 && !a.startsWith('--') && i !== fileIdx + 1) || undefined; + + if (!id) { + console.error('Usage: npx impeccable poll --reply [--file path] [message]'); + process.exit(1); + } + + try { + const res = await fetch(`${base}/poll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: info.token, + id, + type: status, + message, + file: filePath, + }), + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + console.error(`Reply failed (${res.status}):`, body.error || res.statusText); + process.exit(1); + } + + // Success — silent exit (agent doesn't need output for replies) + } catch (err) { + if (err.cause?.code === 'ECONNREFUSED') { + console.error('Live server not running. Start one with: npx impeccable live'); + } else { + console.error('Reply failed:', err.message); + } + process.exit(1); + } + return; + } + + // Poll mode: block until browser event. Default 10 min. Node's built-in + // fetch enforces a 300s headers timeout, so we loop in slices under that + // ceiling and keep re-polling until we get a real event or the user's + // total timeout runs out. + const timeoutArg = args.find(a => a.startsWith('--timeout=')); + const totalTimeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600000; + + const deadline = Date.now() + totalTimeout; + let event; + try { + while (true) { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + event = { type: 'timeout' }; + break; + } + const slice = Math.min(remaining, PER_REQUEST_TIMEOUT_MS); + const res = await fetch(`${base}/poll?token=${info.token}&timeout=${slice}`); + + if (res.status === 401) { + console.error('Authentication failed. The server token may have changed.'); + console.error('Try restarting: npx impeccable live stop && npx impeccable live'); + process.exit(1); + } + + if (!res.ok) { + console.error(`Poll failed: ${res.status} ${res.statusText}`); + process.exit(1); + } + + const next = await res.json(); + // Server-side timeout means no browser event arrived in this slice. + // Loop and re-poll until we get a real event or we hit the user's + // total deadline. + if (next?.type === 'timeout' && Date.now() < deadline) continue; + event = next; + break; + } + + // Auto-handle accept/discard via deterministic script + if (event.type === 'accept' || event.type === 'discard') { + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const acceptScript = path.join(__dirname, 'live-accept.mjs'); + const scriptArgs = event.type === 'discard' + ? ['--id', event.id, '--discard'] + : ['--id', event.id, '--variant', event.variantId]; + if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) { + // Pass through a JSON blob; the shell-safe wrap uses single quotes because + // values are finite {id, number|string|boolean} pairs from a validated payload. + scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`); + } + try { + const out = execSync( + `node "${acceptScript}" ${scriptArgs.join(' ')}`, + { encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 } + ); + event._acceptResult = JSON.parse(out.trim()); + } catch (err) { + event._acceptResult = { handled: false, error: err.message }; + } + } + + // Second signal path: stderr banner in case the agent parses stdout + // JSON but skips nested fields. One line is enough — the full checklist + // is in reference/live.md. + if (event._acceptResult?.carbonize === true) { + process.stderr.write('\n⚠ Carbonize cleanup REQUIRED before next poll. See reference/live.md "Required after accept".\n\n'); + } + + // Print the event as JSON — the agent reads this from stdout + console.log(JSON.stringify(event)); + } catch (err) { + if (err.cause?.code === 'ECONNREFUSED') { + console.error('Live server not running. Start one with: npx impeccable live'); + } else { + console.error('Poll failed:', err.message); + } + process.exit(1); + } +} + +// Auto-execute when run directly +const _running = process.argv[1]; +if (_running?.endsWith('live-poll.mjs') || _running?.endsWith('live-poll.mjs/')) { + pollCli(); +} diff --git a/.kiro/skills/impeccable/scripts/live-server.mjs b/.kiro/skills/impeccable/scripts/live-server.mjs new file mode 100644 index 000000000..3d608e3c0 --- /dev/null +++ b/.kiro/skills/impeccable/scripts/live-server.mjs @@ -0,0 +1,679 @@ +#!/usr/bin/env node +/** + * Live variant mode server (self-contained, zero dependencies). + * + * Serves the browser script (/live.js), the detection overlay (/detect.js), + * uses Server-Sent Events (SSE) for server→browser push, and HTTP POST for + * browser→server events. Agent communicates via HTTP long-poll (/poll). + * + * Usage: + * node /live-server.mjs # start + * node /live-server.mjs stop # stop + remove injected live.js tag + * node /live-server.mjs stop --keep-inject # stop only + * node /live-server.mjs --help + */ + +import http from 'node:http'; +import { randomUUID } from 'node:crypto'; +import { spawn, execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import net from 'node:net'; +import { fileURLToPath } from 'node:url'; +import { parseDesignMd } from './design-parser.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +// PID file in the project root so both the server and agent can find it +// predictably (os.tmpdir() varies across platforms). +const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway +const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s + +// --------------------------------------------------------------------------- +// Port detection +// --------------------------------------------------------------------------- + +async function findOpenPort(start = 8400) { + return new Promise((resolve) => { + const srv = net.createServer(); + srv.listen(start, '127.0.0.1', () => { + const port = srv.address().port; + srv.close(() => resolve(port)); + }); + srv.on('error', () => resolve(findOpenPort(start + 1))); + }); +} + +// --------------------------------------------------------------------------- +// Session state +// --------------------------------------------------------------------------- + +const state = { + token: null, + port: null, + sseClients: new Set(), // SSE response objects (server→browser push) + pendingEvents: [], // browser events waiting for agent poll + pendingPolls: [], // agent poll callbacks waiting for browser events + exitTimer: null, + sessionDir: null, // per-session tmp dir for annotation screenshots +}; + +// Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; +// cap at 10 MB to guard against runaway writes from a misbehaving client. +const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024; + +function enqueueEvent(event) { + if (state.pendingPolls.length > 0) { + state.pendingPolls.shift()(event); + } else { + state.pendingEvents.push(event); + } +} + +/** Push a message to all connected SSE clients. */ +function broadcast(msg) { + const data = 'data: ' + JSON.stringify(msg) + '\n\n'; + for (const res of state.sseClients) { + try { res.write(data); } catch { /* client gone */ } + } +} + +// --------------------------------------------------------------------------- +// Load scripts +// --------------------------------------------------------------------------- + +function loadBrowserScripts() { + // Detection script: look relative to the skill scripts dir, then fall back + // to the npm package location (src/detect-antipatterns-browser.js). + // This one IS cached — detect.js rarely changes during a session. + const detectPaths = [ + path.join(__dirname, '..', '..', '..', '..', 'src', 'detect-antipatterns-browser.js'), + path.join(process.cwd(), 'node_modules', 'impeccable', 'src', 'detect-antipatterns-browser.js'), + ]; + let detectScript = ''; + for (const p of detectPaths) { + try { detectScript = fs.readFileSync(p, 'utf-8'); break; } catch { /* try next */ } + } + + // live-browser.js: DO NOT cache. Return the path so the /live.js handler + // can re-read on every request. Editing the browser script during iteration + // should land on the next tab reload, not require a server restart. + const livePath = path.join(__dirname, 'live-browser.js'); + if (!fs.existsSync(livePath)) { + process.stderr.write('Error: live-browser.js not found at ' + livePath + '\n'); + process.exit(1); + } + + return { detectScript, livePath }; +} + +function hasProjectContext() { + // PRODUCT.md carries brand voice / anti-references — that's what determines + // whether variants are brand-aware. DESIGN.md (visual tokens) is a separate + // concern, surfaced by the design panel's own empty state. Legacy + // .impeccable.md is auto-migrated to PRODUCT.md by load-context.mjs. + try { + fs.accessSync(path.join(process.cwd(), 'PRODUCT.md'), fs.constants.R_OK); + return true; + } catch { return false; } +} + +function statOrNull(filePath) { + try { return fs.statSync(filePath); } catch { return null; } +} + +// --------------------------------------------------------------------------- +// Validation (inline — no external import needed for self-contained script) +// --------------------------------------------------------------------------- + +const VISUAL_ACTIONS = [ + 'impeccable', 'bolder', 'quieter', 'distill', 'polish', 'typeset', + 'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive', +]; + +function validateEvent(msg) { + if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message'; + switch (msg.type) { + case 'generate': + if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id'; + if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action'; + if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8'; + if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context'; + // Optional annotation fields (all-or-nothing: if any present, all must be well-formed). + if (msg.screenshotPath !== undefined && typeof msg.screenshotPath !== 'string') return 'generate: screenshotPath must be string'; + if (msg.comments !== undefined && !Array.isArray(msg.comments)) return 'generate: comments must be array'; + if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array'; + return null; + case 'accept': + if (!msg.id) return 'accept: missing id'; + if (!msg.variantId) return 'accept: missing variantId'; + if (msg.paramValues !== undefined) { + if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) { + return 'accept: paramValues must be an object'; + } + } + return null; + case 'discard': + return msg.id ? null : 'discard: missing id'; + case 'exit': + return null; + case 'prefetch': + if (!msg.pageUrl || typeof msg.pageUrl !== 'string') return 'prefetch: missing pageUrl'; + return null; + default: + return 'Unknown event type: ' + msg.type; + } +} + +// --------------------------------------------------------------------------- +// HTTP request handler +// --------------------------------------------------------------------------- + +function createRequestHandler({ detectScript, livePath }) { + return (req, res) => { + const url = new URL(req.url, `http://localhost:${state.port}`); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } + + const p = url.pathname; + + // --- Scripts --- + if (p === '/live.js') { + // Re-read from disk each request so edits to live-browser.js land on + // the next tab reload. No-store headers prevent browser caching across + // sessions — during iteration, a cached old script silently breaks + // every subsequent session. + let liveScript; + try { + liveScript = fs.readFileSync(livePath, 'utf-8'); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end('Error reading live-browser.js: ' + err.message); + return; + } + const body = + `window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` + + `window.__IMPECCABLE_PORT__ = ${state.port};\n` + + liveScript; + res.writeHead(200, { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', + 'Pragma': 'no-cache', + }); + res.end(body); + return; + } + if (p === '/detect.js' || p === '/') { + if (!detectScript) { res.writeHead(404); res.end('Not available'); return; } + res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.end(detectScript); + return; + } + + // --- Vendored modern-screenshot (UMD build) --- + // Lazy-loaded by live.js when the user clicks Go; exposes + // window.modernScreenshot.domToBlob(...) for capture. + if (p === '/modern-screenshot.js') { + const vendorPath = path.join(__dirname, 'modern-screenshot.umd.js'); + try { + res.writeHead(200, { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'public, max-age=31536000, immutable', + }); + res.end(fs.readFileSync(vendorPath)); + } catch { + res.writeHead(404); res.end('Vendor script not found'); + } + return; + } + + // --- Annotation upload (browser → server, raw PNG body) --- + // Client generates the eventId, POSTs the PNG, then POSTs the generate + // event with screenshotPath already set. Keeps bytes out of the SSE/poll + // bridge and preserves the "one shot from the user's POV" UX. + if (p === '/annotation' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const eventId = url.searchParams.get('eventId'); + if (!eventId || !/^[A-Za-z0-9_-]{1,64}$/.test(eventId)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid eventId' })); + return; + } + if ((req.headers['content-type'] || '').toLowerCase() !== 'image/png') { + res.writeHead(415, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Content-Type must be image/png' })); + return; + } + if (!state.sessionDir) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Session dir unavailable' })); + return; + } + const chunks = []; + let total = 0; + let aborted = false; + req.on('data', (c) => { + if (aborted) return; + total += c.length; + if (total > MAX_ANNOTATION_BYTES) { + aborted = true; + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Payload too large' })); + req.destroy(); + return; + } + chunks.push(c); + }); + req.on('end', () => { + if (aborted) return; + const absPath = path.join(state.sessionDir, eventId + '.png'); + try { + fs.writeFileSync(absPath, Buffer.concat(chunks)); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Write failed: ' + err.message })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, path: absPath })); + }); + req.on('error', () => { + if (!aborted) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Upload failed' })); + } + }); + return; + } + + // --- Health --- + if (p === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'ok', port: state.port, mode: 'variant', + hasProjectContext: hasProjectContext(), + connectedClients: state.sseClients.size, + })); + return; + } + + // --- Design system (unified v2 response) + raw --- + // /design-system.json returns both parsed DESIGN.md and DESIGN.json + // sidecar when present. Panel merges them: + // { present, parsed, sidecar, hasMd, hasSidecar, + // mdNewerThanJson, parseError?, sidecarError? } + // - parsed: output of parseDesignMd (frontmatter + // + six canonical sections) when DESIGN.md exists. + // - sidecar: DESIGN.json contents when present. + // Expected shape: schemaVersion 2, carrying + // extensions + components + narrative. + // /design-system/raw returns DESIGN.md markdown verbatim + if (p === '/design-system.json' || p === '/design-system/raw') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + + const mdPath = path.join(process.cwd(), 'DESIGN.md'); + const jsonPath = path.join(process.cwd(), 'DESIGN.json'); + const mdStat = statOrNull(mdPath); + const jsonStat = statOrNull(jsonPath); + + if (p === '/design-system/raw') { + if (!mdStat) { res.writeHead(404); res.end('Not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' }); + res.end(fs.readFileSync(mdPath, 'utf-8')); + return; + } + + if (!mdStat && !jsonStat) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ present: false })); + return; + } + + const response = { + present: true, + hasMd: !!mdStat, + hasSidecar: !!jsonStat, + mdNewerThanJson: !!(mdStat && jsonStat && mdStat.mtimeMs > jsonStat.mtimeMs + 1000), + }; + + if (mdStat) { + try { + response.parsed = parseDesignMd(fs.readFileSync(mdPath, 'utf-8')); + } catch (err) { + response.parseError = err.message; + } + } + + if (jsonStat) { + try { + response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); + } catch (err) { + response.sidecarError = 'Failed to parse DESIGN.json: ' + err.message; + } + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(response)); + return; + } + + // --- Source file (no-HMR fallback) --- + if (p === '/source') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const filePath = url.searchParams.get('path'); + if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; } + const absPath = path.resolve(process.cwd(), filePath); + if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; } + let content; + try { content = fs.readFileSync(absPath, 'utf-8'); } + catch { res.writeHead(404); res.end('File not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(content); + return; + } + + // --- SSE: server→browser push (replaces WebSocket) --- + if (p === '/events' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }); + res.write('data: ' + JSON.stringify({ + type: 'connected', + hasProjectContext: hasProjectContext(), + }) + '\n\n'); + + state.sseClients.add(res); + clearTimeout(state.exitTimer); + + // Keepalive: SSE comment every 30s prevents silent connection drops. + const heartbeat = setInterval(() => { + try { res.write(': keepalive\n\n'); } catch { clearInterval(heartbeat); } + }, SSE_HEARTBEAT_INTERVAL); + + req.on('close', () => { + clearInterval(heartbeat); + state.sseClients.delete(res); + if (state.sseClients.size === 0) { + clearTimeout(state.exitTimer); + state.exitTimer = setTimeout(() => { + if (state.sseClients.size === 0) enqueueEvent({ type: 'exit' }); + }, 8000); + } + }); + return; + } + + // --- Browser→server events (replaces WebSocket messages) --- + if (p === '/events' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (msg.token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + const error = validateEvent(msg); + if (error) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error })); + return; + } + enqueueEvent(msg); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); + return; + } + + // --- Stop --- + if (p === '/stop') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('stopping'); + shutdown(); + return; + } + + // --- Agent poll --- + if (p === '/poll' && req.method === 'GET') { + handlePollGet(req, res, url); + return; + } + if (p === '/poll' && req.method === 'POST') { + handlePollPost(req, res); + return; + } + + res.writeHead(404); res.end('Not found'); + }; +} + +// --------------------------------------------------------------------------- +// Agent poll endpoints (unchanged from WS version) +// --------------------------------------------------------------------------- + +function handlePollGet(req, res, url) { + const token = url.searchParams.get('token'); + if (token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10); + if (state.pendingEvents.length > 0) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(state.pendingEvents.shift())); + return; + } + const timer = setTimeout(() => { + const idx = state.pendingPolls.indexOf(resolve); + if (idx !== -1) state.pendingPolls.splice(idx, 1); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ type: 'timeout' })); + }, timeout); + function resolve(event) { + clearTimeout(timer); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(event)); + } + state.pendingPolls.push(resolve); + req.on('close', () => { + clearTimeout(timer); + const idx = state.pendingPolls.indexOf(resolve); + if (idx !== -1) state.pendingPolls.splice(idx, 1); + }); +} + +function handlePollPost(req, res) { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (msg.token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + // Forward the reply to the browser via SSE + broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); +} + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +let httpServer = null; + +function shutdown() { + try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + if (state.sessionDir) { + try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} + } + for (const res of state.sseClients) { try { res.end(); } catch {} } + state.sseClients.clear(); + for (const resolve of state.pendingPolls) resolve({ type: 'exit' }); + state.pendingPolls.length = 0; + if (httpServer) httpServer.close(); + process.exit(0); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +const args = process.argv.slice(2); + +if (args.includes('--help') || args.includes('-h')) { + console.log(`Usage: node live-server.mjs [options] + +Start the live variant mode server (zero dependencies). + +Commands: + (default) Start the server (foreground) + stop Stop the server and remove the injected live.js script tag + stop --keep-inject Stop the server only (leave the script tag in the HTML entry) + +Options: + --background Start detached, print connection JSON to stdout, then exit + --port=PORT Use a specific port (default: auto-detect starting at 8400) + --keep-inject Only with stop: skip live-inject.mjs --remove + --help Show this help + +Endpoints: + /live.js Browser script (element picker + variant cycling) + /detect.js Detection overlay (backwards compatible) + /modern-screenshot.js Vendored modern-screenshot UMD build (lazy-loaded by live.js) + /annotation POST raw image/png to stage a variant screenshot + /events SSE stream (server→browser) + POST (browser→server) + /poll Long-poll for agent CLI + /source Raw source file reader (no-HMR fallback) + /health Health check`); + process.exit(0); +} + +if (args.includes('stop')) { + const keepInject = args.includes('--keep-inject'); + try { + const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`); + if (res.ok) console.log(`Stopped live server on port ${info.port}.`); + } catch { + console.log('No running live server found.'); + } + if (!keepInject) { + const injectPath = path.join(__dirname, 'live-inject.mjs'); + try { + const out = execFileSync(process.execPath, [injectPath, '--remove'], { + encoding: 'utf-8', + cwd: process.cwd(), + }); + const line = out.trim().split('\n').filter(Boolean).pop(); + if (line) { + try { + const j = JSON.parse(line); + if (j.removed === true) { + console.log(`Removed live script tag from ${j.file}.`); + } + } catch { + /* ignore non-JSON lines */ + } + } + } catch (err) { + const detail = err.stderr?.toString?.().trim?.() + || err.stdout?.toString?.().trim?.() + || err.message + || String(err); + console.warn(`Note: could not remove live script tag (${detail.split('\n')[0]})`); + } + } + process.exit(0); +} + +// --background: spawn a detached child server, wait for it to be ready, +// print the connection JSON, then exit. This keeps the startup command +// simple (no shell backgrounding or chained commands). +if (args.includes('--background')) { + const childArgs = args.filter(a => a !== '--background'); + const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...childArgs], { + detached: true, + stdio: 'ignore', + cwd: process.cwd(), + }); + child.unref(); + + // Poll for the PID file (the child writes it once the HTTP server is listening). + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + try { + const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + if (info.pid !== process.pid) { + // Output JSON so the agent can read port + token from stdout. + console.log(JSON.stringify(info)); + process.exit(0); + } + } catch { /* not ready yet */ } + await new Promise(r => setTimeout(r, 200)); + } + console.error('Timed out waiting for live server to start.'); + process.exit(1); +} + +// Check for existing session +try { + const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + try { process.kill(existing.pid, 0); + console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`); + console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop'); + process.exit(1); + } catch { fs.unlinkSync(LIVE_PID_FILE); } +} catch {} + +state.token = randomUUID(); +const portArg = args.find(a => a.startsWith('--port=')); +state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort(); +// Annotation screenshots live in the project root so the agent's Read tool +// doesn't trip a per-file permission prompt. Sessioned by token so concurrent +// projects (or quick restarts) don't collide. +const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); +fs.mkdirSync(annotRoot, { recursive: true }); +state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); + +const { detectScript, livePath } = loadBrowserScripts(); +httpServer = http.createServer(createRequestHandler({ detectScript, livePath })); + +httpServer.listen(state.port, '127.0.0.1', () => { + fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token })); + const url = `http://localhost:${state.port}`; + console.log(`\nImpeccable live server running on ${url}`); + console.log(`Token: ${state.token}\n`); + console.log(`Inject: \n' + + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' + ); +} + +function insertTag(content, config, port) { + const block = buildTagBlock(config.commentSyntax, port); + // insertBefore: match the LAST occurrence. Anchors like `` naturally + // belong at the end, and the same literal can appear earlier in code blocks + // within rendered documentation pages. + if (config.insertBefore) { + const idx = content.lastIndexOf(config.insertBefore); + if (idx === -1) return content; + return content.slice(0, idx) + block + content.slice(idx); + } + // insertAfter: match the FIRST occurrence — typical anchors like `` or + // `` open near the top of the document. + const idx = content.indexOf(config.insertAfter); + if (idx === -1) return content; + const after = idx + config.insertAfter.length; + // Preserve a single trailing newline if the anchor didn't end with one + const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; + return prefix + block + content.slice(prefix.length); +} + +/** + * Remove the live script block. Matches either HTML or JSX comment markers + * regardless of config (so stale tags from a wrong config can still be cleaned). + * + * Indent-preserving: captures any whitespace immediately preceding the opener + * marker and re-emits it in place of the removed block. `insertTag` inserted + * the block *after* the original line's indent and *before* the anchor (e.g. + * ``), which moved the indent onto the opener line and left the anchor + * unindented. Replacing the whole block (plus its trailing newline) with just + * the captured indent hands the indent back to the anchor that follows. + */ +function removeTag(content, _syntax) { + const patterns = [ + /([ \t]*)[\s\S]*?[ \t]*\n/, + /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}[ \t]*\n/, + ]; + for (const pat of patterns) { + const next = content.replace(pat, '$1'); + if (next !== content) return next; + } + return content; +} + +// --------------------------------------------------------------------------- +// Auto-execute +// --------------------------------------------------------------------------- + +const _running = process.argv[1]; +if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) { + injectCli(); +} + +export { insertTag, removeTag, validateConfig, buildTagBlock }; diff --git a/.opencode/skills/impeccable/scripts/live-poll.mjs b/.opencode/skills/impeccable/scripts/live-poll.mjs new file mode 100644 index 000000000..5cece1a43 --- /dev/null +++ b/.opencode/skills/impeccable/scripts/live-poll.mjs @@ -0,0 +1,187 @@ +/** + * CLI client for the live variant mode poll/reply protocol. + * + * Usage: + * npx impeccable poll # Block until browser event, print JSON + * npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly + * npx impeccable poll --reply done # Reply "done" to event + * npx impeccable poll --reply error "msg" # Reply with error + */ + +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { fileURLToPath } from 'node:url'; + +// Node's built-in fetch (undici under the hood) enforces a 300s headers +// timeout that can't be lowered per-request. We cap each request below +// that ceiling and loop in `pollOnce` to synthesize a long poll without +// depending on the standalone undici package. +const PER_REQUEST_TIMEOUT_MS = 270_000; + +const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); + +function readServerInfo() { + try { + return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + } catch { + console.error('No running live server found. Start one with: npx impeccable live'); + process.exit(1); + } +} + +export async function pollCli() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log(`Usage: impeccable poll [options] + +Wait for a browser event from the live variant server, or reply to one. + +Modes: + poll Block until a browser event arrives, print JSON + poll --reply done Reply "done" to event + poll --reply error "msg" Reply with an error message + +Options: + --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 + --help Show this help message`); + process.exit(0); + } + + const info = readServerInfo(); + const base = `http://localhost:${info.port}`; + + // Reply mode: npx impeccable poll --reply [--file path] [message] + const replyIdx = args.indexOf('--reply'); + if (replyIdx !== -1) { + const id = args[replyIdx + 1]; + const status = args[replyIdx + 2] || 'done'; + const fileIdx = args.indexOf('--file'); + const filePath = fileIdx !== -1 && fileIdx + 1 < args.length ? args[fileIdx + 1] : undefined; + // Message is any remaining positional arg that isn't a flag + const message = args.find((a, i) => i > replyIdx + 2 && !a.startsWith('--') && i !== fileIdx + 1) || undefined; + + if (!id) { + console.error('Usage: npx impeccable poll --reply [--file path] [message]'); + process.exit(1); + } + + try { + const res = await fetch(`${base}/poll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: info.token, + id, + type: status, + message, + file: filePath, + }), + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + console.error(`Reply failed (${res.status}):`, body.error || res.statusText); + process.exit(1); + } + + // Success — silent exit (agent doesn't need output for replies) + } catch (err) { + if (err.cause?.code === 'ECONNREFUSED') { + console.error('Live server not running. Start one with: npx impeccable live'); + } else { + console.error('Reply failed:', err.message); + } + process.exit(1); + } + return; + } + + // Poll mode: block until browser event. Default 10 min. Node's built-in + // fetch enforces a 300s headers timeout, so we loop in slices under that + // ceiling and keep re-polling until we get a real event or the user's + // total timeout runs out. + const timeoutArg = args.find(a => a.startsWith('--timeout=')); + const totalTimeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600000; + + const deadline = Date.now() + totalTimeout; + let event; + try { + while (true) { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + event = { type: 'timeout' }; + break; + } + const slice = Math.min(remaining, PER_REQUEST_TIMEOUT_MS); + const res = await fetch(`${base}/poll?token=${info.token}&timeout=${slice}`); + + if (res.status === 401) { + console.error('Authentication failed. The server token may have changed.'); + console.error('Try restarting: npx impeccable live stop && npx impeccable live'); + process.exit(1); + } + + if (!res.ok) { + console.error(`Poll failed: ${res.status} ${res.statusText}`); + process.exit(1); + } + + const next = await res.json(); + // Server-side timeout means no browser event arrived in this slice. + // Loop and re-poll until we get a real event or we hit the user's + // total deadline. + if (next?.type === 'timeout' && Date.now() < deadline) continue; + event = next; + break; + } + + // Auto-handle accept/discard via deterministic script + if (event.type === 'accept' || event.type === 'discard') { + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const acceptScript = path.join(__dirname, 'live-accept.mjs'); + const scriptArgs = event.type === 'discard' + ? ['--id', event.id, '--discard'] + : ['--id', event.id, '--variant', event.variantId]; + if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) { + // Pass through a JSON blob; the shell-safe wrap uses single quotes because + // values are finite {id, number|string|boolean} pairs from a validated payload. + scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`); + } + try { + const out = execSync( + `node "${acceptScript}" ${scriptArgs.join(' ')}`, + { encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 } + ); + event._acceptResult = JSON.parse(out.trim()); + } catch (err) { + event._acceptResult = { handled: false, error: err.message }; + } + } + + // Second signal path: stderr banner in case the agent parses stdout + // JSON but skips nested fields. One line is enough — the full checklist + // is in reference/live.md. + if (event._acceptResult?.carbonize === true) { + process.stderr.write('\n⚠ Carbonize cleanup REQUIRED before next poll. See reference/live.md "Required after accept".\n\n'); + } + + // Print the event as JSON — the agent reads this from stdout + console.log(JSON.stringify(event)); + } catch (err) { + if (err.cause?.code === 'ECONNREFUSED') { + console.error('Live server not running. Start one with: npx impeccable live'); + } else { + console.error('Poll failed:', err.message); + } + process.exit(1); + } +} + +// Auto-execute when run directly +const _running = process.argv[1]; +if (_running?.endsWith('live-poll.mjs') || _running?.endsWith('live-poll.mjs/')) { + pollCli(); +} diff --git a/.opencode/skills/impeccable/scripts/live-server.mjs b/.opencode/skills/impeccable/scripts/live-server.mjs new file mode 100644 index 000000000..3d608e3c0 --- /dev/null +++ b/.opencode/skills/impeccable/scripts/live-server.mjs @@ -0,0 +1,679 @@ +#!/usr/bin/env node +/** + * Live variant mode server (self-contained, zero dependencies). + * + * Serves the browser script (/live.js), the detection overlay (/detect.js), + * uses Server-Sent Events (SSE) for server→browser push, and HTTP POST for + * browser→server events. Agent communicates via HTTP long-poll (/poll). + * + * Usage: + * node /live-server.mjs # start + * node /live-server.mjs stop # stop + remove injected live.js tag + * node /live-server.mjs stop --keep-inject # stop only + * node /live-server.mjs --help + */ + +import http from 'node:http'; +import { randomUUID } from 'node:crypto'; +import { spawn, execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import net from 'node:net'; +import { fileURLToPath } from 'node:url'; +import { parseDesignMd } from './design-parser.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +// PID file in the project root so both the server and agent can find it +// predictably (os.tmpdir() varies across platforms). +const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway +const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s + +// --------------------------------------------------------------------------- +// Port detection +// --------------------------------------------------------------------------- + +async function findOpenPort(start = 8400) { + return new Promise((resolve) => { + const srv = net.createServer(); + srv.listen(start, '127.0.0.1', () => { + const port = srv.address().port; + srv.close(() => resolve(port)); + }); + srv.on('error', () => resolve(findOpenPort(start + 1))); + }); +} + +// --------------------------------------------------------------------------- +// Session state +// --------------------------------------------------------------------------- + +const state = { + token: null, + port: null, + sseClients: new Set(), // SSE response objects (server→browser push) + pendingEvents: [], // browser events waiting for agent poll + pendingPolls: [], // agent poll callbacks waiting for browser events + exitTimer: null, + sessionDir: null, // per-session tmp dir for annotation screenshots +}; + +// Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; +// cap at 10 MB to guard against runaway writes from a misbehaving client. +const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024; + +function enqueueEvent(event) { + if (state.pendingPolls.length > 0) { + state.pendingPolls.shift()(event); + } else { + state.pendingEvents.push(event); + } +} + +/** Push a message to all connected SSE clients. */ +function broadcast(msg) { + const data = 'data: ' + JSON.stringify(msg) + '\n\n'; + for (const res of state.sseClients) { + try { res.write(data); } catch { /* client gone */ } + } +} + +// --------------------------------------------------------------------------- +// Load scripts +// --------------------------------------------------------------------------- + +function loadBrowserScripts() { + // Detection script: look relative to the skill scripts dir, then fall back + // to the npm package location (src/detect-antipatterns-browser.js). + // This one IS cached — detect.js rarely changes during a session. + const detectPaths = [ + path.join(__dirname, '..', '..', '..', '..', 'src', 'detect-antipatterns-browser.js'), + path.join(process.cwd(), 'node_modules', 'impeccable', 'src', 'detect-antipatterns-browser.js'), + ]; + let detectScript = ''; + for (const p of detectPaths) { + try { detectScript = fs.readFileSync(p, 'utf-8'); break; } catch { /* try next */ } + } + + // live-browser.js: DO NOT cache. Return the path so the /live.js handler + // can re-read on every request. Editing the browser script during iteration + // should land on the next tab reload, not require a server restart. + const livePath = path.join(__dirname, 'live-browser.js'); + if (!fs.existsSync(livePath)) { + process.stderr.write('Error: live-browser.js not found at ' + livePath + '\n'); + process.exit(1); + } + + return { detectScript, livePath }; +} + +function hasProjectContext() { + // PRODUCT.md carries brand voice / anti-references — that's what determines + // whether variants are brand-aware. DESIGN.md (visual tokens) is a separate + // concern, surfaced by the design panel's own empty state. Legacy + // .impeccable.md is auto-migrated to PRODUCT.md by load-context.mjs. + try { + fs.accessSync(path.join(process.cwd(), 'PRODUCT.md'), fs.constants.R_OK); + return true; + } catch { return false; } +} + +function statOrNull(filePath) { + try { return fs.statSync(filePath); } catch { return null; } +} + +// --------------------------------------------------------------------------- +// Validation (inline — no external import needed for self-contained script) +// --------------------------------------------------------------------------- + +const VISUAL_ACTIONS = [ + 'impeccable', 'bolder', 'quieter', 'distill', 'polish', 'typeset', + 'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive', +]; + +function validateEvent(msg) { + if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message'; + switch (msg.type) { + case 'generate': + if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id'; + if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action'; + if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8'; + if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context'; + // Optional annotation fields (all-or-nothing: if any present, all must be well-formed). + if (msg.screenshotPath !== undefined && typeof msg.screenshotPath !== 'string') return 'generate: screenshotPath must be string'; + if (msg.comments !== undefined && !Array.isArray(msg.comments)) return 'generate: comments must be array'; + if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array'; + return null; + case 'accept': + if (!msg.id) return 'accept: missing id'; + if (!msg.variantId) return 'accept: missing variantId'; + if (msg.paramValues !== undefined) { + if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) { + return 'accept: paramValues must be an object'; + } + } + return null; + case 'discard': + return msg.id ? null : 'discard: missing id'; + case 'exit': + return null; + case 'prefetch': + if (!msg.pageUrl || typeof msg.pageUrl !== 'string') return 'prefetch: missing pageUrl'; + return null; + default: + return 'Unknown event type: ' + msg.type; + } +} + +// --------------------------------------------------------------------------- +// HTTP request handler +// --------------------------------------------------------------------------- + +function createRequestHandler({ detectScript, livePath }) { + return (req, res) => { + const url = new URL(req.url, `http://localhost:${state.port}`); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } + + const p = url.pathname; + + // --- Scripts --- + if (p === '/live.js') { + // Re-read from disk each request so edits to live-browser.js land on + // the next tab reload. No-store headers prevent browser caching across + // sessions — during iteration, a cached old script silently breaks + // every subsequent session. + let liveScript; + try { + liveScript = fs.readFileSync(livePath, 'utf-8'); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end('Error reading live-browser.js: ' + err.message); + return; + } + const body = + `window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` + + `window.__IMPECCABLE_PORT__ = ${state.port};\n` + + liveScript; + res.writeHead(200, { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', + 'Pragma': 'no-cache', + }); + res.end(body); + return; + } + if (p === '/detect.js' || p === '/') { + if (!detectScript) { res.writeHead(404); res.end('Not available'); return; } + res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.end(detectScript); + return; + } + + // --- Vendored modern-screenshot (UMD build) --- + // Lazy-loaded by live.js when the user clicks Go; exposes + // window.modernScreenshot.domToBlob(...) for capture. + if (p === '/modern-screenshot.js') { + const vendorPath = path.join(__dirname, 'modern-screenshot.umd.js'); + try { + res.writeHead(200, { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'public, max-age=31536000, immutable', + }); + res.end(fs.readFileSync(vendorPath)); + } catch { + res.writeHead(404); res.end('Vendor script not found'); + } + return; + } + + // --- Annotation upload (browser → server, raw PNG body) --- + // Client generates the eventId, POSTs the PNG, then POSTs the generate + // event with screenshotPath already set. Keeps bytes out of the SSE/poll + // bridge and preserves the "one shot from the user's POV" UX. + if (p === '/annotation' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const eventId = url.searchParams.get('eventId'); + if (!eventId || !/^[A-Za-z0-9_-]{1,64}$/.test(eventId)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid eventId' })); + return; + } + if ((req.headers['content-type'] || '').toLowerCase() !== 'image/png') { + res.writeHead(415, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Content-Type must be image/png' })); + return; + } + if (!state.sessionDir) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Session dir unavailable' })); + return; + } + const chunks = []; + let total = 0; + let aborted = false; + req.on('data', (c) => { + if (aborted) return; + total += c.length; + if (total > MAX_ANNOTATION_BYTES) { + aborted = true; + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Payload too large' })); + req.destroy(); + return; + } + chunks.push(c); + }); + req.on('end', () => { + if (aborted) return; + const absPath = path.join(state.sessionDir, eventId + '.png'); + try { + fs.writeFileSync(absPath, Buffer.concat(chunks)); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Write failed: ' + err.message })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, path: absPath })); + }); + req.on('error', () => { + if (!aborted) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Upload failed' })); + } + }); + return; + } + + // --- Health --- + if (p === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'ok', port: state.port, mode: 'variant', + hasProjectContext: hasProjectContext(), + connectedClients: state.sseClients.size, + })); + return; + } + + // --- Design system (unified v2 response) + raw --- + // /design-system.json returns both parsed DESIGN.md and DESIGN.json + // sidecar when present. Panel merges them: + // { present, parsed, sidecar, hasMd, hasSidecar, + // mdNewerThanJson, parseError?, sidecarError? } + // - parsed: output of parseDesignMd (frontmatter + // + six canonical sections) when DESIGN.md exists. + // - sidecar: DESIGN.json contents when present. + // Expected shape: schemaVersion 2, carrying + // extensions + components + narrative. + // /design-system/raw returns DESIGN.md markdown verbatim + if (p === '/design-system.json' || p === '/design-system/raw') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + + const mdPath = path.join(process.cwd(), 'DESIGN.md'); + const jsonPath = path.join(process.cwd(), 'DESIGN.json'); + const mdStat = statOrNull(mdPath); + const jsonStat = statOrNull(jsonPath); + + if (p === '/design-system/raw') { + if (!mdStat) { res.writeHead(404); res.end('Not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' }); + res.end(fs.readFileSync(mdPath, 'utf-8')); + return; + } + + if (!mdStat && !jsonStat) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ present: false })); + return; + } + + const response = { + present: true, + hasMd: !!mdStat, + hasSidecar: !!jsonStat, + mdNewerThanJson: !!(mdStat && jsonStat && mdStat.mtimeMs > jsonStat.mtimeMs + 1000), + }; + + if (mdStat) { + try { + response.parsed = parseDesignMd(fs.readFileSync(mdPath, 'utf-8')); + } catch (err) { + response.parseError = err.message; + } + } + + if (jsonStat) { + try { + response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); + } catch (err) { + response.sidecarError = 'Failed to parse DESIGN.json: ' + err.message; + } + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(response)); + return; + } + + // --- Source file (no-HMR fallback) --- + if (p === '/source') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const filePath = url.searchParams.get('path'); + if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; } + const absPath = path.resolve(process.cwd(), filePath); + if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; } + let content; + try { content = fs.readFileSync(absPath, 'utf-8'); } + catch { res.writeHead(404); res.end('File not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(content); + return; + } + + // --- SSE: server→browser push (replaces WebSocket) --- + if (p === '/events' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }); + res.write('data: ' + JSON.stringify({ + type: 'connected', + hasProjectContext: hasProjectContext(), + }) + '\n\n'); + + state.sseClients.add(res); + clearTimeout(state.exitTimer); + + // Keepalive: SSE comment every 30s prevents silent connection drops. + const heartbeat = setInterval(() => { + try { res.write(': keepalive\n\n'); } catch { clearInterval(heartbeat); } + }, SSE_HEARTBEAT_INTERVAL); + + req.on('close', () => { + clearInterval(heartbeat); + state.sseClients.delete(res); + if (state.sseClients.size === 0) { + clearTimeout(state.exitTimer); + state.exitTimer = setTimeout(() => { + if (state.sseClients.size === 0) enqueueEvent({ type: 'exit' }); + }, 8000); + } + }); + return; + } + + // --- Browser→server events (replaces WebSocket messages) --- + if (p === '/events' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (msg.token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + const error = validateEvent(msg); + if (error) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error })); + return; + } + enqueueEvent(msg); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); + return; + } + + // --- Stop --- + if (p === '/stop') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('stopping'); + shutdown(); + return; + } + + // --- Agent poll --- + if (p === '/poll' && req.method === 'GET') { + handlePollGet(req, res, url); + return; + } + if (p === '/poll' && req.method === 'POST') { + handlePollPost(req, res); + return; + } + + res.writeHead(404); res.end('Not found'); + }; +} + +// --------------------------------------------------------------------------- +// Agent poll endpoints (unchanged from WS version) +// --------------------------------------------------------------------------- + +function handlePollGet(req, res, url) { + const token = url.searchParams.get('token'); + if (token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10); + if (state.pendingEvents.length > 0) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(state.pendingEvents.shift())); + return; + } + const timer = setTimeout(() => { + const idx = state.pendingPolls.indexOf(resolve); + if (idx !== -1) state.pendingPolls.splice(idx, 1); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ type: 'timeout' })); + }, timeout); + function resolve(event) { + clearTimeout(timer); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(event)); + } + state.pendingPolls.push(resolve); + req.on('close', () => { + clearTimeout(timer); + const idx = state.pendingPolls.indexOf(resolve); + if (idx !== -1) state.pendingPolls.splice(idx, 1); + }); +} + +function handlePollPost(req, res) { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (msg.token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + // Forward the reply to the browser via SSE + broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); +} + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +let httpServer = null; + +function shutdown() { + try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + if (state.sessionDir) { + try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} + } + for (const res of state.sseClients) { try { res.end(); } catch {} } + state.sseClients.clear(); + for (const resolve of state.pendingPolls) resolve({ type: 'exit' }); + state.pendingPolls.length = 0; + if (httpServer) httpServer.close(); + process.exit(0); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +const args = process.argv.slice(2); + +if (args.includes('--help') || args.includes('-h')) { + console.log(`Usage: node live-server.mjs [options] + +Start the live variant mode server (zero dependencies). + +Commands: + (default) Start the server (foreground) + stop Stop the server and remove the injected live.js script tag + stop --keep-inject Stop the server only (leave the script tag in the HTML entry) + +Options: + --background Start detached, print connection JSON to stdout, then exit + --port=PORT Use a specific port (default: auto-detect starting at 8400) + --keep-inject Only with stop: skip live-inject.mjs --remove + --help Show this help + +Endpoints: + /live.js Browser script (element picker + variant cycling) + /detect.js Detection overlay (backwards compatible) + /modern-screenshot.js Vendored modern-screenshot UMD build (lazy-loaded by live.js) + /annotation POST raw image/png to stage a variant screenshot + /events SSE stream (server→browser) + POST (browser→server) + /poll Long-poll for agent CLI + /source Raw source file reader (no-HMR fallback) + /health Health check`); + process.exit(0); +} + +if (args.includes('stop')) { + const keepInject = args.includes('--keep-inject'); + try { + const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`); + if (res.ok) console.log(`Stopped live server on port ${info.port}.`); + } catch { + console.log('No running live server found.'); + } + if (!keepInject) { + const injectPath = path.join(__dirname, 'live-inject.mjs'); + try { + const out = execFileSync(process.execPath, [injectPath, '--remove'], { + encoding: 'utf-8', + cwd: process.cwd(), + }); + const line = out.trim().split('\n').filter(Boolean).pop(); + if (line) { + try { + const j = JSON.parse(line); + if (j.removed === true) { + console.log(`Removed live script tag from ${j.file}.`); + } + } catch { + /* ignore non-JSON lines */ + } + } + } catch (err) { + const detail = err.stderr?.toString?.().trim?.() + || err.stdout?.toString?.().trim?.() + || err.message + || String(err); + console.warn(`Note: could not remove live script tag (${detail.split('\n')[0]})`); + } + } + process.exit(0); +} + +// --background: spawn a detached child server, wait for it to be ready, +// print the connection JSON, then exit. This keeps the startup command +// simple (no shell backgrounding or chained commands). +if (args.includes('--background')) { + const childArgs = args.filter(a => a !== '--background'); + const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...childArgs], { + detached: true, + stdio: 'ignore', + cwd: process.cwd(), + }); + child.unref(); + + // Poll for the PID file (the child writes it once the HTTP server is listening). + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + try { + const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + if (info.pid !== process.pid) { + // Output JSON so the agent can read port + token from stdout. + console.log(JSON.stringify(info)); + process.exit(0); + } + } catch { /* not ready yet */ } + await new Promise(r => setTimeout(r, 200)); + } + console.error('Timed out waiting for live server to start.'); + process.exit(1); +} + +// Check for existing session +try { + const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + try { process.kill(existing.pid, 0); + console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`); + console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop'); + process.exit(1); + } catch { fs.unlinkSync(LIVE_PID_FILE); } +} catch {} + +state.token = randomUUID(); +const portArg = args.find(a => a.startsWith('--port=')); +state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort(); +// Annotation screenshots live in the project root so the agent's Read tool +// doesn't trip a per-file permission prompt. Sessioned by token so concurrent +// projects (or quick restarts) don't collide. +const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); +fs.mkdirSync(annotRoot, { recursive: true }); +state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); + +const { detectScript, livePath } = loadBrowserScripts(); +httpServer = http.createServer(createRequestHandler({ detectScript, livePath })); + +httpServer.listen(state.port, '127.0.0.1', () => { + fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token })); + const url = `http://localhost:${state.port}`; + console.log(`\nImpeccable live server running on ${url}`); + console.log(`Token: ${state.token}\n`); + console.log(`Inject: \n' + + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' + ); +} + +function insertTag(content, config, port) { + const block = buildTagBlock(config.commentSyntax, port); + // insertBefore: match the LAST occurrence. Anchors like `` naturally + // belong at the end, and the same literal can appear earlier in code blocks + // within rendered documentation pages. + if (config.insertBefore) { + const idx = content.lastIndexOf(config.insertBefore); + if (idx === -1) return content; + return content.slice(0, idx) + block + content.slice(idx); + } + // insertAfter: match the FIRST occurrence — typical anchors like `` or + // `` open near the top of the document. + const idx = content.indexOf(config.insertAfter); + if (idx === -1) return content; + const after = idx + config.insertAfter.length; + // Preserve a single trailing newline if the anchor didn't end with one + const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; + return prefix + block + content.slice(prefix.length); +} + +/** + * Remove the live script block. Matches either HTML or JSX comment markers + * regardless of config (so stale tags from a wrong config can still be cleaned). + * + * Indent-preserving: captures any whitespace immediately preceding the opener + * marker and re-emits it in place of the removed block. `insertTag` inserted + * the block *after* the original line's indent and *before* the anchor (e.g. + * ``), which moved the indent onto the opener line and left the anchor + * unindented. Replacing the whole block (plus its trailing newline) with just + * the captured indent hands the indent back to the anchor that follows. + */ +function removeTag(content, _syntax) { + const patterns = [ + /([ \t]*)[\s\S]*?[ \t]*\n/, + /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}[ \t]*\n/, + ]; + for (const pat of patterns) { + const next = content.replace(pat, '$1'); + if (next !== content) return next; + } + return content; +} + +// --------------------------------------------------------------------------- +// Auto-execute +// --------------------------------------------------------------------------- + +const _running = process.argv[1]; +if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) { + injectCli(); +} + +export { insertTag, removeTag, validateConfig, buildTagBlock }; diff --git a/.pi/skills/impeccable/scripts/live-poll.mjs b/.pi/skills/impeccable/scripts/live-poll.mjs new file mode 100644 index 000000000..5cece1a43 --- /dev/null +++ b/.pi/skills/impeccable/scripts/live-poll.mjs @@ -0,0 +1,187 @@ +/** + * CLI client for the live variant mode poll/reply protocol. + * + * Usage: + * npx impeccable poll # Block until browser event, print JSON + * npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly + * npx impeccable poll --reply done # Reply "done" to event + * npx impeccable poll --reply error "msg" # Reply with error + */ + +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { fileURLToPath } from 'node:url'; + +// Node's built-in fetch (undici under the hood) enforces a 300s headers +// timeout that can't be lowered per-request. We cap each request below +// that ceiling and loop in `pollOnce` to synthesize a long poll without +// depending on the standalone undici package. +const PER_REQUEST_TIMEOUT_MS = 270_000; + +const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); + +function readServerInfo() { + try { + return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + } catch { + console.error('No running live server found. Start one with: npx impeccable live'); + process.exit(1); + } +} + +export async function pollCli() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log(`Usage: impeccable poll [options] + +Wait for a browser event from the live variant server, or reply to one. + +Modes: + poll Block until a browser event arrives, print JSON + poll --reply done Reply "done" to event + poll --reply error "msg" Reply with an error message + +Options: + --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 + --help Show this help message`); + process.exit(0); + } + + const info = readServerInfo(); + const base = `http://localhost:${info.port}`; + + // Reply mode: npx impeccable poll --reply [--file path] [message] + const replyIdx = args.indexOf('--reply'); + if (replyIdx !== -1) { + const id = args[replyIdx + 1]; + const status = args[replyIdx + 2] || 'done'; + const fileIdx = args.indexOf('--file'); + const filePath = fileIdx !== -1 && fileIdx + 1 < args.length ? args[fileIdx + 1] : undefined; + // Message is any remaining positional arg that isn't a flag + const message = args.find((a, i) => i > replyIdx + 2 && !a.startsWith('--') && i !== fileIdx + 1) || undefined; + + if (!id) { + console.error('Usage: npx impeccable poll --reply [--file path] [message]'); + process.exit(1); + } + + try { + const res = await fetch(`${base}/poll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: info.token, + id, + type: status, + message, + file: filePath, + }), + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + console.error(`Reply failed (${res.status}):`, body.error || res.statusText); + process.exit(1); + } + + // Success — silent exit (agent doesn't need output for replies) + } catch (err) { + if (err.cause?.code === 'ECONNREFUSED') { + console.error('Live server not running. Start one with: npx impeccable live'); + } else { + console.error('Reply failed:', err.message); + } + process.exit(1); + } + return; + } + + // Poll mode: block until browser event. Default 10 min. Node's built-in + // fetch enforces a 300s headers timeout, so we loop in slices under that + // ceiling and keep re-polling until we get a real event or the user's + // total timeout runs out. + const timeoutArg = args.find(a => a.startsWith('--timeout=')); + const totalTimeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600000; + + const deadline = Date.now() + totalTimeout; + let event; + try { + while (true) { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + event = { type: 'timeout' }; + break; + } + const slice = Math.min(remaining, PER_REQUEST_TIMEOUT_MS); + const res = await fetch(`${base}/poll?token=${info.token}&timeout=${slice}`); + + if (res.status === 401) { + console.error('Authentication failed. The server token may have changed.'); + console.error('Try restarting: npx impeccable live stop && npx impeccable live'); + process.exit(1); + } + + if (!res.ok) { + console.error(`Poll failed: ${res.status} ${res.statusText}`); + process.exit(1); + } + + const next = await res.json(); + // Server-side timeout means no browser event arrived in this slice. + // Loop and re-poll until we get a real event or we hit the user's + // total deadline. + if (next?.type === 'timeout' && Date.now() < deadline) continue; + event = next; + break; + } + + // Auto-handle accept/discard via deterministic script + if (event.type === 'accept' || event.type === 'discard') { + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const acceptScript = path.join(__dirname, 'live-accept.mjs'); + const scriptArgs = event.type === 'discard' + ? ['--id', event.id, '--discard'] + : ['--id', event.id, '--variant', event.variantId]; + if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) { + // Pass through a JSON blob; the shell-safe wrap uses single quotes because + // values are finite {id, number|string|boolean} pairs from a validated payload. + scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`); + } + try { + const out = execSync( + `node "${acceptScript}" ${scriptArgs.join(' ')}`, + { encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 } + ); + event._acceptResult = JSON.parse(out.trim()); + } catch (err) { + event._acceptResult = { handled: false, error: err.message }; + } + } + + // Second signal path: stderr banner in case the agent parses stdout + // JSON but skips nested fields. One line is enough — the full checklist + // is in reference/live.md. + if (event._acceptResult?.carbonize === true) { + process.stderr.write('\n⚠ Carbonize cleanup REQUIRED before next poll. See reference/live.md "Required after accept".\n\n'); + } + + // Print the event as JSON — the agent reads this from stdout + console.log(JSON.stringify(event)); + } catch (err) { + if (err.cause?.code === 'ECONNREFUSED') { + console.error('Live server not running. Start one with: npx impeccable live'); + } else { + console.error('Poll failed:', err.message); + } + process.exit(1); + } +} + +// Auto-execute when run directly +const _running = process.argv[1]; +if (_running?.endsWith('live-poll.mjs') || _running?.endsWith('live-poll.mjs/')) { + pollCli(); +} diff --git a/.pi/skills/impeccable/scripts/live-server.mjs b/.pi/skills/impeccable/scripts/live-server.mjs new file mode 100644 index 000000000..3d608e3c0 --- /dev/null +++ b/.pi/skills/impeccable/scripts/live-server.mjs @@ -0,0 +1,679 @@ +#!/usr/bin/env node +/** + * Live variant mode server (self-contained, zero dependencies). + * + * Serves the browser script (/live.js), the detection overlay (/detect.js), + * uses Server-Sent Events (SSE) for server→browser push, and HTTP POST for + * browser→server events. Agent communicates via HTTP long-poll (/poll). + * + * Usage: + * node /live-server.mjs # start + * node /live-server.mjs stop # stop + remove injected live.js tag + * node /live-server.mjs stop --keep-inject # stop only + * node /live-server.mjs --help + */ + +import http from 'node:http'; +import { randomUUID } from 'node:crypto'; +import { spawn, execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import net from 'node:net'; +import { fileURLToPath } from 'node:url'; +import { parseDesignMd } from './design-parser.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +// PID file in the project root so both the server and agent can find it +// predictably (os.tmpdir() varies across platforms). +const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway +const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s + +// --------------------------------------------------------------------------- +// Port detection +// --------------------------------------------------------------------------- + +async function findOpenPort(start = 8400) { + return new Promise((resolve) => { + const srv = net.createServer(); + srv.listen(start, '127.0.0.1', () => { + const port = srv.address().port; + srv.close(() => resolve(port)); + }); + srv.on('error', () => resolve(findOpenPort(start + 1))); + }); +} + +// --------------------------------------------------------------------------- +// Session state +// --------------------------------------------------------------------------- + +const state = { + token: null, + port: null, + sseClients: new Set(), // SSE response objects (server→browser push) + pendingEvents: [], // browser events waiting for agent poll + pendingPolls: [], // agent poll callbacks waiting for browser events + exitTimer: null, + sessionDir: null, // per-session tmp dir for annotation screenshots +}; + +// Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; +// cap at 10 MB to guard against runaway writes from a misbehaving client. +const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024; + +function enqueueEvent(event) { + if (state.pendingPolls.length > 0) { + state.pendingPolls.shift()(event); + } else { + state.pendingEvents.push(event); + } +} + +/** Push a message to all connected SSE clients. */ +function broadcast(msg) { + const data = 'data: ' + JSON.stringify(msg) + '\n\n'; + for (const res of state.sseClients) { + try { res.write(data); } catch { /* client gone */ } + } +} + +// --------------------------------------------------------------------------- +// Load scripts +// --------------------------------------------------------------------------- + +function loadBrowserScripts() { + // Detection script: look relative to the skill scripts dir, then fall back + // to the npm package location (src/detect-antipatterns-browser.js). + // This one IS cached — detect.js rarely changes during a session. + const detectPaths = [ + path.join(__dirname, '..', '..', '..', '..', 'src', 'detect-antipatterns-browser.js'), + path.join(process.cwd(), 'node_modules', 'impeccable', 'src', 'detect-antipatterns-browser.js'), + ]; + let detectScript = ''; + for (const p of detectPaths) { + try { detectScript = fs.readFileSync(p, 'utf-8'); break; } catch { /* try next */ } + } + + // live-browser.js: DO NOT cache. Return the path so the /live.js handler + // can re-read on every request. Editing the browser script during iteration + // should land on the next tab reload, not require a server restart. + const livePath = path.join(__dirname, 'live-browser.js'); + if (!fs.existsSync(livePath)) { + process.stderr.write('Error: live-browser.js not found at ' + livePath + '\n'); + process.exit(1); + } + + return { detectScript, livePath }; +} + +function hasProjectContext() { + // PRODUCT.md carries brand voice / anti-references — that's what determines + // whether variants are brand-aware. DESIGN.md (visual tokens) is a separate + // concern, surfaced by the design panel's own empty state. Legacy + // .impeccable.md is auto-migrated to PRODUCT.md by load-context.mjs. + try { + fs.accessSync(path.join(process.cwd(), 'PRODUCT.md'), fs.constants.R_OK); + return true; + } catch { return false; } +} + +function statOrNull(filePath) { + try { return fs.statSync(filePath); } catch { return null; } +} + +// --------------------------------------------------------------------------- +// Validation (inline — no external import needed for self-contained script) +// --------------------------------------------------------------------------- + +const VISUAL_ACTIONS = [ + 'impeccable', 'bolder', 'quieter', 'distill', 'polish', 'typeset', + 'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive', +]; + +function validateEvent(msg) { + if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message'; + switch (msg.type) { + case 'generate': + if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id'; + if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action'; + if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8'; + if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context'; + // Optional annotation fields (all-or-nothing: if any present, all must be well-formed). + if (msg.screenshotPath !== undefined && typeof msg.screenshotPath !== 'string') return 'generate: screenshotPath must be string'; + if (msg.comments !== undefined && !Array.isArray(msg.comments)) return 'generate: comments must be array'; + if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array'; + return null; + case 'accept': + if (!msg.id) return 'accept: missing id'; + if (!msg.variantId) return 'accept: missing variantId'; + if (msg.paramValues !== undefined) { + if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) { + return 'accept: paramValues must be an object'; + } + } + return null; + case 'discard': + return msg.id ? null : 'discard: missing id'; + case 'exit': + return null; + case 'prefetch': + if (!msg.pageUrl || typeof msg.pageUrl !== 'string') return 'prefetch: missing pageUrl'; + return null; + default: + return 'Unknown event type: ' + msg.type; + } +} + +// --------------------------------------------------------------------------- +// HTTP request handler +// --------------------------------------------------------------------------- + +function createRequestHandler({ detectScript, livePath }) { + return (req, res) => { + const url = new URL(req.url, `http://localhost:${state.port}`); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } + + const p = url.pathname; + + // --- Scripts --- + if (p === '/live.js') { + // Re-read from disk each request so edits to live-browser.js land on + // the next tab reload. No-store headers prevent browser caching across + // sessions — during iteration, a cached old script silently breaks + // every subsequent session. + let liveScript; + try { + liveScript = fs.readFileSync(livePath, 'utf-8'); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end('Error reading live-browser.js: ' + err.message); + return; + } + const body = + `window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` + + `window.__IMPECCABLE_PORT__ = ${state.port};\n` + + liveScript; + res.writeHead(200, { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', + 'Pragma': 'no-cache', + }); + res.end(body); + return; + } + if (p === '/detect.js' || p === '/') { + if (!detectScript) { res.writeHead(404); res.end('Not available'); return; } + res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.end(detectScript); + return; + } + + // --- Vendored modern-screenshot (UMD build) --- + // Lazy-loaded by live.js when the user clicks Go; exposes + // window.modernScreenshot.domToBlob(...) for capture. + if (p === '/modern-screenshot.js') { + const vendorPath = path.join(__dirname, 'modern-screenshot.umd.js'); + try { + res.writeHead(200, { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'public, max-age=31536000, immutable', + }); + res.end(fs.readFileSync(vendorPath)); + } catch { + res.writeHead(404); res.end('Vendor script not found'); + } + return; + } + + // --- Annotation upload (browser → server, raw PNG body) --- + // Client generates the eventId, POSTs the PNG, then POSTs the generate + // event with screenshotPath already set. Keeps bytes out of the SSE/poll + // bridge and preserves the "one shot from the user's POV" UX. + if (p === '/annotation' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const eventId = url.searchParams.get('eventId'); + if (!eventId || !/^[A-Za-z0-9_-]{1,64}$/.test(eventId)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid eventId' })); + return; + } + if ((req.headers['content-type'] || '').toLowerCase() !== 'image/png') { + res.writeHead(415, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Content-Type must be image/png' })); + return; + } + if (!state.sessionDir) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Session dir unavailable' })); + return; + } + const chunks = []; + let total = 0; + let aborted = false; + req.on('data', (c) => { + if (aborted) return; + total += c.length; + if (total > MAX_ANNOTATION_BYTES) { + aborted = true; + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Payload too large' })); + req.destroy(); + return; + } + chunks.push(c); + }); + req.on('end', () => { + if (aborted) return; + const absPath = path.join(state.sessionDir, eventId + '.png'); + try { + fs.writeFileSync(absPath, Buffer.concat(chunks)); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Write failed: ' + err.message })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, path: absPath })); + }); + req.on('error', () => { + if (!aborted) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Upload failed' })); + } + }); + return; + } + + // --- Health --- + if (p === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'ok', port: state.port, mode: 'variant', + hasProjectContext: hasProjectContext(), + connectedClients: state.sseClients.size, + })); + return; + } + + // --- Design system (unified v2 response) + raw --- + // /design-system.json returns both parsed DESIGN.md and DESIGN.json + // sidecar when present. Panel merges them: + // { present, parsed, sidecar, hasMd, hasSidecar, + // mdNewerThanJson, parseError?, sidecarError? } + // - parsed: output of parseDesignMd (frontmatter + // + six canonical sections) when DESIGN.md exists. + // - sidecar: DESIGN.json contents when present. + // Expected shape: schemaVersion 2, carrying + // extensions + components + narrative. + // /design-system/raw returns DESIGN.md markdown verbatim + if (p === '/design-system.json' || p === '/design-system/raw') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + + const mdPath = path.join(process.cwd(), 'DESIGN.md'); + const jsonPath = path.join(process.cwd(), 'DESIGN.json'); + const mdStat = statOrNull(mdPath); + const jsonStat = statOrNull(jsonPath); + + if (p === '/design-system/raw') { + if (!mdStat) { res.writeHead(404); res.end('Not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' }); + res.end(fs.readFileSync(mdPath, 'utf-8')); + return; + } + + if (!mdStat && !jsonStat) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ present: false })); + return; + } + + const response = { + present: true, + hasMd: !!mdStat, + hasSidecar: !!jsonStat, + mdNewerThanJson: !!(mdStat && jsonStat && mdStat.mtimeMs > jsonStat.mtimeMs + 1000), + }; + + if (mdStat) { + try { + response.parsed = parseDesignMd(fs.readFileSync(mdPath, 'utf-8')); + } catch (err) { + response.parseError = err.message; + } + } + + if (jsonStat) { + try { + response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); + } catch (err) { + response.sidecarError = 'Failed to parse DESIGN.json: ' + err.message; + } + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(response)); + return; + } + + // --- Source file (no-HMR fallback) --- + if (p === '/source') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const filePath = url.searchParams.get('path'); + if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; } + const absPath = path.resolve(process.cwd(), filePath); + if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; } + let content; + try { content = fs.readFileSync(absPath, 'utf-8'); } + catch { res.writeHead(404); res.end('File not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(content); + return; + } + + // --- SSE: server→browser push (replaces WebSocket) --- + if (p === '/events' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }); + res.write('data: ' + JSON.stringify({ + type: 'connected', + hasProjectContext: hasProjectContext(), + }) + '\n\n'); + + state.sseClients.add(res); + clearTimeout(state.exitTimer); + + // Keepalive: SSE comment every 30s prevents silent connection drops. + const heartbeat = setInterval(() => { + try { res.write(': keepalive\n\n'); } catch { clearInterval(heartbeat); } + }, SSE_HEARTBEAT_INTERVAL); + + req.on('close', () => { + clearInterval(heartbeat); + state.sseClients.delete(res); + if (state.sseClients.size === 0) { + clearTimeout(state.exitTimer); + state.exitTimer = setTimeout(() => { + if (state.sseClients.size === 0) enqueueEvent({ type: 'exit' }); + }, 8000); + } + }); + return; + } + + // --- Browser→server events (replaces WebSocket messages) --- + if (p === '/events' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (msg.token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + const error = validateEvent(msg); + if (error) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error })); + return; + } + enqueueEvent(msg); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); + return; + } + + // --- Stop --- + if (p === '/stop') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('stopping'); + shutdown(); + return; + } + + // --- Agent poll --- + if (p === '/poll' && req.method === 'GET') { + handlePollGet(req, res, url); + return; + } + if (p === '/poll' && req.method === 'POST') { + handlePollPost(req, res); + return; + } + + res.writeHead(404); res.end('Not found'); + }; +} + +// --------------------------------------------------------------------------- +// Agent poll endpoints (unchanged from WS version) +// --------------------------------------------------------------------------- + +function handlePollGet(req, res, url) { + const token = url.searchParams.get('token'); + if (token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10); + if (state.pendingEvents.length > 0) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(state.pendingEvents.shift())); + return; + } + const timer = setTimeout(() => { + const idx = state.pendingPolls.indexOf(resolve); + if (idx !== -1) state.pendingPolls.splice(idx, 1); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ type: 'timeout' })); + }, timeout); + function resolve(event) { + clearTimeout(timer); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(event)); + } + state.pendingPolls.push(resolve); + req.on('close', () => { + clearTimeout(timer); + const idx = state.pendingPolls.indexOf(resolve); + if (idx !== -1) state.pendingPolls.splice(idx, 1); + }); +} + +function handlePollPost(req, res) { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (msg.token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + // Forward the reply to the browser via SSE + broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); +} + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +let httpServer = null; + +function shutdown() { + try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + if (state.sessionDir) { + try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} + } + for (const res of state.sseClients) { try { res.end(); } catch {} } + state.sseClients.clear(); + for (const resolve of state.pendingPolls) resolve({ type: 'exit' }); + state.pendingPolls.length = 0; + if (httpServer) httpServer.close(); + process.exit(0); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +const args = process.argv.slice(2); + +if (args.includes('--help') || args.includes('-h')) { + console.log(`Usage: node live-server.mjs [options] + +Start the live variant mode server (zero dependencies). + +Commands: + (default) Start the server (foreground) + stop Stop the server and remove the injected live.js script tag + stop --keep-inject Stop the server only (leave the script tag in the HTML entry) + +Options: + --background Start detached, print connection JSON to stdout, then exit + --port=PORT Use a specific port (default: auto-detect starting at 8400) + --keep-inject Only with stop: skip live-inject.mjs --remove + --help Show this help + +Endpoints: + /live.js Browser script (element picker + variant cycling) + /detect.js Detection overlay (backwards compatible) + /modern-screenshot.js Vendored modern-screenshot UMD build (lazy-loaded by live.js) + /annotation POST raw image/png to stage a variant screenshot + /events SSE stream (server→browser) + POST (browser→server) + /poll Long-poll for agent CLI + /source Raw source file reader (no-HMR fallback) + /health Health check`); + process.exit(0); +} + +if (args.includes('stop')) { + const keepInject = args.includes('--keep-inject'); + try { + const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`); + if (res.ok) console.log(`Stopped live server on port ${info.port}.`); + } catch { + console.log('No running live server found.'); + } + if (!keepInject) { + const injectPath = path.join(__dirname, 'live-inject.mjs'); + try { + const out = execFileSync(process.execPath, [injectPath, '--remove'], { + encoding: 'utf-8', + cwd: process.cwd(), + }); + const line = out.trim().split('\n').filter(Boolean).pop(); + if (line) { + try { + const j = JSON.parse(line); + if (j.removed === true) { + console.log(`Removed live script tag from ${j.file}.`); + } + } catch { + /* ignore non-JSON lines */ + } + } + } catch (err) { + const detail = err.stderr?.toString?.().trim?.() + || err.stdout?.toString?.().trim?.() + || err.message + || String(err); + console.warn(`Note: could not remove live script tag (${detail.split('\n')[0]})`); + } + } + process.exit(0); +} + +// --background: spawn a detached child server, wait for it to be ready, +// print the connection JSON, then exit. This keeps the startup command +// simple (no shell backgrounding or chained commands). +if (args.includes('--background')) { + const childArgs = args.filter(a => a !== '--background'); + const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...childArgs], { + detached: true, + stdio: 'ignore', + cwd: process.cwd(), + }); + child.unref(); + + // Poll for the PID file (the child writes it once the HTTP server is listening). + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + try { + const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + if (info.pid !== process.pid) { + // Output JSON so the agent can read port + token from stdout. + console.log(JSON.stringify(info)); + process.exit(0); + } + } catch { /* not ready yet */ } + await new Promise(r => setTimeout(r, 200)); + } + console.error('Timed out waiting for live server to start.'); + process.exit(1); +} + +// Check for existing session +try { + const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + try { process.kill(existing.pid, 0); + console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`); + console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop'); + process.exit(1); + } catch { fs.unlinkSync(LIVE_PID_FILE); } +} catch {} + +state.token = randomUUID(); +const portArg = args.find(a => a.startsWith('--port=')); +state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort(); +// Annotation screenshots live in the project root so the agent's Read tool +// doesn't trip a per-file permission prompt. Sessioned by token so concurrent +// projects (or quick restarts) don't collide. +const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); +fs.mkdirSync(annotRoot, { recursive: true }); +state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); + +const { detectScript, livePath } = loadBrowserScripts(); +httpServer = http.createServer(createRequestHandler({ detectScript, livePath })); + +httpServer.listen(state.port, '127.0.0.1', () => { + fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token })); + const url = `http://localhost:${state.port}`; + console.log(`\nImpeccable live server running on ${url}`); + console.log(`Token: ${state.token}\n`); + console.log(`Inject: \n' + + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' + ); +} + +function insertTag(content, config, port) { + const block = buildTagBlock(config.commentSyntax, port); + // insertBefore: match the LAST occurrence. Anchors like `` naturally + // belong at the end, and the same literal can appear earlier in code blocks + // within rendered documentation pages. + if (config.insertBefore) { + const idx = content.lastIndexOf(config.insertBefore); + if (idx === -1) return content; + return content.slice(0, idx) + block + content.slice(idx); + } + // insertAfter: match the FIRST occurrence — typical anchors like `` or + // `` open near the top of the document. + const idx = content.indexOf(config.insertAfter); + if (idx === -1) return content; + const after = idx + config.insertAfter.length; + // Preserve a single trailing newline if the anchor didn't end with one + const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; + return prefix + block + content.slice(prefix.length); +} + +/** + * Remove the live script block. Matches either HTML or JSX comment markers + * regardless of config (so stale tags from a wrong config can still be cleaned). + * + * Indent-preserving: captures any whitespace immediately preceding the opener + * marker and re-emits it in place of the removed block. `insertTag` inserted + * the block *after* the original line's indent and *before* the anchor (e.g. + * ``), which moved the indent onto the opener line and left the anchor + * unindented. Replacing the whole block (plus its trailing newline) with just + * the captured indent hands the indent back to the anchor that follows. + */ +function removeTag(content, _syntax) { + const patterns = [ + /([ \t]*)[\s\S]*?[ \t]*\n/, + /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}[ \t]*\n/, + ]; + for (const pat of patterns) { + const next = content.replace(pat, '$1'); + if (next !== content) return next; + } + return content; +} + +// --------------------------------------------------------------------------- +// Auto-execute +// --------------------------------------------------------------------------- + +const _running = process.argv[1]; +if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) { + injectCli(); +} + +export { insertTag, removeTag, validateConfig, buildTagBlock }; diff --git a/.rovodev/skills/impeccable/scripts/live-poll.mjs b/.rovodev/skills/impeccable/scripts/live-poll.mjs new file mode 100644 index 000000000..5cece1a43 --- /dev/null +++ b/.rovodev/skills/impeccable/scripts/live-poll.mjs @@ -0,0 +1,187 @@ +/** + * CLI client for the live variant mode poll/reply protocol. + * + * Usage: + * npx impeccable poll # Block until browser event, print JSON + * npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly + * npx impeccable poll --reply done # Reply "done" to event + * npx impeccable poll --reply error "msg" # Reply with error + */ + +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { fileURLToPath } from 'node:url'; + +// Node's built-in fetch (undici under the hood) enforces a 300s headers +// timeout that can't be lowered per-request. We cap each request below +// that ceiling and loop in `pollOnce` to synthesize a long poll without +// depending on the standalone undici package. +const PER_REQUEST_TIMEOUT_MS = 270_000; + +const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); + +function readServerInfo() { + try { + return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + } catch { + console.error('No running live server found. Start one with: npx impeccable live'); + process.exit(1); + } +} + +export async function pollCli() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log(`Usage: impeccable poll [options] + +Wait for a browser event from the live variant server, or reply to one. + +Modes: + poll Block until a browser event arrives, print JSON + poll --reply done Reply "done" to event + poll --reply error "msg" Reply with an error message + +Options: + --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 + --help Show this help message`); + process.exit(0); + } + + const info = readServerInfo(); + const base = `http://localhost:${info.port}`; + + // Reply mode: npx impeccable poll --reply [--file path] [message] + const replyIdx = args.indexOf('--reply'); + if (replyIdx !== -1) { + const id = args[replyIdx + 1]; + const status = args[replyIdx + 2] || 'done'; + const fileIdx = args.indexOf('--file'); + const filePath = fileIdx !== -1 && fileIdx + 1 < args.length ? args[fileIdx + 1] : undefined; + // Message is any remaining positional arg that isn't a flag + const message = args.find((a, i) => i > replyIdx + 2 && !a.startsWith('--') && i !== fileIdx + 1) || undefined; + + if (!id) { + console.error('Usage: npx impeccable poll --reply [--file path] [message]'); + process.exit(1); + } + + try { + const res = await fetch(`${base}/poll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: info.token, + id, + type: status, + message, + file: filePath, + }), + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + console.error(`Reply failed (${res.status}):`, body.error || res.statusText); + process.exit(1); + } + + // Success — silent exit (agent doesn't need output for replies) + } catch (err) { + if (err.cause?.code === 'ECONNREFUSED') { + console.error('Live server not running. Start one with: npx impeccable live'); + } else { + console.error('Reply failed:', err.message); + } + process.exit(1); + } + return; + } + + // Poll mode: block until browser event. Default 10 min. Node's built-in + // fetch enforces a 300s headers timeout, so we loop in slices under that + // ceiling and keep re-polling until we get a real event or the user's + // total timeout runs out. + const timeoutArg = args.find(a => a.startsWith('--timeout=')); + const totalTimeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600000; + + const deadline = Date.now() + totalTimeout; + let event; + try { + while (true) { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + event = { type: 'timeout' }; + break; + } + const slice = Math.min(remaining, PER_REQUEST_TIMEOUT_MS); + const res = await fetch(`${base}/poll?token=${info.token}&timeout=${slice}`); + + if (res.status === 401) { + console.error('Authentication failed. The server token may have changed.'); + console.error('Try restarting: npx impeccable live stop && npx impeccable live'); + process.exit(1); + } + + if (!res.ok) { + console.error(`Poll failed: ${res.status} ${res.statusText}`); + process.exit(1); + } + + const next = await res.json(); + // Server-side timeout means no browser event arrived in this slice. + // Loop and re-poll until we get a real event or we hit the user's + // total deadline. + if (next?.type === 'timeout' && Date.now() < deadline) continue; + event = next; + break; + } + + // Auto-handle accept/discard via deterministic script + if (event.type === 'accept' || event.type === 'discard') { + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const acceptScript = path.join(__dirname, 'live-accept.mjs'); + const scriptArgs = event.type === 'discard' + ? ['--id', event.id, '--discard'] + : ['--id', event.id, '--variant', event.variantId]; + if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) { + // Pass through a JSON blob; the shell-safe wrap uses single quotes because + // values are finite {id, number|string|boolean} pairs from a validated payload. + scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`); + } + try { + const out = execSync( + `node "${acceptScript}" ${scriptArgs.join(' ')}`, + { encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 } + ); + event._acceptResult = JSON.parse(out.trim()); + } catch (err) { + event._acceptResult = { handled: false, error: err.message }; + } + } + + // Second signal path: stderr banner in case the agent parses stdout + // JSON but skips nested fields. One line is enough — the full checklist + // is in reference/live.md. + if (event._acceptResult?.carbonize === true) { + process.stderr.write('\n⚠ Carbonize cleanup REQUIRED before next poll. See reference/live.md "Required after accept".\n\n'); + } + + // Print the event as JSON — the agent reads this from stdout + console.log(JSON.stringify(event)); + } catch (err) { + if (err.cause?.code === 'ECONNREFUSED') { + console.error('Live server not running. Start one with: npx impeccable live'); + } else { + console.error('Poll failed:', err.message); + } + process.exit(1); + } +} + +// Auto-execute when run directly +const _running = process.argv[1]; +if (_running?.endsWith('live-poll.mjs') || _running?.endsWith('live-poll.mjs/')) { + pollCli(); +} diff --git a/.rovodev/skills/impeccable/scripts/live-server.mjs b/.rovodev/skills/impeccable/scripts/live-server.mjs new file mode 100644 index 000000000..3d608e3c0 --- /dev/null +++ b/.rovodev/skills/impeccable/scripts/live-server.mjs @@ -0,0 +1,679 @@ +#!/usr/bin/env node +/** + * Live variant mode server (self-contained, zero dependencies). + * + * Serves the browser script (/live.js), the detection overlay (/detect.js), + * uses Server-Sent Events (SSE) for server→browser push, and HTTP POST for + * browser→server events. Agent communicates via HTTP long-poll (/poll). + * + * Usage: + * node /live-server.mjs # start + * node /live-server.mjs stop # stop + remove injected live.js tag + * node /live-server.mjs stop --keep-inject # stop only + * node /live-server.mjs --help + */ + +import http from 'node:http'; +import { randomUUID } from 'node:crypto'; +import { spawn, execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import net from 'node:net'; +import { fileURLToPath } from 'node:url'; +import { parseDesignMd } from './design-parser.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +// PID file in the project root so both the server and agent can find it +// predictably (os.tmpdir() varies across platforms). +const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway +const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s + +// --------------------------------------------------------------------------- +// Port detection +// --------------------------------------------------------------------------- + +async function findOpenPort(start = 8400) { + return new Promise((resolve) => { + const srv = net.createServer(); + srv.listen(start, '127.0.0.1', () => { + const port = srv.address().port; + srv.close(() => resolve(port)); + }); + srv.on('error', () => resolve(findOpenPort(start + 1))); + }); +} + +// --------------------------------------------------------------------------- +// Session state +// --------------------------------------------------------------------------- + +const state = { + token: null, + port: null, + sseClients: new Set(), // SSE response objects (server→browser push) + pendingEvents: [], // browser events waiting for agent poll + pendingPolls: [], // agent poll callbacks waiting for browser events + exitTimer: null, + sessionDir: null, // per-session tmp dir for annotation screenshots +}; + +// Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; +// cap at 10 MB to guard against runaway writes from a misbehaving client. +const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024; + +function enqueueEvent(event) { + if (state.pendingPolls.length > 0) { + state.pendingPolls.shift()(event); + } else { + state.pendingEvents.push(event); + } +} + +/** Push a message to all connected SSE clients. */ +function broadcast(msg) { + const data = 'data: ' + JSON.stringify(msg) + '\n\n'; + for (const res of state.sseClients) { + try { res.write(data); } catch { /* client gone */ } + } +} + +// --------------------------------------------------------------------------- +// Load scripts +// --------------------------------------------------------------------------- + +function loadBrowserScripts() { + // Detection script: look relative to the skill scripts dir, then fall back + // to the npm package location (src/detect-antipatterns-browser.js). + // This one IS cached — detect.js rarely changes during a session. + const detectPaths = [ + path.join(__dirname, '..', '..', '..', '..', 'src', 'detect-antipatterns-browser.js'), + path.join(process.cwd(), 'node_modules', 'impeccable', 'src', 'detect-antipatterns-browser.js'), + ]; + let detectScript = ''; + for (const p of detectPaths) { + try { detectScript = fs.readFileSync(p, 'utf-8'); break; } catch { /* try next */ } + } + + // live-browser.js: DO NOT cache. Return the path so the /live.js handler + // can re-read on every request. Editing the browser script during iteration + // should land on the next tab reload, not require a server restart. + const livePath = path.join(__dirname, 'live-browser.js'); + if (!fs.existsSync(livePath)) { + process.stderr.write('Error: live-browser.js not found at ' + livePath + '\n'); + process.exit(1); + } + + return { detectScript, livePath }; +} + +function hasProjectContext() { + // PRODUCT.md carries brand voice / anti-references — that's what determines + // whether variants are brand-aware. DESIGN.md (visual tokens) is a separate + // concern, surfaced by the design panel's own empty state. Legacy + // .impeccable.md is auto-migrated to PRODUCT.md by load-context.mjs. + try { + fs.accessSync(path.join(process.cwd(), 'PRODUCT.md'), fs.constants.R_OK); + return true; + } catch { return false; } +} + +function statOrNull(filePath) { + try { return fs.statSync(filePath); } catch { return null; } +} + +// --------------------------------------------------------------------------- +// Validation (inline — no external import needed for self-contained script) +// --------------------------------------------------------------------------- + +const VISUAL_ACTIONS = [ + 'impeccable', 'bolder', 'quieter', 'distill', 'polish', 'typeset', + 'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive', +]; + +function validateEvent(msg) { + if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message'; + switch (msg.type) { + case 'generate': + if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id'; + if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action'; + if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8'; + if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context'; + // Optional annotation fields (all-or-nothing: if any present, all must be well-formed). + if (msg.screenshotPath !== undefined && typeof msg.screenshotPath !== 'string') return 'generate: screenshotPath must be string'; + if (msg.comments !== undefined && !Array.isArray(msg.comments)) return 'generate: comments must be array'; + if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array'; + return null; + case 'accept': + if (!msg.id) return 'accept: missing id'; + if (!msg.variantId) return 'accept: missing variantId'; + if (msg.paramValues !== undefined) { + if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) { + return 'accept: paramValues must be an object'; + } + } + return null; + case 'discard': + return msg.id ? null : 'discard: missing id'; + case 'exit': + return null; + case 'prefetch': + if (!msg.pageUrl || typeof msg.pageUrl !== 'string') return 'prefetch: missing pageUrl'; + return null; + default: + return 'Unknown event type: ' + msg.type; + } +} + +// --------------------------------------------------------------------------- +// HTTP request handler +// --------------------------------------------------------------------------- + +function createRequestHandler({ detectScript, livePath }) { + return (req, res) => { + const url = new URL(req.url, `http://localhost:${state.port}`); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } + + const p = url.pathname; + + // --- Scripts --- + if (p === '/live.js') { + // Re-read from disk each request so edits to live-browser.js land on + // the next tab reload. No-store headers prevent browser caching across + // sessions — during iteration, a cached old script silently breaks + // every subsequent session. + let liveScript; + try { + liveScript = fs.readFileSync(livePath, 'utf-8'); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end('Error reading live-browser.js: ' + err.message); + return; + } + const body = + `window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` + + `window.__IMPECCABLE_PORT__ = ${state.port};\n` + + liveScript; + res.writeHead(200, { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', + 'Pragma': 'no-cache', + }); + res.end(body); + return; + } + if (p === '/detect.js' || p === '/') { + if (!detectScript) { res.writeHead(404); res.end('Not available'); return; } + res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.end(detectScript); + return; + } + + // --- Vendored modern-screenshot (UMD build) --- + // Lazy-loaded by live.js when the user clicks Go; exposes + // window.modernScreenshot.domToBlob(...) for capture. + if (p === '/modern-screenshot.js') { + const vendorPath = path.join(__dirname, 'modern-screenshot.umd.js'); + try { + res.writeHead(200, { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'public, max-age=31536000, immutable', + }); + res.end(fs.readFileSync(vendorPath)); + } catch { + res.writeHead(404); res.end('Vendor script not found'); + } + return; + } + + // --- Annotation upload (browser → server, raw PNG body) --- + // Client generates the eventId, POSTs the PNG, then POSTs the generate + // event with screenshotPath already set. Keeps bytes out of the SSE/poll + // bridge and preserves the "one shot from the user's POV" UX. + if (p === '/annotation' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const eventId = url.searchParams.get('eventId'); + if (!eventId || !/^[A-Za-z0-9_-]{1,64}$/.test(eventId)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid eventId' })); + return; + } + if ((req.headers['content-type'] || '').toLowerCase() !== 'image/png') { + res.writeHead(415, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Content-Type must be image/png' })); + return; + } + if (!state.sessionDir) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Session dir unavailable' })); + return; + } + const chunks = []; + let total = 0; + let aborted = false; + req.on('data', (c) => { + if (aborted) return; + total += c.length; + if (total > MAX_ANNOTATION_BYTES) { + aborted = true; + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Payload too large' })); + req.destroy(); + return; + } + chunks.push(c); + }); + req.on('end', () => { + if (aborted) return; + const absPath = path.join(state.sessionDir, eventId + '.png'); + try { + fs.writeFileSync(absPath, Buffer.concat(chunks)); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Write failed: ' + err.message })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, path: absPath })); + }); + req.on('error', () => { + if (!aborted) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Upload failed' })); + } + }); + return; + } + + // --- Health --- + if (p === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'ok', port: state.port, mode: 'variant', + hasProjectContext: hasProjectContext(), + connectedClients: state.sseClients.size, + })); + return; + } + + // --- Design system (unified v2 response) + raw --- + // /design-system.json returns both parsed DESIGN.md and DESIGN.json + // sidecar when present. Panel merges them: + // { present, parsed, sidecar, hasMd, hasSidecar, + // mdNewerThanJson, parseError?, sidecarError? } + // - parsed: output of parseDesignMd (frontmatter + // + six canonical sections) when DESIGN.md exists. + // - sidecar: DESIGN.json contents when present. + // Expected shape: schemaVersion 2, carrying + // extensions + components + narrative. + // /design-system/raw returns DESIGN.md markdown verbatim + if (p === '/design-system.json' || p === '/design-system/raw') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + + const mdPath = path.join(process.cwd(), 'DESIGN.md'); + const jsonPath = path.join(process.cwd(), 'DESIGN.json'); + const mdStat = statOrNull(mdPath); + const jsonStat = statOrNull(jsonPath); + + if (p === '/design-system/raw') { + if (!mdStat) { res.writeHead(404); res.end('Not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' }); + res.end(fs.readFileSync(mdPath, 'utf-8')); + return; + } + + if (!mdStat && !jsonStat) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ present: false })); + return; + } + + const response = { + present: true, + hasMd: !!mdStat, + hasSidecar: !!jsonStat, + mdNewerThanJson: !!(mdStat && jsonStat && mdStat.mtimeMs > jsonStat.mtimeMs + 1000), + }; + + if (mdStat) { + try { + response.parsed = parseDesignMd(fs.readFileSync(mdPath, 'utf-8')); + } catch (err) { + response.parseError = err.message; + } + } + + if (jsonStat) { + try { + response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); + } catch (err) { + response.sidecarError = 'Failed to parse DESIGN.json: ' + err.message; + } + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(response)); + return; + } + + // --- Source file (no-HMR fallback) --- + if (p === '/source') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const filePath = url.searchParams.get('path'); + if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; } + const absPath = path.resolve(process.cwd(), filePath); + if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; } + let content; + try { content = fs.readFileSync(absPath, 'utf-8'); } + catch { res.writeHead(404); res.end('File not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(content); + return; + } + + // --- SSE: server→browser push (replaces WebSocket) --- + if (p === '/events' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }); + res.write('data: ' + JSON.stringify({ + type: 'connected', + hasProjectContext: hasProjectContext(), + }) + '\n\n'); + + state.sseClients.add(res); + clearTimeout(state.exitTimer); + + // Keepalive: SSE comment every 30s prevents silent connection drops. + const heartbeat = setInterval(() => { + try { res.write(': keepalive\n\n'); } catch { clearInterval(heartbeat); } + }, SSE_HEARTBEAT_INTERVAL); + + req.on('close', () => { + clearInterval(heartbeat); + state.sseClients.delete(res); + if (state.sseClients.size === 0) { + clearTimeout(state.exitTimer); + state.exitTimer = setTimeout(() => { + if (state.sseClients.size === 0) enqueueEvent({ type: 'exit' }); + }, 8000); + } + }); + return; + } + + // --- Browser→server events (replaces WebSocket messages) --- + if (p === '/events' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (msg.token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + const error = validateEvent(msg); + if (error) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error })); + return; + } + enqueueEvent(msg); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); + return; + } + + // --- Stop --- + if (p === '/stop') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('stopping'); + shutdown(); + return; + } + + // --- Agent poll --- + if (p === '/poll' && req.method === 'GET') { + handlePollGet(req, res, url); + return; + } + if (p === '/poll' && req.method === 'POST') { + handlePollPost(req, res); + return; + } + + res.writeHead(404); res.end('Not found'); + }; +} + +// --------------------------------------------------------------------------- +// Agent poll endpoints (unchanged from WS version) +// --------------------------------------------------------------------------- + +function handlePollGet(req, res, url) { + const token = url.searchParams.get('token'); + if (token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10); + if (state.pendingEvents.length > 0) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(state.pendingEvents.shift())); + return; + } + const timer = setTimeout(() => { + const idx = state.pendingPolls.indexOf(resolve); + if (idx !== -1) state.pendingPolls.splice(idx, 1); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ type: 'timeout' })); + }, timeout); + function resolve(event) { + clearTimeout(timer); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(event)); + } + state.pendingPolls.push(resolve); + req.on('close', () => { + clearTimeout(timer); + const idx = state.pendingPolls.indexOf(resolve); + if (idx !== -1) state.pendingPolls.splice(idx, 1); + }); +} + +function handlePollPost(req, res) { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (msg.token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + // Forward the reply to the browser via SSE + broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); +} + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +let httpServer = null; + +function shutdown() { + try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + if (state.sessionDir) { + try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} + } + for (const res of state.sseClients) { try { res.end(); } catch {} } + state.sseClients.clear(); + for (const resolve of state.pendingPolls) resolve({ type: 'exit' }); + state.pendingPolls.length = 0; + if (httpServer) httpServer.close(); + process.exit(0); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +const args = process.argv.slice(2); + +if (args.includes('--help') || args.includes('-h')) { + console.log(`Usage: node live-server.mjs [options] + +Start the live variant mode server (zero dependencies). + +Commands: + (default) Start the server (foreground) + stop Stop the server and remove the injected live.js script tag + stop --keep-inject Stop the server only (leave the script tag in the HTML entry) + +Options: + --background Start detached, print connection JSON to stdout, then exit + --port=PORT Use a specific port (default: auto-detect starting at 8400) + --keep-inject Only with stop: skip live-inject.mjs --remove + --help Show this help + +Endpoints: + /live.js Browser script (element picker + variant cycling) + /detect.js Detection overlay (backwards compatible) + /modern-screenshot.js Vendored modern-screenshot UMD build (lazy-loaded by live.js) + /annotation POST raw image/png to stage a variant screenshot + /events SSE stream (server→browser) + POST (browser→server) + /poll Long-poll for agent CLI + /source Raw source file reader (no-HMR fallback) + /health Health check`); + process.exit(0); +} + +if (args.includes('stop')) { + const keepInject = args.includes('--keep-inject'); + try { + const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`); + if (res.ok) console.log(`Stopped live server on port ${info.port}.`); + } catch { + console.log('No running live server found.'); + } + if (!keepInject) { + const injectPath = path.join(__dirname, 'live-inject.mjs'); + try { + const out = execFileSync(process.execPath, [injectPath, '--remove'], { + encoding: 'utf-8', + cwd: process.cwd(), + }); + const line = out.trim().split('\n').filter(Boolean).pop(); + if (line) { + try { + const j = JSON.parse(line); + if (j.removed === true) { + console.log(`Removed live script tag from ${j.file}.`); + } + } catch { + /* ignore non-JSON lines */ + } + } + } catch (err) { + const detail = err.stderr?.toString?.().trim?.() + || err.stdout?.toString?.().trim?.() + || err.message + || String(err); + console.warn(`Note: could not remove live script tag (${detail.split('\n')[0]})`); + } + } + process.exit(0); +} + +// --background: spawn a detached child server, wait for it to be ready, +// print the connection JSON, then exit. This keeps the startup command +// simple (no shell backgrounding or chained commands). +if (args.includes('--background')) { + const childArgs = args.filter(a => a !== '--background'); + const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...childArgs], { + detached: true, + stdio: 'ignore', + cwd: process.cwd(), + }); + child.unref(); + + // Poll for the PID file (the child writes it once the HTTP server is listening). + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + try { + const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + if (info.pid !== process.pid) { + // Output JSON so the agent can read port + token from stdout. + console.log(JSON.stringify(info)); + process.exit(0); + } + } catch { /* not ready yet */ } + await new Promise(r => setTimeout(r, 200)); + } + console.error('Timed out waiting for live server to start.'); + process.exit(1); +} + +// Check for existing session +try { + const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + try { process.kill(existing.pid, 0); + console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`); + console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop'); + process.exit(1); + } catch { fs.unlinkSync(LIVE_PID_FILE); } +} catch {} + +state.token = randomUUID(); +const portArg = args.find(a => a.startsWith('--port=')); +state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort(); +// Annotation screenshots live in the project root so the agent's Read tool +// doesn't trip a per-file permission prompt. Sessioned by token so concurrent +// projects (or quick restarts) don't collide. +const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); +fs.mkdirSync(annotRoot, { recursive: true }); +state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); + +const { detectScript, livePath } = loadBrowserScripts(); +httpServer = http.createServer(createRequestHandler({ detectScript, livePath })); + +httpServer.listen(state.port, '127.0.0.1', () => { + fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token })); + const url = `http://localhost:${state.port}`; + console.log(`\nImpeccable live server running on ${url}`); + console.log(`Token: ${state.token}\n`); + console.log(`Inject: \n' + + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' + ); +} + +function insertTag(content, config, port) { + const block = buildTagBlock(config.commentSyntax, port); + // insertBefore: match the LAST occurrence. Anchors like `` naturally + // belong at the end, and the same literal can appear earlier in code blocks + // within rendered documentation pages. + if (config.insertBefore) { + const idx = content.lastIndexOf(config.insertBefore); + if (idx === -1) return content; + return content.slice(0, idx) + block + content.slice(idx); + } + // insertAfter: match the FIRST occurrence — typical anchors like `` or + // `` open near the top of the document. + const idx = content.indexOf(config.insertAfter); + if (idx === -1) return content; + const after = idx + config.insertAfter.length; + // Preserve a single trailing newline if the anchor didn't end with one + const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; + return prefix + block + content.slice(prefix.length); +} + +/** + * Remove the live script block. Matches either HTML or JSX comment markers + * regardless of config (so stale tags from a wrong config can still be cleaned). + * + * Indent-preserving: captures any whitespace immediately preceding the opener + * marker and re-emits it in place of the removed block. `insertTag` inserted + * the block *after* the original line's indent and *before* the anchor (e.g. + * ``), which moved the indent onto the opener line and left the anchor + * unindented. Replacing the whole block (plus its trailing newline) with just + * the captured indent hands the indent back to the anchor that follows. + */ +function removeTag(content, _syntax) { + const patterns = [ + /([ \t]*)[\s\S]*?[ \t]*\n/, + /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}[ \t]*\n/, + ]; + for (const pat of patterns) { + const next = content.replace(pat, '$1'); + if (next !== content) return next; + } + return content; +} + +// --------------------------------------------------------------------------- +// Auto-execute +// --------------------------------------------------------------------------- + +const _running = process.argv[1]; +if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) { + injectCli(); +} + +export { insertTag, removeTag, validateConfig, buildTagBlock }; diff --git a/.trae-cn/skills/impeccable/scripts/live-poll.mjs b/.trae-cn/skills/impeccable/scripts/live-poll.mjs new file mode 100644 index 000000000..5cece1a43 --- /dev/null +++ b/.trae-cn/skills/impeccable/scripts/live-poll.mjs @@ -0,0 +1,187 @@ +/** + * CLI client for the live variant mode poll/reply protocol. + * + * Usage: + * npx impeccable poll # Block until browser event, print JSON + * npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly + * npx impeccable poll --reply done # Reply "done" to event + * npx impeccable poll --reply error "msg" # Reply with error + */ + +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { fileURLToPath } from 'node:url'; + +// Node's built-in fetch (undici under the hood) enforces a 300s headers +// timeout that can't be lowered per-request. We cap each request below +// that ceiling and loop in `pollOnce` to synthesize a long poll without +// depending on the standalone undici package. +const PER_REQUEST_TIMEOUT_MS = 270_000; + +const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); + +function readServerInfo() { + try { + return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + } catch { + console.error('No running live server found. Start one with: npx impeccable live'); + process.exit(1); + } +} + +export async function pollCli() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log(`Usage: impeccable poll [options] + +Wait for a browser event from the live variant server, or reply to one. + +Modes: + poll Block until a browser event arrives, print JSON + poll --reply done Reply "done" to event + poll --reply error "msg" Reply with an error message + +Options: + --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 + --help Show this help message`); + process.exit(0); + } + + const info = readServerInfo(); + const base = `http://localhost:${info.port}`; + + // Reply mode: npx impeccable poll --reply [--file path] [message] + const replyIdx = args.indexOf('--reply'); + if (replyIdx !== -1) { + const id = args[replyIdx + 1]; + const status = args[replyIdx + 2] || 'done'; + const fileIdx = args.indexOf('--file'); + const filePath = fileIdx !== -1 && fileIdx + 1 < args.length ? args[fileIdx + 1] : undefined; + // Message is any remaining positional arg that isn't a flag + const message = args.find((a, i) => i > replyIdx + 2 && !a.startsWith('--') && i !== fileIdx + 1) || undefined; + + if (!id) { + console.error('Usage: npx impeccable poll --reply [--file path] [message]'); + process.exit(1); + } + + try { + const res = await fetch(`${base}/poll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: info.token, + id, + type: status, + message, + file: filePath, + }), + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + console.error(`Reply failed (${res.status}):`, body.error || res.statusText); + process.exit(1); + } + + // Success — silent exit (agent doesn't need output for replies) + } catch (err) { + if (err.cause?.code === 'ECONNREFUSED') { + console.error('Live server not running. Start one with: npx impeccable live'); + } else { + console.error('Reply failed:', err.message); + } + process.exit(1); + } + return; + } + + // Poll mode: block until browser event. Default 10 min. Node's built-in + // fetch enforces a 300s headers timeout, so we loop in slices under that + // ceiling and keep re-polling until we get a real event or the user's + // total timeout runs out. + const timeoutArg = args.find(a => a.startsWith('--timeout=')); + const totalTimeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600000; + + const deadline = Date.now() + totalTimeout; + let event; + try { + while (true) { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + event = { type: 'timeout' }; + break; + } + const slice = Math.min(remaining, PER_REQUEST_TIMEOUT_MS); + const res = await fetch(`${base}/poll?token=${info.token}&timeout=${slice}`); + + if (res.status === 401) { + console.error('Authentication failed. The server token may have changed.'); + console.error('Try restarting: npx impeccable live stop && npx impeccable live'); + process.exit(1); + } + + if (!res.ok) { + console.error(`Poll failed: ${res.status} ${res.statusText}`); + process.exit(1); + } + + const next = await res.json(); + // Server-side timeout means no browser event arrived in this slice. + // Loop and re-poll until we get a real event or we hit the user's + // total deadline. + if (next?.type === 'timeout' && Date.now() < deadline) continue; + event = next; + break; + } + + // Auto-handle accept/discard via deterministic script + if (event.type === 'accept' || event.type === 'discard') { + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const acceptScript = path.join(__dirname, 'live-accept.mjs'); + const scriptArgs = event.type === 'discard' + ? ['--id', event.id, '--discard'] + : ['--id', event.id, '--variant', event.variantId]; + if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) { + // Pass through a JSON blob; the shell-safe wrap uses single quotes because + // values are finite {id, number|string|boolean} pairs from a validated payload. + scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`); + } + try { + const out = execSync( + `node "${acceptScript}" ${scriptArgs.join(' ')}`, + { encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 } + ); + event._acceptResult = JSON.parse(out.trim()); + } catch (err) { + event._acceptResult = { handled: false, error: err.message }; + } + } + + // Second signal path: stderr banner in case the agent parses stdout + // JSON but skips nested fields. One line is enough — the full checklist + // is in reference/live.md. + if (event._acceptResult?.carbonize === true) { + process.stderr.write('\n⚠ Carbonize cleanup REQUIRED before next poll. See reference/live.md "Required after accept".\n\n'); + } + + // Print the event as JSON — the agent reads this from stdout + console.log(JSON.stringify(event)); + } catch (err) { + if (err.cause?.code === 'ECONNREFUSED') { + console.error('Live server not running. Start one with: npx impeccable live'); + } else { + console.error('Poll failed:', err.message); + } + process.exit(1); + } +} + +// Auto-execute when run directly +const _running = process.argv[1]; +if (_running?.endsWith('live-poll.mjs') || _running?.endsWith('live-poll.mjs/')) { + pollCli(); +} diff --git a/.trae-cn/skills/impeccable/scripts/live-server.mjs b/.trae-cn/skills/impeccable/scripts/live-server.mjs new file mode 100644 index 000000000..3d608e3c0 --- /dev/null +++ b/.trae-cn/skills/impeccable/scripts/live-server.mjs @@ -0,0 +1,679 @@ +#!/usr/bin/env node +/** + * Live variant mode server (self-contained, zero dependencies). + * + * Serves the browser script (/live.js), the detection overlay (/detect.js), + * uses Server-Sent Events (SSE) for server→browser push, and HTTP POST for + * browser→server events. Agent communicates via HTTP long-poll (/poll). + * + * Usage: + * node /live-server.mjs # start + * node /live-server.mjs stop # stop + remove injected live.js tag + * node /live-server.mjs stop --keep-inject # stop only + * node /live-server.mjs --help + */ + +import http from 'node:http'; +import { randomUUID } from 'node:crypto'; +import { spawn, execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import net from 'node:net'; +import { fileURLToPath } from 'node:url'; +import { parseDesignMd } from './design-parser.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +// PID file in the project root so both the server and agent can find it +// predictably (os.tmpdir() varies across platforms). +const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway +const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s + +// --------------------------------------------------------------------------- +// Port detection +// --------------------------------------------------------------------------- + +async function findOpenPort(start = 8400) { + return new Promise((resolve) => { + const srv = net.createServer(); + srv.listen(start, '127.0.0.1', () => { + const port = srv.address().port; + srv.close(() => resolve(port)); + }); + srv.on('error', () => resolve(findOpenPort(start + 1))); + }); +} + +// --------------------------------------------------------------------------- +// Session state +// --------------------------------------------------------------------------- + +const state = { + token: null, + port: null, + sseClients: new Set(), // SSE response objects (server→browser push) + pendingEvents: [], // browser events waiting for agent poll + pendingPolls: [], // agent poll callbacks waiting for browser events + exitTimer: null, + sessionDir: null, // per-session tmp dir for annotation screenshots +}; + +// Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; +// cap at 10 MB to guard against runaway writes from a misbehaving client. +const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024; + +function enqueueEvent(event) { + if (state.pendingPolls.length > 0) { + state.pendingPolls.shift()(event); + } else { + state.pendingEvents.push(event); + } +} + +/** Push a message to all connected SSE clients. */ +function broadcast(msg) { + const data = 'data: ' + JSON.stringify(msg) + '\n\n'; + for (const res of state.sseClients) { + try { res.write(data); } catch { /* client gone */ } + } +} + +// --------------------------------------------------------------------------- +// Load scripts +// --------------------------------------------------------------------------- + +function loadBrowserScripts() { + // Detection script: look relative to the skill scripts dir, then fall back + // to the npm package location (src/detect-antipatterns-browser.js). + // This one IS cached — detect.js rarely changes during a session. + const detectPaths = [ + path.join(__dirname, '..', '..', '..', '..', 'src', 'detect-antipatterns-browser.js'), + path.join(process.cwd(), 'node_modules', 'impeccable', 'src', 'detect-antipatterns-browser.js'), + ]; + let detectScript = ''; + for (const p of detectPaths) { + try { detectScript = fs.readFileSync(p, 'utf-8'); break; } catch { /* try next */ } + } + + // live-browser.js: DO NOT cache. Return the path so the /live.js handler + // can re-read on every request. Editing the browser script during iteration + // should land on the next tab reload, not require a server restart. + const livePath = path.join(__dirname, 'live-browser.js'); + if (!fs.existsSync(livePath)) { + process.stderr.write('Error: live-browser.js not found at ' + livePath + '\n'); + process.exit(1); + } + + return { detectScript, livePath }; +} + +function hasProjectContext() { + // PRODUCT.md carries brand voice / anti-references — that's what determines + // whether variants are brand-aware. DESIGN.md (visual tokens) is a separate + // concern, surfaced by the design panel's own empty state. Legacy + // .impeccable.md is auto-migrated to PRODUCT.md by load-context.mjs. + try { + fs.accessSync(path.join(process.cwd(), 'PRODUCT.md'), fs.constants.R_OK); + return true; + } catch { return false; } +} + +function statOrNull(filePath) { + try { return fs.statSync(filePath); } catch { return null; } +} + +// --------------------------------------------------------------------------- +// Validation (inline — no external import needed for self-contained script) +// --------------------------------------------------------------------------- + +const VISUAL_ACTIONS = [ + 'impeccable', 'bolder', 'quieter', 'distill', 'polish', 'typeset', + 'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive', +]; + +function validateEvent(msg) { + if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message'; + switch (msg.type) { + case 'generate': + if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id'; + if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action'; + if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8'; + if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context'; + // Optional annotation fields (all-or-nothing: if any present, all must be well-formed). + if (msg.screenshotPath !== undefined && typeof msg.screenshotPath !== 'string') return 'generate: screenshotPath must be string'; + if (msg.comments !== undefined && !Array.isArray(msg.comments)) return 'generate: comments must be array'; + if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array'; + return null; + case 'accept': + if (!msg.id) return 'accept: missing id'; + if (!msg.variantId) return 'accept: missing variantId'; + if (msg.paramValues !== undefined) { + if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) { + return 'accept: paramValues must be an object'; + } + } + return null; + case 'discard': + return msg.id ? null : 'discard: missing id'; + case 'exit': + return null; + case 'prefetch': + if (!msg.pageUrl || typeof msg.pageUrl !== 'string') return 'prefetch: missing pageUrl'; + return null; + default: + return 'Unknown event type: ' + msg.type; + } +} + +// --------------------------------------------------------------------------- +// HTTP request handler +// --------------------------------------------------------------------------- + +function createRequestHandler({ detectScript, livePath }) { + return (req, res) => { + const url = new URL(req.url, `http://localhost:${state.port}`); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } + + const p = url.pathname; + + // --- Scripts --- + if (p === '/live.js') { + // Re-read from disk each request so edits to live-browser.js land on + // the next tab reload. No-store headers prevent browser caching across + // sessions — during iteration, a cached old script silently breaks + // every subsequent session. + let liveScript; + try { + liveScript = fs.readFileSync(livePath, 'utf-8'); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end('Error reading live-browser.js: ' + err.message); + return; + } + const body = + `window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` + + `window.__IMPECCABLE_PORT__ = ${state.port};\n` + + liveScript; + res.writeHead(200, { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', + 'Pragma': 'no-cache', + }); + res.end(body); + return; + } + if (p === '/detect.js' || p === '/') { + if (!detectScript) { res.writeHead(404); res.end('Not available'); return; } + res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.end(detectScript); + return; + } + + // --- Vendored modern-screenshot (UMD build) --- + // Lazy-loaded by live.js when the user clicks Go; exposes + // window.modernScreenshot.domToBlob(...) for capture. + if (p === '/modern-screenshot.js') { + const vendorPath = path.join(__dirname, 'modern-screenshot.umd.js'); + try { + res.writeHead(200, { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'public, max-age=31536000, immutable', + }); + res.end(fs.readFileSync(vendorPath)); + } catch { + res.writeHead(404); res.end('Vendor script not found'); + } + return; + } + + // --- Annotation upload (browser → server, raw PNG body) --- + // Client generates the eventId, POSTs the PNG, then POSTs the generate + // event with screenshotPath already set. Keeps bytes out of the SSE/poll + // bridge and preserves the "one shot from the user's POV" UX. + if (p === '/annotation' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const eventId = url.searchParams.get('eventId'); + if (!eventId || !/^[A-Za-z0-9_-]{1,64}$/.test(eventId)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid eventId' })); + return; + } + if ((req.headers['content-type'] || '').toLowerCase() !== 'image/png') { + res.writeHead(415, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Content-Type must be image/png' })); + return; + } + if (!state.sessionDir) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Session dir unavailable' })); + return; + } + const chunks = []; + let total = 0; + let aborted = false; + req.on('data', (c) => { + if (aborted) return; + total += c.length; + if (total > MAX_ANNOTATION_BYTES) { + aborted = true; + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Payload too large' })); + req.destroy(); + return; + } + chunks.push(c); + }); + req.on('end', () => { + if (aborted) return; + const absPath = path.join(state.sessionDir, eventId + '.png'); + try { + fs.writeFileSync(absPath, Buffer.concat(chunks)); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Write failed: ' + err.message })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, path: absPath })); + }); + req.on('error', () => { + if (!aborted) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Upload failed' })); + } + }); + return; + } + + // --- Health --- + if (p === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'ok', port: state.port, mode: 'variant', + hasProjectContext: hasProjectContext(), + connectedClients: state.sseClients.size, + })); + return; + } + + // --- Design system (unified v2 response) + raw --- + // /design-system.json returns both parsed DESIGN.md and DESIGN.json + // sidecar when present. Panel merges them: + // { present, parsed, sidecar, hasMd, hasSidecar, + // mdNewerThanJson, parseError?, sidecarError? } + // - parsed: output of parseDesignMd (frontmatter + // + six canonical sections) when DESIGN.md exists. + // - sidecar: DESIGN.json contents when present. + // Expected shape: schemaVersion 2, carrying + // extensions + components + narrative. + // /design-system/raw returns DESIGN.md markdown verbatim + if (p === '/design-system.json' || p === '/design-system/raw') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + + const mdPath = path.join(process.cwd(), 'DESIGN.md'); + const jsonPath = path.join(process.cwd(), 'DESIGN.json'); + const mdStat = statOrNull(mdPath); + const jsonStat = statOrNull(jsonPath); + + if (p === '/design-system/raw') { + if (!mdStat) { res.writeHead(404); res.end('Not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' }); + res.end(fs.readFileSync(mdPath, 'utf-8')); + return; + } + + if (!mdStat && !jsonStat) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ present: false })); + return; + } + + const response = { + present: true, + hasMd: !!mdStat, + hasSidecar: !!jsonStat, + mdNewerThanJson: !!(mdStat && jsonStat && mdStat.mtimeMs > jsonStat.mtimeMs + 1000), + }; + + if (mdStat) { + try { + response.parsed = parseDesignMd(fs.readFileSync(mdPath, 'utf-8')); + } catch (err) { + response.parseError = err.message; + } + } + + if (jsonStat) { + try { + response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); + } catch (err) { + response.sidecarError = 'Failed to parse DESIGN.json: ' + err.message; + } + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(response)); + return; + } + + // --- Source file (no-HMR fallback) --- + if (p === '/source') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const filePath = url.searchParams.get('path'); + if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; } + const absPath = path.resolve(process.cwd(), filePath); + if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; } + let content; + try { content = fs.readFileSync(absPath, 'utf-8'); } + catch { res.writeHead(404); res.end('File not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(content); + return; + } + + // --- SSE: server→browser push (replaces WebSocket) --- + if (p === '/events' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }); + res.write('data: ' + JSON.stringify({ + type: 'connected', + hasProjectContext: hasProjectContext(), + }) + '\n\n'); + + state.sseClients.add(res); + clearTimeout(state.exitTimer); + + // Keepalive: SSE comment every 30s prevents silent connection drops. + const heartbeat = setInterval(() => { + try { res.write(': keepalive\n\n'); } catch { clearInterval(heartbeat); } + }, SSE_HEARTBEAT_INTERVAL); + + req.on('close', () => { + clearInterval(heartbeat); + state.sseClients.delete(res); + if (state.sseClients.size === 0) { + clearTimeout(state.exitTimer); + state.exitTimer = setTimeout(() => { + if (state.sseClients.size === 0) enqueueEvent({ type: 'exit' }); + }, 8000); + } + }); + return; + } + + // --- Browser→server events (replaces WebSocket messages) --- + if (p === '/events' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (msg.token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + const error = validateEvent(msg); + if (error) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error })); + return; + } + enqueueEvent(msg); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); + return; + } + + // --- Stop --- + if (p === '/stop') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('stopping'); + shutdown(); + return; + } + + // --- Agent poll --- + if (p === '/poll' && req.method === 'GET') { + handlePollGet(req, res, url); + return; + } + if (p === '/poll' && req.method === 'POST') { + handlePollPost(req, res); + return; + } + + res.writeHead(404); res.end('Not found'); + }; +} + +// --------------------------------------------------------------------------- +// Agent poll endpoints (unchanged from WS version) +// --------------------------------------------------------------------------- + +function handlePollGet(req, res, url) { + const token = url.searchParams.get('token'); + if (token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10); + if (state.pendingEvents.length > 0) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(state.pendingEvents.shift())); + return; + } + const timer = setTimeout(() => { + const idx = state.pendingPolls.indexOf(resolve); + if (idx !== -1) state.pendingPolls.splice(idx, 1); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ type: 'timeout' })); + }, timeout); + function resolve(event) { + clearTimeout(timer); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(event)); + } + state.pendingPolls.push(resolve); + req.on('close', () => { + clearTimeout(timer); + const idx = state.pendingPolls.indexOf(resolve); + if (idx !== -1) state.pendingPolls.splice(idx, 1); + }); +} + +function handlePollPost(req, res) { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (msg.token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + // Forward the reply to the browser via SSE + broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); +} + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +let httpServer = null; + +function shutdown() { + try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + if (state.sessionDir) { + try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} + } + for (const res of state.sseClients) { try { res.end(); } catch {} } + state.sseClients.clear(); + for (const resolve of state.pendingPolls) resolve({ type: 'exit' }); + state.pendingPolls.length = 0; + if (httpServer) httpServer.close(); + process.exit(0); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +const args = process.argv.slice(2); + +if (args.includes('--help') || args.includes('-h')) { + console.log(`Usage: node live-server.mjs [options] + +Start the live variant mode server (zero dependencies). + +Commands: + (default) Start the server (foreground) + stop Stop the server and remove the injected live.js script tag + stop --keep-inject Stop the server only (leave the script tag in the HTML entry) + +Options: + --background Start detached, print connection JSON to stdout, then exit + --port=PORT Use a specific port (default: auto-detect starting at 8400) + --keep-inject Only with stop: skip live-inject.mjs --remove + --help Show this help + +Endpoints: + /live.js Browser script (element picker + variant cycling) + /detect.js Detection overlay (backwards compatible) + /modern-screenshot.js Vendored modern-screenshot UMD build (lazy-loaded by live.js) + /annotation POST raw image/png to stage a variant screenshot + /events SSE stream (server→browser) + POST (browser→server) + /poll Long-poll for agent CLI + /source Raw source file reader (no-HMR fallback) + /health Health check`); + process.exit(0); +} + +if (args.includes('stop')) { + const keepInject = args.includes('--keep-inject'); + try { + const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`); + if (res.ok) console.log(`Stopped live server on port ${info.port}.`); + } catch { + console.log('No running live server found.'); + } + if (!keepInject) { + const injectPath = path.join(__dirname, 'live-inject.mjs'); + try { + const out = execFileSync(process.execPath, [injectPath, '--remove'], { + encoding: 'utf-8', + cwd: process.cwd(), + }); + const line = out.trim().split('\n').filter(Boolean).pop(); + if (line) { + try { + const j = JSON.parse(line); + if (j.removed === true) { + console.log(`Removed live script tag from ${j.file}.`); + } + } catch { + /* ignore non-JSON lines */ + } + } + } catch (err) { + const detail = err.stderr?.toString?.().trim?.() + || err.stdout?.toString?.().trim?.() + || err.message + || String(err); + console.warn(`Note: could not remove live script tag (${detail.split('\n')[0]})`); + } + } + process.exit(0); +} + +// --background: spawn a detached child server, wait for it to be ready, +// print the connection JSON, then exit. This keeps the startup command +// simple (no shell backgrounding or chained commands). +if (args.includes('--background')) { + const childArgs = args.filter(a => a !== '--background'); + const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...childArgs], { + detached: true, + stdio: 'ignore', + cwd: process.cwd(), + }); + child.unref(); + + // Poll for the PID file (the child writes it once the HTTP server is listening). + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + try { + const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + if (info.pid !== process.pid) { + // Output JSON so the agent can read port + token from stdout. + console.log(JSON.stringify(info)); + process.exit(0); + } + } catch { /* not ready yet */ } + await new Promise(r => setTimeout(r, 200)); + } + console.error('Timed out waiting for live server to start.'); + process.exit(1); +} + +// Check for existing session +try { + const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + try { process.kill(existing.pid, 0); + console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`); + console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop'); + process.exit(1); + } catch { fs.unlinkSync(LIVE_PID_FILE); } +} catch {} + +state.token = randomUUID(); +const portArg = args.find(a => a.startsWith('--port=')); +state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort(); +// Annotation screenshots live in the project root so the agent's Read tool +// doesn't trip a per-file permission prompt. Sessioned by token so concurrent +// projects (or quick restarts) don't collide. +const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); +fs.mkdirSync(annotRoot, { recursive: true }); +state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); + +const { detectScript, livePath } = loadBrowserScripts(); +httpServer = http.createServer(createRequestHandler({ detectScript, livePath })); + +httpServer.listen(state.port, '127.0.0.1', () => { + fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token })); + const url = `http://localhost:${state.port}`; + console.log(`\nImpeccable live server running on ${url}`); + console.log(`Token: ${state.token}\n`); + console.log(`Inject: \n' + + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' + ); +} + +function insertTag(content, config, port) { + const block = buildTagBlock(config.commentSyntax, port); + // insertBefore: match the LAST occurrence. Anchors like `` naturally + // belong at the end, and the same literal can appear earlier in code blocks + // within rendered documentation pages. + if (config.insertBefore) { + const idx = content.lastIndexOf(config.insertBefore); + if (idx === -1) return content; + return content.slice(0, idx) + block + content.slice(idx); + } + // insertAfter: match the FIRST occurrence — typical anchors like `` or + // `` open near the top of the document. + const idx = content.indexOf(config.insertAfter); + if (idx === -1) return content; + const after = idx + config.insertAfter.length; + // Preserve a single trailing newline if the anchor didn't end with one + const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; + return prefix + block + content.slice(prefix.length); +} + +/** + * Remove the live script block. Matches either HTML or JSX comment markers + * regardless of config (so stale tags from a wrong config can still be cleaned). + * + * Indent-preserving: captures any whitespace immediately preceding the opener + * marker and re-emits it in place of the removed block. `insertTag` inserted + * the block *after* the original line's indent and *before* the anchor (e.g. + * ``), which moved the indent onto the opener line and left the anchor + * unindented. Replacing the whole block (plus its trailing newline) with just + * the captured indent hands the indent back to the anchor that follows. + */ +function removeTag(content, _syntax) { + const patterns = [ + /([ \t]*)[\s\S]*?[ \t]*\n/, + /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}[ \t]*\n/, + ]; + for (const pat of patterns) { + const next = content.replace(pat, '$1'); + if (next !== content) return next; + } + return content; +} + +// --------------------------------------------------------------------------- +// Auto-execute +// --------------------------------------------------------------------------- + +const _running = process.argv[1]; +if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) { + injectCli(); +} + +export { insertTag, removeTag, validateConfig, buildTagBlock }; diff --git a/.trae/skills/impeccable/scripts/live-poll.mjs b/.trae/skills/impeccable/scripts/live-poll.mjs new file mode 100644 index 000000000..5cece1a43 --- /dev/null +++ b/.trae/skills/impeccable/scripts/live-poll.mjs @@ -0,0 +1,187 @@ +/** + * CLI client for the live variant mode poll/reply protocol. + * + * Usage: + * npx impeccable poll # Block until browser event, print JSON + * npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly + * npx impeccable poll --reply done # Reply "done" to event + * npx impeccable poll --reply error "msg" # Reply with error + */ + +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { fileURLToPath } from 'node:url'; + +// Node's built-in fetch (undici under the hood) enforces a 300s headers +// timeout that can't be lowered per-request. We cap each request below +// that ceiling and loop in `pollOnce` to synthesize a long poll without +// depending on the standalone undici package. +const PER_REQUEST_TIMEOUT_MS = 270_000; + +const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); + +function readServerInfo() { + try { + return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + } catch { + console.error('No running live server found. Start one with: npx impeccable live'); + process.exit(1); + } +} + +export async function pollCli() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log(`Usage: impeccable poll [options] + +Wait for a browser event from the live variant server, or reply to one. + +Modes: + poll Block until a browser event arrives, print JSON + poll --reply done Reply "done" to event + poll --reply error "msg" Reply with an error message + +Options: + --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 + --help Show this help message`); + process.exit(0); + } + + const info = readServerInfo(); + const base = `http://localhost:${info.port}`; + + // Reply mode: npx impeccable poll --reply [--file path] [message] + const replyIdx = args.indexOf('--reply'); + if (replyIdx !== -1) { + const id = args[replyIdx + 1]; + const status = args[replyIdx + 2] || 'done'; + const fileIdx = args.indexOf('--file'); + const filePath = fileIdx !== -1 && fileIdx + 1 < args.length ? args[fileIdx + 1] : undefined; + // Message is any remaining positional arg that isn't a flag + const message = args.find((a, i) => i > replyIdx + 2 && !a.startsWith('--') && i !== fileIdx + 1) || undefined; + + if (!id) { + console.error('Usage: npx impeccable poll --reply [--file path] [message]'); + process.exit(1); + } + + try { + const res = await fetch(`${base}/poll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: info.token, + id, + type: status, + message, + file: filePath, + }), + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + console.error(`Reply failed (${res.status}):`, body.error || res.statusText); + process.exit(1); + } + + // Success — silent exit (agent doesn't need output for replies) + } catch (err) { + if (err.cause?.code === 'ECONNREFUSED') { + console.error('Live server not running. Start one with: npx impeccable live'); + } else { + console.error('Reply failed:', err.message); + } + process.exit(1); + } + return; + } + + // Poll mode: block until browser event. Default 10 min. Node's built-in + // fetch enforces a 300s headers timeout, so we loop in slices under that + // ceiling and keep re-polling until we get a real event or the user's + // total timeout runs out. + const timeoutArg = args.find(a => a.startsWith('--timeout=')); + const totalTimeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600000; + + const deadline = Date.now() + totalTimeout; + let event; + try { + while (true) { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + event = { type: 'timeout' }; + break; + } + const slice = Math.min(remaining, PER_REQUEST_TIMEOUT_MS); + const res = await fetch(`${base}/poll?token=${info.token}&timeout=${slice}`); + + if (res.status === 401) { + console.error('Authentication failed. The server token may have changed.'); + console.error('Try restarting: npx impeccable live stop && npx impeccable live'); + process.exit(1); + } + + if (!res.ok) { + console.error(`Poll failed: ${res.status} ${res.statusText}`); + process.exit(1); + } + + const next = await res.json(); + // Server-side timeout means no browser event arrived in this slice. + // Loop and re-poll until we get a real event or we hit the user's + // total deadline. + if (next?.type === 'timeout' && Date.now() < deadline) continue; + event = next; + break; + } + + // Auto-handle accept/discard via deterministic script + if (event.type === 'accept' || event.type === 'discard') { + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const acceptScript = path.join(__dirname, 'live-accept.mjs'); + const scriptArgs = event.type === 'discard' + ? ['--id', event.id, '--discard'] + : ['--id', event.id, '--variant', event.variantId]; + if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) { + // Pass through a JSON blob; the shell-safe wrap uses single quotes because + // values are finite {id, number|string|boolean} pairs from a validated payload. + scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`); + } + try { + const out = execSync( + `node "${acceptScript}" ${scriptArgs.join(' ')}`, + { encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 } + ); + event._acceptResult = JSON.parse(out.trim()); + } catch (err) { + event._acceptResult = { handled: false, error: err.message }; + } + } + + // Second signal path: stderr banner in case the agent parses stdout + // JSON but skips nested fields. One line is enough — the full checklist + // is in reference/live.md. + if (event._acceptResult?.carbonize === true) { + process.stderr.write('\n⚠ Carbonize cleanup REQUIRED before next poll. See reference/live.md "Required after accept".\n\n'); + } + + // Print the event as JSON — the agent reads this from stdout + console.log(JSON.stringify(event)); + } catch (err) { + if (err.cause?.code === 'ECONNREFUSED') { + console.error('Live server not running. Start one with: npx impeccable live'); + } else { + console.error('Poll failed:', err.message); + } + process.exit(1); + } +} + +// Auto-execute when run directly +const _running = process.argv[1]; +if (_running?.endsWith('live-poll.mjs') || _running?.endsWith('live-poll.mjs/')) { + pollCli(); +} diff --git a/.trae/skills/impeccable/scripts/live-server.mjs b/.trae/skills/impeccable/scripts/live-server.mjs new file mode 100644 index 000000000..3d608e3c0 --- /dev/null +++ b/.trae/skills/impeccable/scripts/live-server.mjs @@ -0,0 +1,679 @@ +#!/usr/bin/env node +/** + * Live variant mode server (self-contained, zero dependencies). + * + * Serves the browser script (/live.js), the detection overlay (/detect.js), + * uses Server-Sent Events (SSE) for server→browser push, and HTTP POST for + * browser→server events. Agent communicates via HTTP long-poll (/poll). + * + * Usage: + * node /live-server.mjs # start + * node /live-server.mjs stop # stop + remove injected live.js tag + * node /live-server.mjs stop --keep-inject # stop only + * node /live-server.mjs --help + */ + +import http from 'node:http'; +import { randomUUID } from 'node:crypto'; +import { spawn, execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import net from 'node:net'; +import { fileURLToPath } from 'node:url'; +import { parseDesignMd } from './design-parser.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +// PID file in the project root so both the server and agent can find it +// predictably (os.tmpdir() varies across platforms). +const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway +const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s + +// --------------------------------------------------------------------------- +// Port detection +// --------------------------------------------------------------------------- + +async function findOpenPort(start = 8400) { + return new Promise((resolve) => { + const srv = net.createServer(); + srv.listen(start, '127.0.0.1', () => { + const port = srv.address().port; + srv.close(() => resolve(port)); + }); + srv.on('error', () => resolve(findOpenPort(start + 1))); + }); +} + +// --------------------------------------------------------------------------- +// Session state +// --------------------------------------------------------------------------- + +const state = { + token: null, + port: null, + sseClients: new Set(), // SSE response objects (server→browser push) + pendingEvents: [], // browser events waiting for agent poll + pendingPolls: [], // agent poll callbacks waiting for browser events + exitTimer: null, + sessionDir: null, // per-session tmp dir for annotation screenshots +}; + +// Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; +// cap at 10 MB to guard against runaway writes from a misbehaving client. +const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024; + +function enqueueEvent(event) { + if (state.pendingPolls.length > 0) { + state.pendingPolls.shift()(event); + } else { + state.pendingEvents.push(event); + } +} + +/** Push a message to all connected SSE clients. */ +function broadcast(msg) { + const data = 'data: ' + JSON.stringify(msg) + '\n\n'; + for (const res of state.sseClients) { + try { res.write(data); } catch { /* client gone */ } + } +} + +// --------------------------------------------------------------------------- +// Load scripts +// --------------------------------------------------------------------------- + +function loadBrowserScripts() { + // Detection script: look relative to the skill scripts dir, then fall back + // to the npm package location (src/detect-antipatterns-browser.js). + // This one IS cached — detect.js rarely changes during a session. + const detectPaths = [ + path.join(__dirname, '..', '..', '..', '..', 'src', 'detect-antipatterns-browser.js'), + path.join(process.cwd(), 'node_modules', 'impeccable', 'src', 'detect-antipatterns-browser.js'), + ]; + let detectScript = ''; + for (const p of detectPaths) { + try { detectScript = fs.readFileSync(p, 'utf-8'); break; } catch { /* try next */ } + } + + // live-browser.js: DO NOT cache. Return the path so the /live.js handler + // can re-read on every request. Editing the browser script during iteration + // should land on the next tab reload, not require a server restart. + const livePath = path.join(__dirname, 'live-browser.js'); + if (!fs.existsSync(livePath)) { + process.stderr.write('Error: live-browser.js not found at ' + livePath + '\n'); + process.exit(1); + } + + return { detectScript, livePath }; +} + +function hasProjectContext() { + // PRODUCT.md carries brand voice / anti-references — that's what determines + // whether variants are brand-aware. DESIGN.md (visual tokens) is a separate + // concern, surfaced by the design panel's own empty state. Legacy + // .impeccable.md is auto-migrated to PRODUCT.md by load-context.mjs. + try { + fs.accessSync(path.join(process.cwd(), 'PRODUCT.md'), fs.constants.R_OK); + return true; + } catch { return false; } +} + +function statOrNull(filePath) { + try { return fs.statSync(filePath); } catch { return null; } +} + +// --------------------------------------------------------------------------- +// Validation (inline — no external import needed for self-contained script) +// --------------------------------------------------------------------------- + +const VISUAL_ACTIONS = [ + 'impeccable', 'bolder', 'quieter', 'distill', 'polish', 'typeset', + 'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive', +]; + +function validateEvent(msg) { + if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message'; + switch (msg.type) { + case 'generate': + if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id'; + if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action'; + if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8'; + if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context'; + // Optional annotation fields (all-or-nothing: if any present, all must be well-formed). + if (msg.screenshotPath !== undefined && typeof msg.screenshotPath !== 'string') return 'generate: screenshotPath must be string'; + if (msg.comments !== undefined && !Array.isArray(msg.comments)) return 'generate: comments must be array'; + if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array'; + return null; + case 'accept': + if (!msg.id) return 'accept: missing id'; + if (!msg.variantId) return 'accept: missing variantId'; + if (msg.paramValues !== undefined) { + if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) { + return 'accept: paramValues must be an object'; + } + } + return null; + case 'discard': + return msg.id ? null : 'discard: missing id'; + case 'exit': + return null; + case 'prefetch': + if (!msg.pageUrl || typeof msg.pageUrl !== 'string') return 'prefetch: missing pageUrl'; + return null; + default: + return 'Unknown event type: ' + msg.type; + } +} + +// --------------------------------------------------------------------------- +// HTTP request handler +// --------------------------------------------------------------------------- + +function createRequestHandler({ detectScript, livePath }) { + return (req, res) => { + const url = new URL(req.url, `http://localhost:${state.port}`); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } + + const p = url.pathname; + + // --- Scripts --- + if (p === '/live.js') { + // Re-read from disk each request so edits to live-browser.js land on + // the next tab reload. No-store headers prevent browser caching across + // sessions — during iteration, a cached old script silently breaks + // every subsequent session. + let liveScript; + try { + liveScript = fs.readFileSync(livePath, 'utf-8'); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end('Error reading live-browser.js: ' + err.message); + return; + } + const body = + `window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` + + `window.__IMPECCABLE_PORT__ = ${state.port};\n` + + liveScript; + res.writeHead(200, { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', + 'Pragma': 'no-cache', + }); + res.end(body); + return; + } + if (p === '/detect.js' || p === '/') { + if (!detectScript) { res.writeHead(404); res.end('Not available'); return; } + res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.end(detectScript); + return; + } + + // --- Vendored modern-screenshot (UMD build) --- + // Lazy-loaded by live.js when the user clicks Go; exposes + // window.modernScreenshot.domToBlob(...) for capture. + if (p === '/modern-screenshot.js') { + const vendorPath = path.join(__dirname, 'modern-screenshot.umd.js'); + try { + res.writeHead(200, { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'public, max-age=31536000, immutable', + }); + res.end(fs.readFileSync(vendorPath)); + } catch { + res.writeHead(404); res.end('Vendor script not found'); + } + return; + } + + // --- Annotation upload (browser → server, raw PNG body) --- + // Client generates the eventId, POSTs the PNG, then POSTs the generate + // event with screenshotPath already set. Keeps bytes out of the SSE/poll + // bridge and preserves the "one shot from the user's POV" UX. + if (p === '/annotation' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const eventId = url.searchParams.get('eventId'); + if (!eventId || !/^[A-Za-z0-9_-]{1,64}$/.test(eventId)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid eventId' })); + return; + } + if ((req.headers['content-type'] || '').toLowerCase() !== 'image/png') { + res.writeHead(415, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Content-Type must be image/png' })); + return; + } + if (!state.sessionDir) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Session dir unavailable' })); + return; + } + const chunks = []; + let total = 0; + let aborted = false; + req.on('data', (c) => { + if (aborted) return; + total += c.length; + if (total > MAX_ANNOTATION_BYTES) { + aborted = true; + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Payload too large' })); + req.destroy(); + return; + } + chunks.push(c); + }); + req.on('end', () => { + if (aborted) return; + const absPath = path.join(state.sessionDir, eventId + '.png'); + try { + fs.writeFileSync(absPath, Buffer.concat(chunks)); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Write failed: ' + err.message })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, path: absPath })); + }); + req.on('error', () => { + if (!aborted) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Upload failed' })); + } + }); + return; + } + + // --- Health --- + if (p === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'ok', port: state.port, mode: 'variant', + hasProjectContext: hasProjectContext(), + connectedClients: state.sseClients.size, + })); + return; + } + + // --- Design system (unified v2 response) + raw --- + // /design-system.json returns both parsed DESIGN.md and DESIGN.json + // sidecar when present. Panel merges them: + // { present, parsed, sidecar, hasMd, hasSidecar, + // mdNewerThanJson, parseError?, sidecarError? } + // - parsed: output of parseDesignMd (frontmatter + // + six canonical sections) when DESIGN.md exists. + // - sidecar: DESIGN.json contents when present. + // Expected shape: schemaVersion 2, carrying + // extensions + components + narrative. + // /design-system/raw returns DESIGN.md markdown verbatim + if (p === '/design-system.json' || p === '/design-system/raw') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + + const mdPath = path.join(process.cwd(), 'DESIGN.md'); + const jsonPath = path.join(process.cwd(), 'DESIGN.json'); + const mdStat = statOrNull(mdPath); + const jsonStat = statOrNull(jsonPath); + + if (p === '/design-system/raw') { + if (!mdStat) { res.writeHead(404); res.end('Not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' }); + res.end(fs.readFileSync(mdPath, 'utf-8')); + return; + } + + if (!mdStat && !jsonStat) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ present: false })); + return; + } + + const response = { + present: true, + hasMd: !!mdStat, + hasSidecar: !!jsonStat, + mdNewerThanJson: !!(mdStat && jsonStat && mdStat.mtimeMs > jsonStat.mtimeMs + 1000), + }; + + if (mdStat) { + try { + response.parsed = parseDesignMd(fs.readFileSync(mdPath, 'utf-8')); + } catch (err) { + response.parseError = err.message; + } + } + + if (jsonStat) { + try { + response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); + } catch (err) { + response.sidecarError = 'Failed to parse DESIGN.json: ' + err.message; + } + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(response)); + return; + } + + // --- Source file (no-HMR fallback) --- + if (p === '/source') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const filePath = url.searchParams.get('path'); + if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; } + const absPath = path.resolve(process.cwd(), filePath); + if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; } + let content; + try { content = fs.readFileSync(absPath, 'utf-8'); } + catch { res.writeHead(404); res.end('File not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(content); + return; + } + + // --- SSE: server→browser push (replaces WebSocket) --- + if (p === '/events' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }); + res.write('data: ' + JSON.stringify({ + type: 'connected', + hasProjectContext: hasProjectContext(), + }) + '\n\n'); + + state.sseClients.add(res); + clearTimeout(state.exitTimer); + + // Keepalive: SSE comment every 30s prevents silent connection drops. + const heartbeat = setInterval(() => { + try { res.write(': keepalive\n\n'); } catch { clearInterval(heartbeat); } + }, SSE_HEARTBEAT_INTERVAL); + + req.on('close', () => { + clearInterval(heartbeat); + state.sseClients.delete(res); + if (state.sseClients.size === 0) { + clearTimeout(state.exitTimer); + state.exitTimer = setTimeout(() => { + if (state.sseClients.size === 0) enqueueEvent({ type: 'exit' }); + }, 8000); + } + }); + return; + } + + // --- Browser→server events (replaces WebSocket messages) --- + if (p === '/events' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (msg.token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + const error = validateEvent(msg); + if (error) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error })); + return; + } + enqueueEvent(msg); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); + return; + } + + // --- Stop --- + if (p === '/stop') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('stopping'); + shutdown(); + return; + } + + // --- Agent poll --- + if (p === '/poll' && req.method === 'GET') { + handlePollGet(req, res, url); + return; + } + if (p === '/poll' && req.method === 'POST') { + handlePollPost(req, res); + return; + } + + res.writeHead(404); res.end('Not found'); + }; +} + +// --------------------------------------------------------------------------- +// Agent poll endpoints (unchanged from WS version) +// --------------------------------------------------------------------------- + +function handlePollGet(req, res, url) { + const token = url.searchParams.get('token'); + if (token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10); + if (state.pendingEvents.length > 0) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(state.pendingEvents.shift())); + return; + } + const timer = setTimeout(() => { + const idx = state.pendingPolls.indexOf(resolve); + if (idx !== -1) state.pendingPolls.splice(idx, 1); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ type: 'timeout' })); + }, timeout); + function resolve(event) { + clearTimeout(timer); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(event)); + } + state.pendingPolls.push(resolve); + req.on('close', () => { + clearTimeout(timer); + const idx = state.pendingPolls.indexOf(resolve); + if (idx !== -1) state.pendingPolls.splice(idx, 1); + }); +} + +function handlePollPost(req, res) { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (msg.token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + // Forward the reply to the browser via SSE + broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); +} + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +let httpServer = null; + +function shutdown() { + try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + if (state.sessionDir) { + try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} + } + for (const res of state.sseClients) { try { res.end(); } catch {} } + state.sseClients.clear(); + for (const resolve of state.pendingPolls) resolve({ type: 'exit' }); + state.pendingPolls.length = 0; + if (httpServer) httpServer.close(); + process.exit(0); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +const args = process.argv.slice(2); + +if (args.includes('--help') || args.includes('-h')) { + console.log(`Usage: node live-server.mjs [options] + +Start the live variant mode server (zero dependencies). + +Commands: + (default) Start the server (foreground) + stop Stop the server and remove the injected live.js script tag + stop --keep-inject Stop the server only (leave the script tag in the HTML entry) + +Options: + --background Start detached, print connection JSON to stdout, then exit + --port=PORT Use a specific port (default: auto-detect starting at 8400) + --keep-inject Only with stop: skip live-inject.mjs --remove + --help Show this help + +Endpoints: + /live.js Browser script (element picker + variant cycling) + /detect.js Detection overlay (backwards compatible) + /modern-screenshot.js Vendored modern-screenshot UMD build (lazy-loaded by live.js) + /annotation POST raw image/png to stage a variant screenshot + /events SSE stream (server→browser) + POST (browser→server) + /poll Long-poll for agent CLI + /source Raw source file reader (no-HMR fallback) + /health Health check`); + process.exit(0); +} + +if (args.includes('stop')) { + const keepInject = args.includes('--keep-inject'); + try { + const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`); + if (res.ok) console.log(`Stopped live server on port ${info.port}.`); + } catch { + console.log('No running live server found.'); + } + if (!keepInject) { + const injectPath = path.join(__dirname, 'live-inject.mjs'); + try { + const out = execFileSync(process.execPath, [injectPath, '--remove'], { + encoding: 'utf-8', + cwd: process.cwd(), + }); + const line = out.trim().split('\n').filter(Boolean).pop(); + if (line) { + try { + const j = JSON.parse(line); + if (j.removed === true) { + console.log(`Removed live script tag from ${j.file}.`); + } + } catch { + /* ignore non-JSON lines */ + } + } + } catch (err) { + const detail = err.stderr?.toString?.().trim?.() + || err.stdout?.toString?.().trim?.() + || err.message + || String(err); + console.warn(`Note: could not remove live script tag (${detail.split('\n')[0]})`); + } + } + process.exit(0); +} + +// --background: spawn a detached child server, wait for it to be ready, +// print the connection JSON, then exit. This keeps the startup command +// simple (no shell backgrounding or chained commands). +if (args.includes('--background')) { + const childArgs = args.filter(a => a !== '--background'); + const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...childArgs], { + detached: true, + stdio: 'ignore', + cwd: process.cwd(), + }); + child.unref(); + + // Poll for the PID file (the child writes it once the HTTP server is listening). + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + try { + const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + if (info.pid !== process.pid) { + // Output JSON so the agent can read port + token from stdout. + console.log(JSON.stringify(info)); + process.exit(0); + } + } catch { /* not ready yet */ } + await new Promise(r => setTimeout(r, 200)); + } + console.error('Timed out waiting for live server to start.'); + process.exit(1); +} + +// Check for existing session +try { + const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + try { process.kill(existing.pid, 0); + console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`); + console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop'); + process.exit(1); + } catch { fs.unlinkSync(LIVE_PID_FILE); } +} catch {} + +state.token = randomUUID(); +const portArg = args.find(a => a.startsWith('--port=')); +state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort(); +// Annotation screenshots live in the project root so the agent's Read tool +// doesn't trip a per-file permission prompt. Sessioned by token so concurrent +// projects (or quick restarts) don't collide. +const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); +fs.mkdirSync(annotRoot, { recursive: true }); +state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); + +const { detectScript, livePath } = loadBrowserScripts(); +httpServer = http.createServer(createRequestHandler({ detectScript, livePath })); + +httpServer.listen(state.port, '127.0.0.1', () => { + fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token })); + const url = `http://localhost:${state.port}`; + console.log(`\nImpeccable live server running on ${url}`); + console.log(`Token: ${state.token}\n`); + console.log(`Inject: - - - - - Impeccable Command Cheatsheet - - - - - - - - - - - - -
-

Impeccable Commands

-

Quick reference for all 18 design commands

-
- -
-

Loading commands...

-
- - - - - - diff --git a/public/css/docs-visuals.css b/public/css/docs-visuals.css new file mode 100644 index 000000000..531516a92 --- /dev/null +++ b/public/css/docs-visuals.css @@ -0,0 +1,2942 @@ +/* + * impeccable.style — docs visual components. + * + * Static mocks that illustrate what each command/tutorial is talking about: + * Live Mode frames, PRODUCT/DESIGN file renderings, register side-by-sides, + * step artifacts, flow diagrams, and audit/critique report mocks. + * + * These are deliberately non-interactive. Docs readers should be able to + * scan them at their own pace, not chase animations. + * + * Namespaced with `.docs-viz-` to stay clear of the homepage's .why-*, + * .live-demo-*, and .platforms-* classes. + */ + +/* ============================================ + GENERIC HERO VISUAL WRAPPER + ============================================ */ + +.docs-viz-hero { + margin: 0 0 clamp(1.5rem, 3vw, 2.25rem); + padding: clamp(1rem, 2vw, 1.5rem); + background: var(--color-cream); + border: 1px solid var(--color-mist); + border-radius: 10px; +} + +.docs-viz-hero--plain { + background: transparent; + border: 0; + padding: 0; +} + +.docs-viz-caption { + margin: 10px 0 0; + font-family: var(--font-body); + font-size: 0.8125rem; + color: var(--color-ash); + line-height: 1.5; +} + +.docs-viz-eyebrow { + display: inline-block; + font-family: var(--font-body); + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--color-accent); + margin-bottom: var(--spacing-sm); +} + +/* ============================================ + LIVE MODE — static browser frame + + Shows the picker overlay in "cycling" state: outline on target, + context bar with 2/3 counter, global bar pinned to the bottom. + Dimensionally this mirrors the homepage .live-demo-frame but + paused and stripped to the informative state. + ============================================ */ + +.docs-viz-live-frame { + position: relative; + background: var(--color-paper); + border: 1px solid var(--color-mist); + border-radius: 8px; + overflow: hidden; + box-shadow: 0 12px 32px oklch(0% 0 0 / 0.06); + font-family: var(--font-body); +} + +.docs-viz-live-chrome { + display: flex; + align-items: center; + gap: 6px; + padding: 9px 12px; + border-bottom: 1px solid var(--color-mist); + background: var(--color-cream); +} + +.docs-viz-live-dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--color-mist); +} + +.docs-viz-live-url { + margin-left: 10px; + padding: 3px 10px; + background: var(--color-paper); + border: 1px solid var(--color-mist); + border-radius: 4px; + font-family: var(--font-mono); + font-size: 10px; + color: var(--color-charcoal); +} + +.docs-viz-live-stage { + position: relative; + min-height: 260px; + padding: 36px 28px 68px; + background: var(--color-paper); + display: flex; + align-items: center; + justify-content: center; +} + +.docs-viz-live-stage--tall { + min-height: 340px; +} + +.docs-viz-live-target { + position: relative; + width: min(320px, 78%); + padding: 18px 20px; + background: var(--color-cream); + border: 1px dashed var(--color-accent); + border-radius: 6px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.docs-viz-live-kicker { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--color-accent); +} + +.docs-viz-live-title { + font-family: var(--font-display); + font-style: italic; + font-weight: 400; + font-size: 18px; + line-height: 1.2; + color: var(--color-ink); + margin: 0; +} + +.docs-viz-live-title em { + color: var(--color-accent); + font-style: italic; +} + +.docs-viz-live-body { + font-family: var(--font-body); + font-size: 12px; + line-height: 1.5; + color: var(--color-charcoal); + margin: 0; +} + +.docs-viz-live-btn { + align-self: flex-start; + margin-top: 4px; + padding: 7px 14px; + background: var(--color-accent); + color: var(--color-paper); + font-family: var(--font-body); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + border: 0; + border-radius: 4px; +} + +/* Picker outline — sits around the target */ +.docs-viz-live-outline { + position: absolute; + inset: 28px 24px 60px; + border: 2px solid var(--color-accent); + border-radius: 8px; + box-shadow: 0 0 0 4px var(--color-accent-dim); + pointer-events: none; +} + +/* Context bar floating near the target */ +.docs-viz-live-ctx { + position: absolute; + left: 50%; + top: 30px; + transform: translateX(-50%); + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px; + background: var(--color-paper); + border: 1px solid var(--color-mist); + border-radius: 8px; + box-shadow: 0 4px 20px oklch(0% 0 0 / 0.08); + font-family: var(--font-body); + font-size: 11px; + color: var(--color-ink); + white-space: nowrap; + z-index: 2; +} + +.docs-viz-live-ctx-nav { + padding: 2px 8px; + background: transparent; + border: 0; + color: var(--color-ash); + font-size: 13px; + line-height: 1; +} + +.docs-viz-live-ctx-counter { + font-family: var(--font-mono); + font-size: 10px; + color: var(--color-ink); + padding: 0 4px; + min-width: 30px; + text-align: center; +} + +.docs-viz-live-ctx-divider { + width: 1px; + height: 14px; + background: var(--color-mist); + margin: 0 2px; +} + +.docs-viz-live-ctx-accept { + padding: 4px 10px; + background: var(--color-ink); + color: var(--color-paper); + border: 0; + border-radius: 4px; + font-size: 11px; + font-weight: 600; +} + +/* Global bar pinned to bottom */ +.docs-viz-live-gbar { + position: absolute; + bottom: 12px; + left: 50%; + transform: translateX(-50%); + display: inline-flex; + align-items: center; + gap: 4px; + padding: 0 4px; + min-height: 30px; + background: oklch(14% 0 0); + color: oklch(92% 0 0); + border: 1px solid oklch(22% 0 0); + border-radius: 8px; + font-family: var(--font-body); + font-size: 11px; + box-shadow: 0 6px 20px oklch(0% 0 0 / 0.2); +} + +.docs-viz-live-gbar-brand { + font-family: var(--font-display); + font-size: 14px; + color: var(--color-accent); + padding: 0 8px; + font-style: italic; +} + +.docs-viz-live-gbar-btn { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 5px 8px; + background: transparent; + color: oklch(75% 0 0); + font: inherit; + border: 0; + border-radius: 5px; +} + +.docs-viz-live-gbar-btn.is-active { + background: var(--color-accent-dim); + color: var(--color-accent); +} + +.docs-viz-live-gbar-divider { + width: 1px; + height: 14px; + background: oklch(28% 0 0); + margin: 0 2px; +} + +.docs-viz-live-gbar-x { + padding: 5px 8px; + color: oklch(60% 0 0); + font-size: 12px; +} + +/* Step-size (smaller) variant used inline in tutorials */ +.docs-viz-live-frame--step { + max-width: 520px; + margin: 0 auto; +} + +.docs-viz-live-frame--step .docs-viz-live-stage { + min-height: 200px; + padding: 28px 22px 56px; +} + +.docs-viz-live-frame--step .docs-viz-live-outline { + inset: 22px 20px 50px; +} + +/* ============================================ + PRODUCT.md and DESIGN.md file mocks + + Single file-style card with a chrome row, fields, and a footer + that identifies what loads it. Shared .docs-viz-file for the + chrome; .docs-viz-productmd / .docs-viz-designmd for per-file content. + ============================================ */ + +.docs-viz-file { + display: flex; + flex-direction: column; + background: var(--color-paper); + border: 1px solid var(--color-mist); + border-radius: 8px; + overflow: hidden; + font-family: var(--font-body); +} + +.docs-viz-file-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 9px 14px; + background: var(--color-cream); + border-bottom: 1px solid var(--color-mist); +} + +.docs-viz-file-name { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 500; + color: var(--color-ink); +} + +.docs-viz-file-status { + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--color-accent); +} + +.docs-viz-file-body { + padding: 4px 14px; +} + +.docs-viz-file-row { + display: grid; + grid-template-columns: 108px 1fr; + gap: 14px; + align-items: baseline; + padding: 9px 0; + border-bottom: 1px dashed var(--color-mist); +} + +.docs-viz-file-row:last-child { + border-bottom: 0; +} + +.docs-viz-file-k { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-ash); +} + +.docs-viz-file-v { + font-family: var(--font-body); + font-size: 13px; + line-height: 1.5; + color: var(--color-ink); +} + +.docs-viz-file-footer { + padding: 10px 14px; + background: var(--color-cream); + border-top: 1px solid var(--color-mist); + font-family: var(--font-display); + font-style: italic; + font-size: 12px; + line-height: 1.4; + color: var(--color-charcoal); +} + +/* DESIGN.md specific rows — six numbered sections, richer inline specimens */ + +.docs-viz-designmd-section { + padding: 12px 14px; + border-bottom: 1px dashed var(--color-mist); +} + +.docs-viz-designmd-section:last-child { + border-bottom: 0; +} + +.docs-viz-designmd-head { + display: flex; + align-items: baseline; + gap: 8px; + margin-bottom: 6px; +} + +.docs-viz-designmd-num { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + color: var(--color-ash); + letter-spacing: 0.05em; +} + +.docs-viz-designmd-title { + font-family: var(--font-body); + font-size: 12px; + font-weight: 600; + color: var(--color-ink); + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.docs-viz-designmd-note { + font-family: var(--font-display); + font-style: italic; + font-size: 12px; + color: var(--color-charcoal); + line-height: 1.45; + margin: 0; +} + +.docs-viz-designmd-swatches { + display: flex; + gap: 6px; + margin-top: 4px; +} + +.docs-viz-designmd-swatch { + width: 22px; + height: 22px; + border-radius: 4px; + border: 1px solid oklch(0% 0 0 / 0.06); +} + +.docs-viz-designmd-type { + display: flex; + align-items: baseline; + gap: 10px; + margin-top: 4px; +} + +.docs-viz-designmd-type-display { + font-family: var(--font-display); + font-style: italic; + font-size: 26px; + line-height: 1; + color: var(--color-ink); +} + +.docs-viz-designmd-type-body { + font-family: var(--font-body); + font-size: 13px; + color: var(--color-charcoal); +} + +.docs-viz-designmd-comps { + display: flex; + gap: 8px; + margin-top: 4px; + align-items: center; +} + +.docs-viz-designmd-btn { + padding: 5px 12px; + background: var(--color-ink); + color: var(--color-paper); + font-family: var(--font-body); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.docs-viz-designmd-chip { + padding: 3px 8px; + background: var(--color-accent-dim); + color: var(--color-accent); + font-family: var(--font-mono); + font-size: 10px; + border-radius: 11px; +} + +.docs-viz-designmd-card { + padding: 6px 10px; + background: var(--color-cream); + border: 1px solid var(--color-mist); + border-radius: 4px; + font-family: var(--font-body); + font-size: 10px; + color: var(--color-ink); +} + +.docs-viz-designmd-rules { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px 14px; + margin-top: 4px; +} + +.docs-viz-designmd-do, +.docs-viz-designmd-dont { + display: flex; + align-items: baseline; + gap: 6px; + font-family: var(--font-body); + font-size: 11px; + line-height: 1.4; + color: var(--color-ink); +} + +.docs-viz-designmd-do::before { + content: "DO"; + font-family: var(--font-mono); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.1em; + color: oklch(45% 0.18 145); + flex-shrink: 0; +} + +.docs-viz-designmd-dont::before { + content: "DON'T"; + font-family: var(--font-mono); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.1em; + color: oklch(55% 0.22 25); + flex-shrink: 0; +} + +/* ============================================ + REGISTER SIDE-BY-SIDE + + Two faux browser frames showing the same element rendered in + brand vs product register. Each frame is self-contained and + styled per the lane's conventions. + ============================================ */ + +.docs-viz-register { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} + +@media (max-width: 620px) { + .docs-viz-register { + grid-template-columns: 1fr; + } +} + +.docs-viz-register-side { + display: flex; + flex-direction: column; + gap: 10px; +} + +.docs-viz-register-label { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + padding: 0 2px; +} + +.docs-viz-register-name { + font-family: var(--font-display); + font-style: italic; + font-size: 18px; + color: var(--color-ink); +} + +.docs-viz-register-lane { + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--color-ash); +} + +.docs-viz-register-frame { + position: relative; + padding: 22px 20px; + background: var(--color-paper); + border: 1px solid var(--color-mist); + border-radius: 6px; + min-height: 200px; + display: flex; + flex-direction: column; + justify-content: center; +} + +/* BRAND variant — editorial-magazine, serif display, drenched accent */ +.docs-viz-register-frame--brand { + background: oklch(96% 0.02 30); + border-color: oklch(80% 0.08 30); + padding: 28px 22px; +} + +.docs-viz-register-frame--brand .docs-viz-reg-kicker { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 500; + letter-spacing: 0.2em; + text-transform: uppercase; + color: oklch(40% 0.12 30); + margin-bottom: 8px; +} + +.docs-viz-register-frame--brand .docs-viz-reg-title { + font-family: var(--font-display); + font-style: italic; + font-weight: 400; + font-size: 28px; + line-height: 1; + color: oklch(20% 0.1 30); + margin: 0 0 10px 0; +} + +.docs-viz-register-frame--brand .docs-viz-reg-body { + font-family: var(--font-display); + font-style: italic; + font-size: 13px; + line-height: 1.5; + color: oklch(35% 0.04 30); + margin: 0 0 14px 0; + max-width: 26ch; +} + +.docs-viz-register-frame--brand .docs-viz-reg-btn { + align-self: flex-start; + padding: 8px 18px; + background: oklch(20% 0.1 30); + color: oklch(96% 0.02 30); + font-family: var(--font-mono); + font-size: 10px; + font-weight: 500; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +/* PRODUCT variant — neutral sans, restrained, utility-shaped */ +.docs-viz-register-frame--product { + background: var(--color-paper); + border-color: var(--color-mist); +} + +.docs-viz-register-frame--product .docs-viz-reg-kicker { + font-family: var(--font-body); + font-size: 11px; + font-weight: 600; + color: var(--color-ash); + margin-bottom: 6px; +} + +.docs-viz-register-frame--product .docs-viz-reg-title { + font-family: var(--font-body); + font-weight: 600; + font-size: 17px; + line-height: 1.3; + color: var(--color-ink); + margin: 0 0 6px 0; +} + +.docs-viz-register-frame--product .docs-viz-reg-body { + font-family: var(--font-body); + font-size: 13px; + line-height: 1.5; + color: var(--color-charcoal); + margin: 0 0 14px 0; + max-width: 32ch; +} + +.docs-viz-register-frame--product .docs-viz-reg-btn { + align-self: flex-start; + padding: 7px 14px; + background: var(--color-ink); + color: var(--color-paper); + font-family: var(--font-body); + font-size: 12px; + font-weight: 500; + border-radius: 4px; +} + +.docs-viz-register-notes { + display: flex; + flex-direction: column; + gap: 4px; + padding: 0 4px; + font-family: var(--font-body); + font-size: 11px; + color: var(--color-charcoal); + line-height: 1.5; +} + +.docs-viz-register-notes span::before { + content: "· "; + color: var(--color-ash); +} + +/* ============================================ + FLOW DIAGRAMS + + Horizontal step strip — boxes separated by arrows. Used on + command pages where the value is a multi-phase workflow + (craft, shape, extract). + ============================================ */ + +.docs-viz-flow { + display: flex; + align-items: stretch; + gap: 0; + flex-wrap: wrap; +} + +.docs-viz-flow-step { + flex: 1; + min-width: 130px; + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px 14px; + background: var(--color-paper); + border: 1px solid var(--color-mist); + border-radius: 6px; + position: relative; +} + +.docs-viz-flow-step + .docs-viz-flow-step { + margin-left: 14px; +} + +@media (max-width: 620px) { + .docs-viz-flow-step + .docs-viz-flow-step { + margin-left: 0; + margin-top: 14px; + } +} + +.docs-viz-flow-step + .docs-viz-flow-step::before { + content: "→"; + position: absolute; + left: -14px; + top: 50%; + transform: translate(-50%, -50%); + width: 14px; + text-align: center; + font-family: var(--font-body); + font-size: 14px; + color: var(--color-accent); + line-height: 1; +} + +@media (max-width: 620px) { + .docs-viz-flow-step + .docs-viz-flow-step::before { + top: -14px; + left: 50%; + content: "↓"; + } +} + +.docs-viz-flow-num { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.1em; + color: var(--color-ash); +} + +.docs-viz-flow-name { + font-family: var(--font-body); + font-size: 13px; + font-weight: 600; + color: var(--color-ink); + line-height: 1.3; +} + +.docs-viz-flow-hint { + font-family: var(--font-body); + font-size: 11px; + color: var(--color-charcoal); + line-height: 1.4; +} + +.docs-viz-flow-step--accent { + background: var(--color-accent-dim); + border-color: var(--color-accent-soft); +} + +.docs-viz-flow-step--accent .docs-viz-flow-name { + color: var(--color-accent); +} + +/* ============================================ + AUDIT REPORT MOCK + ============================================ */ + +.docs-viz-report { + padding: 20px 22px; + background: var(--color-paper); + border: 1px solid var(--color-mist); + border-radius: 8px; + font-family: var(--font-body); + display: flex; + flex-direction: column; + gap: 16px; +} + +.docs-viz-report-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; + border-bottom: 1px solid var(--color-mist); + padding-bottom: 12px; +} + +.docs-viz-report-title { + font-family: var(--font-body); + font-size: 13px; + font-weight: 600; + color: var(--color-ink); +} + +.docs-viz-report-target { + font-family: var(--font-mono); + font-size: 11px; + color: var(--color-charcoal); +} + +.docs-viz-report-score { + display: flex; + align-items: baseline; + gap: 8px; +} + +.docs-viz-report-score-num { + font-family: var(--font-display); + font-style: italic; + font-size: 36px; + line-height: 1; + color: var(--color-ink); +} + +.docs-viz-report-score-out { + font-family: var(--font-mono); + font-size: 11px; + color: var(--color-ash); +} + +.docs-viz-report-dims { + display: flex; + flex-direction: column; + gap: 9px; +} + +.docs-viz-report-dim { + display: grid; + grid-template-columns: 110px 1fr 42px; + gap: 12px; + align-items: center; + font-family: var(--font-body); + font-size: 12px; + color: var(--color-ink); +} + +.docs-viz-report-dim-name { + color: var(--color-charcoal); +} + +.docs-viz-report-dim-bar { + height: 5px; + background: var(--color-mist); + border-radius: 3px; + overflow: hidden; +} + +.docs-viz-report-dim-fill { + display: block; + height: 100%; + background: var(--color-accent); + border-radius: 3px; +} + +.docs-viz-report-dim-fill--warn { + background: oklch(72% 0.15 70); +} + +.docs-viz-report-dim-fill--fail { + background: oklch(60% 0.2 25); +} + +.docs-viz-report-dim-score { + font-family: var(--font-mono); + font-size: 11px; + color: var(--color-ink); + text-align: right; +} + +.docs-viz-report-issues { + display: flex; + gap: 8px; + padding-top: 12px; + border-top: 1px solid var(--color-mist); +} + +.docs-viz-report-sev { + padding: 3px 10px; + font-family: var(--font-mono); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.1em; + color: var(--color-paper); + border-radius: 3px; +} + +.docs-viz-report-sev--p0 { background: oklch(50% 0.22 25); } +.docs-viz-report-sev--p1 { background: oklch(58% 0.18 45); } +.docs-viz-report-sev--p2 { background: oklch(70% 0.13 80); } +.docs-viz-report-sev--p3 { background: oklch(55% 0.06 260); } + +.docs-viz-report-sev-n { + font-weight: 500; + margin-left: 4px; + opacity: 0.85; +} + +/* ============================================ + CRITIQUE REPORT MOCK + ============================================ */ + +.docs-viz-critique { + padding: 20px 22px; + background: var(--color-paper); + border: 1px solid var(--color-mist); + border-radius: 8px; + font-family: var(--font-body); + display: flex; + flex-direction: column; + gap: 18px; +} + +.docs-viz-critique-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; +} + +.docs-viz-critique-verdict { + display: flex; + align-items: center; + gap: 10px; +} + +.docs-viz-critique-verdict-label { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--color-ash); +} + +.docs-viz-critique-verdict-value { + display: inline-flex; + align-items: center; + padding: 3px 10px; + background: oklch(60% 0.2 25); + color: var(--color-paper); + font-family: var(--font-mono); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.1em; + border-radius: 3px; +} + +.docs-viz-critique-verdict-value--pass { + background: oklch(52% 0.18 150); +} + +.docs-viz-critique-cols { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} + +@media (max-width: 620px) { + .docs-viz-critique-cols { + grid-template-columns: 1fr; + } +} + +.docs-viz-critique-col-title { + font-family: var(--font-body); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-ash); + margin-bottom: 8px; +} + +.docs-viz-critique-personas { + display: flex; + flex-direction: column; + gap: 8px; +} + +.docs-viz-critique-persona { + display: grid; + grid-template-columns: 1fr auto; + gap: 10px; + align-items: baseline; + padding: 8px 10px; + background: var(--color-cream); + border: 1px solid var(--color-mist); + border-radius: 5px; +} + +.docs-viz-critique-persona-name { + font-family: var(--font-body); + font-size: 12px; + font-weight: 600; + color: var(--color-ink); +} + +.docs-viz-critique-persona-note { + display: block; + font-family: var(--font-body); + font-size: 11px; + color: var(--color-charcoal); + font-weight: 400; + margin-top: 1px; + line-height: 1.4; +} + +.docs-viz-critique-persona-score { + font-family: var(--font-mono); + font-size: 11px; + color: var(--color-ink); + text-align: right; +} + +.docs-viz-critique-heuristics { + display: flex; + flex-direction: column; + gap: 4px; +} + +.docs-viz-critique-heur { + display: grid; + grid-template-columns: 1fr 28px; + gap: 10px; + font-family: var(--font-body); + font-size: 12px; + color: var(--color-ink); + padding: 4px 0; + border-bottom: 1px dashed var(--color-mist); +} + +.docs-viz-critique-heur:last-child { + border-bottom: 0; +} + +.docs-viz-critique-heur-score { + font-family: var(--font-mono); + font-size: 11px; + text-align: right; +} + +.docs-viz-critique-heur-score--good { color: oklch(45% 0.18 150); } +.docs-viz-critique-heur-score--ok { color: oklch(55% 0.15 70); } +.docs-viz-critique-heur-score--bad { color: oklch(55% 0.22 25); } + +/* ============================================ + STEP ARTIFACTS — inline mocks in tutorials + ============================================ */ + +.docs-viz-step { + margin: 14px 0 18px; + padding: 16px 18px; + background: var(--color-cream); + border: 1px solid var(--color-mist); + border-radius: 8px; +} + +/* Picker-on-element strip */ +.docs-viz-picker-row { + position: relative; + display: flex; + align-items: center; + justify-content: center; + padding: 20px 16px; + background: var(--color-paper); + border: 1px solid var(--color-mist); + border-radius: 6px; + min-height: 110px; +} + +.docs-viz-picker-target { + position: relative; + padding: 10px 16px; + border: 2px solid var(--color-accent); + border-radius: 6px; + box-shadow: 0 0 0 4px var(--color-accent-dim); + font-family: var(--font-display); + font-style: italic; + font-size: 15px; + color: var(--color-ink); + background: var(--color-cream); +} + +.docs-viz-picker-pin { + position: absolute; + top: -10px; + right: -10px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + background: var(--color-accent); + color: var(--color-paper); + font-family: var(--font-mono); + font-size: 11px; + font-weight: 600; + border-radius: 50%; + box-shadow: 0 2px 6px oklch(0% 0 0 / 0.2); +} + +.docs-viz-picker-note { + position: absolute; + right: -12px; + top: 24px; + transform: translateX(100%); + padding: 3px 8px; + background: var(--color-ink); + color: var(--color-paper); + font-family: var(--font-mono); + font-size: 10px; + border-radius: 3px; + white-space: nowrap; +} + +@media (max-width: 520px) { + .docs-viz-picker-note { display: none; } +} + +/* Three-up variants strip */ +.docs-viz-variants { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} + +@media (max-width: 520px) { + .docs-viz-variants { + grid-template-columns: 1fr; + } +} + +.docs-viz-variant { + position: relative; + padding: 12px; + background: var(--color-paper); + border: 1px solid var(--color-mist); + border-radius: 6px; + min-height: 120px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.docs-viz-variant.is-active { + border-color: var(--color-accent); + box-shadow: 0 0 0 3px var(--color-accent-dim); +} + +.docs-viz-variant-badge { + position: absolute; + top: 6px; + right: 6px; + padding: 1px 6px; + background: oklch(14% 0 0); + color: oklch(92% 0 0); + font-family: var(--font-mono); + font-size: 9px; + border-radius: 3px; +} + +.docs-viz-variant-kicker { + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--color-ash); +} + +.docs-viz-variant-title { + font-family: var(--font-display); + font-style: italic; + font-size: 14px; + line-height: 1.2; + color: var(--color-ink); + margin: 0; +} + +.docs-viz-variant-btn { + align-self: flex-start; + margin-top: auto; + padding: 4px 8px; + font-family: var(--font-body); + font-size: 9px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + background: var(--color-ink); + color: var(--color-paper); +} + +.docs-viz-variant--v1 { background: var(--color-cream); } +.docs-viz-variant--v2 .docs-viz-variant-btn { background: var(--color-accent); } +.docs-viz-variant--v3 { background: oklch(96% 0.02 350); } + +/* Accept pill */ +.docs-viz-accept-pill { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 7px 14px; + background: var(--color-paper); + border: 1px solid var(--color-mist); + border-radius: 8px; + box-shadow: 0 4px 14px oklch(0% 0 0 / 0.06); + font-family: var(--font-body); + font-size: 12px; + color: oklch(45% 0.18 145); +} + +.docs-viz-accept-pill::before { + content: "✓"; + font-weight: 700; +} + +/* ============================================ + LIVE MODE LANDING PAGE (/live-mode) + + Marketing-style landing that mirrors /visual-mode's structure. + Hosts the animated homepage live-demo and surfaces the tutorial, + reference, and install pathways. + ============================================ */ + +.live-mode-page { + display: flex; + flex-direction: column; + gap: clamp(2.5rem, 5vw, 3.5rem); + max-width: 960px; + margin: 0 auto; + padding: clamp(2rem, 4vw, 3rem) clamp(1.25rem, 3vw, 2rem) clamp(4rem, 8vw, 6rem); +} + +.live-mode-page-header { + display: flex; + flex-direction: column; + gap: 10px; + max-width: 56ch; +} + +.live-mode-page-eyebrow { + display: inline-flex; + align-items: center; + gap: 10px; + font-family: var(--font-body); + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--color-accent); +} + +.live-mode-page-eyebrow-badge { + padding: 2px 7px; + border: 1px solid var(--color-accent); + border-radius: 4px; + font-size: 0.625rem; + letter-spacing: 0.12em; +} + +.live-mode-page-title { + font-family: var(--font-display); + font-weight: 400; + font-size: clamp(2.5rem, 5vw, 3.5rem); + line-height: 1; + letter-spacing: -0.02em; + color: var(--color-ink); + margin: 0; +} + +.live-mode-page-lede { + font-family: var(--font-body); + font-size: 1.0625rem; + line-height: 1.55; + color: var(--color-charcoal); + margin: 0; + max-width: 56ch; +} + +.live-mode-page-alpha-note { + font-family: var(--font-body); + font-size: 0.9375rem; + line-height: 1.55; + color: var(--color-ash); + margin: 0; + max-width: 56ch; + padding: 10px 14px; + border-left: 2px solid var(--color-accent); + background: color-mix(in oklab, var(--color-accent) 6%, transparent); + border-radius: 0 4px 4px 0; +} + +.live-mode-page-alpha-note strong { + color: var(--color-ink); + font-weight: 600; +} + +.live-mode-start { + display: flex; + align-items: center; + gap: 10px; + margin-top: 6px; + padding: 10px 14px; + background: var(--color-ink); + color: var(--color-paper); + border-radius: 8px; + font-family: var(--font-mono); + font-size: 0.875rem; + align-self: flex-start; + max-width: 100%; +} + +.live-mode-start-prompt { + color: var(--color-accent); + user-select: none; +} + +.live-mode-start-cmd { + flex: 1; + overflow-x: auto; + white-space: nowrap; + scrollbar-width: none; +} + +.live-mode-start-cmd::-webkit-scrollbar { display: none; } + +.live-mode-start-copy { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + background: transparent; + color: oklch(75% 0 0); + border: 0; + border-radius: 4px; + cursor: pointer; + flex-shrink: 0; +} + +.live-mode-start-copy:hover { + background: oklch(22% 0 0); + color: oklch(92% 0 0); +} + +.live-mode-start-copy.is-copied { + color: oklch(70% 0.18 145); +} + +/* Demo section — wraps the animated homepage .live-demo block. + The homepage uses a 2-col grid (frame + support cells). /live-mode + replaces the support cells with the stages section below, so the + frame should take the full width here. */ +.live-mode-demo-wrap { + margin: 0; +} + +.live-mode-demo-wrap .live-demo { + grid-template-columns: 1fr; +} + +.live-mode-demo-wrap .live-demo-frame { + max-width: none; +} + +.live-mode-demo-caption { + margin: 14px 0 0; + font-family: var(--font-body); + font-size: 0.875rem; + color: var(--color-ash); + line-height: 1.55; + font-style: italic; +} + +/* Three-card "what happens" section */ +.live-mode-stages { + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +.live-mode-stages-title { + font-family: var(--font-display); + font-weight: 400; + font-style: italic; + font-size: 1.75rem; + color: var(--color-ink); + margin: 0 0 8px 0; +} + +.live-mode-stages-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--spacing-md); +} + +@media (max-width: 720px) { + .live-mode-stages-grid { + grid-template-columns: 1fr; + } +} + +.live-mode-stage { + display: flex; + flex-direction: column; + gap: 10px; + padding: 18px; + background: var(--color-paper); + border: 1px solid var(--color-mist); + border-radius: 8px; +} + +.live-mode-stage-num { + font-family: var(--font-mono); + font-size: 0.6875rem; + letter-spacing: 0.12em; + color: var(--color-ash); + text-transform: uppercase; +} + +.live-mode-stage-name { + font-family: var(--font-display); + font-style: italic; + font-weight: 400; + font-size: 1.375rem; + color: var(--color-ink); + margin: 0; + line-height: 1.15; +} + +.live-mode-stage-desc { + font-family: var(--font-body); + font-size: 0.875rem; + line-height: 1.55; + color: var(--color-charcoal); + margin: 0; +} + +.live-mode-stage-viz { + margin-top: 4px; + padding: 14px; + background: var(--color-cream); + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + min-height: 92px; +} + +/* Three pathway cards — tutorial, reference, install */ +.live-mode-pathways { + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +.live-mode-pathways-title { + font-family: var(--font-display); + font-weight: 400; + font-style: italic; + font-size: 1.75rem; + color: var(--color-ink); + margin: 0 0 8px 0; +} + +.live-mode-pathways-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--spacing-md); +} + +@media (max-width: 720px) { + .live-mode-pathways-grid { + grid-template-columns: 1fr; + } +} + +.live-mode-pathway { + display: flex; + flex-direction: column; + gap: 8px; + padding: 20px; + background: var(--color-paper); + border: 1px solid var(--color-mist); + border-radius: 8px; + text-decoration: none; + transition: border-color 180ms ease, transform 180ms ease; +} + +.live-mode-pathway:hover { + border-color: var(--color-accent); +} + +.live-mode-pathway-kind { + font-family: var(--font-mono); + font-size: 0.625rem; + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--color-accent); +} + +.live-mode-pathway-title { + font-family: var(--font-body); + font-size: 1.125rem; + font-weight: 600; + color: var(--color-ink); + margin: 0; + line-height: 1.3; +} + +.live-mode-pathway-desc { + font-family: var(--font-body); + font-size: 0.875rem; + color: var(--color-charcoal); + line-height: 1.55; + margin: 0; + flex: 1; +} + +.live-mode-pathway-cta { + font-family: var(--font-body); + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-accent); + margin-top: 6px; +} + +/* Frameworks strip */ +.live-mode-frameworks { + display: flex; + flex-direction: column; + gap: 10px; + padding: 18px 20px; + background: var(--color-cream); + border: 1px solid var(--color-mist); + border-radius: 8px; +} + +.live-mode-frameworks-label { + font-family: var(--font-mono); + font-size: 0.6875rem; + letter-spacing: 0.12em; + color: var(--color-ash); + text-transform: uppercase; +} + +.live-mode-frameworks-list { + display: flex; + flex-wrap: wrap; + gap: 8px 14px; + font-family: var(--font-body); + font-size: 0.875rem; + color: var(--color-ink); + margin: 0; + padding: 0; + list-style: none; +} + +.live-mode-frameworks-list li { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.live-mode-frameworks-list li::before { + content: ""; + display: inline-block; + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--color-accent); +} + +/* Docs/live page callout pointing back to /live-mode */ +.docs-live-callout { + display: flex; + align-items: center; + gap: 14px; + padding: 12px 16px; + margin: 0 0 clamp(1.25rem, 2.5vw, 1.75rem); + background: var(--color-accent-dim); + border: 1px solid var(--color-accent-soft); + border-radius: 8px; + font-family: var(--font-body); + font-size: 0.875rem; + color: var(--color-ink); + line-height: 1.5; +} + +.docs-live-callout-icon { + flex-shrink: 0; + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--color-accent); + color: var(--color-paper); + border-radius: 50%; + font-size: 14px; +} + +.docs-live-callout-text { + flex: 1; +} + +.docs-live-callout-text a { + color: var(--color-accent); + text-decoration: underline; + text-underline-offset: 2px; + font-weight: 600; +} + + +/* ============================================ + DESIGNING WITH IMPECCABLE (/designing) + + Editorial orientation page. Cards are rare — most sections + rely on typography, whitespace, and hairline rules for structure. + Four core phases get full weight; three appendix sections (register, + interop, avoid) render at a quieter tier. CTA climax at the bottom. + + Spacing follows DESIGN.md: 8 / 16 / 24 / 32 / 48 / 80 / 120. + Two card radii: 8 (inline chrome like terminal, live frame) and 12 + (only the loop spine + CTA climax). No freestyle paddings. + ============================================ */ + +.designing-page { + max-width: 920px; + margin: 0 auto; + padding: clamp(2.5rem, 5vw, 4rem) clamp(1.25rem, 3vw, 2rem) clamp(4rem, 8vw, 6rem); +} + +/* ---------- Hero ---------- */ + +/* Hero wrapper: hero text on the left, compass loop on the right when + there's enough room. Single column below ~880px so both halves stay + legible. */ +.designing-hero { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr); + column-gap: clamp(1.5rem, 4vw, 3rem); + row-gap: var(--spacing-xl, 48px); + align-items: center; + margin-bottom: var(--spacing-2xl, 80px); +} + +@media (max-width: 880px) { + .designing-hero { + grid-template-columns: 1fr; + align-items: start; + } +} + +.designing-page-header { + max-width: 46ch; +} + +.designing-page-eyebrow { + display: block; + font-family: var(--font-body); + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--color-accent); + margin-bottom: var(--spacing-sm, 16px); +} + +.designing-page-title { + font-family: var(--font-display); + font-weight: 300; + font-style: italic; + font-size: clamp(2.75rem, 6vw, 4rem); + line-height: 1; + letter-spacing: -0.01em; + color: var(--color-ink); + margin: 0 0 var(--spacing-md, 24px) 0; +} + +.designing-page-title em { + font-style: italic; + color: var(--color-accent); +} + +.designing-page-lede { + font-family: var(--font-body); + font-weight: 400; + font-size: clamp(1rem, 1.6vw, 1.125rem); + line-height: 1.65; + color: var(--color-charcoal); + margin: 0; + max-width: 58ch; +} + +/* ---------- Loop spine ---------- */ +/* + * Editorial compass. A thin ink circle is the loop itself; four phase + * labels sit at the N/E/S/W compass points and read clockwise. Four + * small clockwise arrow glyphs mark the transit points on the ring. + * The center carries a quiet italic wordmark so the circle has a + * typographic anchor. + */ + +.designing-loop-wrap { + padding: 0; +} + +.designing-loop-wrap-eyebrow { + display: none; +} + +.designing-loop { + position: relative; + display: grid; + grid-template-columns: minmax(96px, 1fr) clamp(200px, 26vw, 280px) minmax(96px, 1fr); + grid-template-rows: auto clamp(200px, 26vw, 280px) auto; + column-gap: clamp(0.75rem, 2vw, 1.25rem); + row-gap: clamp(0.75rem, 2vw, 1.25rem); + max-width: 540px; + margin: 0 auto; + align-items: center; + justify-items: center; +} + +/* When the loop stands alone (e.g. stacked below the hero on narrow + viewports), the eyebrow comes back and a touch more air surrounds it. */ +@media (max-width: 880px) { + .designing-loop-wrap { + padding-top: clamp(1rem, 3vw, 2rem); + } + .designing-loop-wrap-eyebrow { + display: block; + font-family: var(--font-mono); + font-size: 0.625rem; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--color-ash); + text-align: center; + margin-bottom: clamp(1rem, 2vw, 1.5rem); + } +} + +.designing-loop-node { + display: flex; + flex-direction: column; + gap: 4px; + text-decoration: none; + color: inherit; + max-width: 24ch; +} + +.designing-loop-node--start { + grid-column: 2; + grid-row: 1; + text-align: center; + align-items: center; +} +.designing-loop-node--iterate { + grid-column: 3; + grid-row: 2; + text-align: left; + align-items: flex-start; +} +.designing-loop-node--polish { + grid-column: 2; + grid-row: 3; + text-align: center; + align-items: center; +} +.designing-loop-node--maintain { + grid-column: 1; + grid-row: 2; + text-align: right; + align-items: flex-end; +} + +.designing-loop-num { + font-family: var(--font-mono); + font-size: 0.625rem; + letter-spacing: 0.18em; + color: var(--color-accent); + text-transform: uppercase; +} + +.designing-loop-name { + font-family: var(--font-display); + font-style: italic; + font-weight: 400; + font-size: clamp(1.5rem, 2.4vw, 2rem); + line-height: 1; + color: var(--color-ink); + transition: color 180ms var(--ease-out, ease); + margin-top: 2px; +} + +.designing-loop-node:hover .designing-loop-name { + color: var(--color-accent); +} + +.designing-loop-hint { + font-family: var(--font-body); + font-size: 0.8125rem; + line-height: 1.55; + color: var(--color-charcoal); + margin-top: 6px; + max-width: 20ch; +} + +/* Hide hints inside the compressed hero-side compass; the full phase + description lives in its section below. Shown again when the compass + stacks under the hero on narrow viewports. */ +@media (min-width: 881px) { + .designing-hero .designing-loop-hint { + display: none; + } +} + +/* The wheel — center cell. Thin ink circle + clockwise arrow glyphs at + the 45° positions + an italic wordmark in the middle. */ +.designing-loop-wheel { + grid-column: 2; + grid-row: 2; + position: relative; + width: 100%; + aspect-ratio: 1; + align-self: center; + justify-self: center; +} + +.designing-loop-wheel-svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; +} + +.designing-loop-wheel-ring { + fill: none; + stroke: var(--color-ink); + stroke-width: 1; + opacity: 0.85; +} + +.designing-loop-wheel-tick { + stroke: var(--color-ink); + stroke-width: 1; + opacity: 0.18; +} + +.designing-loop-wheel-tick--cardinal { + stroke-width: 1.25; + opacity: 0.4; +} + +.designing-loop-wheel-dot { + fill: var(--color-accent); +} + +@media (prefers-reduced-motion: no-preference) { + .designing-loop-wheel-dot { + offset-path: path("M 50 4 A 46 46 0 1 1 50 96 A 46 46 0 1 1 50 4"); + animation: designingWheelOrbit 22s linear infinite; + } +} + +@keyframes designingWheelOrbit { + from { offset-distance: 0%; } + to { offset-distance: 100%; } +} + +.designing-loop-wheel-arrow { + position: absolute; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + background: var(--color-cream); + color: var(--color-accent); + font-family: var(--font-body); + font-size: 13px; + line-height: 1; + border-radius: 50%; + user-select: none; +} + +/* 45° offsets: cos 45° ≈ 0.7071. At radius 50% of wheel size, that's + ~35.4% along each axis from center. */ +.designing-loop-wheel-arrow--ne { top: calc(50% - 35.4%); left: calc(50% + 35.4%); transform: translate(-50%, -50%); } +.designing-loop-wheel-arrow--se { top: calc(50% + 35.4%); left: calc(50% + 35.4%); transform: translate(-50%, -50%); } +.designing-loop-wheel-arrow--sw { top: calc(50% + 35.4%); left: calc(50% - 35.4%); transform: translate(-50%, -50%); } +.designing-loop-wheel-arrow--nw { top: calc(50% - 35.4%); left: calc(50% - 35.4%); transform: translate(-50%, -50%); } + +.designing-loop-wheel-center { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + pointer-events: none; +} + +.designing-loop-wheel-center-label { + font-family: var(--font-mono); + font-size: 0.625rem; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--color-ash); +} + +.designing-loop-wheel-center-mark { + font-family: var(--font-display); + font-style: italic; + font-weight: 400; + font-size: clamp(1.125rem, 2.2vw, 1.375rem); + line-height: 1; + color: var(--color-ink); +} + +@media (max-width: 720px) { + .designing-loop { + grid-template-columns: 1fr; + grid-template-rows: repeat(7, auto); + row-gap: 12px; + justify-items: start; + } + .designing-loop-node { + text-align: left; + align-items: flex-start; + max-width: none; + } + .designing-loop-node--start { grid-column: 1; grid-row: 1; } + .designing-loop-node--iterate { grid-column: 1; grid-row: 3; } + .designing-loop-node--polish { grid-column: 1; grid-row: 5; } + .designing-loop-node--maintain { grid-column: 1; grid-row: 7; } + .designing-loop-wheel { display: none; } +} + +/* ============================================ + PHASE SECTIONS — editorial, text-dominant + ============================================ */ + +.designing-phase { + scroll-margin-top: 80px; + padding-top: var(--spacing-xl, 48px); + padding-bottom: var(--spacing-xl, 48px); + border-top: 1px solid var(--color-mist); +} + +/* First phase after the loop: no border (loop-wrap already separates), + but keep a little top air so the eye settles. */ +.designing-loop-wrap + .designing-phase { + border-top: 0; + padding-top: var(--spacing-lg, 32px); +} + +/* Transition from spine to appendix: rely on whitespace, not a thicker + rule. Design system uses only 1px mist separators. */ +.designing-phase:not(.designing-phase--appendix) + .designing-phase--appendix { + margin-top: var(--spacing-2xl, 80px); +} + +.designing-phase-head { + margin-bottom: var(--spacing-lg, 32px); +} + +.designing-phase-num { + display: block; + font-family: var(--font-mono); + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.16em; + color: var(--color-accent); + text-transform: uppercase; + margin-bottom: var(--spacing-sm, 16px); +} + +.designing-phase--appendix .designing-phase-num { + color: var(--color-ash); +} + +.designing-phase-title { + font-family: var(--font-display); + font-weight: 400; + font-style: italic; + font-size: clamp(1.875rem, 3.5vw, 2.5rem); + line-height: 1.15; + color: var(--color-ink); + margin: 0 0 var(--spacing-sm, 16px) 0; + max-width: 22ch; +} + +.designing-phase--appendix .designing-phase-title { + font-size: clamp(1.25rem, 2.2vw, 1.625rem); + font-style: normal; + max-width: 32ch; +} + +.designing-phase-sub { + font-family: var(--font-body); + font-size: 1.0625rem; + line-height: 1.65; + color: var(--color-charcoal); + margin: 0; + max-width: 60ch; +} + +.designing-phase-sub code { + font-family: var(--font-mono); + font-size: 0.875em; + color: var(--color-ink); +} + +.designing-phase--appendix .designing-phase-sub { + font-size: 0.9375rem; + max-width: 64ch; +} + +.designing-phase-commands { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: var(--spacing-md, 24px); +} + +.designing-phase-cmd { + padding: 4px 10px; + background: transparent; + border: 1px solid var(--color-mist); + color: var(--color-ink); + font-family: var(--font-mono); + font-size: 0.8125rem; + text-decoration: none; + border-radius: 4px; + transition: border-color 160ms ease, color 160ms ease; +} + +.designing-phase-cmd:hover { + border-color: var(--color-accent); + color: var(--color-accent); +} + +.designing-phase-body { + margin-top: var(--spacing-lg, 32px); +} + +/* ============================================ + PHASE 1 — Start + ============================================ */ + +.designing-start-grid { + display: grid; + grid-template-columns: 1.2fr 1fr; + gap: clamp(1.5rem, 4vw, 3rem); + align-items: start; +} + +@media (max-width: 720px) { + .designing-start-grid { + grid-template-columns: 1fr; + } +} + +.designing-start-grid-prose { + font-family: var(--font-body); + font-size: 1rem; + line-height: 1.7; + color: var(--color-ink); + max-width: 36ch; +} + +.designing-start-grid-prose p { + margin: 0 0 1rem 0; +} + +.designing-start-grid-prose p:last-child { + margin-bottom: 0; +} + +.designing-start-grid-prose code { + font-family: var(--font-mono); + font-size: 0.875rem; + color: var(--color-accent); +} + +/* Start-phase flow — two step blocks ("in words", "in pictures") + under the single 01 · Start header. Step labels carry the + narrative; no competing display headings inside the body. */ +.designing-start { + display: flex; + flex-direction: column; + gap: var(--spacing-2xl, 80px); +} + +.designing-start-step-label { + display: flex; + align-items: center; + gap: var(--spacing-md, 24px); + font-family: var(--font-mono); + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--color-accent); + margin: 0 0 var(--spacing-md, 24px) 0; +} + +.designing-start-step-label::after { + content: ""; + flex: 1; + height: 1px; + background: var(--color-mist); +} + +.designing-start-step-note { + font-family: var(--font-body); + font-size: 1rem; + line-height: 1.7; + color: var(--color-ink); + margin: 0 0 var(--spacing-lg, 32px) 0; + max-width: 64ch; +} + +.designing-start-step-note code { + font-family: var(--font-mono); + font-size: 0.875rem; + color: var(--color-accent); +} + +.designing-visualize-spread { + display: grid; + grid-template-columns: 1.35fr 1fr; + gap: clamp(2rem, 4vw, 3rem) clamp(1.5rem, 3vw, 2.5rem); + align-items: start; +} + +@media (max-width: 880px) { + .designing-visualize-spread { + grid-template-columns: 1fr; + } +} + +.designing-visualize-plate { + margin: 0; + display: flex; + flex-direction: column; + gap: var(--spacing-md, 24px); +} + +.designing-visualize-plate-frame { + background: var(--color-cream); + border: 1px solid var(--color-mist); + overflow: hidden; +} + +.designing-visualize-plate-frame img { + display: block; + width: 100%; + height: auto; +} + +.designing-visualize-plate-cap { + display: grid; + grid-template-columns: auto 1fr; + column-gap: var(--spacing-md, 24px); + align-items: baseline; +} + +.designing-visualize-plate-kind { + font-family: var(--font-mono); + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--color-accent); + white-space: nowrap; + padding-top: 2px; +} + +.designing-visualize-plate-note { + font-family: var(--font-body); + font-size: 0.9375rem; + line-height: 1.65; + color: var(--color-charcoal); + margin: 0; + max-width: 48ch; +} + +.designing-visualize-plate-note code { + font-family: var(--font-mono); + font-size: 0.8125rem; + color: var(--color-ink); +} + +@media (max-width: 640px) { + .designing-visualize-plate-cap { + grid-template-columns: 1fr; + row-gap: 8px; + } +} + +.designing-visualize-foot { + margin: var(--spacing-xl, 48px) 0 0 0; + padding-top: var(--spacing-md, 24px); + border-top: 1px solid var(--color-mist); + font-family: var(--font-body); + font-size: 0.8125rem; + line-height: 1.7; + color: var(--color-ash); + max-width: 72ch; +} + +.designing-visualize-foot strong { + font-weight: 500; + color: var(--color-charcoal); +} + +/* ============================================ + PHASE 2 — Iterate + ============================================ */ + +.designing-iterate-split { + display: grid; + grid-template-columns: 1fr 1fr; + gap: clamp(1.5rem, 3vw, 2.5rem); +} + +@media (max-width: 720px) { + .designing-iterate-split { + grid-template-columns: 1fr; + gap: var(--spacing-xl, 48px); + } +} + +.designing-iterate-col { + display: flex; + flex-direction: column; + gap: var(--spacing-sm, 16px); +} + +.designing-iterate-kind { + font-family: var(--font-mono); + font-size: 0.625rem; + font-weight: 600; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--color-accent); +} + +.designing-iterate-name { + font-family: var(--font-display); + font-style: italic; + font-size: 1.375rem; + line-height: 1.2; + color: var(--color-ink); + margin: 0; +} + +.designing-iterate-when { + font-family: var(--font-body); + font-size: 0.9375rem; + color: var(--color-charcoal); + line-height: 1.6; + margin: 0; + max-width: 36ch; +} + +.designing-iterate-terminal { + background: var(--color-ink); + color: oklch(90% 0 0); + border-radius: 8px; + padding: 24px 22px; + font-family: var(--font-mono); + font-size: 0.875rem; + line-height: 2.2; + display: flex; + flex-direction: column; + justify-content: center; + min-height: 240px; +} + +.designing-iterate-col { + align-self: stretch; +} + +.designing-iterate-terminal-line { + display: flex; + gap: 8px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.designing-iterate-terminal-prompt { + color: var(--color-accent); + user-select: none; +} + +/* Pass-through wrapper for the embedded docs-viz-live-frame. + Overrides shrink the stage so the frame matches the terminal + height on the opposite column. */ +.designing-iterate-live .docs-viz-live-frame { + box-shadow: 0 4px 18px oklch(0% 0 0 / 0.05); +} + +.designing-iterate-live .docs-viz-live-stage { + min-height: 180px; + padding: 20px 18px 52px; +} + +.designing-iterate-live .docs-viz-live-stage--tall { + min-height: 180px; +} + +.designing-iterate-live .docs-viz-live-target { + width: 60%; +} + +.designing-iterate-live .docs-viz-live-outline { + inset: 14px 14px 44px; +} + +.designing-iterate-live .docs-viz-live-ctx { + top: 16px; +} + +.designing-iterate-live .docs-viz-live-gbar { + bottom: 10px; +} + +/* Decision table (replaces the old matrix card) */ +.designing-iterate-table { + margin-top: var(--spacing-xl, 48px); + width: 100%; + border-collapse: collapse; + font-family: var(--font-body); + font-size: 0.9375rem; +} + +.designing-iterate-table caption { + font-family: var(--font-mono); + font-size: 0.625rem; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--color-ash); + text-align: left; + padding-bottom: 10px; +} + +.designing-iterate-table th, +.designing-iterate-table td { + text-align: left; + padding: 12px 0; + border-bottom: 1px solid var(--color-mist); + vertical-align: top; +} + +.designing-iterate-table th { + font-weight: 500; + color: var(--color-charcoal); + width: 55%; +} + +.designing-iterate-table td { + font-family: var(--font-mono); + font-size: 0.8125rem; + color: var(--color-accent); +} + +.designing-iterate-table td a { + color: inherit; + text-decoration: underline; + text-underline-offset: 3px; + text-decoration-thickness: 1px; +} + +.designing-iterate-table td a:hover { + text-decoration-thickness: 2px; +} + +/* ============================================ + PHASE 3 — Polish (3-column editorial, no cards) + ============================================ */ + +/* Polish section — drenched magenta masthead band over a three-column + title/description grid. Commands live in the band so the grid stays + focused on the editorial titles. */ +.designing-polish { + display: block; +} + +.designing-polish-band { + background: var(--color-accent); + padding: 20px 28px; + display: grid; + grid-template-columns: auto 1fr auto; + gap: 24px; + align-items: baseline; + color: oklch(98% 0 0); +} + +.designing-polish-band-label { + font-family: var(--font-mono); + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.22em; + text-transform: uppercase; + opacity: 0.85; +} + +.designing-polish-band-cmds { + display: flex; + font-family: var(--font-mono); + font-size: 0.8125rem; + font-weight: 500; + letter-spacing: 0.05em; +} + +.designing-polish-band-cmds a { + display: inline-block; + padding: 4px 16px; + color: inherit; + text-decoration: none; + border-right: 1px solid color-mix(in oklch, currentColor 32%, transparent); +} + +.designing-polish-band-cmds a:first-child { padding-left: 0; } +.designing-polish-band-cmds a:last-child { border-right: 0; padding-right: 0; } + +.designing-polish-band-cmds a:hover { + text-decoration: underline; + text-underline-offset: 3px; +} + +.designing-polish-band-meta { + font-family: var(--font-mono); + font-size: 0.625rem; + letter-spacing: 0.2em; + text-transform: uppercase; + opacity: 0.75; +} + +.designing-polish-grid { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 28px; + padding: 28px; + background: var(--color-cream); + border-left: 1px solid var(--color-mist); + border-right: 1px solid var(--color-mist); + border-bottom: 1px solid var(--color-mist); +} + +.designing-polish-col { + display: flex; + flex-direction: column; + gap: 10px; +} + +.designing-polish-name { + font-family: var(--font-display); + font-style: italic; + font-weight: 400; + font-size: 1.5rem; + line-height: 1.15; + color: var(--color-ink); + margin: 0; +} + +.designing-polish-desc { + font-family: var(--font-body); + font-size: 0.9375rem; + line-height: 1.6; + color: var(--color-charcoal); + margin: 0; +} + +@media (max-width: 720px) { + .designing-polish-grid { + grid-template-columns: 1fr; + } + .designing-polish-band { + grid-template-columns: 1fr; + gap: 10px; + } +} + +/* ============================================ + PHASE 4 — Maintain (two editorial columns + one hero viz each) + ============================================ */ + +/* Maintain section — architectural poster diptych. Each tile presents the + domain visualization as the hero (stage) with a quiet caption beneath + (command link, italic title, short description). */ +.designing-maintain { + display: grid; + grid-template-columns: 1fr 1fr; + gap: clamp(1.5rem, 3vw, 2rem); +} + +.designing-maintain-tile { + margin: 0; + display: flex; + flex-direction: column; + gap: 18px; + min-width: 0; +} + +.designing-maintain-stage { + aspect-ratio: 5 / 3; + background: var(--color-cream); + border: 1px solid var(--color-mist); + display: grid; + place-items: center; + padding: 28px; + overflow: hidden; +} + +.designing-maintain-caption { + display: flex; + flex-direction: column; + gap: 6px; +} + +.designing-maintain-label { + font-family: var(--font-mono); + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--color-accent); +} + +.designing-maintain-label a { + color: inherit; + text-decoration: none; +} + +.designing-maintain-label a:hover { + color: var(--color-accent-hover); +} + +.designing-maintain-name { + font-family: var(--font-display); + font-style: italic; + font-weight: 400; + font-size: 1.5rem; + line-height: 1.15; + color: var(--color-ink); + margin: 0; +} + +.designing-maintain-desc { + font-family: var(--font-body); + font-size: 0.875rem; + line-height: 1.55; + color: var(--color-charcoal); + margin: 0; + max-width: 42ch; +} + +/* Extract consolidation viz — cloud of pills converging on a single primitive */ +.designing-extract-viz { + display: flex; + align-items: center; + gap: 20px; + flex-wrap: nowrap; + justify-content: center; + width: 100%; +} + +.designing-extract-before { + display: grid; + grid-template-columns: repeat(2, auto); + gap: 6px; + justify-content: center; +} + +.designing-extract-btn { + display: inline-block; + padding: 5px 12px; + background: var(--color-paper); + border: 1px solid var(--color-mist); + font-family: var(--font-body); + font-size: 0.75rem; + color: var(--color-ash); + opacity: 0.75; +} + +.designing-extract-arrow { + font-family: var(--font-display); + font-style: italic; + font-size: 2.5rem; + line-height: 1; + color: var(--color-accent); +} + +.designing-extract-after { + display: inline-block; + padding: 10px 22px; + background: var(--color-ink); + color: var(--color-paper); + font-family: var(--font-body); + font-size: 1rem; + font-weight: 500; + letter-spacing: 0.04em; +} + +/* DESIGN.md index — enlarged list of numbered sections */ +.designing-designmd-preview { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + max-width: 280px; +} + +.designing-designmd-preview-line { + display: grid; + grid-template-columns: 32px 1fr; + gap: 14px; + padding-bottom: 6px; + border-bottom: 1px solid var(--color-mist); + font-family: var(--font-body); + font-size: 0.9375rem; + color: var(--color-ink); +} + +.designing-designmd-preview-line:last-child { border-bottom: 0; } + +.designing-designmd-preview-num { + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--color-accent); +} + +@media (max-width: 720px) { + .designing-maintain { + grid-template-columns: 1fr; + gap: var(--spacing-xl, 48px); + } + .designing-extract-viz { flex-wrap: wrap; } +} + +/* ============================================ + APPENDIX: Register (cross-link, quieter aside) + ============================================ */ + +/* Two-lane explainer — brand vs product as twin columns divided by a + hairline, each with its own mock to make the vocabulary visible. */ +.designing-lanes { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0; + margin-bottom: var(--spacing-lg, 32px); +} + +.designing-lane { + padding: 0 clamp(18px, 3vw, 32px); + display: flex; + flex-direction: column; + gap: 14px; + border-left: 1px solid var(--color-mist); +} + +.designing-lane:first-child { + padding-left: 0; + border-left: 0; +} + +.designing-lane-kind { + font-family: var(--font-mono); + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--color-accent); +} + +.designing-lane-rule { + margin: 0; + font-family: var(--font-body); + font-size: 0.9375rem; + line-height: 1.55; + color: var(--color-charcoal); + max-width: 38ch; +} + +.designing-lane-mock { + padding: 28px 20px; + display: flex; + flex-direction: column; + gap: 6px; + justify-content: center; + min-height: 116px; + border: 1px solid var(--color-mist); +} + +.designing-lane-mock--brand { + background: oklch(96% 0.02 30); +} + +.designing-lane-mock--brand .designing-lane-mock-label { + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: oklch(40% 0.12 30); +} + +.designing-lane-mock--brand .designing-lane-mock-title { + font-family: var(--font-display); + font-style: italic; + font-size: 22px; + color: oklch(20% 0.1 30); + line-height: 1.1; +} + +.designing-lane-mock--product { + background: var(--color-cream); +} + +.designing-lane-mock--product .designing-lane-mock-label { + font-family: var(--font-body); + font-size: 10px; + font-weight: 600; + color: var(--color-ash); + letter-spacing: 0.04em; +} + +.designing-lane-mock--product .designing-lane-mock-title { + font-family: var(--font-body); + font-weight: 600; + font-size: 15px; + color: var(--color-ink); + line-height: 1.25; +} + +.designing-lane-link { + font-family: var(--font-body); + font-size: 0.875rem; + font-weight: 600; + color: var(--color-accent); + text-decoration: underline; + text-underline-offset: 3px; + text-decoration-thickness: 1px; + display: inline-block; +} + +.designing-lane-link:hover { + text-decoration-thickness: 2px; +} + +@media (max-width: 640px) { + .designing-lanes { + grid-template-columns: 1fr; + gap: var(--spacing-md, 24px); + } + .designing-lane { + padding: var(--spacing-md, 24px) 0 0 0; + border-left: 0; + border-top: 1px solid var(--color-mist); + } + .designing-lane:first-child { + padding-top: 0; + border-top: 0; + } +} + +/* ============================================ + APPENDIX: What to avoid (inline list, no cards) + ============================================ */ + +.designing-avoid { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; +} + +.designing-avoid li { + padding: var(--spacing-sm, 16px) 0; + border-bottom: 1px solid var(--color-mist); + display: grid; + grid-template-columns: 28px 1fr; + gap: var(--spacing-sm, 16px); + align-items: baseline; +} + +.designing-avoid li:last-child { + border-bottom: 0; +} + +.designing-avoid-x { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + background: oklch(95% 0.04 25); + color: oklch(55% 0.22 25); + border-radius: 50%; + font-family: var(--font-mono); + font-size: 12px; + font-weight: 700; + align-self: start; + margin-top: 3px; +} + +.designing-avoid-title { + display: block; + font-family: var(--font-body); + font-size: 1rem; + font-weight: 600; + color: var(--color-ink); + line-height: 1.4; + margin-bottom: 4px; +} + +.designing-avoid-desc { + font-family: var(--font-body); + font-size: 0.9375rem; + line-height: 1.6; + color: var(--color-charcoal); + margin: 0; + max-width: 62ch; +} + +/* ============================================ + CTA climax (largest typographic moment) + ============================================ */ + +.designing-cta { + margin-top: var(--spacing-3xl, 120px); + padding-top: var(--spacing-xl, 48px); + border-top: 1px solid var(--color-mist); + display: grid; + grid-template-columns: 1fr 1fr; + gap: clamp(1.5rem, 3vw, 2.5rem); +} + +@media (max-width: 720px) { + .designing-cta { + grid-template-columns: 1fr; + } +} + +.designing-cta-card { + display: flex; + flex-direction: column; + gap: var(--spacing-sm, 16px); + padding: var(--spacing-lg, 32px); + background: var(--color-paper); + border: 1px solid var(--color-mist); + border-radius: 12px; + text-decoration: none; + color: inherit; + transition: border-color 200ms ease, background 200ms ease; +} + +.designing-cta-card:hover { + border-color: var(--color-accent); + background: var(--color-accent-dim); +} + +.designing-cta-card-kind { + font-family: var(--font-mono); + font-size: 0.625rem; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--color-accent); +} + +.designing-cta-card-title { + font-family: var(--font-display); + font-style: italic; + font-size: clamp(1.5rem, 2.5vw, 1.875rem); + line-height: 1.1; + color: var(--color-ink); + margin: 0; +} + +.designing-cta-card-title em { + color: var(--color-accent); + font-style: italic; +} + +.designing-cta-card-desc { + font-family: var(--font-body); + font-size: 0.9375rem; + line-height: 1.6; + color: var(--color-charcoal); + margin: 0; +} diff --git a/public/css/live-mode.css b/public/css/live-mode.css new file mode 100644 index 000000000..c15c484d2 --- /dev/null +++ b/public/css/live-mode.css @@ -0,0 +1,802 @@ +/* ============================================ + LIVE MODE — interactive demo loop + ============================================ */ + +.live-section { + padding: var(--spacing-2xl) 0; + border-top: 1px solid var(--color-mist); +} + +.live-content .section-lead { + max-width: 64ch; + margin-bottom: var(--spacing-xl); +} + +.live-demo { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(260px, 340px); + gap: var(--spacing-xl); + align-items: start; + margin-bottom: var(--spacing-xl); +} +.live-demo-frame-col { + display: flex; + flex-direction: column; + gap: var(--spacing-md); + min-width: 0; +} +@media (max-width: 920px) { + .live-demo { grid-template-columns: 1fr; } +} + +.live-demo-frame { + position: relative; + background: var(--color-cream); + border: 1px solid var(--color-mist); + border-radius: 12px; + overflow: hidden; + aspect-ratio: 16 / 9; + max-width: 960px; + box-shadow: 0 20px 50px oklch(0% 0 0 / 0.08); + user-select: none; +} + +.live-demo-chrome { + display: flex; + align-items: center; + gap: 7px; + padding: 10px 14px; + border-bottom: 1px solid var(--color-mist); + background: var(--color-paper); +} +.live-demo-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--color-mist); +} +.live-demo-url { + margin-left: 14px; + padding: 4px 14px; + background: var(--color-cream); + border: 1px solid var(--color-mist); + border-radius: 5px; + font-family: var(--font-mono); + font-size: 11px; + color: var(--color-charcoal); +} + +.live-demo-stage { + position: relative; + height: calc(100% - 42px - 60px); /* minus chrome + bar */ + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + overflow: hidden; +} + +/* Target card */ +.live-demo-target { + position: relative; + width: min(360px, 80%); + min-height: 200px; +} +.live-demo-variant { + position: absolute; + inset: 0; + opacity: 0; + transform: translateY(8px) scale(0.99); + transition: opacity 280ms var(--ease-out), transform 280ms var(--ease-out); + pointer-events: none; +} +.live-demo-variant.is-active { + opacity: 1; + transform: translateY(0) scale(1); + pointer-events: auto; +} +.live-demo-card { + display: flex; + flex-direction: column; + gap: 10px; + padding: 22px 24px; + background: var(--color-paper); + border: 1px solid var(--color-mist); + border-radius: 8px; + height: 100%; + box-sizing: border-box; +} +.live-demo-card-kicker { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--color-ash); +} +.live-demo-card h3 { + font-family: var(--font-display); + font-weight: 400; + font-size: 20px; + line-height: 1.2; + color: var(--color-ink); + margin: 0; +} +.live-demo-card p { + font-family: var(--font-body); + font-size: 13px; + line-height: 1.5; + color: var(--color-charcoal); + margin: 0; +} +.live-demo-card button { + align-self: flex-start; + margin-top: auto; + font-family: var(--font-body); + font-weight: 500; + font-size: 12px; + letter-spacing: 0.06em; + text-transform: uppercase; + padding: 9px 16px; + background: var(--color-ink); + color: var(--color-paper); + border: 0; + border-radius: 0; + cursor: pointer; +} + +/* Per-variant look */ +.live-demo-card--plain {} +.live-demo-card--v1 { + background: var(--color-cream); +} +.live-demo-card--v1 h3 { font-style: italic; } +.live-demo-card--v1 h3 em { color: var(--color-accent); font-style: italic; } +.live-demo-card--v2 { + background: var(--color-paper); + border: 1px dashed var(--color-accent); +} +.live-demo-card--v2 .live-demo-card-kicker { color: var(--color-accent); } +.live-demo-card--v2 h3 { font-family: var(--font-display); font-style: italic; } +.live-demo-card--v2 button { + background: var(--color-accent); +} +.live-demo-card--v3 { + background: oklch(96% 0.02 350); + border: 1px solid var(--color-accent-soft); +} +.live-demo-card--v3 h3 em { color: var(--color-accent); font-style: italic; } +.live-demo-card--v3 .live-demo-card-kicker { color: var(--color-accent-hover); } + +/* Highlight outline over the target */ +.live-demo-outline { + position: absolute; + border: 2px solid var(--color-accent); + border-radius: 8px; + pointer-events: none; + opacity: 0; + transition: opacity 200ms var(--ease-out), top 320ms var(--ease-out), left 320ms var(--ease-out), width 320ms var(--ease-out), height 320ms var(--ease-out); + box-shadow: 0 0 0 4px var(--color-accent-dim); +} +.live-demo-outline.is-visible { + opacity: 1; +} + +/* Annotations */ +.live-demo-annotations { + position: absolute; + inset: 0; + pointer-events: none; + opacity: 0; + transition: opacity 280ms var(--ease-out); + color: var(--color-accent); +} +.live-demo-annotations.is-visible { opacity: 1; } +.live-demo-stroke { + position: absolute; + width: 280px; + height: 56px; + top: 50%; + left: 50%; + transform: translate(-50%, -24px); + pointer-events: none; +} +.live-demo-stroke path { + stroke-dasharray: 1; + stroke-dashoffset: 1; +} +.live-demo-annotations.is-visible .live-demo-stroke path { + animation: liveDemoStroke 800ms var(--ease-out) forwards; +} +@keyframes liveDemoStroke { + to { stroke-dashoffset: 0; } +} +.live-demo-comment { + position: absolute; + top: 56%; + left: 50%; + transform: translate(-50%, 0); + padding: 5px 10px; + background: var(--color-ink); + color: var(--color-paper); + font-family: var(--font-mono); + font-size: 11px; + border-radius: 4px; + white-space: nowrap; + opacity: 0; + transition: opacity 200ms var(--ease-out); +} +.live-demo-annotations.is-comment-visible .live-demo-comment { opacity: 1; } + +/* Simulated cursor */ +.live-demo-cursor { + position: absolute; + top: 0; + left: 0; + width: 18px; + height: 22px; + pointer-events: none; + opacity: 0; + transform: translate(0, 0); + transition: opacity 200ms var(--ease-out), transform 560ms var(--ease-out-quint); + z-index: 5; + filter: drop-shadow(0 2px 4px oklch(0% 0 0 / 0.2)); +} +.live-demo-cursor.is-visible { opacity: 1; } +.live-demo-cursor.is-click svg path { + transform-origin: 4px 4px; + animation: liveDemoCursorClick 220ms var(--ease-out); +} +@keyframes liveDemoCursorClick { + 0%, 100% { transform: scale(1); } + 40% { transform: scale(0.78); } +} + +/* Global bar — the persistent dark pill at the bottom of every session. */ +.live-demo-gbar { + position: absolute; + bottom: 14px; + left: 50%; + transform: translateX(-50%); + min-height: 36px; + padding: 0 4px; + background: oklch(14% 0 0); + color: oklch(92% 0 0); + border: 1px solid oklch(22% 0 0); + border-radius: 10px; + display: flex; + align-items: center; + gap: 2px; + font-family: var(--font-body); + font-size: 12px; + box-shadow: 0 8px 24px oklch(0% 0 0 / 0.2); + z-index: 4; +} +.live-demo-gbar-brand { + font-family: var(--font-display); + font-size: 16px; + color: var(--color-accent); + padding: 0 10px; +} +.live-demo-gbar-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 10px; + background: transparent; + border: 0; + border-radius: 7px; + color: oklch(75% 0 0); + font: inherit; + cursor: pointer; +} +.live-demo-gbar-btn.is-active { + background: var(--color-accent-dim); + color: var(--color-accent); +} +.live-demo-gbar-dmd { + display: inline-grid; + grid-template: repeat(2, 1fr) / repeat(2, 1fr); + width: 12px; + height: 12px; + border-radius: 3px; + overflow: hidden; +} +.live-demo-gbar-dmd span:nth-child(1) { background: oklch(60% 0.25 350); } +.live-demo-gbar-dmd span:nth-child(2) { background: oklch(60% 0.15 45); } +.live-demo-gbar-dmd span:nth-child(3) { background: oklch(55% 0.12 250); } +.live-demo-gbar-dmd span:nth-child(4) { background: oklch(30% 0 0); } +.live-demo-gbar-divider { + width: 1px; + height: 18px; + background: oklch(28% 0 0); + margin: 0 4px; +} +.live-demo-gbar-x { + padding: 7px 10px; + background: transparent; + border: 0; + border-radius: 7px; + color: oklch(60% 0 0); + font-size: 13px; + cursor: pointer; +} + +/* Contextual bar — LIGHT paper-backed pill that floats near the picked + element during a session. Matches live-browser.js's buildConfigureRow + look (dark command pill, transparent input, ×N count, magenta Go). */ +.live-demo-ctx { + position: absolute; + left: 50%; + transform: translate(-50%, 6px); + padding: 6px; + background: var(--color-paper); + border: 1px solid var(--color-mist); + border-radius: 10px; + box-shadow: 0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06); + font-family: var(--font-body); + font-size: 12px; + color: var(--color-ink); + display: none; + opacity: 0; + transition: opacity 260ms var(--ease-out), transform 260ms var(--ease-out); + z-index: 3; + max-width: 90%; +} +.live-demo-ctx[data-phase="configuring"], +.live-demo-ctx[data-phase="generating"], +.live-demo-ctx[data-phase="cycling"], +.live-demo-ctx[data-phase="accepted"] { + display: block; + opacity: 1; + transform: translate(-50%, 0); +} +.live-demo-ctx-row { + display: none; + align-items: center; + gap: 4px; +} +.live-demo-ctx[data-phase="configuring"] .live-demo-ctx-row--configure, +.live-demo-ctx[data-phase="generating"] .live-demo-ctx-row--generating, +.live-demo-ctx[data-phase="cycling"] .live-demo-ctx-row--cycling, +.live-demo-ctx[data-phase="accepted"] .live-demo-ctx-row--accepted { + display: flex; +} + +/* Configure row: [delight ▾] [input] [×3] [Go →] */ +.live-demo-ctx-pill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 5px 10px; + background: var(--color-ink); + color: var(--color-paper); + border: 0; + border-radius: 6px; + font-family: var(--font-body); + font-size: 12px; + font-weight: 500; + cursor: pointer; + white-space: nowrap; +} +.live-demo-ctx-pill-caret { font-size: 9px; opacity: 0.7; margin-left: 2px; } +.live-demo-ctx-input { + display: inline-flex; + align-items: center; + flex: 1; + min-width: 180px; + padding: 5px 8px; + font-family: var(--font-body); + font-size: 12px; + color: var(--color-ink); +} +.live-demo-ctx-caret { + display: inline-block; + width: 1px; + height: 13px; + background: var(--color-ink); + margin-left: 2px; + animation: liveDemoCaret 1s steps(1) infinite; +} +@keyframes liveDemoCaret { + 50% { opacity: 0; } +} +.live-demo-ctx-count { + padding: 4px 6px; + background: transparent; + border: 1px solid var(--color-mist); + border-radius: 5px; + font-family: var(--font-mono); + font-size: 11px; + font-weight: 600; + color: var(--color-ash); + cursor: pointer; +} +.live-demo-ctx-go { + padding: 5px 12px; + background: var(--color-accent); + color: var(--color-paper); + border: 0; + border-radius: 6px; + font-family: var(--font-body); + font-size: 12px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; +} + +/* Generating row */ +.live-demo-ctx-row--generating { + gap: 10px; + padding: 4px 12px 4px 6px; + font-family: var(--font-body); + font-size: 12px; + color: var(--color-charcoal); +} +.live-demo-ctx-spinner { + width: 14px; + height: 14px; + border-radius: 50%; + border: 1.5px solid var(--color-mist); + border-top-color: var(--color-accent); + animation: liveDemoSpin 700ms linear infinite; +} +@keyframes liveDemoSpin { to { transform: rotate(360deg); } } + +/* Cycling row */ +.live-demo-ctx-row--cycling { gap: 2px; padding: 2px; } +.live-demo-ctx-nav { + padding: 4px 10px; + background: transparent; + border: 0; + border-radius: 5px; + color: var(--color-charcoal); + font-size: 14px; + cursor: pointer; +} +.live-demo-ctx-counter { + font-family: var(--font-mono); + font-size: 11px; + color: var(--color-ink); + padding: 0 6px; + min-width: 40px; + text-align: center; +} +.live-demo-ctx-divider { + width: 1px; + height: 18px; + background: var(--color-mist); + margin: 0 4px; +} +.live-demo-ctx-discard { + padding: 4px 10px; + background: transparent; + border: 0; + border-radius: 5px; + color: var(--color-ash); + font-size: 13px; + cursor: pointer; +} +.live-demo-ctx-accept { + padding: 5px 14px; + background: var(--color-ink); + color: var(--color-paper); + border: 0; + border-radius: 6px; + font-family: var(--font-body); + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +/* Accepted confirmation row */ +.live-demo-ctx-row--accepted { + gap: 8px; + padding: 6px 14px; + font-family: var(--font-body); + font-size: 12px; + color: oklch(45% 0.18 145); +} + +/* Phase caption under the frame */ +.live-demo-caption { + display: flex; + align-items: baseline; + gap: 10px; + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--color-ash); +} +.live-demo-caption::before { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--color-accent); + animation: liveDemoPulse 1.6s var(--ease-out) infinite; +} +@keyframes liveDemoPulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.4; transform: scale(0.7); } +} +.live-demo-caption-label { color: var(--color-ink); } + +/* Supporting row under the demo */ +.live-demo-support { + display: flex; + flex-direction: column; + gap: var(--spacing-md); + align-self: stretch; +} +.live-demo-support-cell { + display: grid; + grid-template-columns: 1fr; + gap: 4px; + padding-bottom: var(--spacing-md); + border-bottom: 1px dashed var(--color-mist); +} +.live-demo-support-cell:last-child { + border-bottom: 0; + padding-bottom: 0; +} +.live-demo-support-k { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--color-accent); +} +.live-demo-support-v { + font-family: var(--font-body); + font-size: 14px; + line-height: 1.55; + color: var(--color-ink); +} +.live-demo-support-v code { + font-family: var(--font-mono); + font-size: 0.875em; + background: var(--color-mist); + padding: 1px 6px; + border-radius: 3px; + color: var(--color-ink); +} + +@media (max-width: 760px) { + .live-demo-frame { aspect-ratio: 4 / 5; } + .live-demo-target { width: 90%; } + .live-demo-bar { font-size: 11px; min-height: 32px; } + .live-demo-support { grid-template-columns: 1fr; gap: var(--spacing-md); } +} + +@media (prefers-reduced-motion: reduce) { + .live-demo-cursor, + .live-demo-outline, + .live-demo-variant, + .live-demo-annotations, + .live-demo-stroke path { + transition: none !important; + animation: none !important; + } + .live-demo-caption::before { animation: none; } +} +/* ============================================ + LIVE MODE — skeleton page + richer variants + ============================================ */ + +/* Skeleton elements behind the target card — suggest "this is a real page" */ +.live-demo-skeleton { + position: absolute; + inset: 24px; + display: flex; + flex-direction: column; + gap: 14px; + opacity: 0.3; + pointer-events: none; + filter: blur(0.3px); +} +.live-demo-skel-nav { + display: flex; + align-items: center; + gap: 10px; + padding-bottom: 12px; + border-bottom: 1px solid var(--color-mist); +} +.live-demo-skel-logo { + width: 22px; + height: 22px; + border-radius: 4px; + background: var(--color-charcoal); +} +.live-demo-skel-link { + width: 48px; + height: 8px; + border-radius: 2px; + background: var(--color-mist); +} +.live-demo-skel-cta { + margin-left: auto; + width: 72px; + height: 24px; + border-radius: 4px; + background: var(--color-charcoal); +} +.live-demo-skel-heading { + width: 60%; + height: 18px; + border-radius: 3px; + background: var(--color-mist); + margin-top: 20px; +} +.live-demo-skel-line { + height: 8px; + border-radius: 2px; + background: var(--color-mist); +} +.live-demo-skel-line--short { width: 40%; } + +/* Target stays above skeleton */ +.live-demo-target { position: relative; z-index: 1; } + +/* ---- Card variants — sharper, more distinct ---- */ + +/* Variant 1: magazine column — italic serif, rule lines, off-white */ +.live-demo-card--v1 { + background: var(--color-cream); + padding: 18px 22px; + border: 0; + border-top: 3px solid var(--color-ink); + border-radius: 0; + gap: 6px; +} +.live-demo-card--v1 .live-demo-card-kicker { + color: var(--color-accent); + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.28em; +} +.live-demo-card--v1 h3 { + font-family: var(--font-display); + font-style: italic; + font-weight: 400; + font-size: 28px; + line-height: 1.05; + letter-spacing: -0.01em; + margin-top: 4px; +} +.live-demo-card--v1 h3 em { color: var(--color-accent); font-style: italic; } +.live-demo-card--v1 p { + font-family: var(--font-display); + font-style: italic; + font-size: 14px; + line-height: 1.55; + color: var(--color-charcoal); +} +.live-demo-card--v1 button { + font-family: var(--font-body); + align-self: flex-start; + margin-top: 6px; + background: transparent; + color: var(--color-ink); + border: 0; + border-bottom: 1.5px solid var(--color-ink); + padding: 4px 0; + border-radius: 0; + letter-spacing: 0.08em; +} + +/* Variant 2: brutalist ticket — thick ink block, stamp glyph, mono */ +.live-demo-card--v2 { + position: relative; + background: var(--color-ink); + color: var(--color-paper); + padding: 20px 24px; + border: 0; + border-radius: 0; + overflow: hidden; + gap: 8px; +} +.live-demo-card--v2::before { + content: ""; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 6px; + background: var(--color-accent); +} +.live-demo-card-stamp { + position: absolute; + top: 14px; + right: 18px; + font-size: 28px; + line-height: 1; + color: var(--color-accent); + transform: rotate(-8deg); +} +.live-demo-card--v2 .live-demo-card-kicker { + color: var(--color-accent); + letter-spacing: 0.3em; + font-weight: 600; +} +.live-demo-card--v2 h3 { + font-family: var(--font-body); + font-weight: 700; + font-size: 24px; + line-height: 1.1; + color: var(--color-paper); + letter-spacing: -0.02em; +} +.live-demo-card--v2 button { + font-family: var(--font-mono); + font-weight: 500; + align-self: flex-start; + margin-top: 6px; + background: var(--color-accent); + color: var(--color-paper); + border: 0; + border-radius: 0; + padding: 9px 14px; + letter-spacing: 0.04em; + text-transform: none; + font-size: 12px; +} + +/* Variant 3: playful ticket — accent background, sticker stars, postcard vibe */ +.live-demo-card--v3 { + position: relative; + background: + radial-gradient(circle at 20% 80%, oklch(92% 0.08 350) 0, transparent 45%), + var(--color-cream); + border: 1px dashed var(--color-accent); + border-radius: 10px; + padding: 22px 24px 20px; + gap: 8px; +} +.live-demo-card-sticker { + position: absolute; + top: 10px; + right: 14px; + display: flex; + gap: 3px; + color: var(--color-accent); + font-size: 14px; + line-height: 1; + transform: rotate(6deg); +} +.live-demo-card--v3 .live-demo-card-kicker { + font-family: var(--font-display); + font-style: italic; + font-weight: 400; + font-size: 13px; + letter-spacing: 0.04em; + color: var(--color-accent-hover); + text-transform: none; +} +.live-demo-card--v3 h3 { + font-family: var(--font-display); + font-weight: 400; + font-size: 22px; + line-height: 1.2; + color: var(--color-ink); +} +.live-demo-card--v3 button { + display: inline-flex; + align-items: center; + gap: 6px; + align-self: flex-start; + background: var(--color-ink); + color: var(--color-paper); + border: 0; + border-radius: 999px; + padding: 10px 18px; + font-family: var(--font-body); + font-weight: 500; + letter-spacing: 0.06em; + text-transform: none; + font-size: 12px; + margin-top: 6px; +} + diff --git a/public/css/main.css b/public/css/main.css index 0cacbeca6..581e8fc90 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -11,6 +11,7 @@ /* Section partials (landing page only) */ @import "./problem-section.css"; @import "./workflow.css"; +@import "./live-mode.css"; @import "./gallery.css"; @import "./skill-demos.css"; @@ -1153,7 +1154,7 @@ code { .language-content { display: grid; - gap: var(--spacing-lg); + gap: var(--spacing-sm); } /* Grid and flex items default to min-width: auto, which lets them expand to @@ -1666,6 +1667,11 @@ code { margin: 0 0 var(--spacing-xs) 0; } +.mobile-cmd-namespace { + color: var(--color-ash); + font-weight: 400; +} + .mobile-cmd-desc { font-size: 0.875rem; color: var(--color-charcoal); @@ -2437,28 +2443,76 @@ code { padding: var(--spacing-lg); } -.patterns-tabs { - display: flex; - flex-wrap: wrap; - gap: 6px; +.patterns-tabs-wrap { + position: relative; margin-bottom: var(--spacing-md); } -@media (max-width: 768px) { - .patterns-tabs { - flex-wrap: nowrap; - overflow-x: auto; - scrollbar-width: none; - -webkit-overflow-scrolling: touch; - margin-left: calc(-1 * var(--spacing-lg)); - margin-right: calc(-1 * var(--spacing-lg)); - padding-left: var(--spacing-lg); - padding-right: var(--spacing-lg); - } +.patterns-tabs { + display: flex; + flex-wrap: nowrap; + gap: 6px; + overflow-x: auto; + scrollbar-width: none; + -webkit-overflow-scrolling: touch; + scroll-snap-type: x proximity; +} - .patterns-tabs::-webkit-scrollbar { - display: none; - } +.patterns-tabs::-webkit-scrollbar { + display: none; +} + +/* Edge fade only on sides where more content exists. data-scroll is set + by JS: start | middle | end | none. */ +.patterns-tabs[data-scroll="start"] { + mask-image: linear-gradient(to right, black calc(100% - 40px), transparent); + -webkit-mask-image: linear-gradient(to right, black calc(100% - 40px), transparent); +} +.patterns-tabs[data-scroll="middle"] { + mask-image: linear-gradient(to right, transparent, black 32px, black calc(100% - 40px), transparent); + -webkit-mask-image: linear-gradient(to right, transparent, black 32px, black calc(100% - 40px), transparent); +} +.patterns-tabs[data-scroll="end"] { + mask-image: linear-gradient(to right, transparent, black 32px); + -webkit-mask-image: linear-gradient(to right, transparent, black 32px); +} +.patterns-tabs[data-scroll="none"] { + mask-image: none; + -webkit-mask-image: none; +} + +/* Chevrons sit on the wrapper (outside the mask) so they're always fully + opaque, regardless of edge fade. Visible only when scroll can progress + in that direction. */ +.patterns-tabs-wrap::before, +.patterns-tabs-wrap::after { + content: ''; + position: absolute; + top: 50%; + width: 9px; + height: 9px; + border-top: 1.5px solid var(--color-ash); + border-right: 1.5px solid var(--color-ash); + pointer-events: none; + opacity: 0; + transition: opacity 0.18s ease; +} +.patterns-tabs-wrap::before { + left: 2px; + transform: translateY(-50%) rotate(-135deg); +} +.patterns-tabs-wrap::after { + right: 2px; + transform: translateY(-50%) rotate(45deg); +} +.patterns-tabs-wrap[data-scroll="start"]::after, +.patterns-tabs-wrap[data-scroll="middle"]::after { opacity: 1; } +.patterns-tabs-wrap[data-scroll="end"]::before, +.patterns-tabs-wrap[data-scroll="middle"]::before { opacity: 1; } + +.patterns-tab { + scroll-snap-align: start; + flex-shrink: 0; } .patterns-tab { @@ -2741,7 +2795,7 @@ code { /* Row 1: full-width primary install card with internal 2-column split */ .install-row-primary { display: grid; - grid-template-columns: 1.1fr 0.9fr; + grid-template-columns: 1fr 1fr; gap: var(--spacing-xl); align-items: start; margin: 0 0 var(--spacing-xl); @@ -2751,19 +2805,356 @@ code { min-width: 0; } -.install-primary-main { +.install-primary-main, +.install-primary-howto { display: flex; flex-direction: column; min-width: 0; } +.install-primary-howto { + padding-left: var(--spacing-xl); + border-left: 1px solid var(--color-mist); +} + +/* The "Use it" side uses the hero body copy size so both columns feel balanced + in typographic weight, not just pixel width. */ +.install-primary-howto .install-path-desc { + font-size: 0.9375rem; + line-height: 1.6; + color: var(--color-ink); + max-width: 48ch; +} + +.install-primary-howto .install-path-desc em { + font-family: var(--font-display); + font-style: italic; + font-weight: 500; + color: var(--color-accent); +} + .install-primary-alts { + min-width: 0; +} + +/* "Other install methods" is a collapsed
panel that sits + directly under the main install card. The alternatives are worth + keeping discoverable but don't need to be visible by default. */ +.install-primary-main > .install-primary-alts { + margin-top: var(--spacing-lg); + padding-top: var(--spacing-md); + border-top: 1px solid var(--color-mist); +} + +.install-primary-alts[open] > .install-alts-summary > .install-alts-arrow { + transform: rotate(90deg); +} + +.install-alts-summary { + display: flex; + align-items: center; + gap: var(--spacing-xs); + list-style: none; + cursor: pointer; + padding: 2px 0; +} + +.install-alts-summary::-webkit-details-marker { + display: none; +} + +.install-alts-arrow { + color: var(--color-ash); + transition: transform var(--duration-fast) var(--ease-out, ease); +} + +.install-primary-alts[open] > .install-alts-summary { + margin-bottom: var(--spacing-md); +} + +.install-primary-alts[open] > .install-alt-method + .install-alt-method { + margin-top: var(--spacing-lg); +} + +/* Editorial "How to use" step list on the right side of the install row. + Uses display-font numerals as prominent visual anchors, generous vertical + rhythm, and body text at the same scale as the hero copy for hierarchy + parity with the rest of the page. */ +.install-howto-steps { + list-style: none; + counter-reset: howto-step; + padding: 0; + margin: var(--spacing-lg) 0 0; display: flex; flex-direction: column; gap: var(--spacing-lg); +} + +.install-howto-steps > li { + counter-increment: howto-step; + position: relative; + padding-left: 3rem; + min-height: 2.5rem; +} + +.install-howto-steps > li::before { + content: counter(howto-step, decimal-leading-zero); + position: absolute; + left: 0; + top: -0.1em; + font-family: var(--font-display); + font-size: 1.75rem; + font-weight: 400; + font-style: italic; + color: var(--color-accent); + line-height: 1; + letter-spacing: -0.02em; +} + +.install-howto-step-label { + font-family: var(--font-body); + font-size: 1rem; + font-weight: 600; + color: var(--color-ink); + margin-bottom: 0.35rem; + line-height: 1.3; + letter-spacing: -0.005em; +} + +.install-howto-steps > li p { + margin: 0; + font-size: 0.9375rem; + color: var(--color-ink); + line-height: 1.6; +} + +.install-howto-steps code { + font-family: var(--font-mono); + font-size: 0.8125rem; + font-weight: 500; + color: var(--color-ink); + background: var(--color-accent-dim); + padding: 1px 6px; + border-radius: 3px; + white-space: nowrap; +} + +.install-howto-steps .install-path-slash { + color: var(--color-accent); +} + +/* Compact 3-row "try it" table. Replaces the numbered sub-steps + inside step 2 to condense vertical height. Each row: mono command + example + one-line caption. Collapses to stacked on narrow screens. */ +.install-howto-rows { + margin: var(--spacing-lg) 0 0; + padding: 0; + display: flex; + flex-direction: column; +} + +.install-howto-row { + display: grid; + grid-template-columns: minmax(0, 0.42fr) minmax(0, 0.58fr); + gap: var(--spacing-md); + align-items: baseline; + padding: var(--spacing-sm) 0; + border-bottom: 1px solid var(--color-mist); +} + +.install-howto-row:first-child { + padding-top: 0; +} + +.install-howto-row:last-child { + padding-bottom: 0; + border-bottom: 0; +} + +.install-howto-row dt { + margin: 0; min-width: 0; - padding-left: var(--spacing-xl); - border-left: 1px solid var(--color-mist); +} + +.install-howto-row dt code { + font-family: var(--font-mono); + font-size: 0.8125rem; + font-weight: 500; + color: var(--color-ink); + background: var(--color-accent-dim); + padding: 3px 8px; + border-radius: 3px; + white-space: nowrap; +} + +.install-howto-row dd { + margin: 0; + font-size: 0.9375rem; + line-height: 1.55; + color: var(--color-ink); +} + +.install-howto-row dd code { + font-family: var(--font-mono); + font-size: 0.8125em; + background: var(--color-mist); + padding: 1px 5px; + border-radius: 3px; + color: var(--color-ink); + white-space: nowrap; +} + +.install-howto-row .install-path-slash { + color: var(--color-accent); +} + +@media (max-width: 700px) { + .install-howto-row { + grid-template-columns: 1fr; + gap: 6px; + } +} + +.install-howto-footer { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-lg); + margin-top: var(--spacing-xl); + padding-top: var(--spacing-lg); + border-top: 1px solid var(--color-mist); +} + +.install-howto-link { + font-family: var(--font-body); + font-size: 0.875rem; + font-weight: 500; + color: var(--color-accent); + text-decoration: none; + display: inline-flex; + align-items: baseline; + gap: 0.35em; + transition: gap 0.2s var(--ease-out-quart, ease); +} + +.install-howto-link:hover { + gap: 0.6em; +} + +.install-howto-link span { + display: inline-block; + transition: transform 0.2s var(--ease-out-quart, ease); +} + +.install-howto-link:hover span { + transform: translateX(2px); +} + +/* Two-tool grid inside the "Anti-pattern tools" step. */ +.install-tool-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-lg); + margin-top: var(--spacing-md); +} + +.install-tool { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + min-width: 0; +} + +/* Solo install-tool (no grid wrapper) — used when step 3 is Chrome-only. + The preview screenshot sits to the left of the CTA button so neither + dominates vertical space. Collapses to a stack on narrow screens. */ +.install-tool--solo { + flex-direction: row; + align-items: center; + gap: var(--spacing-lg); + margin-top: var(--spacing-md); + width: 100%; +} + +.install-tool--solo .install-tool-preview { + flex: 0 1 260px; + min-width: 0; + margin-top: 0; +} + +.install-tool--solo .install-tool-cta { + flex: 1 1 auto; + margin-top: 0; + width: auto; + white-space: nowrap; +} + +@media (max-width: 640px) { + .install-tool--solo { + flex-direction: column; + align-items: stretch; + } + + .install-tool--solo .install-tool-preview, + .install-tool--solo .install-tool-cta { + flex: 1 1 auto; + } +} + +.install-tool-label { + font-family: var(--font-body); + font-size: 0.9375rem; + font-weight: 600; + color: var(--color-ink); + display: flex; + align-items: center; + gap: 0.4rem; +} + +.install-tool-desc { + margin: 0; + font-size: 0.8125rem; + color: var(--color-ink); + line-height: 1.55; +} + +/* Chrome extension screenshot preview inside the install-tool column */ +.install-tool-preview { + display: block; + margin-top: var(--spacing-sm); + border: 1px solid var(--color-mist); + border-radius: 8px; + overflow: hidden; + text-decoration: none; + background: var(--color-paper); + transition: border-color var(--duration-fast) var(--ease-out, ease), + transform var(--duration-fast) var(--ease-out, ease); +} + +.install-tool-preview:hover { + border-color: var(--color-accent); + transform: translateY(-1px); +} + +.install-tool-preview img { + display: block; + width: 100%; + height: auto; +} + +.install-tool-preview-caption { + display: block; + padding: 0.4rem 0.75rem; + font-family: var(--font-body); + font-size: 0.75rem; + color: var(--color-ash); + border-top: 1px solid var(--color-mist); +} + +/* "Install from Chrome Web Store" uses the standard .btn .btn-primary + styles; this modifier only adds layout (full width + top spacing). */ +.install-tool-cta { + margin-top: var(--spacing-sm); + width: 100%; } .install-alts-label { @@ -3025,6 +3416,7 @@ code { color: var(--color-accent); } + .install-path-subcommands { display: flex; flex-direction: column; @@ -3112,6 +3504,7 @@ code { font-size: 0.75rem; color: var(--color-ash); line-height: 1.5; + text-wrap: pretty; } .install-cmd-note code { @@ -3121,6 +3514,11 @@ code { padding: 2px 5px; border-radius: 3px; color: var(--color-ink); + white-space: nowrap; +} + +.install-cmd-note a { + white-space: nowrap; } @@ -3311,13 +3709,17 @@ a.install-updated-ref:hover { max-width: calc(100vw - var(--spacing-md) * 2); } - .install-primary-alts { + .install-primary-howto { padding-left: 0; padding-top: var(--spacing-lg); border-left: none; border-top: 1px solid var(--color-mist); } + .install-tool-grid { + grid-template-columns: 1fr; + } + .install-path-primary { margin-bottom: var(--spacing-sm); padding: var(--spacing-md); diff --git a/public/css/sub-pages.css b/public/css/sub-pages.css index 716b13307..0c5874a13 100644 --- a/public/css/sub-pages.css +++ b/public/css/sub-pages.css @@ -7,6 +7,7 @@ */ @import "./tokens.css"; +@import "./docs-visuals.css"; /* ============================================ SKIP LINK (a11y) @@ -37,9 +38,8 @@ BASE ============================================ */ -html { - scroll-behavior: smooth; -} +/* No smooth scroll — long editorial pages feel slow on anchor jumps, and + a secretly-smooth CSS rule also overrides JS `behavior: 'auto'` calls. */ body { font-family: var(--font-body); @@ -486,138 +486,291 @@ main#main { padding: clamp(2rem, 4vw, 3.5rem) 0 clamp(4rem, 8vw, 6rem); } -.skills-overview-content { - max-width: 720px; +/* ============================================ + DOCS OVERVIEW — /skills page + ============================================ */ + +.docs-overview { + max-width: 920px; } -.skills-overview-header { - margin-bottom: clamp(2.5rem, 5vw, 4rem); +.docs-overview-header { + margin-bottom: clamp(2rem, 4vw, 3rem); } -.skills-overview-header .sub-page-lede a { - color: var(--color-ink); - text-decoration: underline; - text-decoration-thickness: 1px; - text-decoration-color: var(--color-accent); - text-underline-offset: 4px; - font-family: var(--font-mono); - font-weight: 500; -} - -.skills-overview-howto { - padding: var(--spacing-lg) var(--spacing-lg); - background: var(--color-cream); - border: 1px solid var(--color-mist); - border-radius: 10px; - margin-bottom: clamp(2.5rem, 5vw, 4rem); -} - -.skills-overview-howto-title { - font-family: var(--font-display); - font-size: 1.25rem; - font-style: italic; - font-weight: 500; - color: var(--color-ink); - margin-bottom: var(--spacing-sm); -} - -.skills-overview-howto p { - font-size: 0.9375rem; - line-height: 1.7; - color: var(--color-charcoal); +.docs-overview-header .sub-page-lede { max-width: 60ch; } -.skills-overview-howto a { - color: var(--color-ink); - font-family: var(--font-mono); - font-size: 0.875em; - font-weight: 500; - text-decoration: none; - border-bottom: 1px solid var(--color-accent); - transition: color var(--duration-fast) var(--ease-out); +/* Home command hero — the /impeccable root card that sits above the + category sections. It gets special treatment because it's the entry + point, not just another command in a category. */ +.docs-home-card { + display: grid; + grid-template-columns: 1.1fr 1fr; + gap: clamp(1.5rem, 3vw, 2.5rem); + padding: clamp(1.5rem, 3vw, 2rem); + background: #ffffff; + border: 1px solid var(--color-mist); + border-radius: 12px; + margin-bottom: clamp(2.5rem, 5vw, 3.5rem); } -.skills-overview-howto a:hover { +.docs-home-card-identity { + min-width: 0; +} + +.docs-home-card-eyebrow { + display: inline-block; + font-family: var(--font-body); + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--color-accent); + margin-bottom: var(--spacing-sm); +} + +.docs-home-card-title { + font-family: var(--font-display); + font-size: clamp(2rem, 4vw, 2.75rem); + font-weight: 500; + font-style: italic; + color: var(--color-ink); + letter-spacing: -0.02em; + line-height: 1; + margin: 0 0 var(--spacing-sm) 0; +} + +.docs-home-card-tagline { + font-family: var(--font-body); + font-size: 1rem; + font-weight: 500; + color: var(--color-ink); + line-height: 1.5; + margin: 0 0 var(--spacing-md) 0; + max-width: 32ch; +} + +.docs-home-card-desc { + font-size: 0.9375rem; + line-height: 1.6; + color: var(--color-ink); + max-width: 36ch; + margin: 0; +} + +.docs-home-card-desc code { + font-family: var(--font-mono); + font-size: 0.8125rem; + font-weight: 500; + color: var(--color-ink); + background: var(--color-accent-dim); + padding: 1px 6px; + border-radius: 3px; +} + +.docs-home-card-modes { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +.docs-home-card-modes a { + display: block; + text-decoration: none; + padding-left: 10px; + border-left: 2px solid transparent; + transition: + border-color var(--duration-fast) var(--ease-out, ease), + color var(--duration-fast) var(--ease-out, ease); +} + +.docs-home-card-modes a:hover { + border-left-color: var(--color-accent); +} + +.docs-home-card-modes a:hover .docs-home-mode-label { color: var(--color-accent); } -.skills-overview-categories { +.docs-home-mode-label { + display: block; + font-family: var(--font-mono); + font-size: 0.875rem; + font-weight: 600; + color: var(--color-ink); + margin-bottom: 2px; +} + +.docs-home-mode-slash { + color: var(--color-accent); + font-weight: 400; +} + +.docs-home-mode-hint { + display: block; + font-family: var(--font-body); + font-size: 0.8125rem; + color: var(--color-charcoal); + line-height: 1.4; +} + +/* Category sections with rich command cards */ +.docs-categories { display: flex; flex-direction: column; - gap: clamp(2rem, 4vw, 3rem); + gap: clamp(2.5rem, 5vw, 3.5rem); } -.skills-overview-category { - padding-bottom: clamp(2rem, 4vw, 2.5rem); - border-bottom: 1px solid var(--color-mist); -} - -.skills-overview-category:last-child { - border-bottom: none; -} - -.skills-overview-category-meta { +.docs-category-header { display: flex; align-items: baseline; justify-content: space-between; gap: var(--spacing-md); - margin-bottom: 6px; + margin-bottom: var(--spacing-md); + padding-bottom: var(--spacing-sm); + border-bottom: 1px solid var(--color-mist); } -.skills-overview-category-title { +.docs-category-title { font-family: var(--font-display); font-size: clamp(1.5rem, 3vw, 2rem); font-weight: 500; font-style: italic; color: var(--color-ink); letter-spacing: -0.01em; + line-height: 1.1; + margin: 0 0 4px 0; } -.skills-overview-category-count { +.docs-category-desc { + font-size: 0.875rem; + line-height: 1.5; + color: var(--color-charcoal); + max-width: 58ch; + margin: 0; +} + +.docs-category-count { font-family: var(--font-mono); font-size: 0.6875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.12em; color: var(--color-ash); + white-space: nowrap; + flex-shrink: 0; } -.skills-overview-category-desc { - font-size: 0.9375rem; - line-height: 1.7; - color: var(--color-charcoal); - max-width: 58ch; - margin-bottom: var(--spacing-md); -} - -.skills-overview-chips { +/* Dense two-column rows for each command: name on the left, description + + relationship on the right. Mirrors the original cheatsheet density. */ +.docs-category-rows { display: flex; - flex-wrap: wrap; - gap: 8px; + flex-direction: column; } -.skills-overview-chip { - display: inline-flex; - align-items: center; - padding: 6px 12px; +.command-row { + display: grid; + grid-template-columns: minmax(11rem, 13rem) 1fr; + gap: var(--spacing-lg); + padding: var(--spacing-md) 0; + border-bottom: 1px solid var(--color-mist); + align-items: baseline; +} + +.command-row:last-child { + border-bottom: none; +} + +.command-row-name { font-family: var(--font-mono); - font-size: 0.8125rem; - font-weight: 500; + font-size: 0.875rem; + font-weight: 600; color: var(--color-ink); - background: var(--color-paper); - border: 1px solid var(--color-mist); - border-radius: 99px; - text-decoration: none; - transition: border-color var(--duration-fast) var(--ease-out), - color var(--duration-fast) var(--ease-out), - background var(--duration-fast) var(--ease-out); + line-height: 1.4; + min-width: 0; } -.skills-overview-chip:hover { +.command-row-name a { + color: inherit; + text-decoration: none; + border-bottom: 1px solid transparent; + transition: border-color var(--duration-fast) var(--ease-out, ease); +} + +.command-row-name a:hover { + border-bottom-color: var(--color-accent); +} + +.command-row-namespace { + color: var(--color-ash); + font-weight: 400; +} + +.command-row-beta { + display: inline-block; + font-family: var(--font-mono); + font-size: 0.5625rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-accent); + border: 1px solid var(--color-accent); + border-radius: 3px; + padding: 1px 5px; + vertical-align: 1px; + margin-left: 4px; +} + +.command-row-info { + min-width: 0; +} + +.command-row-desc { + font-family: var(--font-body); + font-size: 0.875rem; + line-height: 1.55; + color: var(--color-ink); + margin: 0; +} + +.command-row-rel { + font-family: var(--font-body); + font-size: 0.75rem; + line-height: 1.5; + color: var(--color-ash); + margin-top: 4px; +} + +.command-row-rel a { + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--color-charcoal); + text-decoration: none; + border-bottom: 1px solid var(--color-mist); + transition: color var(--duration-fast) var(--ease-out, ease), + border-color var(--duration-fast) var(--ease-out, ease); +} + +.command-row-rel a:hover { color: var(--color-accent); border-color: var(--color-accent); - background: var(--color-cream); +} + +/* Narrow viewport: stack name above info */ +@media (max-width: 820px) { + .docs-home-card { + grid-template-columns: 1fr; + } + + .command-row { + grid-template-columns: 1fr; + gap: 4px; + } } /* ============================================ @@ -751,27 +904,135 @@ main#main { align-items: baseline; justify-content: space-between; gap: var(--spacing-md); - padding-bottom: var(--spacing-sm); + padding-bottom: 10px; border-bottom: 1px solid var(--color-mist); - margin-bottom: var(--spacing-lg); + margin-bottom: var(--spacing-md); } +/* Catalog subsections sit under the "The catalog" h2 on /slop, so they + must read clearly as h3 — smaller, uppercase mono label instead of + display italic, to avoid competing with the parent section heading. */ .anti-patterns-section-title { - font-family: var(--font-display); - font-size: clamp(1.75rem, 3vw, 2.25rem); - font-weight: 500; - font-style: italic; + font-family: var(--font-mono); + font-size: 0.8125rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.12em; color: var(--color-ink); - letter-spacing: -0.01em; } .anti-patterns-section-count { font-family: var(--font-mono); font-size: 0.6875rem; + font-weight: 500; + letter-spacing: 0.08em; + color: var(--color-ash); +} + +/* ============================================ + SLOP PAGE (/slop) + Merger of the former /anti-patterns + /visual-mode. + Reuses .anti-patterns-* for the catalog and .visual-mode-* + for the demo / gallery / methods blocks; the rules below wire + the four numbered top-level sections and the nested sidebar. + ============================================ */ + +.slop-content { width: 100%; } + +.slop-header { + max-width: 720px; +} + +.slop-section { + margin-top: clamp(3rem, 6vw, 4.5rem); + scroll-margin-top: 80px; +} + +.slop-section-heading { + font-family: var(--font-display); + font-size: clamp(2rem, 3.5vw, 2.75rem); + font-weight: 500; + font-style: italic; + color: var(--color-ink); + letter-spacing: -0.015em; + line-height: 1.15; + margin-bottom: var(--spacing-lg); + padding-bottom: var(--spacing-sm); + border-bottom: 1px solid var(--color-mist); + display: flex; + align-items: baseline; + gap: var(--spacing-md); +} + +.slop-section-num { + font-family: var(--font-mono); + font-size: 0.75rem; font-weight: 600; - text-transform: uppercase; + font-style: normal; letter-spacing: 0.12em; color: var(--color-ash); + text-transform: uppercase; + flex-shrink: 0; +} + +.slop-catalog-header { + margin-bottom: var(--spacing-lg); +} + +.slop-catalog-lede { + font-size: 0.9375rem; + line-height: 1.7; + color: var(--color-charcoal); + max-width: 72ch; + margin-top: var(--spacing-sm); +} + +.slop-catalog-lede strong { + color: var(--color-ink); + font-weight: 600; +} + +.slop-catalog-lede a { + color: var(--color-ink); + text-decoration: none; + border-bottom: 1px solid var(--color-accent); + font-family: var(--font-mono); + font-size: 0.875em; + font-weight: 500; +} + +.slop-catalog-lede a:hover { + color: var(--color-accent); +} + +/* Sidebar: nested catalog-section list under "The catalog". */ +.slop-sidebar-sublist { + list-style: none; + margin: 6px 0 4px 0; + padding: 0 0 0 var(--spacing-md); + border-left: 1px solid var(--color-mist); +} + +.slop-sidebar-sublist li { + margin: 0; +} + +.slop-sidebar-sublist a { + display: flex !important; + align-items: baseline; + justify-content: space-between; + gap: var(--spacing-sm); + font-size: 0.8125rem; + color: var(--color-ash); + padding: 4px 0; +} + +.slop-sidebar-sublist a:hover { + color: var(--color-ink); +} + +.slop-sidebar-sublist .anti-patterns-sidebar-count { + font-size: 0.625rem; } .rule-card-grid { @@ -1122,24 +1383,6 @@ main#main { VISUAL MODE PAGE ============================================ */ -.visual-mode-page-body main#main { - /* Reset the docs-layout override; this page uses its own max-width. */ - max-width: none; - margin: 0; - padding: 0; -} - -.visual-mode-page { - max-width: 1100px; - margin: 0 auto; - padding: clamp(2rem, 4vw, 3.5rem) clamp(var(--spacing-md), 4vw, var(--spacing-2xl)) clamp(4rem, 8vw, 6rem); -} - -.visual-mode-page-header { - max-width: 720px; - margin-bottom: clamp(2.5rem, 5vw, 3.5rem); -} - .visual-mode-demo-wrap { margin-bottom: clamp(3rem, 6vw, 4.5rem); } @@ -1152,12 +1395,11 @@ main#main { text-align: center; } -/* Mac-window chrome around the iframe. Scoped to .visual-mode-page so - the sub-page's preview styles don't leak onto the homepage, which - carries its own .visual-mode-preview rules in main.css. The auto - margins below would disable grid-item stretch on the homepage and - collapse the preview to the iframe's 300px intrinsic width. */ -.visual-mode-page .visual-mode-preview { +/* Mac-window chrome around the iframe. Lives inside sub-pages.css, which + is not loaded on the homepage — the homepage has its own copy in + main.css. The max-width + auto margins would disable grid stretch on + the homepage and collapse the preview to the iframe's intrinsic width. */ +.visual-mode-preview { border-radius: 10px; overflow: hidden; border: 1px solid var(--color-mist); @@ -1166,7 +1408,7 @@ main#main { margin: 0 auto; } -.visual-mode-page .visual-mode-preview-header { +.visual-mode-preview-header { display: flex; align-items: center; gap: 6px; @@ -1175,18 +1417,18 @@ main#main { border-bottom: 1px solid var(--color-mist); } -.visual-mode-page .visual-mode-preview-dot { +.visual-mode-preview-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } -.visual-mode-page .visual-mode-preview-dot.red { background: #ff5f56; } -.visual-mode-page .visual-mode-preview-dot.yellow { background: #ffbd2e; } -.visual-mode-page .visual-mode-preview-dot.green { background: #27c93f; } +.visual-mode-preview-dot.red { background: #ff5f56; } +.visual-mode-preview-dot.yellow { background: #ffbd2e; } +.visual-mode-preview-dot.green { background: #27c93f; } -.visual-mode-page .visual-mode-preview-title { +.visual-mode-preview-title { margin-left: auto; font-family: var(--font-mono); font-size: 0.75rem; @@ -1201,21 +1443,8 @@ main#main { background: white; } -/* Three invocation methods. */ -.visual-mode-methods { - margin-bottom: clamp(3rem, 6vw, 4.5rem); -} - -.visual-mode-methods-title { - font-family: var(--font-display); - font-size: clamp(1.75rem, 3vw, 2.25rem); - font-weight: 500; - font-style: italic; - color: var(--color-ink); - letter-spacing: -0.01em; - margin-bottom: var(--spacing-lg); -} - +/* Three invocation methods grid. Section wrapper margins come from + .slop-section; this block only controls the grid of cards. */ .visual-mode-methods-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); @@ -1300,26 +1529,18 @@ main#main { color: var(--color-accent); } -/* Gallery (specimens) section */ +/* Gallery (specimens) section header. Title comes from .slop-section-heading; + this block just scopes the lede + spacing under the heading. */ .visual-mode-gallery-header { margin-bottom: var(--spacing-lg); } -.visual-mode-gallery-title { - font-family: var(--font-display); - font-size: clamp(1.75rem, 3vw, 2.25rem); - font-weight: 500; - font-style: italic; - color: var(--color-ink); - letter-spacing: -0.01em; - margin-bottom: var(--spacing-sm); -} - .visual-mode-gallery-lede { font-size: 0.9375rem; line-height: 1.6; color: var(--color-charcoal); max-width: 60ch; + margin-top: var(--spacing-sm); } /* ============================================ @@ -1686,6 +1907,24 @@ main#main { font-weight: 300; } +/* Sub-commands: show "/impeccable" as a smaller label above the command name + so it stacks and the command name stays at full display size. */ +.skill-detail-title-namespace { + display: block; + font-family: var(--font-body); + font-size: 0.9375rem; + font-weight: 500; + color: var(--color-ash); + letter-spacing: 0.04em; + margin-bottom: 0.4em; + line-height: 1; +} + +.skill-detail-title-namespace .skill-detail-title-slash { + color: var(--color-accent); + font-weight: 400; +} + .skill-detail-tagline { font-family: var(--font-body); font-size: clamp(1rem, 1.4vw, 1.125rem); diff --git a/public/css/workflow.css b/public/css/workflow.css index 8dfa984dd..a81706c07 100644 --- a/public/css/workflow.css +++ b/public/css/workflow.css @@ -181,6 +181,24 @@ font-weight: 300; } +/* Sub-commands: stack the /impeccable namespace as a smaller label above the + command name so the command name stays at full display size and never + clips (e.g., "overdrive" is long). */ +.spread-command-name .spread-namespace { + display: block; + font-family: var(--font-body); + font-size: 0.875rem; + font-weight: 500; + color: var(--color-ash); + letter-spacing: 0.04em; + margin-bottom: 0.4em; +} + +.spread-command-name .spread-namespace .spread-slash { + color: var(--spread-accent); + font-weight: 400; +} + .spread-command-name .beta-badge { font-family: var(--font-body); font-size: 0.55rem; @@ -434,6 +452,14 @@ margin-left: 6px; } +.section-title .beta-badge { + font-size: 0.6875rem; + padding: 2px 7px; + border-radius: 4px; + vertical-align: 0.35em; + margin-left: 10px; +} + .manual-cmd-desc { font-size: 0.875rem; line-height: 1.5; @@ -1143,8 +1169,9 @@ margin: 0; padding-left: var(--spacing-md); color: var(--color-charcoal); - line-height: 1.7; - max-width: 70ch; + font-size: 0.9375rem; + line-height: 1.65; + max-width: 72ch; } .changelog-items li { @@ -1228,12 +1255,10 @@ font-size: 1.125rem; font-weight: 500; color: var(--color-ink); - padding: var(--spacing-md) 0; + padding: var(--spacing-md) calc(var(--spacing-md) + 1.5rem) var(--spacing-md) 0; cursor: pointer; list-style: none; - display: flex; - align-items: center; - justify-content: space-between; + position: relative; transition: color 0.2s ease; } @@ -1248,10 +1273,14 @@ font-weight: 300; color: var(--color-accent); transition: transform 0.3s var(--ease-out); + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); } .faq-item[open] .faq-question::after { - transform: rotate(45deg); + transform: translateY(-50%) rotate(45deg); } .faq-question:hover { @@ -1345,3 +1374,2044 @@ transform: translateY(0); } } + +/* ============================================ + WHY IMPECCABLE — tabbed feature loop + ============================================ */ + +.why-section { + padding: var(--spacing-2xl) 0; + border-top: 1px solid var(--color-mist); +} + +.why-layout { + display: grid; + grid-template-columns: minmax(240px, 300px) 1fr; + gap: var(--spacing-xl); + align-items: start; +} + +.why-left { + display: flex; + flex-direction: column; + gap: var(--spacing-lg); +} + +.why-tabs { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0; +} + +.why-tabs li + li { + border-top: 1px solid var(--color-mist); +} + +.why-tab { + display: grid; + grid-template-columns: 40px 1fr; + gap: var(--spacing-sm); + align-items: baseline; + width: 100%; + padding: var(--spacing-sm) var(--spacing-sm) var(--spacing-sm) 0; + background: transparent; + border: 0; + border-left: 2px solid transparent; + padding-left: var(--spacing-sm); + cursor: pointer; + text-align: left; + font-family: var(--font-body); + color: var(--color-ash); + transition: color 180ms var(--ease-out), border-color 180ms var(--ease-out), background 180ms var(--ease-out); +} + +.why-tab-num { + font-family: var(--font-mono); + font-size: 0.6875rem; + letter-spacing: 0.18em; + color: var(--color-ash); + transition: color 180ms var(--ease-out); +} + +.why-tab-label { + font-family: var(--font-body); + font-size: 0.9375rem; + font-weight: 500; + line-height: 1.3; +} + +.why-tab:hover { + color: var(--color-ink); +} + +.why-tab:hover .why-tab-num { + color: var(--color-charcoal); +} + +.why-tab.is-active { + color: var(--color-ink); +} + +.why-tab.is-active .why-tab-num { + color: var(--color-accent); +} + +.why-tab:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +.why-panels { + position: relative; + min-height: 280px; + background: var(--color-paper); + border: 1px solid var(--color-mist); + border-radius: 10px; + padding: var(--spacing-xl); + box-shadow: 0 1px 2px oklch(0.2 0 0 / 0.04); + /* All panels stack in one grid cell so the outgoing panel can cross-fade + out while the incoming one fades in. Container auto-sizes to the + tallest panel, so switching between tabs doesn't jump. */ + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-template-areas: "stack"; +} + +.why-panel, +.why-panel[hidden] { + /* [hidden] attribute on initial markup would force display:none and skip + the crossfade; keep the element rendered so opacity/transform can run. */ + display: block; + grid-area: stack; + min-width: 0; + opacity: 0; + visibility: hidden; + transform: translateY(8px); + transition: + opacity 650ms var(--ease-out), + transform 800ms var(--ease-out), + visibility 0s linear 650ms; + pointer-events: none; +} + +.why-panel-title, +.why-panel-body, +.why-panel-meta { + max-width: 60ch; +} + +.why-panel.is-active { + opacity: 1; + visibility: visible; + transform: translateY(0); + pointer-events: auto; + transition: + opacity 650ms var(--ease-out), + transform 800ms var(--ease-out), + visibility 0s linear 0s; +} + +.why-panel-title { + font-family: var(--font-display); + font-style: italic; + font-weight: 400; + font-size: clamp(1.75rem, 3vw, 2.5rem); + line-height: 1.1; + color: var(--color-ink); + margin: 0 0 var(--spacing-md); + letter-spacing: -0.01em; +} + +.why-panel-body { + font-family: var(--font-body); + font-size: 1.0625rem; + line-height: 1.65; + color: var(--color-charcoal); + margin: 0 0 var(--spacing-md); +} + +.why-panel-body em { + font-style: italic; + color: var(--color-ink); + font-weight: 500; +} + +.why-panel-body a { + color: var(--color-ink); + text-decoration: underline; + text-underline-offset: 3px; + text-decoration-color: var(--color-accent); +} + +.why-panel-body a:hover { + color: var(--color-accent); +} + +.why-panel-body code { + font-family: var(--font-mono); + font-size: 0.875em; + background: var(--color-mist); + padding: 2px 6px; + border-radius: 3px; + color: var(--color-ink); +} + +.why-panel-meta { + font-family: var(--font-mono); + font-size: 0.75rem; + letter-spacing: 0.05em; + color: var(--color-ash); + margin: var(--spacing-md) 0 0; +} + +.why-panel-meta code { + font-family: var(--font-mono); + font-size: 0.875em; + color: var(--color-charcoal); +} + +.why-panel-meta a { + color: var(--color-ink); + text-decoration: underline; + text-underline-offset: 3px; + text-decoration-color: var(--color-accent); +} + +.why-panel-meta a:hover { color: var(--color-accent); } + +@media (prefers-reduced-motion: reduce) { + .why-panel, + .why-panel.is-active { + transition: none; + transform: none; + } +} + +@media (max-width: 900px) { + .why-layout { + grid-template-columns: minmax(0, 1fr); + gap: var(--spacing-md); + } + + /* Sticky horizontal tab strip under the 54px site header */ + .why-left { + position: sticky; + top: 54px; + z-index: 10; + min-width: 0; + padding: var(--spacing-xs) 0; + background: oklch(96% 0.005 350 / 0.92); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border-bottom: 1px solid var(--color-mist); + gap: 0; + /* Horizontal edge fade signals scrollable content */ + mask-image: linear-gradient(to right, transparent, black 16px, black calc(100% - 24px), transparent); + -webkit-mask-image: linear-gradient(to right, transparent, black 16px, black calc(100% - 24px), transparent); + } + + .why-tabs { + flex-direction: row; + flex-wrap: nowrap; + gap: 4px; + min-width: 0; + width: 100%; + overflow-x: auto; + scrollbar-width: none; + -webkit-overflow-scrolling: touch; + scroll-snap-type: x proximity; + padding: 0 var(--spacing-sm); + } + + .why-tabs::-webkit-scrollbar { + display: none; + } + + .why-tabs li + li { + border-top: 0; + } + + .why-tab { + display: inline-flex; + grid-template-columns: none; + align-items: center; + gap: 6px; + width: auto; + padding: 7px 12px; + border: 0; + border-radius: 100px; + scroll-snap-align: center; + white-space: nowrap; + } + + .why-tab-label { + font-size: 0.8125rem; + font-weight: 500; + } + + .why-tab-num { + font-size: 0.5625rem; + letter-spacing: 0.1em; + } + + .why-tab.is-active { + background: var(--color-ink); + color: var(--color-paper); + } + + .why-tab.is-active .why-tab-label, + .why-tab.is-active .why-tab-num { + color: var(--color-paper); + } + + /* Vertical progress stripe is meaningless on a horizontal strip */ + .why-tab-progress { + display: none; + } + + .why-panels { + padding: var(--spacing-md); + min-height: auto; + border-radius: 8px; + } +} + +/* ============================================ + WHY — per-panel visuals + auto-rotation + ============================================ */ + +.why-tab-progress { + position: absolute; + left: -2px; + top: 0; + bottom: 0; + width: 2px; + background: var(--color-accent); + transform-origin: top; + transform: scaleY(0); + pointer-events: none; + opacity: 0; + transition: opacity 220ms var(--ease-out); +} + +.why-tab { position: relative; } + +/* Active tab always shows the accent column. When cycling, the column + fills from top to bottom over the cycle window. When not cycling + (user paused or clicked), it stays full-height so the active state + is unambiguous. */ +.why-tab.is-active .why-tab-progress { + opacity: 1; + transform: scaleY(1); +} + +.why-tab.is-active.is-cycling .why-tab-progress { + animation: whyTabProgress var(--why-cycle-ms, 7000ms) linear forwards; +} + +@keyframes whyTabProgress { + from { transform: scaleY(0); } + to { transform: scaleY(1); } +} + +@media (prefers-reduced-motion: reduce) { + .why-tab.is-active.is-cycling .why-tab-progress { + animation: none; + transform: scaleY(1); + } +} + +/* Visual frame — common base */ +.why-visual { + background: transparent; + border: 0; + padding: 0; + margin-bottom: var(--spacing-md); + min-height: 0; + display: flex; + align-items: stretch; + position: relative; + overflow: hidden; +} + +/* ─ Panel 01: Generic vs PRODUCT.md ─ */ +.why-visual--compare { + gap: var(--spacing-md); +} +.why-compare-card { + flex: 1; + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; +} +.why-compare-label { + font-family: var(--font-mono); + font-size: 0.625rem; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--color-ash); +} +.why-slop-card { + flex: 1; + background: linear-gradient(135deg, oklch(35% 0.14 280), oklch(55% 0.18 220)); + border-radius: 10px; + padding: 14px; + color: white; + display: flex; + flex-direction: column; + gap: 6px; + box-shadow: 0 20px 40px oklch(40% 0.14 270 / 0.25); +} +.why-slop-pill { + font-family: 'Inter', system-ui, sans-serif; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.12em; + background: rgba(255, 255, 255, 0.2); + padding: 2px 6px; + border-radius: 999px; + align-self: flex-start; +} +.why-slop-title { + font-family: 'Inter', system-ui, sans-serif; + font-size: 14px; + font-weight: 700; + background: linear-gradient(135deg, #fff, #c4b5fd); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + line-height: 1.1; +} +.why-slop-line { + height: 4px; + background: rgba(255, 255, 255, 0.15); + border-radius: 2px; +} +.why-slop-line--short { width: 60%; } +.why-slop-cta { + font-family: 'Inter', system-ui, sans-serif; + font-size: 10px; + font-weight: 600; + padding: 6px 10px; + background: rgba(255, 255, 255, 0.2); + border-radius: 6px; + align-self: flex-start; + margin-top: auto; +} +.why-impeccable-card { + flex: 1; + background: var(--color-cream); + border: 1px solid var(--color-mist); + padding: 14px; + display: flex; + flex-direction: column; + gap: 6px; +} +.why-impeccable-kicker { + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--color-accent); +} +.why-impeccable-title { + font-family: var(--font-display); + font-size: 22px; + line-height: 1.05; + color: var(--color-ink); +} +.why-impeccable-title em { + font-style: italic; + color: var(--color-accent); +} +.why-impeccable-line { + height: 4px; + background: var(--color-mist); + border-radius: 2px; +} +.why-impeccable-line--short { width: 55%; } +.why-impeccable-cta { + font-family: var(--font-body); + font-size: 11px; + font-weight: 500; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-ink); + border-bottom: 1.5px solid var(--color-ink); + align-self: flex-start; + margin-top: auto; + padding-bottom: 3px; +} + +/* ─ Panel 02: Brand vs Product registers ─ */ +.why-visual--registers { + gap: var(--spacing-md); +} +.why-register { + flex: 1; + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; +} +.why-register-label { + font-family: var(--font-mono); + font-size: 0.625rem; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--color-accent); +} +.why-register-mock { + flex: 1; + border-radius: 6px; + padding: 18px; + display: flex; + flex-direction: column; + justify-content: center; +} +.why-register-mock--brand { + background: var(--color-cream); + border: 1px solid var(--color-mist); + gap: 12px; +} +.why-brand-hero-mono { + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.24em; + text-transform: uppercase; + color: var(--color-ash); +} +.why-brand-hero-title { + font-family: var(--font-display); + font-size: 28px; + line-height: 1; + color: var(--color-ink); + letter-spacing: -0.02em; +} +.why-brand-hero-title em { + font-style: italic; + color: var(--color-accent); +} +.why-register-mock--product { + background: var(--color-paper); + border: 1px solid var(--color-mist); + padding: 0; + gap: 0; +} +.why-product-row { + display: flex; + justify-content: space-between; + align-items: baseline; + padding: 10px 14px; + border-bottom: 1px solid var(--color-mist); +} +.why-product-row:last-child { border-bottom: 0; } +.why-product-k { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-ash); +} +.why-product-v { + font-family: var(--font-body); + font-size: 14px; + font-weight: 500; + color: var(--color-ink); +} + +/* ─ Panel 03: Terminal output ─ */ +.why-visual--terminal, +.why-visual--ci { + padding: 0; + background: oklch(12% 0 0); + border-color: oklch(18% 0 0); +} +.why-terminal { + width: 100%; + display: flex; + flex-direction: column; +} +.why-terminal-header { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 14px; + border-bottom: 1px solid oklch(20% 0 0); + background: oklch(14% 0 0); + border-radius: 7px 7px 0 0; +} +.why-terminal-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: oklch(35% 0 0); +} +.why-terminal-title { + font-family: var(--font-mono); + font-size: 11px; + color: oklch(65% 0 0); + margin-left: 10px; +} +.why-terminal-body { + padding: 14px; + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.7; + color: oklch(80% 0 0); +} +.why-terminal-line { white-space: pre; } +.why-terminal-line--prompt { color: oklch(90% 0 0); } +.why-terminal-prompt { color: var(--color-accent); margin-right: 4px; } +.why-terminal-ok { color: oklch(75% 0.15 145); } +.why-terminal-arrow { color: var(--color-accent); } +.why-terminal-line--hint { + margin-top: 6px; + color: oklch(90% 0 0); +} + +/* ─ Panel 04: Harness grid ─ */ +.why-visual--harnesses { + flex-direction: column; + padding: 16px; + gap: 14px; +} +.why-prompt-bar { + font-family: var(--font-mono); + font-size: 12px; + color: var(--color-ink); + background: var(--color-cream); + border: 1px solid var(--color-mist); + border-radius: 4px; + padding: 8px 12px; + display: flex; + align-items: center; + gap: 2px; +} +.why-prompt-slash { + color: var(--color-accent); + margin-right: 4px; + font-weight: 500; +} +.why-prompt-caret { + display: inline-block; + width: 7px; + height: 14px; + background: var(--color-accent); + margin-left: 2px; + animation: whyCaret 1.1s steps(1) infinite; +} +@keyframes whyCaret { + 0%, 50% { opacity: 1; } + 50.01%, 100% { opacity: 0; } +} +.why-harness-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; + flex: 1; +} +.why-harness { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + padding: 10px 4px; + background: var(--color-cream); + border: 1px solid var(--color-mist); + border-radius: 6px; + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-charcoal); +} +.why-harness img { + width: 24px; + height: 24px; + object-fit: contain; + opacity: 0.85; +} + +/* ─ Panel 05: DESIGN.md file ─ */ +.why-visual--designmd { + padding: 0; +} +.why-designmd-file { + flex: 1; + display: flex; + flex-direction: column; + background: var(--color-paper); + border-radius: 7px; +} +.why-designmd-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 14px; + border-bottom: 1px solid var(--color-mist); + background: var(--color-cream); + border-radius: 7px 7px 0 0; +} +.why-designmd-filename { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 500; + color: var(--color-ink); +} +.why-designmd-badge { + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--color-accent); + background: var(--color-accent-dim); + border: 1px solid var(--color-accent-soft); + padding: 3px 8px; + border-radius: 999px; +} +.why-designmd-sections { + list-style: none; + padding: 10px 14px; + margin: 0; + display: flex; + flex-direction: column; + font-family: var(--font-mono); + font-size: 12px; + line-height: 2; + color: var(--color-ink); +} +.why-designmd-num { + color: var(--color-accent); + margin-right: 8px; + font-weight: 500; +} +.why-designmd-footer { + padding: 10px 14px; + border-top: 1px solid var(--color-mist); + font-family: var(--font-display); + font-style: italic; + font-size: 13px; + color: var(--color-charcoal); +} + +/* ─ Panel 06: CI output ─ */ +.why-ci-window { + width: 100%; + display: flex; + flex-direction: column; +} +.why-ci-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 14px; + border-bottom: 1px solid oklch(20% 0 0); + background: oklch(14% 0 0); + border-radius: 7px 7px 0 0; +} +.why-ci-branch { + font-family: var(--font-mono); + font-size: 11px; + color: oklch(75% 0 0); +} +.why-ci-status { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.1em; + text-transform: uppercase; + padding: 2px 8px; + border-radius: 999px; +} +.why-ci-status--fail { + color: oklch(78% 0.18 25); + background: oklch(30% 0.15 25 / 0.3); +} +.why-ci-body { + padding: 14px; + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.8; + color: oklch(80% 0 0); +} +.why-ci-line { color: oklch(85% 0 0); } +.why-ci-cmd { color: var(--color-accent); margin-right: 6px; } +.why-ci-issue { + display: flex; + align-items: baseline; + gap: 10px; + padding-left: 4px; + color: oklch(85% 0 0); +} +.why-ci-issue code { + background: transparent; + color: oklch(70% 0.12 220); + padding: 0; + font-size: 1em; +} +.why-ci-x { color: oklch(75% 0.18 25); } +.why-ci-summary { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid oklch(20% 0 0); + font-weight: 500; + color: oklch(85% 0 0); +} + +/* ─ Panel 07: Chrome extension ─ */ +.why-visual--extension { + padding: 0; + background: var(--color-cream); +} +.why-browser { + flex: 1; + display: flex; + flex-direction: column; + background: var(--color-paper); + border-radius: 7px; + overflow: hidden; +} +.why-browser-chrome { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 14px; + background: var(--color-mist); + border-bottom: 1px solid oklch(86% 0 0); +} +.why-browser-dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: oklch(75% 0 0); +} +.why-browser-url { + margin-left: 12px; + padding: 3px 10px; + background: var(--color-paper); + border-radius: 4px; + font-family: var(--font-mono); + font-size: 10px; + color: var(--color-charcoal); + flex: 1; +} +.why-browser-body { + flex: 1; + position: relative; + padding: 20px; + background: + linear-gradient(var(--color-cream), var(--color-cream)) padding-box, + repeating-linear-gradient(90deg, transparent 0 60px, var(--color-mist) 60px 61px); +} +.why-ext-popup { + position: absolute; + top: 16px; + right: 16px; + width: 180px; + background: var(--color-paper); + border: 1px solid var(--color-mist); + border-radius: 6px; + box-shadow: 0 8px 24px oklch(0% 0 0 / 0.12); + overflow: hidden; + z-index: 2; +} +.why-ext-popup-header { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-ink); + padding: 8px 12px; + background: var(--color-cream); + border-bottom: 1px solid var(--color-mist); +} +.why-ext-popup-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + font-size: 11px; + color: var(--color-charcoal); + border-bottom: 1px solid var(--color-mist); +} +.why-ext-popup-row:last-child { border-bottom: 0; } +.why-ext-sev { + color: var(--color-accent); + font-weight: 600; +} +.why-ext-overlay-a, +.why-ext-overlay-b { + position: absolute; + border: 2px solid var(--color-accent); + border-radius: 4px; + pointer-events: none; +} +.why-ext-overlay-a { + left: 20px; + top: 30px; + width: 42%; + height: 36px; +} +.why-ext-overlay-b { + left: 20px; + bottom: 24px; + width: 30%; + height: 30px; +} + +/* Mobile */ +@media (max-width: 700px) { + .why-visual { min-height: 200px; } + .why-harness-grid { grid-template-columns: repeat(3, 1fr); } + .why-compare-card, .why-register { gap: 8px; } +} + +/* ============================================ + WHY — updated visuals (panels 1, 3, 4, 5) + ============================================ */ + +/* ─ Panel 01: PRODUCT.md file view ─ */ +.why-visual--productmd { + padding: 0; + background: transparent; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: var(--spacing-lg); + align-items: start; +} +@media (max-width: 780px) { + .why-visual--productmd { + grid-template-columns: 1fr; + } +} +.why-productmd-file { + display: flex; + flex-direction: column; + background: var(--color-paper); + border: 1px solid var(--color-mist); + border-radius: 8px; + overflow: hidden; + min-width: 0; +} +.why-productmd-commands { + display: flex; + flex-direction: column; + gap: 8px; + padding-top: 4px; + min-width: 160px; +} +.why-productmd-commands-label { + font-family: var(--font-mono); + font-size: 9.5px; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--color-ash); + margin-bottom: 4px; +} +.why-productmd-commands code { + font-family: var(--font-mono); + font-size: 12px; + color: var(--color-ink); + background: var(--color-paper); + border: 1px solid var(--color-mist); + border-radius: 5px; + padding: 5px 9px; + white-space: nowrap; +} +.why-productmd-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + border-bottom: 1px solid var(--color-mist); + background: var(--color-cream); + border-radius: 7px 7px 0 0; +} +.why-productmd-filename { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 500; + color: var(--color-ink); +} +.why-productmd-status { + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--color-accent); +} +.why-productmd-body { + padding: 6px 14px; + display: flex; + flex-direction: column; +} +.why-productmd-field { + display: grid; + grid-template-columns: 110px 1fr; + gap: 16px; + align-items: baseline; + padding: 9px 0; + border-bottom: 1px dashed var(--color-mist); +} +.why-productmd-field:last-child { border-bottom: 0; } +.why-productmd-k { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-ash); +} +.why-productmd-v { + font-family: var(--font-body); + font-size: 13px; + line-height: 1.5; + color: var(--color-ink); +} +.why-productmd-footer { + padding: 10px 14px; + border-top: 1px solid var(--color-mist); + font-family: var(--font-display); + font-style: italic; + font-size: 13px; + line-height: 1.4; + color: var(--color-charcoal); + display: flex; + align-items: center; + gap: 8px; + background: var(--color-cream); + border-radius: 0 0 7px 7px; +} +.why-productmd-footer-dot { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--color-accent); + animation: whyPulse 2.4s ease-out infinite; + flex-shrink: 0; +} +@keyframes whyPulse { + 0%, 100% { transform: scale(1); opacity: 1; } + 50% { transform: scale(0.6); opacity: 0.5; } +} +@media (prefers-reduced-motion: reduce) { + .why-productmd-footer-dot { animation: none; } +} + +/* ─ Panel 03: path + meta additions ─ */ +.why-terminal-path { + color: oklch(88% 0.05 220); +} +.why-terminal-meta { + color: oklch(58% 0 0); + font-size: 0.9em; +} + +/* ─ Panel 04: waterfall vs unified surface ─ */ +.why-visual--flow { + flex-direction: column; + gap: 14px; + padding: 16px; +} +.why-flow-row { + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; + min-height: 0; +} +.why-flow-label { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--color-ash); +} +.why-flow-row--old .why-flow-label { color: var(--color-ash); } +.why-flow-row--new .why-flow-label { color: var(--color-accent); } + +.why-flow-steps { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + opacity: 0.5; +} +.why-flow-step { + font-family: var(--font-body); + font-size: 12px; + color: var(--color-charcoal); + padding: 4px 10px; + background: var(--color-paper); + border: 1px solid var(--color-mist); + border-radius: 3px; + text-decoration: line-through; + text-decoration-color: var(--color-ash); +} +.why-flow-arrow { + color: var(--color-ash); + font-size: 12px; +} +.why-flow-pair { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 12px; + align-items: stretch; +} +.why-flow-tile { + display: flex; + flex-direction: column; + gap: 6px; + padding: 14px 16px; + background: var(--color-paper); + border: 1px solid var(--color-mist); + border-radius: 6px; + min-width: 0; +} +.why-flow-tile--agent { + background: var(--color-ink); + border-color: var(--color-ink); + color: var(--color-paper); +} +.why-flow-tile--browser { + background: var(--color-cream); + border-color: var(--color-mist); +} +.why-flow-tile-kicker { + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.24em; + text-transform: uppercase; + color: var(--color-accent); +} +.why-flow-tile--agent .why-flow-tile-kicker { color: oklch(72% 0.15 350); } +.why-flow-tile-title { + font-family: var(--font-display); + font-style: italic; + font-size: 20px; + line-height: 1.1; + color: inherit; +} +.why-flow-tile--browser .why-flow-tile-title { color: var(--color-ink); } +.why-flow-tile-meta { + font-family: var(--font-body); + font-size: 12px; + line-height: 1.4; + color: inherit; + opacity: 0.75; +} +.why-flow-tile--browser .why-flow-tile-meta { color: var(--color-charcoal); opacity: 1; } +.why-flow-plus { + align-self: center; + font-family: var(--font-display); + font-size: 28px; + color: var(--color-accent); + font-weight: 300; + padding: 0 4px; +} +.why-flow-surface-foot { + font-family: var(--font-display); + font-style: italic; + font-size: 13px; + color: var(--color-charcoal); + padding: 0 4px; + margin-top: 6px; +} + +/* ─ Panel 05: rich DESIGN.md tiles ─ */ +.why-visual--designmd-v2 { + flex-direction: column; + gap: 12px; + padding: 12px; + background: var(--color-cream); +} +.why-dm-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 2px; +} +.why-dm-filename { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 500; + color: var(--color-ink); +} +.why-dm-badge { + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--color-accent); + background: var(--color-accent-dim); + border: 1px solid var(--color-accent-soft); + padding: 3px 8px; + border-radius: 999px; +} +.why-dm-grid { + display: grid; + grid-template-columns: 1.1fr 1fr 1fr; + grid-template-rows: 1fr 1fr; + gap: 8px; + flex: 1; + min-height: 0; +} +.why-dm-tile { + background: var(--color-paper); + border: 1px solid var(--color-mist); + border-radius: 6px; + padding: 10px; + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; + overflow: hidden; +} +.why-dm-tile-meta { + display: flex; + justify-content: space-between; + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-ash); +} +.why-dm-tile-meta span:first-child { color: var(--color-ink); } + +.why-dm-tile--color { grid-row: 1 / 3; } +.why-dm-color-hero { + flex: 1; + background: var(--c); + border-radius: 4px; + min-height: 40px; +} +.why-dm-color-ramp { + display: grid; + grid-template-columns: repeat(8, 1fr); + gap: 2px; + height: 16px; +} +.why-dm-color-ramp span { + border-radius: 2px; +} + +.why-dm-tile--type { + align-items: flex-start; +} +.why-dm-type-specimen { + font-family: var(--font-display); + font-style: italic; + font-size: 48px; + line-height: 0.9; + color: var(--color-ink); + flex: 1; + display: flex; + align-items: center; +} +.why-dm-type-specimen--sans { + font-family: var(--font-body); + font-style: normal; + font-weight: 400; +} +.why-dm-type-family { + font-family: var(--font-mono); + font-size: 10px; + color: var(--color-charcoal); +} + +.why-dm-tile--components { + justify-content: space-between; +} +.why-dm-comp-row { + display: flex; + gap: 6px; +} +.why-dm-comp-chip { + font-family: var(--font-body); + font-size: 10px; + font-weight: 500; + padding: 5px 10px; + background: var(--color-paper); + border: 1px solid var(--color-mist); + border-radius: 3px; + color: var(--color-ink); +} +.why-dm-comp-chip--primary { + background: var(--color-ink); + color: var(--color-paper); + border-color: var(--color-ink); +} +.why-dm-comp-input { + font-family: var(--font-body); + font-size: 11px; + padding: 6px 10px; + background: var(--color-paper); + border: 1px solid var(--color-mist); + border-radius: 4px; + color: var(--color-ash); +} + +.why-dm-foot { + font-family: var(--font-display); + font-style: italic; + font-size: 13px; + color: var(--color-charcoal); + padding: 0 2px; +} + +@media (max-width: 700px) { + .why-dm-grid { + grid-template-columns: 1fr 1fr; + } + .why-dm-tile--color { grid-row: 1 / 3; } + .why-flow-step { font-size: 11px; padding: 4px 8px; } +} + +/* ============================================ + WHY panel 04 — split diagonal (canvas vs. agent+browser) + Carbonized from live session afb9794d. + ============================================ */ + +.why-visual--v2 { + padding: 0; + gap: 0; + display: grid; + grid-template-columns: 1fr 1fr; + overflow: hidden; +} +.why-visual--v2 .v2-side { + padding: 16px; + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; +} +.why-visual--v2 .v2-side--old { + background: oklch(94% 0 0); + position: relative; +} +.why-visual--v2 .v2-side--old::after { + content: ""; + position: absolute; + inset: 0; + background: repeating-linear-gradient(0deg, transparent 0 24px, oklch(0% 0 0 / 0.04) 24px 25px); + pointer-events: none; +} +.why-visual--v2 .v2-side--new { + background: var(--color-cream); + border-left: 1px solid var(--color-mist); +} +.why-visual--v2 .v2-label { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--color-ash); + z-index: 1; +} +.why-visual--v2 .v2-side--new .v2-label { color: var(--color-accent); } +.why-visual--v2 .v2-canvas-chrome { + display: flex; + gap: 6px; + align-items: center; + padding-bottom: 8px; + border-bottom: 1px solid oklch(86% 0 0); + z-index: 1; +} +.why-visual--v2 .v2-canvas-tool { + font-family: var(--font-mono); + font-size: 9px; + color: oklch(45% 0 0); + background: var(--color-paper); + padding: 3px 7px; + border-radius: 3px; + border: 1px solid oklch(86% 0 0); +} +.why-visual--v2 .v2-canvas-art { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 6px; + z-index: 1; +} +.why-visual--v2 .v2-canvas-file { + font-family: var(--font-mono); + font-size: 10px; + color: oklch(40% 0 0); +} +.why-visual--v2 .v2-canvas-mock { + width: 100%; + max-width: 120px; + height: 60px; + background: var(--color-paper); + border: 1px solid oklch(82% 0 0); + border-radius: 4px; + box-shadow: 0 4px 16px oklch(0% 0 0 / 0.08); +} +.why-visual--v2 .v2-canvas-stamp { + font-family: var(--font-display); + font-style: italic; + font-size: 11px; + color: oklch(50% 0 0); +} +.why-visual--v2 .v2-new-surface { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + gap: 8px; +} +.why-visual--v2 .v2-new-line { + font-family: var(--font-mono); + font-size: 12px; + color: var(--color-ink); + background: var(--color-paper); + border: 1px solid var(--color-mist); + padding: 8px 12px; + border-radius: 4px; + display: flex; + align-items: center; + gap: 8px; +} +.why-visual--v2 .v2-new-line::before { + content: "/"; + color: var(--color-accent); + font-weight: 500; +} +.why-visual--v2 .v2-new-browser { + flex: 1; + background: var(--color-paper); + border: 1px solid var(--color-mist); + border-radius: 4px; + overflow: hidden; + display: flex; + flex-direction: column; + min-height: 60px; +} +.why-visual--v2 .v2-new-browser-dots { + display: flex; + gap: 4px; + padding: 6px 8px; + background: var(--color-mist); +} +.why-visual--v2 .v2-new-browser-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: oklch(78% 0 0); +} +.why-visual--v2 .v2-new-browser-frame { + flex: 1; + background: linear-gradient(0deg, var(--color-cream) 0, transparent 40%), var(--color-paper); + position: relative; +} +.why-visual--v2 .v2-new-browser-frame::after { + content: ""; + position: absolute; + left: 10px; + top: 10px; + width: 40%; + height: 8px; + border: 1.5px solid var(--color-accent); + border-radius: 3px; +} +.why-visual--v2 .v2-new-bar { + align-self: center; + background: var(--color-ink); + color: var(--color-paper); + padding: 4px 10px; + border-radius: 999px; + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.15em; + text-transform: uppercase; + margin-top: -2px; +} + +/* ─ Panel: Visualize, then build — two plates from GPT Image 2 ─ */ +.why-visual--vizfirst { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + align-items: start; + padding: 0; + overflow: visible; +} + +.why-vz-plate { + margin: 0; + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.why-vz-plate-kind { + font-family: var(--font-mono); + font-size: 0.625rem; + font-weight: 600; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--color-accent); +} + +.why-vz-plate-frame { + background: var(--color-cream); + border: 1px solid var(--color-mist); + overflow: hidden; + height: 240px; +} + +.why-vz-plate-frame img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + object-position: top center; +} + +@media (max-width: 720px) { + .why-visual--vizfirst { + gap: 12px; + } + .why-vz-plate-frame { + height: 240px; + } +} + + +/* ============================================ + LANGUAGE section — Palette / Periodic tabs + ============================================ */ + +.language-intro-row { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: var(--spacing-lg); +} + +.language-intro-row .section-lead { + margin: 0; + flex: 1; +} + +.language-view-toggle { + display: inline-flex; + gap: 2px; + padding: 3px; + background: var(--color-mist); + border-radius: 999px; + flex-shrink: 0; +} + +.language-view-tab { + font-family: var(--font-mono); + font-size: 0.6875rem; + letter-spacing: 0.14em; + text-transform: uppercase; + padding: 8px 18px; + background: transparent; + border: 0; + border-radius: 999px; + color: var(--color-ash); + cursor: pointer; + transition: background 200ms var(--ease-out), color 200ms var(--ease-out); +} + +.language-view-tab:hover { color: var(--color-ink); } + +.language-view-tab.is-active { + background: var(--color-paper); + color: var(--color-ink); + box-shadow: 0 1px 3px oklch(0% 0 0 / 0.08); +} + +.language-view-tab:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +.language-view { display: none; } +.language-view.is-active { display: block; } + +@media (max-width: 680px) { + .language-intro-row { + flex-direction: column; + align-items: flex-start; + } +} + +/* ============================================ + ANTIDOTE — two-column editorial layout + ============================================ */ + +.antidote-row--split { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(320px, 420px); + gap: var(--spacing-2xl); + align-items: start; +} + +.antidote-intro { + display: flex; + flex-direction: column; + gap: var(--spacing-lg); + max-width: 60ch; +} + +.antidote-intro .section-lead { + margin-bottom: 0; +} + +.antidote-stats { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + margin: 0; + padding: var(--spacing-md) 0 0; + border-top: 1px solid var(--color-mist); +} + +.antidote-stat { + display: grid; + grid-template-columns: 80px 1fr; + gap: var(--spacing-md); + align-items: baseline; + padding: var(--spacing-sm) 0; + border-bottom: 1px dashed var(--color-mist); +} + +.antidote-stat:last-child { border-bottom: 0; } + +.antidote-stat dt { + font-family: var(--font-display); + font-style: italic; + font-weight: 400; + font-size: 3rem; + line-height: 1; + color: var(--color-accent); + letter-spacing: -0.02em; +} + +.antidote-stat dd strong { + font-weight: 600; + color: var(--color-ink); +} + +.antidote-stat dd { + margin: 0; + font-family: var(--font-body); + font-size: 0.9375rem; + line-height: 1.5; + color: var(--color-charcoal); +} + +.antidote-stat dd code { + font-family: var(--font-mono); + font-size: 0.875em; + background: var(--color-mist); + padding: 1px 6px; + border-radius: 3px; + color: var(--color-ink); +} + +.antidote-catalog-link { + font-family: var(--font-body); + font-weight: 500; + font-size: 0.875rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--color-ink); + border-bottom: 2px solid var(--color-ink); + padding-bottom: 4px; + text-decoration: none; + align-self: flex-start; + transition: color 200ms var(--ease-out), border-color 200ms var(--ease-out); +} + +.antidote-catalog-link:hover { + color: var(--color-accent); + border-color: var(--color-accent); +} + +@media (max-width: 900px) { + .antidote-row--split { + grid-template-columns: 1fr; + gap: var(--spacing-xl); + } +} + +/* ============================================ + ANTIDOTE — visual-first wall of three + ============================================ */ + +.antidote-content .section-lead { + max-width: 54ch; + margin-bottom: var(--spacing-xl); +} + +.antidote-wall { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--spacing-lg); + margin-bottom: var(--spacing-xl); +} + +.antidote-pair { + display: grid; + grid-template-rows: 1fr 1fr auto; + gap: 12px; + text-decoration: none; + color: inherit; + transition: transform 260ms var(--ease-out); +} +.antidote-pair:hover { transform: translateY(-4px); } + +.antidote-pair-figure { + position: relative; + margin: 0; + min-height: 140px; + border-radius: 8px; + background: var(--color-paper); + border: 1px solid var(--color-mist); + overflow: hidden; + transition: box-shadow 260ms var(--ease-out); + display: flex; + align-items: center; + justify-content: center; + padding: 20px 18px; +} +.antidote-pair-figure--before { + background: oklch(98% 0 0); +} +.antidote-pair-figure--after { + background: var(--color-cream); +} +.ab-slot { + width: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; +} +.antidote-pair-figure img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center top; + display: block; +} +.antidote-pair-figure figcaption { + position: absolute; + top: 8px; + right: 8px; + z-index: 2; + font-family: var(--font-mono); + font-size: 9px; + font-weight: 600; + letter-spacing: 0.2em; + text-transform: uppercase; + padding: 2px 8px; + border-radius: 3px; + background: oklch(100% 0 0 / 0.9); + color: var(--color-ink); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); +} +.antidote-pair-figure--before figcaption { color: oklch(35% 0.15 25); background: oklch(98% 0.04 25 / 0.9); } +.antidote-pair-figure--after figcaption { color: oklch(45% 0.18 145); background: oklch(96% 0.04 145 / 0.9); } +.antidote-pair:hover .antidote-pair-figure { + box-shadow: 0 14px 28px oklch(0% 0 0 / 0.1); +} + +.antidote-pair-caption { + display: flex; + align-items: baseline; + gap: var(--spacing-sm); + padding: 0 2px; +} +.antidote-pair-num { + font-family: var(--font-mono); + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--color-accent); +} +.antidote-pair-name { + font-family: var(--font-display); + font-style: italic; + font-weight: 400; + font-size: 1.375rem; + line-height: 1.1; + color: var(--color-ink); + letter-spacing: -0.01em; +} + +/* After mocks — pure HTML/CSS impressions of what Impeccable steers toward */ +.ab-after { + position: absolute; + inset: 0; + padding: 12px; + display: flex; + flex-direction: column; +} + +/* After 01: editorial hero */ +.ab-after--editorial { + background: var(--color-cream); + gap: 10px; +} +.ab-after-nav { + display: flex; + align-items: center; + gap: 6px; + padding-bottom: 8px; + border-bottom: 1px solid var(--color-mist); +} +.ab-after-logo { + width: 12px; + height: 12px; + border-radius: 3px; + background: var(--color-ink); +} +.ab-after-link { + width: 22px; + height: 5px; + border-radius: 2px; + background: var(--color-mist); +} +.ab-after-cta { + margin-left: auto; + width: 48px; + height: 16px; + border-radius: 2px; + background: var(--color-ink); +} +.ab-after-hero { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; + justify-content: center; +} +.ab-after-kicker { + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--color-accent); +} +.ab-after-title { + font-family: var(--font-display); + font-weight: 400; + font-size: 20px; + line-height: 1.05; + color: var(--color-ink); + letter-spacing: -0.01em; + max-width: 10ch; +} +.ab-after-title em { + font-style: italic; + color: var(--color-accent); +} +.ab-after-line { + height: 5px; + border-radius: 2px; + background: var(--color-mist); +} +.ab-after-line--short { width: 60%; } +.ab-after-btn { + align-self: flex-start; + margin-top: 4px; + font-family: var(--font-body); + font-weight: 500; + font-size: 10px; + letter-spacing: 0.12em; + text-transform: uppercase; + padding: 7px 12px; + background: var(--color-ink); + color: var(--color-paper); + border-radius: 0; +} + +/* After 02: flat data table */ +.ab-after--data { + background: var(--color-paper); + gap: 0; + padding: 14px 14px 10px; +} +.ab-after-data-kicker { + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--color-ash); + margin-bottom: 10px; +} +.ab-after-data-row { + display: grid; + grid-template-columns: 1fr max-content max-content; + gap: 10px; + align-items: baseline; + padding: 8px 0; + border-bottom: 1px solid var(--color-mist); + font-family: var(--font-body); + font-size: 11px; +} +.ab-after-data-row:last-child { border-bottom: 0; } +.ab-after-data-k { color: var(--color-charcoal); } +.ab-after-data-v { + font-family: var(--font-display); + font-weight: 500; + font-size: 14px; + color: var(--color-ink); + letter-spacing: -0.01em; +} +.ab-after-data-d { + font-family: var(--font-mono); + font-size: 10px; + color: oklch(45% 0.18 145); +} + +/* After 03: one big honest insight */ +.ab-after--insight { + background: var(--color-cream); + padding: 18px 20px; + gap: 6px; + justify-content: center; +} +.ab-after-insight-kicker { + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.24em; + text-transform: uppercase; + color: var(--color-ash); +} +.ab-after-insight-num { + font-family: var(--font-display); + font-style: italic; + font-weight: 400; + font-size: 52px; + line-height: 1; + color: var(--color-ink); + letter-spacing: -0.03em; +} +.ab-after-insight-num span { + font-size: 0.5em; + font-style: normal; + color: var(--color-accent); + margin-left: 2px; + vertical-align: top; +} +.ab-after-insight-label { + font-family: var(--font-body); + font-size: 12px; + line-height: 1.4; + color: var(--color-charcoal); + max-width: 24ch; +} +.ab-after-insight-label em { + font-style: italic; + color: var(--color-ink); +} +.ab-after-spark { + width: 100%; + height: 28px; + margin-top: 6px; + color: var(--color-accent); + opacity: 0.8; +} + +.antidote-foot { + display: flex; + align-items: baseline; + gap: var(--spacing-lg); + padding-top: var(--spacing-md); + border-top: 1px solid var(--color-mist); + flex-wrap: wrap; +} + +.antidote-foot-meta { + flex: 1; + min-width: 0; + font-family: var(--font-body); + font-style: italic; + font-size: 0.875rem; + line-height: 1.55; + color: var(--color-charcoal); +} + +.antidote-foot .antidote-suggest-link { + font-size: 0.8125rem; + color: var(--color-ash); + text-decoration: none; + white-space: nowrap; +} +.antidote-foot .antidote-suggest-link:hover { + color: var(--color-accent); + text-decoration: underline; +} + +@media (max-width: 820px) { + .antidote-wall { + grid-template-columns: 1fr; + gap: var(--spacing-md); + } + .antidote-foot { + flex-direction: column; + align-items: flex-start; + gap: var(--spacing-sm); + } +} + +/* ============================================ + WHY — Anti-patterns, caught (tab 08 visual) + ============================================ */ + +.why-visual--antidote { + display: grid; + grid-template-columns: minmax(0, 1fr) 340px; + gap: var(--spacing-lg); + align-items: start; +} + +.why-visual--antidote .patterns-tabbed { + margin: 0; + min-width: 0; + width: 100%; +} + +.why-visual--antidote .gallery-stack-container { + flex: none; + width: 100%; + max-width: none; +} + +.gallery-stack-link { + display: inline-flex; + align-self: center; + margin-top: var(--spacing-sm); + font-family: var(--font-body); + font-size: 0.82rem; + color: var(--color-ink); + text-decoration: underline; + text-underline-offset: 3px; + text-decoration-color: var(--color-accent); + transition: color 0.12s ease; +} + +.gallery-stack-link:hover { color: var(--color-accent); } + +@media (max-width: 860px) { + .why-visual--antidote { grid-template-columns: 1fr; } +} + diff --git a/public/designing/index.html b/public/designing/index.html new file mode 100644 index 000000000..3c0a39ecb --- /dev/null +++ b/public/designing/index.html @@ -0,0 +1,569 @@ + + + + + + Designing with Impeccable + + + + + + + + + + + + + + +
+ +
+
+
+ The core loop +

Designing with Impeccable

+

Shipping a real interface is a loop. Four phases, each with one place to begin.

+
+ + +
+ +
+
+ 01 · Start +

From a blank file to a designed feature.

+

Three commands, one arc. /impeccable teach writes the brief, once per project. /impeccable shape drafts a reference you can look at. /impeccable craft codes toward what you can see. Words, then pictures, then code.

+ +
+ +
+
+ teach · in words +
+
+
+ PRODUCT.md + Written by teach +
+
+
+ Register + Product. Design serves the task. +
+
+ Users + SREs on call, reading fast, often in the dark. +
+
+ Voice + Calm, clinical, no hype. +
+
+ Anti-references + Purple gradients. Glassmorphism. Hype. +
+
+
+
+

Teach runs a short discovery interview about audience, register, voice, and anti-references. It writes PRODUCT.md and, if there's code to scan, a DESIGN.md. Every later command reads both files before generating.

+
+
+
+ +
+ shape + craft · in pictures +

Since image generation crossed the reference-quality threshold, shape drafts a brand toolkit you can review at a glance, and craft codes toward a hi-fi mock instead of a paragraph.

+ +
+
+
+ Auto-generated brand toolkit plate: identity lockups, colour palette, type specimens, icon system, and application mocks for a fictional AI design conference, rendered in warm earth tones. +
+
+ Shape +

Brand toolkit. Identity, palette, type, icon language, applications, social tiles, UI direction. One plate, reviewable at a glance. Approved decisions get written into DESIGN.md.

+
+
+ +
+
+ Auto-generated hi-fi landing-page mock: a long vertical editorial comp for a fictional Tokyo AI design conference, in warm earth tones with committed serif display type. +
+
+ Craft +

Hi-fi reference. The destination, before the first line of CSS. Craft codes toward a concrete image, not an abstract brief. That is the step change.

+
+
+
+ +

Plates generated by OpenAI GPT Image 2. Gemini Nano Banana Pro, Imagen 4 Ultra, and Grok Imagen work the same way, via Codex, Gemini CLI, and compatible harnesses.

+
+
+
+ +
+
+ 02 · Iterate +

Refine what's there.

+

Once something exists, you're iterating. There are two paths: specific commands for named dimensions, or Live Mode for visual exploration.

+
+ +
+
+
+ Command line +

When the edit has a name.

+

Type a command and let the skill encode a specific discipline. Best when you know the word: typography, layout, color, motion.

+ +
+ +
+ Live Mode +

When the edit is easier to point at.

+

Pick any element in the browser, draw, type, hit Go. Three production-quality variants. Accept one and it writes to source.

+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
When to reach for which
Fix something "off" that you can't name/impeccable live
Apply a specific discipline: type, layout, color, motion/typeset · /layout · /colorize · /animate
Explore three directions side by side/impeccable live
Ask "is this any good?"/impeccable critique
Bring a safe design to life, or tone a shouting one down/bolder · /quieter
+
+
+ +
+
+ 03 · Polish +

The pre-ship gauntlet.

+

Three commands in sequence before anything ships. They don't redesign; they find what still needs to change.

+
+ +
+
+
+ Pre-ship +
+ audit + clarify + harden +
+ 03 · 04 +
+
+
+

Score it.

+

Five dimensions scored 0 to 4: accessibility, performance, theming, responsive, anti-patterns. Findings tagged P0 to P3.

+
+
+

Rewrite the copy.

+

Labels, error messages, empty-state prose, microcopy. Tuned to the audience from PRODUCT.md.

+
+
+

Stress-test reality.

+

60-character names, German product titles, prices in the billions, 500s, offline. Production data is messy.

+
+
+
+
+
+ +
+
+ 04 · Maintain +

Design debt is real. Pay it down.

+

Features ship, drift happens. Two commands close the gap before it solidifies.

+
+ +
+
+
+
+ +
+
+ /impeccable extract +

Consolidate drift.

+

Find patterns used three or more times with the same intent. Propose tokens and primitives.

+
+
+ +
+
+ +
+
+ /impeccable document +

Re-capture the system.

+

Scans tokens, components, and rendered output. Writes a spec-compliant DESIGN.md.

+
+
+
+
+
+ +
+
+ Two lanes +

Brand, or product.

+

Two defaults with different vocabularies. Impeccable picks the lane from your task cue and PRODUCT.md before every command, so typeset, animate, colorize, and friends adjust their output to match. You rarely need to set it by hand.

+
+ +
+
+
+ Brand +

Design IS the product. Marketing, landing, editorial, long-form, portfolio.

+ +
+
+ Product +

Design serves the task. App UI, admin, dashboards, tools.

+ +
+
+ Read the brand-vs-product tutorial → +
+
+ +
+
+ Common mistakes +

What to avoid.

+

An anti-patterns list, for using the anti-patterns tool.

+
+ +
+
    +
  • + +
    + Running both Impeccable and Anthropic's frontend-design skill +

    Anthropic still promotes their skill in Claude Code, but it's been unmaintained and is now behind on recommended patterns. Run both and they collide on vocabulary, cancelling each other out. Pick one.

    +
    +
  • +
  • + +
    + Pinning every command +

    Pinning brings back /audit, /polish, /critique as shortcuts. Pin everything and you've re-exploded the / menu the v3.0 consolidation cleaned up. Pin the two or three you reach for daily.

    +
    +
  • +
  • + +
    + Skipping teach +

    Commands still run without PRODUCT.md and DESIGN.md. They default to generic SaaS patterns. The floor is meaningfully higher with context. Run teach once; every later command benefits.

    +
    +
  • +
  • + +
    + Treating it like a linter +

    Impeccable is an opinionated design partner, not a validator. It has a point of view. Push back with a reason and it'll work with you. Ignore the opinion without a reason and output gets worse, not better.

    +
    +
  • +
+
+
+ + +
+
+ + + diff --git a/public/index.html b/public/index.html index bc9012563..78a359b12 100644 --- a/public/index.html +++ b/public/index.html @@ -13,7 +13,7 @@ Impeccable: The missing upgrade to Anthropic's impeccable skill - + @@ -21,7 +21,7 @@ - + @@ -29,7 +29,7 @@ - + @@ -40,6 +40,7 @@ + @@ -57,13 +58,14 @@ @@ -73,8 +75,8 @@
@@ -642,19 +1089,35 @@
  • Claude Code plugin: Open /plugin, go to the Discover tab.
  • Manual ZIP: Download from above and extract to the project root.
  • -

    Your .impeccable.md context file is never overwritten.

    +

    Your PRODUCT.md and DESIGN.md context files are never overwritten.

    +
    + + +
    + I used to type /critique directly. How do I get that back? +
    +

    Pinning is built in. Run /impeccable pin critique and /critique becomes a standalone shortcut again, without reversing the consolidation.

    +

    Under the hood it writes a lightweight redirect skill that delegates to /impeccable critique, so updates to the parent skill flow through automatically. It works for every command.

    +

    Examples:

    +
      +
    • /impeccable pin polish/polish works again
    • +
    • /impeccable pin audit/audit works again
    • +
    • /impeccable pin live/live works again
    • +
    +

    To remove: /impeccable unpin critique. To see your current pins, check your harness skills directory (.claude/skills/, .cursor/skills/, etc.) for directories prefixed with i-.

    Commands or skills aren't appearing. What do I do?
    -

    For commands: Type / in your AI harness and look for commands like /audit, /polish, etc. If they don't appear, double-check the files are in the correct location.

    +

    For commands: Type /impeccable in your AI harness and look for commands like /impeccable audit, /impeccable polish, etc. If they don't appear, double-check the files are in the correct location.

    For skills: Skills are applied automatically when relevant. To verify, explicitly mention "use the impeccable skill" in your prompt. This forces the AI to acknowledge and apply it.

    Tool-specific setup:

    • Cursor: Requires Nightly channel + Agent Skills enabled in Settings → Rules
    • Gemini CLI: Requires @google/gemini-cli@preview + Skills enabled via /settings
    • +
    • Codex: Skills do not appear in the normal / command picker. Open /skills or type $. Repo installs live in .agents/skills/, user installs live in ~/.agents/skills/. Restart Codex if a new skill does not show up.
    @@ -686,10 +1149,34 @@
    + + + + + + + + + + + +
    -

    Work with me

    -

    Impeccable is built by Renaissance Geek. I work with enterprise teams on large-scale rollouts, custom integrations, and training for designers and developers. If you're a frontier lab, design tool company, or enterprise looking to raise the bar on AI-generated design, let's talk.

    +

    Work with me

    +

    Impeccable is built by Renaissance Geek. I work with enterprise teams on large-scale rollouts, custom integrations, and training for designers and developers. If you're a frontier lab, design tool company, or enterprise looking to raise the bar on AI-generated design, let's talk.

    + + + + + + + + + + + +
    Get in touch @@ -706,10 +1193,11 @@