# Utility digest - part 3/8 (scope: workspace/agents/opencode) ================ File: workspace/agents/opencode/engine/skills/design/SKILL.md ================ --- name: design description: "Comprehensive design skill: brand identity, design tokens, UI styling, logo generation (55 styles, Gemini AI), corporate identity program (50 deliverables, CIP mockups), HTML presentations (Chart.js), banner design (22 styles, social/ads/web/print), icon design (15 styles, SVG, Gemini 3.1 Pro), social photos (HTML→screenshot, multi-platform). Actions: design logo, create CIP, generate mockups, build slides, design banner, generate icon, create social photos, social media images, brand identity, design system. Platforms: Facebook, Twitter, LinkedIn, YouTube, Instagram, Pinterest, TikTok, Threads, Google Ads." argument-hint: "[design-type] [context]" license: MIT metadata: author: claudekit version: "2.1.0" --- # Design Unified design skill: brand, tokens, UI, logo, CIP, slides, banners, social photos, icons. ## When to Use - Brand identity, voice, assets - Design system tokens and specs - UI styling with shadcn/ui + Tailwind - Logo design and AI generation - Corporate identity program (CIP) deliverables - Presentations and pitch decks - Banner design for social media, ads, web, print - Social photos for Instagram, Facebook, LinkedIn, Twitter, Pinterest, TikTok ## Sub-skill Routing | Task | Sub-skill | Details | |------|-----------|---------| | Brand identity, voice, assets | `brand` | External skill | | Tokens, specs, CSS vars | `design-system` | External skill | | shadcn/ui, Tailwind, code | `ui-styling` | External skill | | Logo creation, AI generation | Logo (built-in) | `references/logo-design.md` | | CIP mockups, deliverables | CIP (built-in) | `references/cip-design.md` | | Presentations, pitch decks | Slides (built-in) | `references/slides.md` | | Banners, covers, headers | Banner (built-in) | `references/banner-sizes-and-styles.md` | | Social media images/photos | Social Photos (built-in) | `references/social-photos-design.md` | | SVG icons, icon sets | Icon (built-in) | `references/icon-design.md` | ## Logo Design (Built-in) 55+ styles, 30 color palettes, 25 industry guides. Gemini Nano Banana models. ### Logo: Generate Design Brief ```bash python3 ~/.claude/skills/design/scripts/logo/search.py "tech startup modern" --design-brief -p "BrandName" ``` ### Logo: Search Styles/Colors/Industries ```bash python3 ~/.claude/skills/design/scripts/logo/search.py "minimalist clean" --domain style python3 ~/.claude/skills/design/scripts/logo/search.py "tech professional" --domain color python3 ~/.claude/skills/design/scripts/logo/search.py "healthcare medical" --domain industry ``` ### Logo: Generate with AI **ALWAYS** generate output logo images with white background. ```bash python3 ~/.claude/skills/design/scripts/logo/generate.py --brand "TechFlow" --style minimalist --industry tech python3 ~/.claude/skills/design/scripts/logo/generate.py --prompt "coffee shop vintage badge" --style vintage ``` **IMPORTANT:** When scripts fail, try to fix them directly. After generation, **ALWAYS** ask user about HTML preview via `AskUserQuestion`. If yes, invoke `/ui-ux-pro-max` for gallery. ## CIP Design (Built-in) 50+ deliverables, 20 styles, 20 industries. Gemini Nano Banana (Flash/Pro). ### CIP: Generate Brief ```bash python3 ~/.claude/skills/design/scripts/cip/search.py "tech startup" --cip-brief -b "BrandName" ``` ### CIP: Search Domains ```bash python3 ~/.claude/skills/design/scripts/cip/search.py "business card letterhead" --domain deliverable python3 ~/.claude/skills/design/scripts/cip/search.py "luxury premium elegant" --domain style python3 ~/.claude/skills/design/scripts/cip/search.py "hospitality hotel" --domain industry python3 ~/.claude/skills/design/scripts/cip/search.py "office reception" --domain mockup ``` ### CIP: Generate Mockups ```bash # With logo (RECOMMENDED) python3 ~/.claude/skills/design/scripts/cip/generate.py --brand "TopGroup" --logo /path/to/logo.png --deliverable "business card" --industry "consulting" # Full CIP set python3 ~/.claude/skills/design/scripts/cip/generate.py --brand "TopGroup" --logo /path/to/logo.png --industry "consulting" --set # Pro model (4K text) python3 ~/.claude/skills/design/scripts/cip/generate.py --brand "TopGroup" --logo logo.png --deliverable "business card" --model pro # Without logo python3 ~/.claude/skills/design/scripts/cip/generate.py --brand "TechFlow" --deliverable "business card" --no-logo-prompt ``` Models: `flash` (default, `gemini-2.5-flash-image`), `pro` (`gemini-3-pro-image-preview`) ### CIP: Render HTML Presentation ```bash python3 ~/.claude/skills/design/scripts/cip/render-html.py --brand "TopGroup" --industry "consulting" --images /path/to/cip-output ``` **Tip:** If no logo exists, use Logo Design section above first. ## Slides (Built-in) Strategic HTML presentations with Chart.js, design tokens, copywriting formulas. Load `references/slides-create.md` for the creation workflow. ### Slides: Knowledge Base | Topic | File | |-------|------| | Creation Guide | `references/slides-create.md` | | Layout Patterns | `references/slides-layout-patterns.md` | | HTML Template | `references/slides-html-template.md` | | Copywriting | `references/slides-copywriting-formulas.md` | | Strategies | `references/slides-strategies.md` | ## Banner Design (Built-in) 22 art direction styles across social, ads, web, print. Uses `frontend-design`, `ai-artist`, `ai-multimodal`, `chrome-devtools` skills. Load `references/banner-sizes-and-styles.md` for complete sizes and styles reference. ### Banner: Workflow 1. **Gather requirements** via `AskUserQuestion` — purpose, platform, content, brand, style, quantity 2. **Research** — Activate `ui-ux-pro-max`, browse Pinterest for references 3. **Design** — Create HTML/CSS banner with `frontend-design`, generate visuals with `ai-artist`/`ai-multimodal` 4. **Export** — Screenshot to PNG at exact dimensions via `chrome-devtools` 5. **Present** — Show all options side-by-side, iterate on feedback ### Banner: Quick Size Reference | Platform | Type | Size (px) | |----------|------|-----------| | Facebook | Cover | 820 x 312 | | Twitter/X | Header | 1500 x 500 | | LinkedIn | Personal | 1584 x 396 | | YouTube | Channel art | 2560 x 1440 | | Instagram | Story | 1080 x 1920 | | Instagram | Post | 1080 x 1080 | | Google Ads | Med Rectangle | 300 x 250 | | Website | Hero | 1920 x 600-1080 | ### Banner: Top Art Styles | Style | Best For | |-------|----------| | Minimalist | SaaS, tech | | Bold Typography | Announcements | | Gradient | Modern brands | | Photo-Based | Lifestyle, e-com | | Geometric | Tech, fintech | | Glassmorphism | SaaS, apps | | Neon/Cyberpunk | Gaming, events | ### Banner: Design Rules - Safe zones: critical content in central 70-80% - One CTA per banner, bottom-right, min 44px height - Max 2 fonts, min 16px body, ≥32px headline - Text under 20% for ads (Meta penalizes) - Print: 300 DPI, CMYK, 3-5mm bleed ## Icon Design (Built-in) 15 styles, 12 categories. Gemini 3.1 Pro Preview generates SVG text output. ### Icon: Generate Single Icon ```bash python3 ~/.claude/skills/design/scripts/icon/generate.py --prompt "settings gear" --style outlined python3 ~/.claude/skills/design/scripts/icon/generate.py --prompt "shopping cart" --style filled --color "#6366F1" python3 ~/.claude/skills/design/scripts/icon/generate.py --name "dashboard" --category navigation --style duotone ``` ### Icon: Generate Batch Variations ```bash python3 ~/.claude/skills/design/scripts/icon/generate.py --prompt "cloud upload" --batch 4 --output-dir ./icons ``` ### Icon: Multi-size Export ```bash python3 ~/.claude/skills/design/scripts/icon/generate.py --prompt "user profile" --sizes "16,24,32,48" --output-dir ./icons ``` ### Icon: Top Styles | Style | Best For | |-------|----------| | outlined | UI interfaces, web apps | | filled | Mobile apps, nav bars | | duotone | Marketing, landing pages | | rounded | Friendly apps, health | | sharp | Tech, fintech, enterprise | | flat | Material design, Google-style | | gradient | Modern brands, SaaS | **Model:** `gemini-3.1-pro-preview` — text-only output (SVG is XML text). No image generation API needed. ## Social Photos (Built-in) Multi-platform social image design: HTML/CSS → screenshot export. Uses `ui-ux-pro-max`, `brand`, `design-system`, `chrome-devtools` skills. Load `references/social-photos-design.md` for sizes, templates, best practices. ### Social Photos: Workflow 1. **Orchestrate** — `project-management` skill for TODO tasks; parallel subagents for independent work 2. **Analyze** — Parse prompt: subject, platforms, style, brand context, content elements 3. **Ideate** — 3-5 concepts, present via `AskUserQuestion` 4. **Design** — `/ckm:brand` → `/ckm:design-system` → randomly invoke `/ck:ui-ux-pro-max` OR `/ck:frontend-design`; HTML per idea × size 5. **Export** — `chrome-devtools` or Playwright screenshot at exact px (2x deviceScaleFactor) 6. **Verify** — Use Chrome MCP or `chrome-devtools` skill to visually inspect exported designs; fix layout/styling issues and re-export 7. **Report** — Summary to `plans/reports/` with design decisions 8. **Organize** — Invoke `assets-organizing` skill to sort output files and reports ### Social Photos: Key Sizes | Platform | Size (px) | Platform | Size (px) | |----------|-----------|----------|-----------| | IG Post | 1080×1080 | FB Post | 1200×630 | | IG Story | 1080×1920 | X Post | 1200×675 | | IG Carousel | 1080×1350 | LinkedIn | 1200×627 | | YT Thumb | 1280×720 | Pinterest | 1000×1500 | ## Workflows ### Complete Brand Package 1. **Logo** → `scripts/logo/generate.py` → Generate logo variants 2. **CIP** → `scripts/cip/generate.py --logo ...` → Create deliverable mockups 3. **Presentation** → Load `references/slides-create.md` → Build pitch deck ### New Design System 1. **Brand** (brand skill) → Define colors, typography, voice 2. **Tokens** (design-system skill) → Create semantic token layers 3. **Implement** (ui-styling skill) → Configure Tailwind, shadcn/ui ## References | Topic | File | |-------|------| | Design Routing | `references/design-routing.md` | | Logo Design Guide | `references/logo-design.md` | | Logo Styles | `references/logo-style-guide.md` | | Logo Colors | `references/logo-color-psychology.md` | | Logo Prompts | `references/logo-prompt-engineering.md` | | CIP Design Guide | `references/cip-design.md` | | CIP Deliverables | `references/cip-deliverable-guide.md` | | CIP Styles | `references/cip-style-guide.md` | | CIP Prompts | `references/cip-prompt-engineering.md` | | Slides Create | `references/slides-create.md` | | Slides Layouts | `references/slides-layout-patterns.md` | | Slides Template | `references/slides-html-template.md` | | Slides Copy | `references/slides-copywriting-formulas.md` | | Slides Strategy | `references/slides-strategies.md` | | Banner Sizes & Styles | `references/banner-sizes-and-styles.md` | | Social Photos Guide | `references/social-photos-design.md` | | Icon Design Guide | `references/icon-design.md` | ## Scripts | Script | Purpose | |--------|---------| | `scripts/logo/search.py` | Search logo styles, colors, industries | | `scripts/logo/generate.py` | Generate logos with Gemini AI | | `scripts/logo/core.py` | BM25 search engine for logo data | | `scripts/cip/search.py` | Search CIP deliverables, styles, industries | | `scripts/cip/generate.py` | Generate CIP mockups with Gemini | | `scripts/cip/render-html.py` | Render HTML presentation from CIP mockups | | `scripts/cip/core.py` | BM25 search engine for CIP data | | `scripts/icon/generate.py` | Generate SVG icons with Gemini 3.1 Pro | ## Prerequisites **Python:** This skill uses Python scripts. On Windows, use `python` instead of `python3` (e.g., `python scripts/logo/search.py` instead of `python3 scripts/logo/search.py`). Check if Python is installed: ```bash python3 --version || python --version ``` ## Setup ```bash export GEMINI_API_KEY="your-key" # https://aistudio.google.com/apikey pip install google-genai pillow ``` > **Note for Windows:** Use `python` instead of `pip` where needed (e.g., `python -m pip install ...`). ## Integration **External sub-skills:** brand, design-system, ui-styling **Related Skills:** frontend-design, ui-ux-pro-max, ai-multimodal, chrome-devtools ================ File: workspace/agents/opencode/engine/skills/design-exemplar/SKILL.md ================ --- name: design-exemplar description: >- Reference library of 84 DESIGN.md files extracted from real production websites — Linear, Stripe, Vercel, Apple, Notion, Cursor, Claude, Figma and more. Use when the user references a known brand aesthetic ("make it look like X"), wants exemplar-driven design grounded in a proven design language, or needs a real design system as the starting point for a new UI. Each DESIGN.md is a plain-markdown design system document that AI agents read natively. Source: https://github.com/VoltAgent/awesome-design-md (MIT). This skill is the Exemplar layer of the mandatory 4-layer UI pipeline (Exemplar > Generator > Taste > Gate). --- # design-exemplar — Exemplar Layer of the UI Pipeline ## What is DESIGN.md? DESIGN.md is a Google Stitch concept: a **plain-markdown design system document** that AI agents read natively — no parsing, no tooling, just markdown. The relationship: - `AGENTS.md` = how to **build** (conventions, workflow, tooling rules) - `DESIGN.md` = how to **look** (visual language, palette, type, components) Each file was extracted from a real, publicly deployed website — production CSS distilled into an agent-readable design language. Handing a DESIGN.md to an agent is equivalent to handing it the actual design system of that brand. ## The 9 sections every DESIGN.md contains 1. **Visual theme** — overall mood, personality, aesthetic direction 2. **Color palette + roles** — full palette with semantic roles (surface, primary, accent, danger…) 3. **Typography rules** — typefaces, scale, weights, line-height, usage per element 4. **Component stylings** — buttons, inputs, cards, nav, modals, tables, etc. 5. **Layout principles** — grid, spacing rhythm, container widths, section structure 6. **Depth & elevation** — shadows, borders, blur, layering rules 7. **Do's & don'ts** — the brand's explicit design prohibitions and requirements 8. **Responsive behavior** — breakpoints, mobile adaptations, density changes 9. **Agent prompt guide** — ready-made instructions for AI agents consuming the design Each entry ships alongside `preview.html` / `preview-dark.html` — rendered references of the design in light and dark modes. ## Brand index highlights **AI & LLM:** Claude, Mistral, Ollama, OpenCode AI, Runway, xAI **Dev tools:** Cursor, Vercel, Raycast, Warp, Expo, Lovable **Backend / DB:** Supabase, MongoDB, Sentry, PostHog, ClickHouse **SaaS:** Linear, Notion, Cal.com, Resend, Zapier **Fintech:** Stripe, Coinbase, Mercury, Revolut, Wise, Mastercard **E-commerce:** Airbnb, Nike, Shopify, Starbucks **Media:** Apple, Spotify, NVIDIA, SpaceX, Uber **Automotive:** Tesla, Ferrari, Lamborghini, BMW **Retro:** Dell 1996, Nintendo 2001 Full index: the [GitHub repo README](https://github.com/VoltAgent/awesome-design-md). ## Fetch workflow 1. Fetch `https://getdesign.md//design-md` (or the raw file from the GitHub repo) with the fetch/webfetch tool. 2. Save the result as `DESIGN.md` at the **project root**. 3. Follow it: the agent consumes DESIGN.md as its design language for the task. 4. Persist the full index lookup to the repo README when the brand is not in the highlights above. ## Usage rules - **Never mix** exemplar + generated design system on the same page. Pick one source of truth. - **Cite the source brand** in the final summary (e.g. "design based on the Linear DESIGN.md"). - The taste-skill (`design-taste-frontend`) should **push beyond** the copied language — use the exemplar as grounding, not a ceiling, to avoid derivative output. - This skill is the **Exemplar layer** of the mandatory 4-layer UI pipeline: **Exemplar (design-exemplar) > Generator (ui-ux-pro-max) > Taste (design-taste-frontend) > Gate (impeccable)**. Order is mandatory. No UI task is done without the impeccable gate. ## Licensing MIT licensed. Design data was extracted from publicly visible CSS of live websites, provided as-is, with no ownership claims over the referenced brands or their trademarks. Use as reference material; do not claim the design as your own original work. ================ File: workspace/agents/opencode/engine/skills/design-system/scripts/embed-tokens.cjs ================ #!/usr/bin/env node /** * embed-tokens.cjs * Reads design-tokens.css and outputs embeddable inline CSS. * Use when generating standalone HTML files (infographics, slides, etc.) * * Usage: * node embed-tokens.cjs # Output full CSS * node embed-tokens.cjs --minimal # Output only commonly used tokens * node embed-tokens.cjs --style # Wrap in `; } else { output = `/* Design Tokens (embedded for standalone HTML) */\n${output}`; } console.log(output); } catch (err) { console.error(`Error reading tokens: ${err.message}`); process.exit(1); } ================ File: workspace/agents/opencode/engine/skills/design-system/scripts/fetch-background.py ================ #!/usr/bin/env python3 """ Background Image Fetcher Fetches real images from Pexels for slide backgrounds. Uses web scraping (no API key required) or WebFetch tool integration. """ import json import csv import re import sys from pathlib import Path # Project root relative to this script PROJECT_ROOT = Path(__file__).parent.parent.parent.parent.parent TOKENS_PATH = PROJECT_ROOT / 'assets' / 'design-tokens.json' BACKGROUNDS_CSV = Path(__file__).parent.parent / 'data' / 'slide-backgrounds.csv' def resolve_token_reference(ref: str, tokens: dict) -> str: """Resolve token reference like {primitive.color.ocean-blue.500} to hex value.""" if not ref or not ref.startswith('{') or not ref.endswith('}'): return ref # Already a value, not a reference # Parse reference: {primitive.color.ocean-blue.500} path = ref[1:-1].split('.') # ['primitive', 'color', 'ocean-blue', '500'] current = tokens for key in path: if isinstance(current, dict): current = current.get(key) else: return None # Invalid path # Return $value if it's a token object if isinstance(current, dict) and '$value' in current: return current['$value'] return current def load_brand_colors(): """Load colors from assets/design-tokens.json for overlay gradients. Resolves semantic token references to actual hex values. """ try: with open(TOKENS_PATH) as f: tokens = json.load(f) colors = tokens.get('primitive', {}).get('color', {}) semantic = tokens.get('semantic', {}).get('color', {}) # Try semantic tokens first (preferred) - resolve references if semantic: primary_ref = semantic.get('primary', {}).get('$value') secondary_ref = semantic.get('secondary', {}).get('$value') accent_ref = semantic.get('accent', {}).get('$value') background_ref = semantic.get('background', {}).get('$value') primary = resolve_token_reference(primary_ref, tokens) secondary = resolve_token_reference(secondary_ref, tokens) accent = resolve_token_reference(accent_ref, tokens) background = resolve_token_reference(background_ref, tokens) if primary and secondary: return { 'primary': primary, 'secondary': secondary, 'accent': accent or primary, 'background': background or '#0D0D0D', } # Fallback: find first color palette with 500 value (primary) primary_keys = ['ocean-blue', 'coral', 'blue', 'primary'] secondary_keys = ['golden-amber', 'purple', 'amber', 'secondary'] accent_keys = ['emerald', 'mint', 'green', 'accent'] primary_color = None secondary_color = None accent_color = None for key in primary_keys: if key in colors and isinstance(colors[key], dict): primary_color = colors[key].get('500', {}).get('$value') if primary_color: break for key in secondary_keys: if key in colors and isinstance(colors[key], dict): secondary_color = colors[key].get('500', {}).get('$value') if secondary_color: break for key in accent_keys: if key in colors and isinstance(colors[key], dict): accent_color = colors[key].get('500', {}).get('$value') if accent_color: break background = colors.get('dark', {}).get('800', {}).get('$value', '#0D0D0D') return { 'primary': primary_color or '#3B82F6', 'secondary': secondary_color or '#F59E0B', 'accent': accent_color or '#10B981', 'background': background, } except (FileNotFoundError, KeyError, TypeError): # Fallback defaults return { 'primary': '#3B82F6', 'secondary': '#F59E0B', 'accent': '#10B981', 'background': '#0D0D0D', } def load_backgrounds_config(): """Load background configuration from CSV.""" config = {} try: with open(BACKGROUNDS_CSV, newline='') as f: reader = csv.DictReader(f) for row in reader: config[row['slide_type']] = row except FileNotFoundError: print(f"Warning: {BACKGROUNDS_CSV} not found") return config def get_overlay_css(style: str, brand_colors: dict) -> str: """Generate overlay CSS using brand colors from design-tokens.json.""" overlays = { 'gradient-dark': f"linear-gradient(135deg, {brand_colors['background']}E6, {brand_colors['background']}B3)", 'gradient-brand': f"linear-gradient(135deg, {brand_colors['primary']}CC, {brand_colors['secondary']}99)", 'gradient-accent': f"linear-gradient(135deg, {brand_colors['accent']}99, transparent)", 'blur-dark': f"rgba(13,13,13,0.8)", 'desaturate-dark': f"rgba(13,13,13,0.7)", } return overlays.get(style, overlays['gradient-dark']) # Curated high-quality images from Pexels (free to use, pre-selected for brand aesthetic) CURATED_IMAGES = { 'hero': [ 'https://images.pexels.com/photos/3861969/pexels-photo-3861969.jpeg?auto=compress&cs=tinysrgb&w=1920', 'https://images.pexels.com/photos/2582937/pexels-photo-2582937.jpeg?auto=compress&cs=tinysrgb&w=1920', 'https://images.pexels.com/photos/1089438/pexels-photo-1089438.jpeg?auto=compress&cs=tinysrgb&w=1920', ], 'vision': [ 'https://images.pexels.com/photos/3183150/pexels-photo-3183150.jpeg?auto=compress&cs=tinysrgb&w=1920', 'https://images.pexels.com/photos/3182812/pexels-photo-3182812.jpeg?auto=compress&cs=tinysrgb&w=1920', 'https://images.pexels.com/photos/3184291/pexels-photo-3184291.jpeg?auto=compress&cs=tinysrgb&w=1920', ], 'team': [ 'https://images.pexels.com/photos/3184418/pexels-photo-3184418.jpeg?auto=compress&cs=tinysrgb&w=1920', 'https://images.pexels.com/photos/3184338/pexels-photo-3184338.jpeg?auto=compress&cs=tinysrgb&w=1920', 'https://images.pexels.com/photos/3182773/pexels-photo-3182773.jpeg?auto=compress&cs=tinysrgb&w=1920', ], 'testimonial': [ 'https://images.pexels.com/photos/3184465/pexels-photo-3184465.jpeg?auto=compress&cs=tinysrgb&w=1920', 'https://images.pexels.com/photos/1181622/pexels-photo-1181622.jpeg?auto=compress&cs=tinysrgb&w=1920', ], 'cta': [ 'https://images.pexels.com/photos/3184339/pexels-photo-3184339.jpeg?auto=compress&cs=tinysrgb&w=1920', 'https://images.pexels.com/photos/3184298/pexels-photo-3184298.jpeg?auto=compress&cs=tinysrgb&w=1920', ], 'problem': [ 'https://images.pexels.com/photos/3760529/pexels-photo-3760529.jpeg?auto=compress&cs=tinysrgb&w=1920', 'https://images.pexels.com/photos/897817/pexels-photo-897817.jpeg?auto=compress&cs=tinysrgb&w=1920', ], 'solution': [ 'https://images.pexels.com/photos/3184292/pexels-photo-3184292.jpeg?auto=compress&cs=tinysrgb&w=1920', 'https://images.pexels.com/photos/3184644/pexels-photo-3184644.jpeg?auto=compress&cs=tinysrgb&w=1920', ], 'hook': [ 'https://images.pexels.com/photos/2582937/pexels-photo-2582937.jpeg?auto=compress&cs=tinysrgb&w=1920', 'https://images.pexels.com/photos/1089438/pexels-photo-1089438.jpeg?auto=compress&cs=tinysrgb&w=1920', ], 'social': [ 'https://images.pexels.com/photos/3184360/pexels-photo-3184360.jpeg?auto=compress&cs=tinysrgb&w=1920', 'https://images.pexels.com/photos/3184287/pexels-photo-3184287.jpeg?auto=compress&cs=tinysrgb&w=1920', ], 'demo': [ 'https://images.pexels.com/photos/1181675/pexels-photo-1181675.jpeg?auto=compress&cs=tinysrgb&w=1920', 'https://images.pexels.com/photos/3861958/pexels-photo-3861958.jpeg?auto=compress&cs=tinysrgb&w=1920', ], } def get_curated_images(slide_type: str) -> list: """Get curated images for slide type.""" return CURATED_IMAGES.get(slide_type, CURATED_IMAGES.get('hero', [])) def get_pexels_search_url(keywords: str) -> str: """Generate Pexels search URL for manual lookup.""" import urllib.parse return f"https://www.pexels.com/search/{urllib.parse.quote(keywords)}/" def get_background_image(slide_type: str) -> dict: """ Get curated image matching slide type and brand aesthetic. Uses pre-selected Pexels images (no API/scraping needed). """ brand_colors = load_brand_colors() config = load_backgrounds_config() slide_config = config.get(slide_type) overlay_style = 'gradient-dark' keywords = slide_type if slide_config: keywords = slide_config.get('search_keywords', slide_config.get('image_category', slide_type)) overlay_style = slide_config.get('overlay_style', 'gradient-dark') # Get curated images urls = get_curated_images(slide_type) if urls: return { 'url': urls[0], 'all_urls': urls, 'overlay': get_overlay_css(overlay_style, brand_colors), 'attribution': 'Photo from Pexels (free to use)', 'source': 'pexels-curated', 'search_url': get_pexels_search_url(keywords), } # Fallback: provide search URL for manual selection return { 'url': None, 'overlay': get_overlay_css(overlay_style, brand_colors), 'keywords': keywords, 'search_url': get_pexels_search_url(keywords), 'available_types': list(CURATED_IMAGES.keys()), } def generate_css_for_background(result: dict, slide_class: str = '.slide-with-bg') -> str: """Generate CSS for a background slide.""" if not result.get('url'): search_url = result.get('search_url', '') return f"""/* No image scraped. Search manually: {search_url} */ /* Overlay ready: {result.get('overlay', 'gradient-dark')} */ """ return f"""{slide_class} {{ background-image: url('{result['url']}'); background-size: cover; background-position: center; position: relative; }} {slide_class}::before {{ content: ''; position: absolute; inset: 0; background: {result['overlay']}; }} {slide_class} .content {{ position: relative; z-index: 1; }} /* {result.get('attribution', 'Pexels')} - {result.get('search_url', '')} */ """ def main(): """CLI entry point.""" import argparse parser = argparse.ArgumentParser(description='Get background images for slides') parser.add_argument('slide_type', nargs='?', help='Slide type (hero, vision, team, etc.)') parser.add_argument('--list', action='store_true', help='List available slide types') parser.add_argument('--css', action='store_true', help='Output CSS for the background') parser.add_argument('--json', action='store_true', help='Output JSON') parser.add_argument('--colors', action='store_true', help='Show brand colors') parser.add_argument('--all', action='store_true', help='Show all curated URLs') args = parser.parse_args() if args.colors: colors = load_brand_colors() print("\nBrand Colors (from design-tokens.json):") for name, value in colors.items(): print(f" {name}: {value}") return if args.list: print("\nAvailable slide types (curated images):") for slide_type, urls in CURATED_IMAGES.items(): print(f" {slide_type}: {len(urls)} images") return if not args.slide_type: parser.print_help() return result = get_background_image(args.slide_type) if args.json: print(json.dumps(result, indent=2)) elif args.css: print(generate_css_for_background(result)) elif args.all: print(f"\nAll images for '{args.slide_type}':") for i, url in enumerate(result.get('all_urls', []), 1): print(f" {i}. {url}") else: print(f"\nImage URL: {result['url']}") print(f"Alternatives: {len(result.get('all_urls', []))} available (use --all)") print(f"Overlay: {result['overlay']}") if __name__ == '__main__': main() ================ File: workspace/agents/opencode/engine/skills/design-system/scripts/generate-slide.py ================ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Slide Generator - Generates HTML slides using design tokens ALL styles MUST use CSS variables from design-tokens.css NO hardcoded colors, fonts, or spacing allowed """ import argparse import json from html import escape from pathlib import Path from datetime import datetime def _e(value, default=''): """HTML-escape a user-supplied value for safe embedding in HTML content.""" return escape(str(value if value is not None else default)) def _safe_url(url, default='#'): """Validate and escape a URL for use in href attributes. Only allows http://, https://, #, and / schemes to prevent javascript: URI injection (CWE-79). """ if url and str(url).strip().lower().startswith(('http://', 'https://', '#', '/')): return escape(str(url), quote=True) return default # Paths SCRIPT_DIR = Path(__file__).parent DATA_DIR = SCRIPT_DIR.parent / "data" TOKENS_CSS = Path(__file__).resolve().parents[4] / "assets" / "design-tokens.css" TOKENS_JSON = Path(__file__).resolve().parents[4] / "assets" / "design-tokens.json" OUTPUT_DIR = Path(__file__).resolve().parents[4] / "assets" / "designs" / "slides" # ============ BRAND-COMPLIANT SLIDE TEMPLATE ============ # ALL values reference CSS variables from design-tokens.css SLIDE_TEMPLATE = ''' {title}
{slides_content}
''' # ============ SLIDE GENERATORS ============ def generate_title_slide(data): """Title slide with gradient headline""" return f'''
{_e(data.get('badge', 'Pitch Deck'))}

{_e(data.get('title', 'Your Title Here'))}

{_e(data.get('subtitle', 'Your compelling subtitle'))}

''' def generate_problem_slide(data): """Problem statement slide using PAS formula""" return f'''
The Problem

{_e(data.get('headline', 'The problem your audience faces'))}

01

{_e(data.get('pain_1_title', 'Pain Point 1'))}

{_e(data.get('pain_1_desc', 'Description of the first pain point'))}

02

{_e(data.get('pain_2_title', 'Pain Point 2'))}

{_e(data.get('pain_2_desc', 'Description of the second pain point'))}

03

{_e(data.get('pain_3_title', 'Pain Point 3'))}

{_e(data.get('pain_3_desc', 'Description of the third pain point'))}

''' def generate_solution_slide(data): """Solution slide with feature highlights""" return f'''
The Solution

{_e(data.get('headline', 'How we solve this'))}

✓

{_e(data.get('feature_1_title', 'Feature 1'))}

{_e(data.get('feature_1_desc', 'Description of feature 1'))}

✓

{_e(data.get('feature_2_title', 'Feature 2'))}

{_e(data.get('feature_2_desc', 'Description of feature 2'))}

✓

{_e(data.get('feature_3_title', 'Feature 3'))}

{_e(data.get('feature_3_desc', 'Description of feature 3'))}

◆

Product screenshot or demo

''' def generate_metrics_slide(data): """Traction/metrics slide with large numbers""" metrics = data.get('metrics', [ {'value': '10K+', 'label': 'Active Users'}, {'value': '95%', 'label': 'Retention Rate'}, {'value': '3x', 'label': 'Revenue Growth'}, {'value': '$2M', 'label': 'ARR'} ]) metrics_html = ''.join([f'''
{_e(m.get('value', ''))}
{_e(m.get('label', ''))}
''' for m in metrics[:4]]) return f'''
Traction

{_e(data.get('headline', 'Our Growth'))}

{metrics_html}
''' def generate_chart_slide(data): """Chart slide with CSS bar chart""" bars = data.get('bars', [ {'label': 'Q1', 'value': 40}, {'label': 'Q2', 'value': 60}, {'label': 'Q3', 'value': 80}, {'label': 'Q4', 'value': 100} ]) bars_html = ''.join([f'''
{_e(b.get('display', str(b.get('value', 0)) + '%'))} {_e(b.get('label', ''))}
''' for b in bars]) return f'''
{_e(data.get('badge', 'Growth'))}

{_e(data.get('headline', 'Revenue Growth'))}

{_e(data.get('chart_title', 'Quarterly Revenue'))}
{bars_html}
''' def generate_testimonial_slide(data): """Social proof slide""" return f'''
What They Say

"{_e(data.get('quote', 'This product changed how we work. Incredible results.'))}"

{_e(data.get('author', 'Jane Doe'))}

{_e(data.get('role', 'CEO, Example Company'))}

''' def generate_cta_slide(data): """Closing CTA slide""" return f'''

{_e(data.get('headline', 'Ready to get started?'))}

{_e(data.get('subheadline', 'Join thousands of teams already using our solution.'))}

''' # Slide type mapping SLIDE_GENERATORS = { 'title': generate_title_slide, 'problem': generate_problem_slide, 'solution': generate_solution_slide, 'metrics': generate_metrics_slide, 'traction': generate_metrics_slide, 'chart': generate_chart_slide, 'testimonial': generate_testimonial_slide, 'cta': generate_cta_slide, 'closing': generate_cta_slide } def generate_deck(slides_data, title="Pitch Deck"): """Generate complete deck from slide data list""" slides_html = "" for slide in slides_data: slide_type = slide.get('type', 'title') generator = SLIDE_GENERATORS.get(slide_type) if generator: slides_html += generator(slide) else: print(f"Warning: Unknown slide type '{slide_type}'") # Calculate relative path to tokens CSS tokens_rel_path = "../../../assets/design-tokens.css" return SLIDE_TEMPLATE.format( title=escape(str(title)), tokens_css_path=tokens_rel_path, slides_content=slides_html ) def main(): parser = argparse.ArgumentParser(description="Generate brand-compliant slides") parser.add_argument("--json", "-j", help="JSON file with slide data") parser.add_argument("--output", "-o", help="Output HTML file path") parser.add_argument("--demo", action="store_true", help="Generate demo deck") args = parser.parse_args() if args.demo: # Demo deck showcasing all slide types demo_slides = [ { 'type': 'title', 'badge': 'Investor Deck 2024', 'title': 'ClaudeKit Marketing', 'subtitle': 'Your AI marketing team. Always on.', 'cta': 'Join Waitlist', 'secondary_cta': 'See Demo', 'company': 'ClaudeKit', 'date': 'December 2024' }, { 'type': 'problem', 'headline': 'Marketing teams are drowning', 'pain_1_title': 'Content Overload', 'pain_1_desc': 'Need to produce 10x content with same headcount', 'pain_2_title': 'Tool Fatigue', 'pain_2_desc': '15+ tools that don\'t talk to each other', 'pain_3_title': 'No Time to Think', 'pain_3_desc': 'Strategy suffers when execution consumes all hours', 'company': 'ClaudeKit', 'page': '2' }, { 'type': 'solution', 'headline': 'AI agents that actually get marketing', 'feature_1_title': 'Content Creation', 'feature_1_desc': 'Blog posts, social, email - all on brand, all on time', 'feature_2_title': 'Campaign Management', 'feature_2_desc': 'Multi-channel orchestration with one command', 'feature_3_title': 'Analytics & Insights', 'feature_3_desc': 'Real-time optimization without the spreadsheets', 'company': 'ClaudeKit', 'page': '3' }, { 'type': 'metrics', 'headline': 'Early traction speaks volumes', 'metrics': [ {'value': '500+', 'label': 'Beta Users'}, {'value': '85%', 'label': 'Weekly Active'}, {'value': '4.9', 'label': 'NPS Score'}, {'value': '50hrs', 'label': 'Saved/Week'} ], 'company': 'ClaudeKit', 'page': '4' }, { 'type': 'chart', 'badge': 'Revenue', 'headline': 'Growing month over month', 'chart_title': 'MRR Growth ($K)', 'bars': [ {'label': 'Sep', 'value': 20, 'display': '$5K'}, {'label': 'Oct', 'value': 40, 'display': '$12K'}, {'label': 'Nov', 'value': 70, 'display': '$28K'}, {'label': 'Dec', 'value': 100, 'display': '$45K'} ], 'company': 'ClaudeKit', 'page': '5' }, { 'type': 'testimonial', 'quote': 'ClaudeKit replaced 3 tools and 2 contractors. Our content output tripled while costs dropped 60%.', 'author': 'Sarah Chen', 'role': 'Head of Marketing, TechStartup', 'company': 'ClaudeKit', 'page': '6' }, { 'type': 'cta', 'headline': 'Ship campaigns while you sleep', 'subheadline': 'Early access available. Limited spots.', 'cta': 'Join the Waitlist', 'contact': 'hello@claudekit.ai', 'website': 'claudekit.ai' } ] html = generate_deck(demo_slides, "ClaudeKit Marketing - Pitch Deck") OUTPUT_DIR.mkdir(parents=True, exist_ok=True) output_path = OUTPUT_DIR / f"demo-pitch-{datetime.now().strftime('%y%m%d')}.html" output_path.write_text(html, encoding='utf-8') print(f"Demo deck generated: {output_path}") elif args.json: with open(args.json, 'r') as f: data = json.load(f) html = generate_deck(data.get('slides', []), data.get('title', 'Presentation')) output_path = Path(args.output) if args.output else OUTPUT_DIR / f"deck-{datetime.now().strftime('%y%m%d-%H%M')}.html" output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text(html, encoding='utf-8') print(f"Deck generated: {output_path}") else: parser.print_help() if __name__ == "__main__": main() ================ File: workspace/agents/opencode/engine/skills/design-system/scripts/generate-tokens.cjs ================ #!/usr/bin/env node /** * Generate CSS variables from design tokens JSON * * Usage: * node generate-tokens.cjs --config tokens.json -o tokens.css * node generate-tokens.cjs --config tokens.json --format tailwind */ const fs = require('fs'); const path = require('path'); /** * Parse command line arguments */ function parseArgs() { const args = process.argv.slice(2); const options = { config: null, output: null, format: 'css' // css | tailwind }; for (let i = 0; i < args.length; i++) { if (args[i] === '--config' || args[i] === '-c') { options.config = args[++i]; } else if (args[i] === '--output' || args[i] === '-o') { options.output = args[++i]; } else if (args[i] === '--format' || args[i] === '-f') { options.format = args[++i]; } else if (args[i] === '--help' || args[i] === '-h') { console.log(` Usage: node generate-tokens.cjs [options] Options: -c, --config Input JSON token file (required) -o, --output Output file (default: stdout) -f, --format Output format: css | tailwind (default: css) -h, --help Show this help `); process.exit(0); } } return options; } /** * Resolve token references like {primitive.color.blue.600} */ function resolveReference(value, tokens) { if (typeof value !== 'string' || !value.startsWith('{')) { return value; } const path = value.slice(1, -1).split('.'); let result = tokens; for (const key of path) { result = result?.[key]; } if (result?.$value) { return resolveReference(result.$value, tokens); } return result || value; } /** * Convert token name to CSS variable name */ function toCssVarName(path) { return '--' + path.join('-').replace(/\./g, '-'); } /** * Flatten tokens into CSS variables */ function flattenTokens(obj, tokens, prefix = [], result = {}) { for (const [key, value] of Object.entries(obj)) { const currentPath = [...prefix, key]; if (value && typeof value === 'object') { if (value.$value !== undefined) { // This is a token const cssVar = toCssVarName(currentPath); const resolvedValue = resolveReference(value.$value, tokens); result[cssVar] = resolvedValue; } else { // Recurse into nested object flattenTokens(value, tokens, currentPath, result); } } } return result; } /** * Generate CSS output */ function generateCSS(tokens) { const primitive = flattenTokens(tokens.primitive || {}, tokens, ['primitive']); const semantic = flattenTokens(tokens.semantic || {}, tokens, []); const component = flattenTokens(tokens.component || {}, tokens, []); const darkSemantic = flattenTokens(tokens.dark?.semantic || {}, tokens, []); let css = `/* Design Tokens - Auto-generated */ /* Do not edit directly - modify tokens.json instead */ /* === PRIMITIVES === */ :root { ${Object.entries(primitive).map(([k, v]) => ` ${k}: ${v};`).join('\n')} } /* === SEMANTIC === */ :root { ${Object.entries(semantic).map(([k, v]) => ` ${k}: ${v};`).join('\n')} } /* === COMPONENTS === */ :root { ${Object.entries(component).map(([k, v]) => ` ${k}: ${v};`).join('\n')} } `; if (Object.keys(darkSemantic).length > 0) { css += ` /* === DARK MODE === */ .dark { ${Object.entries(darkSemantic).map(([k, v]) => ` ${k}: ${v};`).join('\n')} } `; } return css; } /** * Generate Tailwind config output */ function generateTailwind(tokens) { const semantic = flattenTokens(tokens.semantic || {}, tokens, []); // Extract colors for Tailwind const colors = {}; for (const [key, value] of Object.entries(semantic)) { if (key.includes('color')) { const name = key.replace('--color-', '').replace(/-/g, '.'); colors[name] = `var(${key})`; } } return `// Tailwind color config - Auto-generated // Add to tailwind.config.ts theme.extend.colors module.exports = { colors: ${JSON.stringify(colors, null, 2).replace(/"/g, "'")} }; `; } /** * Main */ function main() { const options = parseArgs(); if (!options.config) { console.error('Error: --config is required'); process.exit(1); } // Resolve config path const configPath = path.resolve(process.cwd(), options.config); if (!fs.existsSync(configPath)) { console.error(`Error: Config file not found: ${configPath}`); process.exit(1); } // Read and parse tokens const tokens = JSON.parse(fs.readFileSync(configPath, 'utf-8')); // Generate output let output; if (options.format === 'tailwind') { output = generateTailwind(tokens); } else { output = generateCSS(tokens); } // Write output if (options.output) { const outputPath = path.resolve(process.cwd(), options.output); fs.mkdirSync(path.dirname(outputPath), { recursive: true }); fs.writeFileSync(outputPath, output); console.log(`Generated: ${outputPath}`); } else { console.log(output); } } main(); ================ File: workspace/agents/opencode/engine/skills/design-system/scripts/html-token-validator.py ================ #!/usr/bin/env python3 """ HTML Design Token Validator Ensures all HTML assets (slides, infographics, etc.) use design tokens. Source of truth: assets/design-tokens.css Usage: python html-token-validator.py # Validate all HTML assets python html-token-validator.py --type slides # Validate only slides python html-token-validator.py --type infographics # Validate only infographics python html-token-validator.py path/to/file.html # Validate specific file python html-token-validator.py --fix # Auto-fix issues (WIP) """ import re import json import sys from pathlib import Path from typing import Dict, List, Tuple, Optional # Project root relative to this script PROJECT_ROOT = Path(__file__).parent.parent.parent.parent.parent TOKENS_JSON_PATH = PROJECT_ROOT / 'assets' / 'design-tokens.json' TOKENS_CSS_PATH = PROJECT_ROOT / 'assets' / 'design-tokens.css' # Asset directories to validate ASSET_DIRS = { 'slides': PROJECT_ROOT / 'assets' / 'designs' / 'slides', 'infographics': PROJECT_ROOT / 'assets' / 'infographics', } # Patterns that indicate hardcoded values (should use tokens) FORBIDDEN_PATTERNS = [ (r'#[0-9A-Fa-f]{3,8}\b', 'hex color'), (r'rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)', 'rgb color'), (r'rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*[\d.]+\s*\)', 'rgba color'), (r'hsl\([^)]+\)', 'hsl color'), (r"font-family:\s*'[^v][^a][^r][^']*',", 'hardcoded font'), # Exclude var() (r'font-family:\s*"[^v][^a][^r][^"]*",', 'hardcoded font'), ] # Allowed rgba patterns (brand colors with transparency - CSS limitation) # These are derived from brand tokens but need rgba for transparency ALLOWED_RGBA_PATTERNS = [ r'rgba\(\s*59\s*,\s*130\s*,\s*246', # --color-primary (#3B82F6) r'rgba\(\s*245\s*,\s*158\s*,\s*11', # --color-secondary (#F59E0B) r'rgba\(\s*16\s*,\s*185\s*,\s*129', # --color-accent (#10B981) r'rgba\(\s*20\s*,\s*184\s*,\s*166', # --color-accent alt (#14B8A6) r'rgba\(\s*0\s*,\s*0\s*,\s*0', # black transparency (common) r'rgba\(\s*255\s*,\s*255\s*,\s*255', # white transparency (common) r'rgba\(\s*15\s*,\s*23\s*,\s*42', # --color-surface (#0F172A) r'rgba\(\s*7\s*,\s*11\s*,\s*20', # --color-background (#070B14) ] # Allowed exceptions (external images, etc.) ALLOWED_EXCEPTIONS = [ 'pexels.com', 'unsplash.com', 'youtube.com', 'ytimg.com', 'googlefonts', 'fonts.googleapis.com', 'fonts.gstatic.com', ] class ValidationResult: """Validation result for a single file.""" def __init__(self, file_path: Path): self.file_path = file_path self.errors: List[str] = [] self.warnings: List[str] = [] self.passed = True def add_error(self, msg: str): self.errors.append(msg) self.passed = False def add_warning(self, msg: str): self.warnings.append(msg) def load_css_variables() -> Dict[str, str]: """Load CSS variables from design-tokens.css.""" variables = {} if TOKENS_CSS_PATH.exists(): content = TOKENS_CSS_PATH.read_text() # Extract --var-name: value patterns for match in re.finditer(r'(--[\w-]+):\s*([^;]+);', content): variables[match.group(1)] = match.group(2).strip() return variables def is_inside_block(content: str, match_pos: int, open_tag: str, close_tag: str) -> bool: """Check if position is inside a specific HTML block.""" pre = content[:match_pos] tag_open = pre.rfind(open_tag) tag_close = pre.rfind(close_tag) return tag_open > tag_close def is_allowed_exception(context: str) -> bool: """Check if the hardcoded value is in an allowed exception context.""" context_lower = context.lower() return any(exc in context_lower for exc in ALLOWED_EXCEPTIONS) def is_allowed_rgba(match_text: str) -> bool: """Check if rgba pattern uses brand colors (allowed for transparency).""" return any(re.match(pattern, match_text) for pattern in ALLOWED_RGBA_PATTERNS) def get_context(content: str, pos: int, chars: int = 100) -> str: """Get surrounding context for a match position.""" start = max(0, pos - chars) end = min(len(content), pos + chars) return content[start:end] def validate_html(content: str, file_path: Path, verbose: bool = False) -> ValidationResult: """ Validate HTML content for design token compliance. Checks: 1. design-tokens.css import present 2. No hardcoded colors in CSS (except in '): if verbose: result.add_warning(f"Allowed in ``` ### Token Compliance ```css /* CORRECT - uses token */ background: var(--slide-bg); color: var(--color-primary); font-family: var(--typography-font-heading); /* WRONG - hardcoded */ background: #0D0D0D; color: #FF6B6B; font-family: 'Space Grotesk'; ``` ### Reference Implementation Working example with all features: ``` assets/designs/slides/claudekit-pitch-251223.html ``` ### Command ```bash /slides:create "10-slide investor pitch for ClaudeKit Marketing" ``` ## Best Practices 1. Never use raw hex in components - always reference tokens 2. Semantic layer enables theme switching (light/dark) 3. Component tokens enable per-component customization 4. Use HSL format for opacity control 5. Document every token's purpose 6. **Slides must import design-tokens.css and use var() exclusively** ================ File: workspace/agents/opencode/engine/skills/design-taste-frontend/assets/scenes/stage.css ================ /* tasteskill / scenes / stage.css — the held stage under every scroll scene (references/scenes.md). Without .is-live (no script, a failed CDN, reduced motion, a phone set to 'rest') a scene is an ordinary section showing its finished composition. With it, the section is (hold + 1) screens tall and the stage sticks to the viewport for the hold. */ .scene { position: relative; } .scene > .scene-stage { position: relative; } html.js .scene.is-live { height: calc((var(--hold, 2) + 1) * 100svh); } html.js .scene.is-live > .scene-stage { position: sticky; top: 0; height: 100svh; overflow: clip; /* clip, not hidden: hidden would make the stage a scroll container */ contain: layout paint; } /* Everything a scene moves is composited; nothing it moves is laid out again. */ .scene.is-live [data-move] { will-change: transform, opacity; } /* Lenis */ html.is-smooth { scroll-behavior: auto; } html.is-smooth body { overscroll-behavior: none; } @media (prefers-reduced-motion: reduce) { html.js .scene.is-live { height: auto; } html.js .scene.is-live > .scene-stage { position: relative; height: auto; } } ================ File: workspace/agents/opencode/engine/skills/design-taste-frontend/assets/scenes/stage.js ================ // tasteskill / scenes / stage.js — the shared stage every scroll scene runs on (references/scenes.md). // // A scene is a tall section whose stage sticks to the viewport while the page scrolls past it; a GSAP // timeline is scrubbed by that scroll. The page always scrolls natively (no wheel capture), the // scrollbar always tells the truth, and the finished composition is what reduced motion, no script and // a failed CDN all show. // // Load at the end of , in this order (versions pinned): // // // (only if a scene splits text) // (optional smoothing) // // // and keep the one-line in . // // Stage.smooth() weighted wheel scrolling (Lenis), off for touch and reduced motion // Stage.scene(root, { hold, scrub, build, phone, rest }) // Stage.split(el, 'lines' | 'words' | 'chars') masked split text, SplitText when loaded // Stage.ready() resolves when fonts and the scenes' pictures are ready // Stage.init() starts every
on the page // // A page: Stage.smooth(); Stage.init(); (() => { const html = document.documentElement; const mq = (q) => window.matchMedia(q).matches; const live = !!(window.gsap && window.ScrollTrigger); if (live) gsap.registerPlugin(ScrollTrigger); if (live && window.SplitText) gsap.registerPlugin(SplitText); const Stage = { live, get reduced() { return mq('(prefers-reduced-motion: reduce)'); }, get phone() { return mq('(max-width: 767px)'); }, get touch() { return mq('(pointer: coarse)'); }, lenis: null, scenes: [], }; // Weighted wheel scrolling. lerp 0.1-0.14 feels heavy without lagging behind the hand; touch keeps the // phone's own scrolling, and reduced motion keeps the browser's. Stage.smooth = ({ lerp = 0.12 } = {}) => { if (!live || !window.Lenis || Stage.reduced || Stage.touch || Stage.lenis) return Stage.lenis; const lenis = new Lenis({ lerp, smoothWheel: true, anchors: true, autoRaf: false }); lenis.on('scroll', ScrollTrigger.update); gsap.ticker.add((t) => lenis.raf(t * 1000)); gsap.ticker.lagSmoothing(0); html.classList.add('is-smooth'); Stage.lenis = lenis; return lenis; }; // One scroll scene. // root
holding one
// hold screens of scroll the stage is held for (1-4); the section is (hold + 1) screens tall // scrub true = locked to the scrollbar; a number = seconds of catch-up (0.3-0.8 reads as weight) // build(tl, ctx) add the tweens to tl; the timeline runs 0 → 1 over the hold. ctx: { root, stage, phone, q } // phone 'same' (default) | 'rest' (the phone gets the finished composition) | { hold, build } // rest(ctx) optional: anything extra the finished composition needs (most scenes need nothing) // Sets --p (0-1) and data-beat on root, so CSS and captions can follow the scene too. Stage.scene = (root, opts = {}) => { if (!root) return null; const { hold = 2, scrub = true, build, phone = 'same', rest, beats = 0 } = opts; const stage = root.querySelector(':scope > .scene-stage') || root.firstElementChild; const q = (sel) => [...root.querySelectorAll(sel)]; const toRest = (ctx) => { root.classList.remove('is-live'); root.classList.add('is-rest'); root.style.removeProperty('--hold'); if (rest) rest(ctx); }; if (!live) { toRest({ root, stage, phone: Stage.phone, q }); return null; } const mm = gsap.matchMedia(); mm.add({ motion: '(prefers-reduced-motion: no-preference)', phone: '(max-width: 767px)' }, (c) => { const isPhone = c.conditions.phone; const ctx = { root, stage, phone: isPhone, q }; const plan = isPhone ? (phone === 'rest' ? null : phone === 'same' ? { hold, build } : { hold: phone.hold ?? hold, build: phone.build ?? build }) : { hold, build }; if (!c.conditions.motion || !plan || !plan.build) { toRest(ctx); return () => root.classList.remove('is-rest'); } root.classList.remove('is-rest'); root.classList.add('is-live'); root.style.setProperty('--hold', plan.hold); const tl = gsap.timeline({ defaults: { ease: 'none' }, scrollTrigger: { trigger: root, start: 'top top', end: 'bottom bottom', scrub, invalidateOnRefresh: true, onUpdate: (self) => { root.style.setProperty('--p', self.progress.toFixed(4)); if (beats) root.dataset.beat = String(Math.min(beats - 1, Math.floor(self.progress * beats))); }, }, }); plan.build(tl, ctx); // pad the timeline to exactly 1 so positions in build() read as fractions of the hold if (tl.duration() < 1) tl.to({}, { duration: 1 - tl.duration() }, tl.duration()); return () => { root.classList.remove('is-live'); root.style.removeProperty('--hold'); root.style.removeProperty('--p'); }; }); Stage.scenes.push(mm); return mm; }; // Masked split text. Lines rise out of their own mask; words and characters are for short headings. Stage.split = (el, type = 'lines') => { if (!el) return []; if (window.SplitText && live) { const s = SplitText.create(el, { type: type === 'lines' ? 'lines' : type === 'words' ? 'lines,words' : 'lines,words,chars', mask: 'lines', aria: 'auto', autoSplit: false }); return type === 'lines' ? s.lines : type === 'words' ? s.words : s.chars; } // fallback: words in inline-block spans, the full text kept for screen readers if (!el.dataset.split) { el.setAttribute('aria-label', el.textContent.trim()); const words = el.textContent.trim().split(/\s+/).map((w) => { const span = document.createElement('span'); span.setAttribute('aria-hidden', 'true'); span.style.display = 'inline-block'; span.textContent = w; return span; }); el.replaceChildren(...words.flatMap((span, i) => (i ? [' ', span] : [span]))); el.dataset.split = 'words'; } return [...el.children]; }; // Fonts and the scenes' pictures decide every height a trigger measures; refresh once they are in. Stage.ready = () => { const imgs = [...document.querySelectorAll('.scene img')].map((i) => (i.complete ? null : i.decode().catch(() => {}))).filter(Boolean); return Promise.all([document.fonts ? document.fonts.ready : null, ...imgs]).then(() => { if (live) ScrollTrigger.refresh(); }); }; // Every
on the page, started by // the scene file of that id (assets/scenes/.js registers Scenes['']). Stage.init = () => { for (const el of document.querySelectorAll('.scene[data-scene]')) { if (el.__scene) continue; const make = window.Scenes[el.dataset.scene]; if (!make) { console.warn('No scene file loaded for data-scene="' + el.dataset.scene + '"'); continue; } let o = {}; try { o = JSON.parse(el.dataset.options || '{}'); } catch {} el.__scene = make(el, o) || true; } }; window.Scenes = window.Scenes || {}; window.Stage = Stage; if (live) { window.addEventListener('load', () => Stage.ready()); } })(); ================ File: workspace/agents/opencode/engine/skills/design-taste-frontend/assets/app.css ================ /* tasteskill / app.css A neutral foundation for app screens (dashboards, admin, settings, inboxes), set to the defaults measured in the study of real product screens (references/app-screens.md). Copy it into the project, replace the token values with the ones on the app sheet (draw.mjs --app), and build on top. Delete what you do not use. It has no brand of its own: neutral grounds, one quiet line, one accent. */ /* ---------- tokens (replace with the dealt style) ---------- */ :root { /* names match the app sheet printed by draw.mjs --app */ --ground: #f9fafb; /* the app chrome and page */ --surface: #ffffff; /* panels and the work sheet */ --raised: #f4f5f6; /* a filled inner well, hover rows */ --active: #eeeeee; /* the active nav pill */ --border: #e8e8e8; /* panel edges: about 1.2:1 on white */ --rule: #f1f2f3; /* row rules inside tables */ --control: #d1d5db; /* inputs and outlined buttons: one step stronger */ --text: #111111; --muted: #5f6368; /* secondary text, labels (4.5:1 or more on its ground) */ --faint: #6b6b6b; /* meta and placeholders: the lightest grey that holds 4.5:1 on white */ --accent: #1f6f5c; /* one hue: data, focus, the selected thing */ --primary: #1d1f20; /* the primary button is near-black unless the style says otherwise */ --primary-ink: #ffffff; --positive: #247b50; --negative: #c4242a; --warning: #92400e; /* references/app-screens.md §9.5; a green accent is also --positive */ --pill-positive-bg: #dcfae5; --pill-positive-ink: #04250e; --pill-negative-bg: #ffedea; --pill-negative-ink: #831c1e; --pill-warning-bg: #fffbeb; --pill-warning-ink: #92400e; --pill-neutral-bg: #eeeeee; --pill-neutral-ink: #3d3d3d; --chart-1: var(--accent); --chart-2: #8fb8ad; --chart-3: #b08a2e; --chart-muted: #cfd3d6; --chart-mark: var(--text); /* a marked period in Charts.diverging: the second tone */ --chart-span: 0.06; /* opacity of a shaded span (a selling window, an outage) */ --grid: var(--rule); /* chart gridlines (charts.js) */ --axis-ink: var(--muted); /* chart tick labels (charts.js) */ --ink: var(--text); --font: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif; --fs-title: 24px; --fs-section: 16px; --fs-kpi: 28px; --fs-body: 14px; --fs-small: 12px; --sidebar-width: 232px; --pad: 24px; /* canvas padding */ --gap: 16px; /* one gutter, horizontal and vertical */ --card-pad: 20px; --radius-card: 8px; /* radius is bimodal: tight 4–8 (controls 6) or soft 16–24 (controls full); never 11–12 */ --radius-control: 6px; --control-height: 32px; /* every control in the title and filter rows */ --row-height: 48px; /* single-line table row */ --nav-row: 32px; /* sidebar item height, on a 34px pitch */ --ease-out: cubic-bezier(0.22, 1, 0.36, 1); --t-press: 120ms; --t-hover: 150ms; --t-ui: 200ms; } /* dark: one true-neutral ground, elevation by lightness; two greys that read apart (not #aaa beside #969696) */ :root[data-theme="dark"] { --ground: #000000; --surface: #111111; --raised: #222222; --active: #222222; --border: #1a1a1a; --rule: #161616; --control: #444444; --text: #ffffff; --muted: #b4b4b4; --faint: #949494; --primary: #ffffff; --primary-ink: #000000; --positive: #3fc07a; --negative: #e5566f; --warning: #e5a93b; /* a green or yellow-green accent is also --positive */ --pill-positive-bg: #172a1d; --pill-positive-ink: #3fc07a; --pill-negative-bg: #2c1a20; --pill-negative-ink: #e5566f; --pill-warning-bg: #2b2414; --pill-warning-ink: #e5a93b; --pill-neutral-bg: #222222; --pill-neutral-ink: #ffffff; --chart-muted: #4a4a4a; --grid: #2b2b2b; --chart-span: 0.12; } /* ---------- reset ---------- */ *, *::before, *::after { box-sizing: border-box; } html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; } body { margin: 0; background: var(--ground); color: var(--text); font: var(--fs-body)/20px var(--font); -webkit-font-smoothing: antialiased; } h1, h2, h3, h4, p, figure, ul, ol, dl, dd { margin: 0; } ul[class], ol[class] { padding: 0; list-style: none; } img, svg, video, canvas { display: block; max-width: 100%; } button, input, select, textarea { font: inherit; color: inherit; } a { color: inherit; text-decoration: none; } :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; } .num, table, .kpi-value { font-variant-numeric: tabular-nums; } /* ---------- shell: sidebar + canvas ---------- */ .app { display: grid; grid-template-columns: var(--sidebar-width) minmax(0, 1fr); min-height: 100svh; } .sidebar { position: sticky; top: 0; height: 100svh; display: flex; flex-direction: column; gap: 2px; padding: 16px 12px; overflow: auto; } .sidebar .group-label { margin: 20px 8px 6px; font-size: var(--fs-small); color: var(--faint); } .nav-item { display: flex; align-items: center; gap: 10px; height: var(--nav-row); padding: 0 8px; border-radius: 8px; color: var(--muted); transition: background-color var(--t-hover) ease, color var(--t-hover) ease; } .nav-item svg { width: 16px; height: 16px; flex: none; stroke-width: 1.5; } .nav-item:hover { background: var(--raised); color: var(--text); } .nav-item[aria-current="page"] { background: var(--active); color: var(--text); } .sidebar .bottom { margin-top: auto; } .canvas { min-width: 0; padding: var(--pad); } /* ---------- title block and filters ---------- */ .title-row { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 20px; } .title-row h1 { font-size: var(--fs-title); line-height: 1.2; font-weight: 600; letter-spacing: -0.01em; } .title-row .sub { margin-top: 4px; color: var(--muted); } .filters { display: flex; align-items: center; gap: 8px; margin-bottom: var(--gap); flex-wrap: wrap; } .filters .spacer { flex: 1; } .stamp { color: var(--faint); font-size: var(--fs-small); } /* ---------- panels and grid ---------- */ .grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: var(--gap); } .panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-card); padding: var(--card-pad); min-width: 0; } .panel > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; } .panel h2 { font-size: var(--fs-section); line-height: 1.3; font-weight: 600; } .panel .meta { color: var(--faint); font-size: var(--fs-small); } /* paired cards in one row: a card stretches to its neighbour only when it has content to fill. Otherwise it keeps its own height (add .top to the row), and only an action or a total may be pinned to a stretched card's bottom */ .grid.top { align-items: start; } /* a chart's heading row: its title (and legend or period) with a fixed 8px to the svg */ .chart-head { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 4px 12px; margin-bottom: 8px; } .chart-head h3 { font-size: var(--fs-body); line-height: 20px; font-weight: 500; } .chart { position: relative; min-width: 0; } /* a link at a card's foot or header ("Show all 7", "Sell-out history"): ink, a 12px chevron, an underline on hover, and no inline padding, so its text sits on the card's content edge */ .card-link { display: inline-flex; align-items: center; gap: 2px; padding: 0; border: 0; background: none; color: var(--text); font-weight: 500; cursor: pointer; text-underline-offset: 3px; } .card-link::after { content: ""; width: 12px; height: 12px; flex: none; background: currentColor; -webkit-mask: var(--chevron) center / 12px no-repeat; mask: var(--chevron) center / 12px no-repeat; } .card-link:hover { text-decoration: underline; } :root { --chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M4.5 2.5 8 6l-3.5 3.5' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); } /* list rows: a rule between rows and none above the first. A rule on a rounded box curls up at both ends, so a row that carries a rule has square corners */ .rows { display: grid; } .row { display: flex; align-items: center; gap: 12px; min-height: 44px; padding: 10px 0; } .row + .row { border-top: 1px solid var(--rule); border-radius: 0; } /* KPIs as one divided strip: a label above a number, no icons, no tiles */ .kpis { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-card); } .kpi { padding: 16px var(--card-pad); } .kpi + .kpi { border-left: 1px solid var(--border); } .kpi-label { color: var(--muted); } .kpi-value { margin-top: 6px; font-size: var(--fs-kpi); line-height: 1.15; font-weight: 500; letter-spacing: -0.01em; } .kpi-value small { font-size: 0.55em; color: var(--muted); font-weight: 400; margin-left: 2px; } .delta { margin-top: 4px; font-size: var(--fs-small); color: var(--muted); } .delta .up { color: var(--positive); } .delta .down { color: var(--negative); } /* KPIs as the chart's tabs: the selected one carries a 2px rule and an ink label; the others keep a grey label and their value in ink at a lighter weight (a grey value reads as disabled). When the tabs are the per-entity breakdown the brief asks for (takings by shop), add .entities: nothing changes but the rule */ .kpis[role="tablist"] .kpi { position: relative; cursor: pointer; } .kpis[role="tablist"] .kpi[aria-selected="true"]::after { content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 2px; background: var(--text); } .kpis[role="tablist"]:not(.entities) .kpi[aria-selected="true"] .kpi-label { color: var(--text); } .kpis[role="tablist"]:not(.entities) .kpi[aria-selected="false"] .kpi-value { color: var(--text); font-weight: 400; } /* ---------- tables ---------- */ .table-wrap { overflow-x: auto; } table { width: 100%; border-collapse: collapse; } th { height: 36px; padding: 0 12px; text-align: left; font-weight: 400; font-size: var(--fs-small); color: var(--muted); border-bottom: 1px solid var(--border); white-space: nowrap; } /* cells share one baseline, so a figure never sits 2px below the name beside it; the row height comes from padding */ td { padding: calc((var(--row-height) - 20px) / 2) 12px; border-bottom: 1px solid var(--rule); vertical-align: baseline; } tbody tr:last-child td { border-bottom: 0; } /* no hover that changes the fill; a row that drives a detail pane takes the nav pill's fill when selected */ tbody tr[aria-selected="true"] td { background: var(--active); } th.n, td.n { text-align: right; } td .sub { display: block; font-size: var(--fs-small); color: var(--muted); } .truncate { max-width: 28ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* status pill: a pale tint with dark ink of the same hue, one per row */ .pill { display: inline-flex; align-items: center; gap: 6px; height: 22px; padding: 0 8px; border-radius: 999px; font-size: var(--fs-small); font-weight: 500; background: var(--pill-neutral-bg); color: var(--pill-neutral-ink); white-space: nowrap; } .pill.pos { background: var(--pill-positive-bg); color: var(--pill-positive-ink); } .pill.neg { background: var(--pill-negative-bg); color: var(--pill-negative-ink); } .pill.warn { background: var(--pill-warning-bg); color: var(--pill-warning-ink); } /* ---------- controls ---------- */ .btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; height: var(--control-height); padding: 0 12px; border-radius: var(--radius-control); border: 1px solid var(--control); background: var(--surface); font-weight: 500; cursor: pointer; transition: background-color var(--t-hover) ease, transform var(--t-press) var(--ease-out); } .btn:hover { background: var(--raised); } .btn:active { transform: translateY(1px); } .btn.primary { background: var(--primary); color: var(--primary-ink); border-color: transparent; } .btn.ghost { border-color: transparent; background: transparent; } .field { display: grid; gap: 8px; } .field label { font-weight: 500; } .field .hint { color: var(--muted); font-size: var(--fs-small); } .input, select.input { height: 38px; padding: 0 12px; border: 1px solid var(--control); border-radius: var(--radius-control); background: var(--surface); } .input::placeholder { color: var(--faint); } .tabs { display: flex; gap: 24px; border-bottom: 1px solid var(--border); margin-bottom: var(--gap); } .tabs a { position: relative; padding: 10px 0; color: var(--muted); transition: color var(--t-hover) ease; } .tabs a[aria-selected="true"] { color: var(--text); } .tabs a[aria-selected="true"]::after { content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 2px; background: var(--text); } /* as wide as its options; full width only on a narrow phone */ .segmented { display: inline-flex; width: max-content; max-width: 100%; padding: 2px; border-radius: 8px; background: var(--raised); } .segmented button { height: 28px; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: var(--muted); cursor: pointer; } .segmented button[aria-pressed="true"] { background: var(--surface); color: var(--text); } .switch { appearance: none; width: 32px; height: 20px; border-radius: 999px; background: var(--control); position: relative; cursor: pointer; transition: background-color var(--t-ui) ease; } .switch::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: transform var(--t-ui) var(--ease-out); } .switch:checked { background: var(--text); } .switch:checked::after { transform: translateX(12px); } /* empty and loading states keep the frame; a loading value is a flat grey bar at its real size, with no shimmer */ .empty { display: grid; place-items: center; min-height: 96px; padding: 16px; border: 1px dashed var(--control); border-radius: var(--radius-control); color: var(--muted); text-align: center; } .skeleton { background: var(--raised); border-radius: 4px; } /* ---------- phone: the navigation folds away and the canvas takes the width ---------- */ .menu-toggle { display: none; } @media (max-width: 860px) { .app { grid-template-columns: minmax(0, 1fr); } /* the menu opens as a full-screen sheet */ .sidebar { position: fixed; inset: 0; width: 100%; height: 100%; z-index: 20; background: var(--surface); transform: translateX(-100%); transition: transform var(--t-ui) var(--ease-out); } .sidebar.open { transform: none; } .menu-toggle { display: inline-flex; } .canvas { padding: 16px; } .grid > * { grid-column: 1 / -1 !important; } .kpis { grid-auto-flow: row; grid-template-columns: repeat(2, minmax(0, 1fr)); } .kpi + .kpi { border-left: 0; } .kpi:nth-child(n + 3) { border-top: 1px solid var(--border); } .kpi:nth-child(2n) { border-left: 1px solid var(--border); } /* an odd count leaves no half-empty row: the last cell takes the full width */ .kpi:last-child:nth-child(odd) { grid-column: 1 / -1; } /* KPI tabs in a 2-up grid: the selected cell takes a fill, never an underline floating mid-grid */ .kpis[role="tablist"] .kpi[aria-selected="true"] { background: var(--active); } .kpis[role="tablist"] .kpi[aria-selected="true"]::after { content: none; } .title-row { flex-wrap: wrap; align-items: flex-start; } } @media (max-width: 480px) { .segmented { display: flex; width: 100%; } .segmented button { flex: 1; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition-duration: 0.01ms !important; animation: none !important; } } /* an empty message spans the block it stands in, never one squeezed column */ .rows .empty, .grid > .empty, li > .empty { grid-column: 1 / -1; width: 100%; } ================ File: workspace/agents/opencode/engine/skills/design-taste-frontend/assets/base.css ================ /* tasteskill / base.css A neutral foundation: reset, token names, fluid type steps, a 12-column grid, states and the motion helpers from references/motion.md. It has no look of its own. Copy it into the project, replace the token values with the ones on the direction sheet, and build on top. Delete what you do not use. */ /* ---------- tokens (replace with the dealt palette and type system) ---------- */ :root { --bg: #ffffff; --surface: #f2f2f2; --ink: #111111; --muted: #5c5c5c; --accent: #111111; --accent-ink: #ffffff; --font-display: system-ui, sans-serif; --font-text: system-ui, sans-serif; --step-small: clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem); --step-body: clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem); --step-lead: clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem); --step-head: clamp(2rem, 1.3rem + 3.2vw, 4rem); --step-display: clamp(3rem, 1rem + 9vw, 9rem); /* set from the hero blueprint */ --margin: clamp(1rem, 2.2vw, 2rem); /* page side margin: tight, so content reaches the edges */ --gutter: clamp(1rem, 2vw, 2rem); /* column gap */ --section: clamp(5rem, 12vw, 13rem); /* space between sections */ --radius: 0px; /* one radius for the whole page: 0, or large, decided once */ --ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* entrances, moving hovers, UI under 600ms */ --ease-expo: cubic-bezier(0.16, 1, 0.3, 1); /* reveals of 800ms and longer, line rises, openings */ --ease-in-out: cubic-bezier(0.76, 0, 0.24, 1); /* wipes, panels, things that travel while visible */ --ease-soft: cubic-bezier(0.33, 1, 0.68, 1); /* slow scale and drift on photographs */ --ease-exit: cubic-bezier(0.55, 0.055, 0.675, 0.19); /* something leaving for good, short */ --t-press: 140ms; --t-hover: 180ms; --t-ui: 240ms; --t-reveal: 800ms; --t-open: 1200ms; /* picture openings and clip wipes */ --stagger-line: 90ms; --stagger-item: 70ms; } /* ---------- reset ---------- */ *, *::before, *::after { box-sizing: border-box; } html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; scroll-behavior: auto; } body { margin: 0; background: var(--bg); color: var(--ink); font-family: var(--font-text); font-size: var(--step-body); line-height: 1.5; font-kerning: normal; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; } h1, h2, h3, h4, p, figure, blockquote, ul, ol, dl, dd { margin: 0; } ul[class], ol[class] { padding: 0; list-style: none; } img, video, svg, canvas { display: block; max-width: 100%; } img, video { height: auto; background: var(--surface); } button, input, select, textarea { font: inherit; color: inherit; } button { background: none; border: 0; padding: 0; cursor: pointer; } a { color: inherit; } :target { scroll-margin-top: 6rem; } ::selection { background: var(--accent); color: var(--accent-ink); } :focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; } /* ---------- type ---------- */ h1, h2, h3 { font-family: var(--font-display); font-weight: inherit; text-wrap: balance; hyphens: none; } .display { font-size: var(--step-display); line-height: 0.95; letter-spacing: -0.035em; } .head { font-size: var(--step-head); line-height: 1.02; letter-spacing: -0.025em; } .lead { font-size: var(--step-lead); line-height: 1.35; max-width: 40ch; } .small { font-size: var(--step-small); line-height: 1.45; } .muted { color: var(--muted); } p { max-width: 62ch; text-wrap: pretty; } p + p { margin-top: 0.9em; } .nums { font-variant-numeric: tabular-nums; } .only-wide { display: none; } @media (min-width: 48em) { .only-wide { display: inline; } } /* ---------- layout ---------- */ .wrap { padding-inline: var(--margin); } .grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); column-gap: var(--gutter); } .section { padding-block: calc(var(--section) / 2); } .section + .section { margin-top: 0; } .bleed { margin-inline: calc(var(--margin) * -1); } .cover { width: 100%; height: 100%; object-fit: cover; } .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } /* ---------- controls ---------- */ .button { display: inline-flex; align-items: center; justify-content: center; min-height: 3rem; padding: 0 1.4rem; background: var(--accent); color: var(--accent-ink); border-radius: var(--radius); text-decoration: none; white-space: nowrap; transition: transform var(--t-press) var(--ease-out), background-color var(--t-hover) ease, color var(--t-hover) ease; } .button:active { transform: scale(0.97); } .link { text-decoration: underline; text-decoration-color: transparent; text-underline-offset: 0.2em; text-decoration-thickness: 0.07em; transition: text-decoration-color var(--t-hover) ease; } .link-plain { text-decoration: underline; text-underline-offset: 0.18em; text-decoration-thickness: 0.07em; } @media (hover: hover) and (pointer: fine) { .link:hover { text-decoration-color: currentColor; } } .field { width: 100%; min-height: 3rem; padding: 0.7rem 0.9rem; background: var(--surface); border: 0; border-radius: var(--radius); font-size: max(1rem, var(--step-body)); } /* ---------- motion helpers (content stays visible without JavaScript) ---------- */ .lines .line { display: block; overflow: hidden; padding-bottom: 0.08em; margin-bottom: -0.08em; } .lines [data-line] { display: block; } .shutter { overflow: hidden; } @media (prefers-reduced-motion: no-preference) { .js [data-rise] { opacity: 0; transform: translateY(18px); animation: ad-rise var(--t-reveal) var(--ease-out) forwards; animation-delay: calc(var(--i, 0) * 90ms + 120ms); } .js .lines [data-line] { transform: translateY(105%); animation: ad-line 900ms var(--ease-out) forwards; animation-delay: calc(var(--i, 0) * 110ms + 150ms); } .js [data-reveal] { opacity: 0; transform: translateY(24px); transition: opacity 700ms var(--ease-out), transform 900ms var(--ease-out); } .js [data-reveal].is-in { opacity: 1; transform: none; } .js .shutter[data-reveal] { opacity: 1; transform: none; clip-path: inset(0 0 100% 0); transition: clip-path 1100ms var(--ease-in-out); } .js .shutter[data-reveal] img { transform: scale(1.12); transition: transform 1500ms var(--ease-soft); } .js .shutter[data-reveal].is-in { clip-path: inset(0); } .js .shutter[data-reveal].is-in img { transform: none; } } @keyframes ad-rise { to { opacity: 1; transform: none; } } @keyframes ad-line { to { transform: none; } } ================ File: workspace/agents/opencode/engine/skills/design-taste-frontend/assets/base.js ================ // tasteskill / base.js — the small amount of script the motion helpers in base.css need. // Put this line in so the hidden states only apply when scripting works: // // Then load this file at the end of (or with defer). // Reveal once when an element enters the viewport. Never replays, never leaves content hidden. (() => { const targets = document.querySelectorAll('[data-reveal]'); if (!targets.length) return; if (!('IntersectionObserver' in window)) { targets.forEach((el) => el.classList.add('is-in')); return; } const io = new IntersectionObserver((entries) => { for (const entry of entries) { if (!entry.isIntersecting) continue; entry.target.classList.add('is-in'); io.unobserve(entry.target); } }, { rootMargin: '0px 0px -12% 0px', threshold: 0.1 }); targets.forEach((el) => io.observe(el)); // safety net: if anything is still hidden a few seconds after load (observer never fired), show it window.addEventListener('load', () => setTimeout(() => { targets.forEach((el) => { const r = el.getBoundingClientRect(); if (r.top < innerHeight && r.bottom > 0) el.classList.add('is-in'); }); }, 2500)); })(); // Optional, for "colour by chapter": each chapter paints its own full-bleed ground in CSS // (style="background: var(--field-2); color: var(--field-2-ink)"), so the colour changes exactly at // section edges. This only lets a fixed header take the ink of the chapter under it. Give each chapter // data-ink="#hex" and style the header with color: var(--header-ink, var(--ink)). // Never recolour the whole page from an observer: mid-change, a paragraph sits on two colours. (() => { const chapters = document.querySelectorAll('[data-ink]'); if (!chapters.length || !('IntersectionObserver' in window)) return; const io = new IntersectionObserver((entries) => { for (const entry of entries) if (entry.isIntersecting) document.documentElement.style.setProperty('--header-ink', entry.target.dataset.ink); }, { rootMargin: '0px 0px -92% 0px', threshold: 0 }); chapters.forEach((el) => io.observe(el)); })(); ================ File: workspace/agents/opencode/engine/skills/design-taste-frontend/assets/charts.js ================ // tasteskill / charts.js — small, dependency-free charts for app screens, drawn in SVG from your data. // They follow references/app-screens.md: one question per chart, one series that matters, 3–5 quiet // horizontal gridlines, round ticks that top out above the largest value, 2px straight lines with no // markers, square bars, no gradients, a bare tooltip, tabular figures. Colours come from CSS variables: // --chart-1 … --chart-4 (series), --chart-muted (other series), --chart-mark (a marked period; ink by default), // --chart-span (the opacity of a shaded span: about .06 on light, .12 on dark), --chart-track (a time strip's track), // --grid, --axis-ink, --ink, --surface, --raised, --rule, --negative // Usage (after this file loads): // Charts.line(el, { labels: ['1 Oct', …], series: [{ name: 'Requests', values: [...] }], format: Charts.fmt.compact }) // Charts.bars(el, { labels, values, highlight: 6, format }) one bar (or [indices]) may carry the accent; dim: [indices] // Charts.diverging(el, { labels, up, down, above: 'Sold', below: 'Bought', mark: [3, 9], markLabel: 'Car charged' }) // Charts.ring(el, { value: 0.72, label: '72%', sub: 'of capacity' }) a progress ring with its total inside // Charts.barList(el, [{ label: 'Berlin', value: 412 }, …], { format, thin }) a ranked list where the row is the bar // Charts.plan(el, [{ label: 'Groceries', planned: 600, spent: 412, committed: 60 }, …], { scale: 'row' | 'plan' }) planned against spent // Charts.bullet(el, [{ label: 'Bonds', value: 0.31, target: 0.25 }, …]) share against a target // Charts.timeline(el, rows, kinds, { days, today, weekends }) who is away when, each kind of booking its own mark // Charts.matrix(el, rows, { cols, comfort: 2, today }) slack against a minimum, team by day // Charts.timeStrip(el, rows, { open: '08:00', close: '15:00', compareLabel: 'Last Sunday' }) when, within the day // line, bars and diverging take annotations: [{ at: index, label: 'Deploy a3f09c1', short: 'a3f09c1' }] or spans // [{ from, to, label: 'Cloud' }]. Their labels sit in rows above the plot, measured once the fonts have loaded, and no // leader line crosses another label; the rows are reserved only when a label exists. reference: { value, label }, its // label set where no series passes. // line takes step: true for a balance that only changes on given days; today: index (the forecast after it is dashed) // with todayLabel: 'Now 20:34' (set clear of the line); endLabel: true (the last value at the line's end); max: caps a // flat series with one spike (the peak is labelled); yTicks: fixed ticks for a bounded quantity (0–100%); tickFormat: // axis labels apart from the tooltip format; xAt: the indices to label. Without xAt the x labels keep one step. // diverging, timeline, matrix and timeStrip return { svg, legend }, and line returns { legend } for two series or more: // the legend is drawn from the same style objects as the marks, so a swatch is always the mark scaled down. Put it in // the card's header, or pass legend: element (or a selector) to have it filled. // Numbers follow the page's language (). Every chart gets role="img" and an aria-label that states what it shows. (() => { const NS = 'http://www.w3.org/2000/svg', MINUS = '\u2212', ROW = 14, LANE = (rows) => 8 + rows * ROW + (rows ? 4 : 0); const css = (el, name, fb) => getComputedStyle(el).getPropertyValue(name).trim() || fb; const cssNum = (el, name, fb) => { const v = parseFloat(css(el, name, '')); return Number.isFinite(v) ? v : fb; }; const svg = (tag, attrs = {}, parent) => { const n = document.createElementNS(NS, tag); for (const [k, v] of Object.entries(attrs)) if (v != null) n.setAttribute(k, v); if (parent) parent.appendChild(n); return n; }; const text = (parent, str, attrs) => { const t = svg('text', attrs, parent); t.textContent = str; return t; }; const titled = (parent, str) => { svg('title', {}, parent).textContent = str; return parent; }; // numbers follow the page's language (), so separators match the rest of the screen const nf = (o) => new Intl.NumberFormat(document.documentElement.lang || 'en', o); // every text value that reaches innerHTML goes through esc: a label from a database is text, never markup const esc = (v) => String(v).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); const fmt = { int: (v) => nf({ maximumFractionDigits: 0 }).format(v), dec: (d) => (v) => nf({ minimumFractionDigits: d, maximumFractionDigits: d }).format(v), pct: (d = 1) => (v) => nf({ style: 'percent', minimumFractionDigits: d, maximumFractionDigits: d }).format(v), money: (cur = 'EUR', d = 0) => (v) => nf({ style: 'currency', currency: cur, minimumFractionDigits: d, maximumFractionDigits: d }).format(v), compact: (v) => nf({ notation: 'compact', maximumFractionDigits: 1 }).format(v), ms: (v) => `${nf({ maximumFractionDigits: 0 }).format(v)} ms`, // a sign on every change, with a true minus: fmt.signed() → '+2', '0', '−1'; fmt.signed(fmt.dec(1)) → '+0.4' signed: (f = (v) => nf({ maximumFractionDigits: 0 }).format(v)) => (v) => (v > 0 ? '+' : v < 0 ? MINUS : '') + f(Math.abs(v)), // minutes since midnight as a 24-hour time: 680 → '11:20' clock: (m) => `${String(Math.floor(m / 60) % 24).padStart(2, '0')}:${String(Math.round(m % 60)).padStart(2, '0')}`, // taste-skill-ignore: a clock, not a counter }; const minutes = (t) => (t == null || t === '' ? null : typeof t === 'number' ? t : (([hh, mm]) => hh * 60 + (mm || 0))(String(t).split(':').map(Number))); // Round ticks (steps of 1, 2, 2.5 or 5 × 10^n) whose top sits above the largest value. function ticks(min, max, count = 4) { if (max === min) max = min + 1; const raw = (max - min) / count, mag = 10 ** Math.floor(Math.log10(raw)); const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw) || 10 * mag; const lo = Math.floor(min / step) * step; let hi = Math.ceil(max / step) * step; if (hi <= max) hi += step; const out = []; for (let v = lo; v <= hi + step / 2; v += step) out.push(+v.toFixed(10)); return out; } // x labels keep one step all the way: every k-th label, with k chosen so the labels fit the width, and a step that // lands on the last label when one gives enough labels. Never a forced end label that breaks the step. Clock labels // ('08:30') step in whole hours (or halves and quarters) from a round time, so a half-hourly axis reads 06:00, 09:00. function xPicks(labels, span, at) { const n = labels.length; if (at) return at.filter((i) => i >= 0 && i < n); if (n <= 1) return n ? [0] : []; const longest = Math.max(...labels.map((l) => String(l).length)); const cap = Math.max(2, Math.min(8, Math.floor(span / (longest * 7 + 18)))); if (n <= cap) return [...Array(n).keys()]; const mins = labels.every((l) => /^\d{1,2}:\d{2}$/.test(String(l))) ? labels.map(minutes) : null; const d = mins ? mins[1] - mins[0] : 0; let first = null, land = null; for (let k = 1; k < n; k++) { const stepM = k * d, clock = mins && d > 0; if (clock && (stepM >= 60 ? ![1, 2, 3, 4, 6, 12].includes(stepM / 60) : 60 % stepM !== 0)) continue; // start on a round time: a multiple of the step (09:00 for three hours), or a whole hour for shorter steps let s = clock ? mins.findIndex((m) => m % Math.max(60, stepM) === 0) : 0; if (s < 0) s = Math.max(0, mins.findIndex((m) => m % 60 === 0)); const count = Math.floor((n - 1 - s) / k) + 1; if (count > cap) continue; if (first == null) first = { k, s }; if ((n - 1 - s) % k === 0) { land = { k, s, count }; break; } } const pick = land && land.count >= Math.max(3, Math.ceil(cap * 0.6)) ? land : first || { k: n - 1, s: 0 }; const out = []; for (let i = pick.s; i < n; i += pick.k) out.push(i); return out; } // colours: tokens resolved to rgb, so a mark can be mixed with the surface (a pale fill, a darker step) const swatchProbe = document.createElement('span'); swatchProbe.style.display = 'none'; const rgb = (el, value) => { el.appendChild(swatchProbe); swatchProbe.style.color = ''; swatchProbe.style.color = value; const c = getComputedStyle(swatchProbe).color; swatchProbe.remove(); const m = c.match(/-?[\d.]+/g); if (!m || m.length < 3) return null; return c.startsWith('color(') ? m.slice(0, 3).map((v) => Math.round(+v * 255)) : m.slice(0, 3).map(Number); }; const mix = (el, a, b, t) => { const p = rgb(el, a), q = rgb(el, b); return p && q ? `rgb(${p.map((v, i) => Math.round(v * t + q[i] * (1 - t))).join(',')})` : a; }; // text width, measured in the chart's own face (falls back to an estimate when the chart is hidden) const measure = (s, str, size = 11, weight = 400) => { const t = text(s, str, { x: -999, y: -999, 'font-size': size, 'font-weight': weight, visibility: 'hidden' }); let wd = 0; try { wd = t.getComputedTextLength(); } catch {} t.remove(); return wd > 0 ? wd : String(str).length * size * 0.56; }; const fit = (s, str, max, size, weight) => { str = String(str); if (measure(s, str, size, weight) <= max) return str; while (str.length > 1 && measure(s, str + '\u2026', size, weight) > max) str = str.slice(0, -1); return str.trimEnd() + '\u2026'; }; function frame(el, h) { el.innerHTML = ''; const w = Math.max(240, el.clientWidth || 600); const s = svg('svg', { viewBox: `0 0 ${w} ${h}`, width: '100%', height: h, role: 'img', style: 'display:block;overflow:visible;font-variant-numeric:tabular-nums' }, el); return { s, w, h }; } function tooltip(el) { let t = el.querySelector('.chart-tip'); if (!t) { t = document.createElement('div'); t.className = 'chart-tip'; t.style.cssText = 'position:absolute;pointer-events:none;padding:6px 8px;font:inherit;font-size:12px;line-height:1.35;border-radius:6px;white-space:nowrap;opacity:0;transition:opacity 120ms ease;background:var(--surface,#fff);color:var(--ink,#111);border:1px solid var(--grid,#e5e7eb)'; if (getComputedStyle(el).position === 'static') el.style.position = 'relative'; el.appendChild(t); } return t; } function axes(g, el, { w, h, padL, padB, yt, y, labels, x, band = false, at = null, tick, avoid = null }) { const grid = css(el, '--grid', '#e8e8e8'), ink = css(el, '--axis-ink', '#6b6b6b'), s = g.ownerSVGElement; for (const v of yt) { const yy = y(v); svg('line', { x1: padL, x2: w, y1: yy, y2: yy, stroke: grid, 'stroke-width': 1, 'shape-rendering': 'crispEdges' }, g); text(g, tick(v), { x: padL - 8, y: yy + 4, 'text-anchor': 'end', 'font-size': 12, fill: ink }); } const n = labels.length; for (const i of xPicks(labels, w - padL, at)) { const lab = String(labels[i]), tw = measure(s, lab, 12), xx = x(i); // the first and last labels are pulled inside the plot; none runs past either edge let left = band ? xx - tw / 2 : i === 0 ? xx : i === n - 1 ? xx - tw : xx - tw / 2; left = Math.max(band ? 0 : padL, Math.min(w - tw, left)); if (avoid && left < avoid[1] + 8 && left + tw > avoid[0] - 8) continue; text(g, lab, { x: left, y: h - padB + 18, 'font-size': 12, fill: ink }); } } // Annotation labels sit in rows above the plot, never on the data. A label sits beside the top of its own leader // line, right of it or left of it, in one of three rows. A place is free when no other label in that row overlaps // it, no leader line passes through it, and its own leader passes through no label below it. The layout is searched // (close markers often need the first label on the left so the next leader runs clear): full labels first, then // short forms (`short`), and only then is a label dropped to its marker's tooltip, with a note in the console. function lane(s, items, w, maxRows = 3) { const gap = 6, rank = (it) => (it.fixed != null ? 0 : it.span ? 1 : 2); // fixed captions first, then spans, then markers from left to right const order = [...items].sort((a, b) => rank(a) - rank(b) || (a.x ?? a.x0) - (b.x ?? b.x0)); const opts = order.map((it) => { const out = []; for (const str of [it.label, it.short].filter(Boolean)) { const tw = measure(s, str, 11) + (it.glyph ? 9 : 0), mid = it.span ? (it.x0 + it.x1) / 2 : 0; const cands = it.fixed != null ? [[it.fixed, it.fixed + tw]] : it.span ? [[mid - tw / 2, mid + tw / 2], [it.x0, it.x0 + tw], [it.x1 - tw, it.x1]] : [[it.x + 4, it.x + 4 + tw], [it.x - 4 - tw, it.x - 4]]; for (let row = 0; row < maxRows; row++) for (const [x0, x1] of cands) if (x0 >= 0 && x1 <= w) out.push({ ...it, text: str, isShort: str !== it.label, row, x0, x1 }); } return out; }); const free = (c, placed) => placed.every((p) => p.dropped || (!(p.row === c.row && c.x0 < p.x1 + gap && c.x1 > p.x0 - gap) && !(p.leader != null && p.row >= c.row && p.leader > c.x0 - 3 && p.leader < c.x1 + 3) && !(c.leader != null && p.row <= c.row && c.leader > p.x0 - 3 && c.leader < p.x1 + 3))); let best = null, steps = 0; const solve = (i, placed, allowShort, allowDrop) => { if (++steps > 20000) return false; if (i === order.length) { best = placed.slice(); return true; } for (const c of opts[i]) { if ((c.isShort && !allowShort) || !free(c, placed)) continue; placed.push(c); if (solve(i + 1, placed, allowShort, allowDrop)) return true; placed.pop(); } if (allowDrop) { placed.push({ ...order[i], dropped: true }); if (solve(i + 1, placed, allowShort, allowDrop)) return true; placed.pop(); } return false; }; for (const [sh, dr] of [[false, false], [true, false], [true, true]]) { steps = 0; if (solve(0, [], sh, dr)) break; } const placed = (best || []).filter((p) => !p.dropped), dropped = (best || order.map((it) => ({ ...it, dropped: true }))).filter((p) => p.dropped); return { placed, dropped, rows: placed.length ? Math.max(...placed.map((p) => p.row)) + 1 : 0 }; } function prepNotes(s, annotations = [], x, w, extra = []) { const items = []; for (const a of annotations || []) { if (a.from != null) items.push({ span: true, x0: Math.min(x(a.from), x(a.to)), x1: Math.max(x(a.from), x(a.to)), label: a.label, short: a.short, colour: a.colour }); else if (a.at != null) items.push({ x: x(a.at), at: a.at, leader: x(a.at), label: a.label, short: a.short, colour: a.colour }); } items.push(...extra); return { items, ...lane(s, items.filter((it) => it.label), w) }; } // shaded spans go under the data; marker lines and the label rows over it function notesUnder(g, el, N, { padT, h, padB }) { const ink = css(el, '--axis-ink', '#6b6b6b'), op = cssNum(el, '--chart-span', 0.07); for (const it of N.items) if (it.span) svg('rect', { x: it.x0, y: padT, width: it.x1 - it.x0, height: h - padT - padB, fill: ink, opacity: op }, g); } function notesOver(g, el, N, { padT, h, padB }) { const ink = css(el, '--axis-ink', '#6b6b6b'); // a marker line runs to the plot's foot, or stops above the bar it points at (it.end) for (const it of N.items) if (it.leader != null && !it.drawn) svg('line', { x1: it.leader, x2: it.leader, y1: padT, y2: Math.max(padT, it.end ?? h - padB), stroke: ink, 'stroke-width': 1, opacity: 0.45, 'shape-rendering': 'crispEdges' }, g); for (const p of N.placed) { const base = padT - 9 - p.row * ROW; if (p.leader != null) svg('line', { x1: p.leader, x2: p.leader, y1: base - 8, y2: padT, stroke: ink, 'stroke-width': 1, opacity: 0.45, 'shape-rendering': 'crispEdges' }, g); if (p.glyph) svg('path', { d: p.glyph === 'up' ? `M${p.x0} ${base - 1}l3 -6l3 6z` : `M${p.x0} ${base - 7}l3 6l3 -6z`, fill: ink }, g); text(g, p.text, { x: p.x0 + (p.glyph ? 9 : 0), y: base, 'font-size': 11, fill: p.colour || ink }); } const el2 = g.ownerSVGElement.parentNode; el2._warned = el2._warned || new Set(); for (const d of N.dropped) { if (d.leader != null) { const hit = svg('line', { x1: d.leader, x2: d.leader, y1: padT, y2: h - padB, stroke: 'transparent', 'stroke-width': 8 }, g); titled(hit, d.label, {}); } if (!el2._warned.has(d.label)) { el2._warned.add(d.label); console.warn(`Charts: no room above the plot for "${d.label}". Give it a short form (short) or label fewer markers.`); } } } // does a label box touch a drawn line? polylines are [[x, y], …]; a box is { x0, x1, y0, y1 } function hits(b, lines) { for (const pts of lines) for (let k = 1; k < pts.length; k++) { const a = pts[k - 1], c = pts[k]; if (!a || !c) continue; if (a[0] === c[0]) { if (a[0] >= b.x0 - 3 && a[0] <= b.x1 + 3 && Math.max(a[1], c[1]) >= b.y0 - 3 && Math.min(a[1], c[1]) <= b.y1 + 3) return true; continue; } const lo = Math.max(b.x0 - 3, Math.min(a[0], c[0])), hi = Math.min(b.x1 + 3, Math.max(a[0], c[0])); for (let xx = lo; xx <= hi; xx += 2) { const yy = a[1] + ((xx - a[0]) / (c[0] - a[0])) * (c[1] - a[1]); if (yy >= b.y0 - 3 && yy <= b.y1 + 3) return true; } } return false; } const boxHits = (b, boxes) => boxes.some((r) => b.x0 < r.x1 + 3 && b.x1 > r.x0 - 3 && b.y0 < r.y1 + 3 && b.y1 > r.y0 - 3); // a reference line (a budget, an SLO) with its label at an end where no series passes function references(g, el, refs, { y, padL, w, lines = [], boxes = [] }) { const ink = css(el, '--axis-ink', '#6b6b6b'), s = g.ownerSVGElement; for (const r of [].concat(refs || [])) { const yy = y(r.value); svg('line', { x1: padL, x2: w, y1: yy, y2: yy, stroke: ink, 'stroke-width': 1, 'stroke-dasharray': '4 4', opacity: 0.7 }, g); if (!r.label) continue; const tw = measure(s, r.label, 11), right = r.at !== 'left'; const cand = [[right, -5], [right, 13], [!right, -5], [!right, 13]].map(([rt, dy]) => ({ x0: rt ? w - tw : padL + 4, x1: rt ? w : padL + 4 + tw, y0: yy + dy - 9, y1: yy + dy + 2, base: yy + dy })); const b = cand.find((c) => !hits(c, lines) && !boxHits(c, boxes)) || cand[0]; text(g, r.label, { x: b.x0, y: b.base, 'font-size': 11, fill: ink }); } } // One style object per kind of mark draws both the mark and its legend swatch, so the two cannot drift apart. function paintRect(parent, st, x, y, w, h, r = 0) { let fill = st.fill || 'none'; if (st.hatch) { const s = parent.ownerSVGElement || parent, id = 'h' + Math.random().toString(36).slice(2, 8); const p = svg('pattern', { id, width: 5, height: 5, patternUnits: 'userSpaceOnUse', patternTransform: 'rotate(45)' }, svg('defs', {}, s)); svg('rect', { width: 5, height: 5, fill: st.fill }, p); svg('line', { x1: 0, y1: 0, x2: 0, y2: 5, stroke: st.hatch, 'stroke-width': 1.2 }, p); fill = `url(#${id})`; } const inset = st.stroke ? (st.width || 1) / 2 : 0; const n = svg('rect', { x: x + inset, y: y + inset, width: Math.max(0, w - inset * 2), height: Math.max(0, h - inset * 2), rx: r, fill, stroke: st.stroke, 'stroke-width': st.stroke ? st.width || 1 : null, 'stroke-dasharray': st.dash, opacity: st.opacity, 'shape-rendering': r ? null : 'crispEdges' }, parent); if (st.edge) svg('rect', { x, y, width: 3, height: h, rx: Math.min(r, 1.5), fill: st.edge }, parent); return n; } const legendCss = ':where(.chart-legend){list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:4px 14px;font-size:12px;line-height:16px;color:var(--muted,#5f6368)}:where(.chart-legend li){display:inline-flex;align-items:center;gap:6px;white-space:nowrap}:where(.chart-legend svg){flex:none;overflow:visible}@media (max-width:480px){:where(.chart-legend){display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}}'; function legendEl(items, target) { if (!document.getElementById('charts-legend-css')) { const st = document.createElement('style'); st.id = 'charts-legend-css'; st.textContent = legendCss; document.head.appendChild(st); } const ul = document.createElement('ul'); ul.className = 'chart-legend'; for (const it of items) { const li = document.createElement('li'), sw = svg('svg', { width: it.w || 14, height: 12, 'aria-hidden': 'true' }); it.draw(sw); li.append(sw, document.createTextNode(it.label)); ul.appendChild(li); } const t = typeof target === 'string' ? document.querySelector(target) : target; if (t) t.replaceChildren(ul); return ul; } const rectSwatch = (st, w = 14, h = 10, r = 2) => (sw) => paintRect(sw, st, 0, (12 - h) / 2, w, h, r); // charts are laid out with measured text: draw again once the page's fonts have arrived function settle(draw) { if (document.fonts) { document.fonts.ready.then(draw); document.fonts.addEventListener?.('loadingdone', draw, { once: true }); } } const heightOf = (h) => (typeof h === 'function' ? h() : h); function line(el, { labels, series, format = fmt.int, tickFormat = null, height = 220, fill = false, min, max, step = false, annotations, reference, today = null, todayLabel = 'Today', endLabel = false, yTicks = null, xAt = null, legend = null }) { el._fmt = format; const colourOf = (sr, k) => sr.colour || (k === 0 ? css(el, '--chart-1', '#2f6fdf') : css(el, `--chart-${k + 1}`, css(el, '--chart-muted', '#a3a3a3'))); const draw = () => { const { s, w, h } = frame(el, heightOf(height)); const all = series.flatMap((x) => x.values.filter((v) => v != null)).concat([].concat(reference || []).map((r) => r.value)); // max caps the range (a flat series with one spike): values above it are clipped and their peak labelled const top = max ?? Math.max(...all); const yt = yTicks || ticks(min ?? Math.min(0, ...all), top, 4); const tick = tickFormat || format; const lastVal = series[0].values.filter((v) => v != null).at(-1); const padL = 12 + Math.max(...yt.map((v) => measure(s, tick(v), 12))), padB = 26, padR = endLabel ? 10 + measure(s, format(lastVal), 12, 600) : 0; const x = (i) => padL + (i * (w - padL - padR)) / Math.max(1, labels.length - 1); // the peaks clipped by max are labelled in the rows above the plot, where the line leaves it const peaks = []; series.forEach((sr, k) => sr.values.forEach((v, i) => { if (v != null && v > yt.at(-1) && (i === 0 || sr.values[i - 1] == null || sr.values[i - 1] <= yt.at(-1))) { let j = i, peak = v; while (j < sr.values.length && sr.values[j] != null && sr.values[j] > yt.at(-1)) { peak = Math.max(peak, sr.values[j]); j++; } peaks.push({ x: x(i), label: format(peak) + ' at ' + labels[i], colour: colourOf(sr, k) }); } })); const scale = (N) => { const padT = LANE(N.rows); return { padT, y: (v) => padT + (h - padT - padB) * (1 - (v - yt[0]) / (yt.at(-1) - yt[0])) }; }; const linesAt = (y) => series.map((sr) => { const out = []; sr.values.forEach((v, i) => { if (v == null) { out.push(null); return; } const p = [x(i), y(Math.min(v, yt.at(-1)))]; const prev = out.at(-1); if (step && prev) out.push([p[0], prev[1]]); out.push(p); }); return out; }).concat([].concat(reference || []).map((r) => [[padL, y(r.value)], [w, y(r.value)]])); let N = prepNotes(s, annotations, x, w, peaks), { padT, y } = scale(N), todayBox = null; // the today label goes inside the plot on whichever side of its line no series crosses; failing that, above if (today != null && todayLabel) { const cut = x(today), tw = measure(s, todayLabel, 11), lines = linesAt(y); const cand = [[cut + 4, h - padB - 6], [cut - 4 - tw, h - padB - 6], [cut + 4, padT + 11], [cut - 4 - tw, padT + 11]].map(([x0, b]) => ({ x0, x1: x0 + tw, y0: b - 9, y1: b + 2, base: b })); todayBox = cand.find((b) => b.x0 >= padL && b.x1 <= w && !hits(b, lines)) || null; if (!todayBox) { N = prepNotes(s, annotations, x, w, [...peaks, { x: cut, leader: cut, drawn: true, label: todayLabel }]); ({ padT, y } = scale(N)); } } const g = svg('g', {}, s); axes(g, el, { w, h, padL, padB, yt, y, labels, x, at: xAt, tick }); notesUnder(g, el, N, { padT, h, padB }); series.forEach((sr, k) => { const col = colourOf(sr, k); const pts = sr.values.map((v, i) => (v == null ? null : [x(i), y(Math.min(v, yt.at(-1)))])); // step: a balance that only changes on given days holds its value until the next one const d = pts.reduce((acc, p, i) => (p ? acc + (acc && pts[i - 1] ? (step ? `L${p[0].toFixed(1)} ${pts[i - 1][1].toFixed(1)}L` : 'L') : 'M') + p[0].toFixed(1) + ' ' + p[1].toFixed(1) : acc), ''); if (fill && k === 0) { const first = pts.find(Boolean), last = [...pts].reverse().find(Boolean); svg('path', { d: `${d}L${last[0]} ${y(yt[0])}L${first[0]} ${y(yt[0])}Z`, fill: col, opacity: 0.12 }, g); } if (today == null) svg('path', { d, fill: 'none', stroke: col, 'stroke-width': 2, 'stroke-linejoin': 'round', 'stroke-linecap': 'round' }, g); else { // a forecast: the line after today is dashed, so a projection never reads as a fact const cut = x(today), id = 'c' + k + Math.random().toString(36).slice(2, 8); const defs = svg('defs', {}, g); const a = svg('clipPath', { id: id + 'a' }, defs); svg('rect', { x: 0, y: 0, width: cut, height: h }, a); const b = svg('clipPath', { id: id + 'b' }, defs); svg('rect', { x: cut, y: 0, width: w, height: h }, b); svg('path', { d, fill: 'none', stroke: col, 'stroke-width': 2, 'stroke-linejoin': 'round', 'clip-path': 'url(#' + id + 'a)' }, g); svg('path', { d, fill: 'none', stroke: col, 'stroke-width': 2, 'stroke-dasharray': '5 4', opacity: 0.8, 'clip-path': 'url(#' + id + 'b)' }, g); if (k === 0) { svg('line', { x1: cut, x2: cut, y1: padT, y2: h - padB, stroke: css(el, '--axis-ink', '#6b6b6b'), 'stroke-width': 1, opacity: 0.4 }, g); if (todayBox) text(g, todayLabel, { x: todayBox.x0, y: todayBox.base, 'font-size': 11, fill: css(el, '--axis-ink', '#6b6b6b') }); } } if (endLabel && k === 0) { const li = sr.values.map((v, i) => (v == null ? -1 : i)).filter((i) => i >= 0).at(-1); text(g, format(sr.values[li]), { x: x(li) + 6, y: y(Math.min(sr.values[li], yt.at(-1))) + 4, 'font-size': 12, 'font-weight': 600, fill: css(el, '--ink', '#111') }); } }); references(g, el, reference, { y, padL, w, lines: linesAt(y).slice(0, series.length) }); notesOver(g, el, N, { padT, h, padB }); // hover: a rule, a dot per series and a bare tooltip const tip = tooltip(el), rule = svg('line', { y1: padT, y2: h - padB, stroke: css(el, '--axis-ink', '#6b6b6b'), 'stroke-width': 1, opacity: 0 }, g); const dots = series.map((sr, k) => svg('circle', { r: 3.5, fill: css(el, '--surface', '#fff'), stroke: colourOf(sr, k), 'stroke-width': 2, opacity: 0 }, g)); const hit = svg('rect', { x: padL, y: padT, width: w - padL, height: h - padT - padB, fill: 'transparent' }, s); const move = (ev) => { const r = s.getBoundingClientRect(); const px = ((ev.clientX - r.left) / r.width) * w; const i = Math.max(0, Math.min(labels.length - 1, Math.round(((px - padL) / (w - padL - padR)) * (labels.length - 1)))); rule.setAttribute('x1', x(i)); rule.setAttribute('x2', x(i)); rule.setAttribute('opacity', 0.35); series.forEach((sr, k) => { const v = sr.values[i]; dots[k].setAttribute('cx', x(i)); dots[k].setAttribute('cy', v == null ? -99 : y(Math.min(v, yt.at(-1)))); dots[k].setAttribute('opacity', v == null ? 0 : 1); }); tip.innerHTML = `
${esc(labels[i])}
` + series.map((sr) => `
${sr.values[i] == null ? '–' : esc(format(sr.values[i]))} ${series.length > 1 ? esc(sr.name) : ''}
`).join(''); const left = Math.min((x(i) / w) * r.width + 12, r.width - tip.offsetWidth - 4); tip.style.left = `${Math.max(0, left)}px`; tip.style.top = '0px'; tip.style.opacity = 1; }; hit.addEventListener('pointermove', move); hit.addEventListener('pointerleave', () => { tip.style.opacity = 0; rule.setAttribute('opacity', 0); dots.forEach((d) => d.setAttribute('opacity', 0)); }); const last = series[0].values.filter((v) => v != null).at(-1); s.setAttribute('aria-label', `${series.map((x) => x.name).join(', ')}, ${labels[0]} to ${labels.at(-1)}; latest ${last == null ? 'no data' : format(last)}`); }; draw(); watch(el, draw); settle(draw); const lg = series.length > 1 ? legendEl(series.map((sr, k) => ({ label: sr.name, w: 16, draw: (sw) => svg('line', { x1: 0, x2: 16, y1: 6, y2: 6, stroke: colourOf(sr, k), 'stroke-width': 2, 'stroke-dasharray': sr.dash ? '4 3' : null }, sw) })), legend) : null; return { legend: lg }; } function bars(el, { labels, values, format = fmt.int, tickFormat = null, height = 200, highlight = null, dim = [], colour, mutedColour, annotations, reference, xAt = null }) { el._fmt = format; const lit = (i) => highlight == null || (Array.isArray(highlight) ? highlight.includes(i) : highlight === i); const draw = () => { const { s, w, h } = frame(el, heightOf(height)); const yt = ticks(0, Math.max(...values, ...[].concat(reference || []).map((r) => r.value)), 4), tick = tickFormat || format; const padL = 12 + Math.max(...yt.map((v) => measure(s, tick(v), 12))), padB = 26; const band = (w - padL) / values.length, bw = Math.max(2, band * 0.62); const x = (i) => padL + band * i + band / 2; const N = prepNotes(s, annotations, x, w), padT = LANE(N.rows); const y = (v) => padT + (h - padT - padB) * (1 - v / yt.at(-1)); const g = svg('g', {}, s); axes(g, el, { w, h, padL, padB, yt, y, labels, x, band: true, at: xAt, tick }); notesUnder(g, el, N, { padT, h, padB }); for (const it of N.items) if (it.at != null && values[it.at] != null) it.end = y(values[it.at]) - 4; const main = colour || css(el, '--chart-1', '#2f6fdf'), muted = mutedColour || css(el, '--chart-muted', '#c9c9c9'); values.forEach((v, i) => { const r = svg('rect', { x: x(i) - bw / 2, y: y(v), width: bw, height: Math.max(0, y(0) - y(v)), fill: lit(i) ? main : muted, opacity: dim.includes(i) ? 0.45 : null, 'shape-rendering': 'crispEdges' }, g); titled(r, `${labels[i]}: ${format(v)}`, {}); }); references(g, el, reference, { y, padL, w, boxes: values.map((v, i) => ({ x0: x(i) - bw / 2, x1: x(i) + bw / 2, y0: y(v), y1: y(0) })) }); notesOver(g, el, N, { padT, h, padB }); s.setAttribute('aria-label', `${labels.length} bars from ${labels[0]} to ${labels.at(-1)}; highest ${format(Math.max(...values))}`); }; draw(); watch(el, draw); settle(draw); } // Two directions on one scale: what went out above the zero line and what came in below it (sold and bought, // income and spend). The axis says which is which, with signed ticks and a caption on each side of zero. // mark: [indices] draws those periods in a second tone with their own legend entry (the nights the car charged). function diverging(el, { labels, up, down, values, above = 'Above', below = 'Below', mark = [], markLabel = null, markSide = 'below', partial = null, format = fmt.int, tickFormat = null, height = 200, annotations, reference, xAt = null, legend = null }) { if (values) { up = values.map((v) => Math.max(0, v || 0)); down = values.map((v) => Math.max(0, -(v || 0))); } up = up || labels.map(() => 0); down = down || labels.map(() => 0); const st = { above: { fill: css(el, '--chart-1', '#2f6fdf') }, below: { fill: css(el, '--chart-muted', '#c9c9c9') }, mark: { fill: css(el, '--chart-mark', css(el, '--ink', '#111')) } }; const marked = (i, side) => mark.includes(i) && (markSide === 'both' || markSide === side); const tick = (v) => (v > 0 ? '+' : v < 0 ? MINUS : '') + (tickFormat || format)(Math.abs(v)); const draw = () => { const { s, w, h } = frame(el, heightOf(height)); const hiUp = Math.max(0, ...up), hiDown = Math.max(0, ...down); const yt = ticks(-hiDown, hiUp || 1, 4); const stp = yt[1] - yt[0]; if (hiDown > 0 && yt[0] >= -hiDown) yt.unshift(+(yt[0] - stp).toFixed(10)); const padL = 12 + Math.max(...yt.map((v) => measure(s, tick(v), 12))), padB = 26; const band = (w - padL) / labels.length, bw = Math.max(2, band * 0.62); const x = (i) => padL + band * i + band / 2; // the caption for the upper side opens the label rows above the plot; the lower one sits under the plot, at left const N = prepNotes(s, annotations, x, w, [{ fixed: padL, glyph: 'up', label: above }]), padT = LANE(N.rows); const y = (v) => padT + (h - padT - padB) * (1 - (v - yt[0]) / (yt.at(-1) - yt[0])); const g = svg('g', {}, s), ink = css(el, '--axis-ink', '#6b6b6b'); const bwid = measure(s, below, 11) + 9; axes(g, el, { w, h, padL, padB, yt, y, labels, x, band: true, at: xAt, tick, avoid: [padL, padL + bwid] }); svg('path', { d: `M${padL} ${h - padB + 11}l3 6l3 -6z`, fill: ink }, g); text(g, below, { x: padL + 9, y: h - padB + 18, 'font-size': 11, fill: ink }); notesUnder(g, el, N, { padT, h, padB }); for (const it of N.items) if (it.at != null) it.end = y(up[it.at] || 0) - 4; labels.forEach((lab, i) => { const o = partial === i ? 0.5 : null; if (up[i] > 0) { const r = paintRect(g, { ...(marked(i, 'above') ? st.mark : st.above), opacity: o }, x(i) - bw / 2, y(up[i]), bw, Math.max(0, y(0) - y(up[i]) - 1)); titled(r, `${lab}: ${above} ${format(up[i])}`, {}); } if (down[i] > 0) { const r = paintRect(g, { ...(marked(i, 'below') ? st.mark : st.below), opacity: o }, x(i) - bw / 2, y(0) + 1, bw, Math.max(0, y(-down[i]) - y(0) - 1)); titled(r, `${lab}: ${below} ${format(down[i])}`, {}); } }); svg('line', { x1: padL, x2: w, y1: y(0), y2: y(0), stroke: ink, 'stroke-width': 1, opacity: 0.8, 'shape-rendering': 'crispEdges' }, g); references(g, el, reference, { y, padL, w, boxes: labels.map((_, i) => ({ x0: x(i) - bw / 2, x1: x(i) + bw / 2, y0: y(up[i]), y1: y(-down[i]) })) }); notesOver(g, el, N, { padT, h, padB }); s.setAttribute('aria-label', `${above} above the line and ${below} below it, ${labels[0]} to ${labels.at(-1)}`); }; draw(); watch(el, draw); settle(draw); const items = []; if (up.some((v) => v > 0)) items.push({ label: above, draw: rectSwatch(st.above) }); if (down.some((v) => v > 0)) items.push({ label: below, draw: rectSwatch(st.below) }); if (mark.length && markLabel) items.push({ label: markLabel, draw: rectSwatch(st.mark) }); return { get svg() { return el.querySelector(':scope > svg'); }, legend: legendEl(items, legend) }; } function ring(el, { value, label, sub, size = 132, stroke = 8, colour }) { el.innerHTML = ''; const r = (size - stroke) / 2, c = 2 * Math.PI * r, v = Math.max(0, Math.min(1, value)); const wrap = document.createElement('div'); wrap.style.cssText = `position:relative;width:${size}px;height:${size}px`; const s = svg('svg', { viewBox: `0 0 ${size} ${size}`, width: size, height: size, role: 'img', 'aria-label': `${label}${sub ? ' ' + sub : ''}` }, wrap); svg('circle', { cx: size / 2, cy: size / 2, r, fill: 'none', stroke: css(el, '--grid', '#e8e8e8'), 'stroke-width': stroke }, s); svg('circle', { cx: size / 2, cy: size / 2, r, fill: 'none', stroke: colour || css(el, '--chart-1', '#2f6fdf'), 'stroke-width': stroke, 'stroke-dasharray': `${c * v} ${c}`, transform: `rotate(-90 ${size / 2} ${size / 2})` }, s); const t = document.createElement('div'); t.style.cssText = 'position:absolute;inset:0;display:grid;place-content:center;text-align:center;font-variant-numeric:tabular-nums'; t.innerHTML = `
${esc(label)}
${sub ? `
${esc(sub)}
` : ''}`; wrap.appendChild(t); el.appendChild(wrap); } // A ranked list. By default the row's fill is the bar (a one-figure list); thin: true puts a 2px grey bar under each // label instead, ink for the top item, for a ranked list of money in a narrow card. function barList(el, rows, { format = fmt.int, colour, thin = false } = {}) { el.innerHTML = ''; const max = Math.max(...rows.map((r) => r.value)) || 1; const list = document.createElement('ol'); list.style.cssText = `list-style:none;margin:0;padding:0;display:grid;gap:${thin ? 10 : 2}px;font-variant-numeric:tabular-nums`; const valueCol = Math.max(...rows.map((r) => String(format(r.value)).length)) + 1; rows.forEach((r, k) => { const li = document.createElement('li'); li.style.cssText = 'display:grid;grid-template-columns:minmax(0,1fr) ' + valueCol + 'ch;align-items:center;column-gap:12px'; const a = document.createElement('span'); a.textContent = r.label; a.style.cssText = 'position:relative;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis'; const b = document.createElement('span'); b.textContent = format(r.value); b.style.cssText = 'font-weight:500;text-align:right'; if (thin) { const bar = document.createElement('span'); bar.style.cssText = `grid-column:1 / -1;height:2px;margin-top:4px;width:${(r.value / max) * 100}%;background:${k === 0 ? colour || 'var(--ink,#111)' : 'var(--chart-muted,#c9c9c9)'}`; li.append(a, b, bar); } else { const track = document.createElement('span'); track.style.cssText = 'position:relative;padding:10px 12px;border-radius:6px;isolation:isolate;min-width:0'; const bar = document.createElement('span'); bar.style.cssText = `position:absolute;inset:0 auto 0 0;width:${(r.value / max) * 100}%;background:${colour || 'var(--bar-fill, color-mix(in srgb, var(--chart-1, #2f6fdf) 12%, transparent))'};border-radius:inherit;z-index:-1`; track.append(bar, a); li.append(track, b); } list.appendChild(li); }); el.appendChild(list); } // Planned against spent: the track is the plan, the fill what is spent, a lighter segment what is // already committed (bills to come), and an overrun past the track's end in the negative colour. // One row per category: label and figures on one line, the 8px track under them. function plan(el, rows, { format = fmt.money('EUR', 0), scale = 'row', note = (r) => r.spent > r.planned ? `${format(r.spent - r.planned)} over` : `${format(r.planned - r.spent - (r.committed || 0))} left` } = {}) { el.innerHTML = ''; const list = document.createElement('ul'); list.style.cssText = 'list-style:none;margin:0;padding:0;display:grid;gap:14px;font-variant-numeric:tabular-nums'; const max = Math.max(...rows.map((r) => Math.max(r.planned, r.spent + (r.committed || 0)))); for (const r of rows) { const over = r.spent > r.planned; const li = document.createElement('li'); const top = document.createElement('div'); top.style.cssText = 'display:flex;justify-content:space-between;gap:12px;margin-bottom:6px'; top.innerHTML = `${esc(r.label)}${esc(format(r.spent))} of ${esc(format(r.planned))} · ${esc(note(r))}`; // scale 'row': each track is full width and shows the share used (rows in a grid compare); 'plan': as long as its plan const room = over ? 100 / (1 + Math.max(0.06, (r.spent - r.planned) / r.planned)) : 100; const track = document.createElement('div'); track.style.cssText = 'position:relative;height:8px;border-radius:999px;background:var(--rule,#eceeef);overflow:visible;width:' + (scale === 'plan' ? (r.planned / max) * room : room) + '%'; const pct = (v) => (v / r.planned) * 100; const spent = Math.min(r.spent, r.planned); track.innerHTML = `` + (r.committed ? `` : '') + (over ? `` : ''); track.setAttribute('role', 'img'); track.setAttribute('aria-label', `${r.label}: ${format(r.spent)} of ${format(r.planned)}${r.committed ? `, ${format(r.committed)} committed` : ''}${over ? ', over plan' : ''}`); li.append(top, track); list.appendChild(li); } el.appendChild(list); } // Share against a target: one row per part, the current share as a bar and a 1px tick at the target, // so drift reads at a glance (never two stacked bars to compare). function bullet(el, rows, { format = fmt.pct(1), max = Math.max(...rows.map((r) => Math.max(r.value, r.target))) * 1.15 } = {}) { el.innerHTML = ''; const list = document.createElement('ul'); list.style.cssText = 'list-style:none;margin:0;padding:0;display:grid;gap:12px;font-variant-numeric:tabular-nums'; for (const r of rows) { const drift = r.value - r.target; const li = document.createElement('li'); li.style.cssText = 'display:grid;grid-template-columns:minmax(90px,30%) 1fr auto;align-items:center;gap:12px'; li.innerHTML = `${esc(r.label)}${esc(format(r.value))} ${drift >= 0 ? '+' : '−'}${esc(format(Math.abs(drift)))} vs ${esc(format(r.target))}`; li.setAttribute('aria-label', `${r.label}: ${format(r.value)}, target ${format(r.target)}`); list.appendChild(li); } el.appendChild(list); } // shared by the row charts (timeline, matrix, time strip): a label column with an optional grey subline. // A subline needs a 40-44px row pitch; one-line labels sit on 32px. function rowLabel(g, s, el, r, { y0, pitch, lw }) { const ink = css(el, '--ink', '#111'), muted = css(el, '--axis-ink', '#6b6b6b'); if (r.sub) { text(g, fit(s, r.label, lw - 12, 14), { x: 0, y: y0 + pitch / 2 - 3, 'font-size': 14, fill: ink }); text(g, fit(s, r.sub, lw - 12, 12), { x: 0, y: y0 + pitch / 2 + 13, 'font-size': 12, fill: muted }); } else text(g, fit(s, r.label, lw - 12, 14), { x: 0, y: y0 + pitch / 2 + 5, 'font-size': 14, fill: ink }); } const dayText = (d) => (typeof d === 'object' && d ? d.label : String(d)); // day headers: the full label when every one fits its column, else the short form ('19'), else every k-th short // label with today always shown and its neighbours left out, so no two headers ever touch function dayHeads(g, s, el, days, { x0, cw, y, today }) { const ink = css(el, '--ink', '#111'), muted = css(el, '--axis-ink', '#6b6b6b'); const full = days.map(dayText), short = days.map((d) => (typeof d === 'object' && d ? d.short || dayText(d) : dayText(d).split(' ').at(-1))); const wide = (labs) => Math.max(...labs.map((t) => measure(s, t, 12))) + 6; const labs = wide(full) <= cw ? full : short, k = Math.max(1, Math.ceil(wide(labs) / cw)); labs.forEach((t, i) => { if (i !== today && (i % k || (today != null && k > 1 && Math.abs(i - today) < k))) return; text(g, t, { x: x0 + i * cw + cw / 2, y, 'text-anchor': 'middle', 'font-size': 12, 'font-weight': i === today ? 600 : 400, fill: i === today ? ink : muted }); }); } // The style of each kind of booking: a pale fill, a 1px diagonal hatch, a darker grey step, an outline, or the one // chart hue for the screen's main data element. tone: 'negative' turns the edge and the label red (it needs a decision). function kindStyle(el, k = {}) { const ink = css(el, '--ink', '#111'), surface = css(el, '--surface', '#fff'), neg = css(el, '--negative', '#c4242a'), hue = k.colour || css(el, '--chart-1', '#2f6fdf'); const tone = k.tone === 'negative' ? { stroke: neg, width: 1.5 } : {}; const dash = k.dash ? { dash: '3 2' } : {}; if (k.mark === 'hatch') return { fill: mix(el, ink, surface, 0.04), hatch: mix(el, ink, surface, 0.32), ...tone, ...dash }; if (k.mark === 'step') return { fill: mix(el, ink, surface, 0.24), ...tone, ...dash }; if (k.mark === 'outline') return { fill: surface, stroke: mix(el, ink, surface, 0.55), width: 1, ...tone, ...dash }; if (k.mark === 'hue') return { fill: mix(el, hue, surface, 0.18), edge: hue, ...tone, ...dash }; return { fill: mix(el, ink, surface, 0.12), ...tone, ...dash }; } // Who is away when: people (or rooms, vans) down the left, days across, a bar per booking told apart by its kind. // rows: [{ label: 'Lena Hoffmann', sub: 'Product design · Berlin', bars: [{ from: 3, to: 7, kind: 'holiday', label, note }] }] // and { group: 'Brand' } rows for a team subheader // kinds: { holiday: { label: 'Holiday', mark: 'fill' | 'hatch' | 'step' | 'outline' | 'hue', code: 'H', tone, dash } } // days: ['Mon 19', …] or [{ label: 'Mon 19', short: '19' }]; weekends: [indices]; today: index; // holidays: [{ day: index, label: 'All Saints’ Day (Lisbon)' }]; onSelect(bar, row) makes the bars buttons. // A label that does not fit its bar becomes the kind's code inside it, then goes after the bar's end (never into the // days before it), and is always in the bar's tooltip. function timeline(el, rows, kinds = {}, { days = [], weekends = [], today = null, holidays = [], labelWidth = null, rowHeight = null, barHeight = 22, onSelect = null, legend = null, extras = true } = {}) { const draw = () => { const pitch = rowHeight || (rows.some((r) => r.sub) ? 44 : 32), head = 30, groupH = 30; const height = head + rows.reduce((a, r) => a + (r.group ? groupH : pitch), 0) + 2; const { s, w, h } = frame(el, height); const lw = labelWidth || Math.round(Math.min(220, Math.max(w < 480 ? 96 : 120, w * 0.2))); const cw = (w - lw) / Math.max(1, days.length), col = (i) => lw + i * cw; const ink = css(el, '--ink', '#111'), muted = css(el, '--axis-ink', '#6b6b6b'), rule = css(el, '--rule', '#f0f0f0'), raised = css(el, '--raised', '#f4f5f6'), hue = css(el, '--chart-1', '#2f6fdf'); const g = svg('g', {}, s); // weekends one tone step darker, faint day lines, the day header (today in ink) for (const i of weekends) svg('rect', { x: col(i), y: head, width: cw, height: h - head, fill: raised }, g); for (let i = 0; i <= days.length; i++) svg('line', { x1: col(i), x2: col(i), y1: head, y2: h, stroke: rule, 'stroke-width': 1, 'shape-rendering': 'crispEdges' }, g); dayHeads(g, s, el, days, { x0: lw, cw, y: head - 10, today }); for (const hd of holidays) { const c = svg('circle', { cx: col(hd.day) + cw / 2, cy: head - 3, r: 2, fill: muted }, g); titled(c, hd.label, {}); } if (today != null) svg('line', { x1: col(today) + cw / 2, x2: col(today) + cw / 2, y1: head, y2: h, stroke: hue, 'stroke-width': 1, 'shape-rendering': 'crispEdges' }, g); let y0 = head; for (const r of rows) { if (r.group) { svg('line', { x1: 0, x2: w, y1: y0 + 0.5, y2: y0 + 0.5, stroke: rule, 'stroke-width': 1 }, g); text(g, r.group, { x: 0, y: y0 + 20, 'font-size': 12, 'font-weight': 500, fill: muted }); y0 += groupH; continue; } rowLabel(g, s, el, r, { y0, pitch, lw }); const bs = (r.bars || []).filter((b) => b.to >= 0 && b.from < days.length).sort((a, b) => a.from - b.from); bs.forEach((b, k) => { const kind = kinds[b.kind] || {}, st = kindStyle(el, kind); const x0 = col(Math.max(0, b.from)) + 2, x1 = col(Math.min(days.length - 1, b.to) + 1) - 2, by = y0 + (pitch - barHeight) / 2; const grp = svg('g', onSelect ? { tabindex: 0, role: 'button', style: 'cursor:pointer' } : {}, g); paintRect(grp, st, x0, by, x1 - x0, barHeight, 4); const label = b.label ?? kind.label ?? '', code = b.code ?? kind.code, tw = measure(s, label, 12), labInk = kind.tone === 'negative' ? css(el, '--negative', '#c4242a') : ink; const next = bs[k + 1] ? col(Math.max(0, bs[k + 1].from)) : w; // over a hatch the label keeps a halo of the bar's own fill, so the lines never run through the words const halo = st.hatch ? { stroke: st.fill, 'stroke-width': 4, 'stroke-linejoin': 'round', 'paint-order': 'stroke' } : {}; if (label && tw + 16 <= x1 - x0) text(grp, label, { x: x0 + 8 + (st.edge ? 2 : 0), y: by + barHeight / 2 + 4, 'font-size': 12, fill: labInk, ...halo }); else if (code && measure(s, code, 12) + 8 <= x1 - x0) text(grp, code, { x: (x0 + x1) / 2, y: by + barHeight / 2 + 4, 'text-anchor': 'middle', 'font-size': 12, fill: labInk, ...halo }); else if (label && x1 + 6 + tw <= Math.min(next - 6, w)) text(grp, label, { x: x1 + 6, y: by + barHeight / 2 + 4, 'font-size': 12, fill: kind.tone === 'negative' ? labInk : muted }); titled(grp, `${r.label}: ${label}${b.note ? ' \u00b7 ' + b.note : ''}`, {}); if (onSelect) { grp.addEventListener('click', () => onSelect(b, r)); grp.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(b, r); } }); } }); y0 += pitch; } s.setAttribute('aria-label', `${rows.filter((r) => !r.group).length} rows across ${days.length} days, ${dayText(days[0])} to ${dayText(days.at(-1))}`); }; draw(); watch(el, draw); settle(draw); const used = new Set(rows.flatMap((r) => (r.bars || []).map((b) => b.kind))); const items = Object.entries(kinds).filter(([id]) => used.has(id)).map(([, k]) => ({ label: k.label, w: 22, draw: (sw) => { const st = kindStyle(el, k); paintRect(sw, st, 0, 0, 22, 12, 3); } })); if (extras && today != null) items.push({ label: 'Today', w: 8, draw: (sw) => svg('line', { x1: 4, x2: 4, y1: 0, y2: 12, stroke: css(el, '--chart-1', '#2f6fdf'), 'stroke-width': 1.5 }, sw) }); return { get svg() { return el.querySelector(':scope > svg'); }, legend: legendEl(items, legend) }; } // Is there enough cover? A team-by-day grid of slack against the minimum (+2, +1, 0, −1). Cells with room to spare // stay plain, and one hue ramps toward the negative colour as the slack runs out; the headcount is in the tooltip. // rows: [{ label: 'Product design', sub: 'needs 7 of 10', values: [2, 1, 0, -1, null, …], titles: ['8 in', …] }] // cols: ['Mon 19', …]; comfort: the slack that needs no attention (2); null is a day with no cover to count (a dash) function matrix(el, rows, { cols = [], comfort = 2, today = null, format = fmt.signed(), labelWidth = null, rowHeight = null, levels = ['Room to spare', 'Tight', 'At the minimum', 'Short'], legend = null } = {}) { const level = (v) => (v >= comfort ? 0 : v > 0 ? 1 : v === 0 ? 2 : 3); const styles = () => { const neg = css(el, '--negative', '#c4242a'), surface = css(el, '--surface', '#fff'), ink = css(el, '--ink', '#111'); return [{ fill: surface, ink: css(el, '--axis-ink', '#6b6b6b'), stroke: css(el, '--grid', '#e5e5e5') }, { fill: mix(el, neg, surface, 0.1), ink }, { fill: mix(el, neg, surface, 0.26), ink }, { fill: neg, ink: surface }]; }; const draw = () => { const pitch = rowHeight || (rows.some((r) => r.sub) ? 44 : 32), head = 26; const { s, w, h } = frame(el, head + rows.length * pitch); const lw = labelWidth || Math.round(Math.min(220, Math.max(w < 480 ? 96 : 120, w * 0.22))); const cw = (w - lw) / Math.max(1, cols.length), ST = styles(), muted = css(el, '--axis-ink', '#6b6b6b'), ink = css(el, '--ink', '#111'); const g = svg('g', {}, s); dayHeads(g, s, el, cols, { x0: lw, cw, y: head - 10, today }); rows.forEach((r, k) => { const y0 = head + k * pitch; rowLabel(g, s, el, r, { y0, pitch, lw }); const ch = Math.min(pitch - 6, 28), cy = y0 + (pitch - ch) / 2; r.values.forEach((v, i) => { const cx = lw + i * cw + 1; if (v == null) { svg('rect', { x: cx + cw / 2 - 4, y: cy + ch / 2 - 1, width: 6, height: 2, fill: muted }, g); return; } const st = ST[level(v)], cell = paintRect(g, { fill: st.fill }, cx, cy, cw - 2, ch, 3); text(g, format(v), { x: cx + (cw - 2) / 2, y: cy + ch / 2 + 4, 'text-anchor': 'middle', 'font-size': 12, 'font-weight': level(v) === 3 ? 600 : 400, fill: st.ink }); titled(cell, `${r.label}, ${dayText(cols[i])}: ${r.titles?.[i] || format(v) + ' against the minimum'}`, {}); }); }); s.setAttribute('aria-label', `Cover against the minimum for ${rows.length} rows across ${cols.length} days`); }; draw(); watch(el, draw); settle(draw); const seen = new Set(rows.flatMap((r) => r.values.filter((v) => v != null).map(level))); const ST = styles(); const items = [0, 1, 2, 3].filter((l) => seen.has(l)).map((l) => ({ label: levels[l], w: 14, draw: rectSwatch(l === 0 ? { fill: ST[0].fill, stroke: ST[0].stroke } : { fill: ST[l].fill }, 14, 12, 3) })); return { get svg() { return el.querySelector(':scope > svg'); }, legend: legendEl(items, legend) }; } // When, within the day: one row per item on a shared open–close axis. A 2px grey track, an ink dot at today's time and // a hollow tick at the comparison day's, so "earlier than last Sunday" is drawn and not only written. // rows: [{ label: 'White sourdough', sub: 'Headingley', at: '11:20', compare: '12:05', value: '11:20', note: '3 h 40 m before close' }] // at or compare may be null (it did not sell out); step: minutes between axis labels (60). function timeStrip(el, rows, { open = '08:00', close = '18:00', step = 60, todayLabel = 'Today', compareLabel = null, labelWidth = null, valueWidth = null, rowHeight = null, legend = null } = {}) { const o = minutes(open), c = minutes(close); const st = { today: { r: 4.5 }, compare: { w: 5, h: 12 } }; const dot = (parent, cx, cy) => svg('circle', { cx, cy, r: st.today.r, fill: css(el, '--ink', '#111') }, parent); const tick = (parent, cx, cy) => svg('rect', { x: cx - st.compare.w / 2, y: cy - st.compare.h / 2, width: st.compare.w, height: st.compare.h, rx: 2.5, fill: css(el, '--surface', '#fff'), stroke: css(el, '--axis-ink', '#6b6b6b'), 'stroke-width': 1.5 }, parent); const draw = () => { const narrow = (el.clientWidth || 600) < 560, axisH = 24; const pitch = rowHeight || (narrow ? 62 : rows.some((r) => r.sub || r.note) ? 44 : 32); const { s, w, h } = frame(el, rows.length * pitch + axisH); const lw = narrow ? 0 : labelWidth || Math.round(Math.min(200, Math.max(110, w * 0.26))); const vw = narrow ? 0 : valueWidth ?? Math.min(w * 0.32, Math.max(0, ...rows.map((r) => Math.max(r.value ? measure(s, r.value, 13, 500) : 0, r.note ? measure(s, r.note, 12) : 0))) + (rows.some((r) => r.value || r.note) ? 16 : 0)); const x0 = narrow ? 18 : lw + 6, x1 = narrow ? w - 18 : w - vw - 6, tx = (m) => x0 + ((m - o) / Math.max(1, c - o)) * (x1 - x0); const ink = css(el, '--ink', '#111'), muted = css(el, '--axis-ink', '#6b6b6b'), rule = css(el, '--rule', '#f0f0f0'); const track = css(el, '--chart-track', mix(el, ink, css(el, '--surface', '#fff'), 0.16)); const g = svg('g', {}, s), plotH = rows.length * pitch; // the axis: one step from the opening, the labels thinned by the same rule as the charts const marks = []; for (let m = Math.ceil(o / step) * step; m <= c; m += step) marks.push(m); const picks = xPicks(marks.map(fmt.clock), x1 - x0); for (const i of picks) { const xx = tx(marks[i]); if (!narrow) svg('line', { x1: xx, x2: xx, y1: 0, y2: plotH, stroke: rule, 'stroke-width': 1, 'shape-rendering': 'crispEdges' }, g); const lab = fmt.clock(marks[i]), tw = measure(s, lab, 12); text(g, lab, { x: Math.max(x0 - 2, Math.min(x1 + 6 - tw, xx - tw / 2)), y: plotH + 17, 'font-size': 12, fill: muted }); } rows.forEach((r, k) => { const y0 = k * pitch, cy = narrow ? y0 + pitch - 12 : y0 + pitch / 2; if (narrow) { const vwid = r.value ? measure(s, r.value, 13, 500) + 12 : 0; text(g, fit(s, r.label, w - vwid, 14), { x: 0, y: y0 + 16, 'font-size': 14, fill: ink }); if (r.value) text(g, r.value, { x: w, y: y0 + 16, 'text-anchor': 'end', 'font-size': 13, 'font-weight': 500, fill: ink }); const second = [r.sub, r.note].filter(Boolean).join(' \u00b7 '); if (second) text(g, fit(s, second, w, 12), { x: 0, y: y0 + 33, 'font-size': 12, fill: muted }); } else rowLabel(g, s, el, r, { y0, pitch, lw }); svg('line', { x1: x0, x2: x1, y1: cy, y2: cy, stroke: track, 'stroke-width': 2, 'stroke-linecap': 'round' }, g); const cm = minutes(r.compare), am = minutes(r.at); if (cm != null) titled(tick(g, tx(cm), cy), `${compareLabel || 'Comparison'}: ${fmt.clock(cm)}`, {}); if (am != null) titled(dot(g, tx(am), cy), `${todayLabel}: ${fmt.clock(am)}`, {}); if (narrow) return; if (r.note) { if (r.value) text(g, r.value, { x: w, y: cy - 3, 'text-anchor': 'end', 'font-size': 13, 'font-weight': 500, fill: ink }); text(g, fit(s, r.note, vw - 8, 12), { x: w, y: r.value ? cy + 13 : cy + 4, 'text-anchor': 'end', 'font-size': 12, fill: muted }); } else if (r.value) text(g, r.value, { x: w, y: cy + 5, 'text-anchor': 'end', 'font-size': 13, 'font-weight': 500, fill: ink }); }); s.setAttribute('aria-label', `${rows.length} rows between ${fmt.clock(o)} and ${fmt.clock(c)}`); }; draw(); watch(el, draw); settle(draw); const items = []; if (rows.some((r) => minutes(r.at) != null)) items.push({ label: todayLabel, w: 10, draw: (sw) => dot(sw, 5, 6) }); if (compareLabel && rows.some((r) => minutes(r.compare) != null)) items.push({ label: compareLabel, w: 8, draw: (sw) => tick(sw, 4, 6) }); return { get svg() { return el.querySelector(':scope > svg'); }, legend: legendEl(items, legend) }; } function watch(el, draw) { if (!('ResizeObserver' in window)) return; let w = el.clientWidth; new ResizeObserver(() => { if (Math.abs(el.clientWidth - w) > 4) { w = el.clientWidth; draw(); } }).observe(el); } window.Charts = { line, bars, diverging, ring, barList, plan, bullet, timeline, matrix, timeStrip, ticks, fmt }; })(); ================ File: workspace/agents/opencode/engine/skills/design-taste-frontend/evals/evals.json ================ { "skill_name": "design-taste-frontend", "how_to_run": "For each eval, give the prompt to a fresh agent that has this skill, and to a second fresh agent without it (the baseline). Render both with scripts/inspect.mjs and have a judge who does not know which is which score the screenshots with references/critique.md. Record inspector FAIL counts for both. Evals 7 to 10 are app screens: inspect them with --app (a sheet dealt by draw.mjs --app switches it on by itself) and judge them with the app-screen passes in references/critique.md.", "evals": [ { "id": 1, "name": "saas-invoicing", "prompt": "Build a landing page for Ledgerly, an invoicing tool for freelance designers. What it does: you write an invoice in under a minute, the client pays by card or bank transfer, and it sends polite reminders by itself when an invoice is overdue. It costs 9 euros a month flat, with a 14-day free trial and no card needed. Include pricing and a short FAQ. There is no existing brand and no screenshots; the product name is all we have. Plain static HTML, CSS and JS in one folder.", "expected_output": "A static landing page with a clear idea drawn from invoicing itself, headlines of two lines or fewer at 1440/768/390, no eyebrow labels, no monospace, no index numbering, no thin rules, no gradients or shadows, a non-default palette and type system, a pricing section that is not three cards, and no invented customers or statistics.", "files": [] }, { "id": 2, "name": "restaurant-osteria", "prompt": "Make a one-page website for Osteria Nove, a 38-seat Italian restaurant in Alfama, Lisbon. Handmade pasta, a short natural wine list, dinner only, Tuesday to Saturday from 19:00. Address: Rua dos Remédios 9. Reservations by phone (+351 21 000 0009) or email (mesa@osterianove.pt). The page needs the menu (invent a believable short one: antipasti, pasta, dolci, with prices in euros), hours, address and how to book. We have no photographs yet. Static HTML/CSS/JS.", "expected_output": "A one-page restaurant site with a specific idea (for example the page as the menu itself), the menu set typographically without divider lines, practical information easy to find, bundled photographs used only as atmosphere and listed for replacement, and all house rules met.", "files": [] }, { "id": 3, "name": "portfolio-designer", "prompt": "I'm Mara Lindqvist, a product designer in Stockholm with eight years in fintech and health apps. I need a portfolio homepage: six selected projects (make up plausible names, clients, years and one-line descriptions), a short about paragraph, and a way to contact me (mara@lindqvist.studio). I don't have project images ready, so design it so it still looks finished without them, or use placeholders that I can swap. Static HTML/CSS/JS.", "expected_output": "A portfolio page that looks finished without project imagery (a typographic index, a name-led hero, or atmosphere photographs clearly marked for replacement), with a designed footer, a phone layout that is its own composition, and all house rules met.", "files": [] }, { "id": 4, "name": "product-ceramics", "prompt": "Launch page for Kiln, a ceramic pour-over coffee set (dripper, carafe and two cups) that is hand-glazed in small batches in Porto. It costs 84 euros and ships across the EU within a week. The glaze is mixed by hand so no two sets are identical. We have not shot product photos yet, so there are no images of the set. One page, with a way to buy (a button is enough). Static HTML/CSS/JS.", "expected_output": "A product page that does not fake product photography: it either commits to type and colour, uses one shape from the object as a motif, or uses atmosphere photographs honestly, with the price and purchase action clear, and all house rules met.", "files": [] }, { "id": 5, "name": "guesthouse-ericeira", "prompt": "Website for Casa Mirante, a six-room guesthouse on the cliffs above Ericeira, Portugal. Rooms from 140 euros a night with breakfast included; ten minutes' walk to the surf at Ribeira d'Ilhas; open March to November. One page: what it is, the rooms, what's nearby, and how to book (email reservas@casamirante.pt). We have no photographs yet. Static HTML/CSS/JS.", "expected_output": "A calm, photograph-led or type-led one-pager whose hero is one clear thing, with rooms and booking information easy to find, bundled coast photographs used as atmosphere and listed for replacement, and all house rules met.", "files": [] }, { "id": 6, "name": "conference-format", "prompt": "Landing page for Format, a two-day design conference in Rotterdam on 14 and 15 May 2027. Twelve speakers (invent plausible names and talk titles), 400 seats, tickets at 390 euros, venue: Maassilo. Sections: what it is, the line-up, the schedule in brief, tickets, and a short FAQ. No brand exists yet beyond the name. Static HTML/CSS/JS.", "expected_output": "An event page with a strong typographic or colour idea, the line-up and schedule set as structured text without divider lines or index numbers, a ticket section that is not three cards, and all house rules met.", "files": [] }, { "id": 7, "name": "app-budget-overview", "prompt": "Build the main screen of Ledgerly, a budgeting app for couples who share money: the October overview with their accounts and balances, the spending plan (planned against spent by category), bills due in the next 14 days and recent transactions. Two people use it, Mia and Tom. Desktop first, and it must work on a phone. Static HTML/CSS/JS.", "expected_output": "An app screen, not a landing page: a quiet shell (sidebar or rail), a clear screen title, numbers that add up and are right-aligned with tabular figures, one chart per question, colour spent on data and status only, no icons in tinted squares, no gradients or glows, no placeholder names, and a phone layout where the navigation folds away. inspect.mjs --app passes.", "files": [] }, { "id": 8, "name": "app-deploy-dashboard", "prompt": "Dashboard for Relay, an edge deployment platform for web teams: one project's overview with recent deployments (status, branch, commit message, author, duration, region), requests and p95 latency over the last 24 hours, the error rate, and usage against the plan's limits. Dark theme. Static HTML/CSS/JS.", "expected_output": "A dense, readable dark screen on a neutral (not navy) ground: one surface step or one hairline between regions, a deployments table with honest statuses and aligned numbers, charts with few gridlines and one series each, IDs in monospace at most, no rainbow series, no glow, and app inspector checks passing.", "files": [] }, { "id": 9, "name": "app-settings-billing", "prompt": "Workspace settings for Proofline, a design-review tool: general settings, members and roles (a members table with an invite form), billing (current plan, seats, invoices) and notifications. Static HTML/CSS/JS, desktop and phone.", "expected_output": "A settings screen with a label column beside its fields (or stacked fields on a phone), labels above inputs, one control height, quiet borders, honest disabled states, a members table with right-aligned figures and one status pill per row, and no decorative icons or gradients.", "files": [] }, { "id": 10, "name": "app-support-queue", "prompt": "The queue screen of Hearth, a support inbox for a six-person SaaS team: the list of conversations with assignee, priority, channel and time to the SLA, the open conversation with the customer's details beside it, plus filters and saved views. Static HTML/CSS/JS.", "expected_output": "A three-region work screen (navigation, list, conversation) that stays readable at 1440 and folds to one region at a time on a phone, with status shown once per row, times in words, no tinted icon squares or emoji, and app inspector checks passing.", "files": [] } ] } ================ File: workspace/agents/opencode/engine/skills/design-taste-frontend/scripts/lib/app-draw.mjs ================ // tasteskill / draw --app — deal a style for an app screen (dashboard, admin, settings, inbox). // // node draw.mjs --app --kind --brief "one line" [--screens overview,list,detail,settings] // [--theme light|dark] [--keep palette,type] [--options 3] [--style ] [--seed N] // node draw.mjs --show reprint the saved sheet // node draw.mjs --redraw style|shell|screen: // node draw.mjs --app --list kinds|styles|screens // // Same idea as the page draw: the script deals, you choose among what it dealt, so two products do not // end up in the same default look. The library (library/app.json) was distilled from a study of real // product screens; it holds styles (tokens and the one move that makes each recognisable), shells and // screen patterns. import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { randomInt } from 'node:crypto'; import { homedir } from 'node:os'; const HERE = dirname(fileURLToPath(import.meta.url)); const LIB = JSON.parse(readFileSync(join(HERE, '..', '..', 'library', 'app.json'), 'utf8')); const argv = process.argv.slice(2); const opt = (name, def) => { const i = argv.indexOf('--' + name); if (i === -1) return def; const v = argv[i + 1]; return v === undefined || v.startsWith('--') ? true : v; }; const list = (v) => (typeof v === 'string' ? v.split(',').map((s) => s.trim()).filter(Boolean) : []); const outDir = resolve(String(opt('out', '.taste-skill'))); const sheetPath = join(outDir, 'direction.json'); const KINDS = Object.keys(LIB.kinds); // signature patterns are elements of one kind of product (a power flow, a sell-out strip), not whole screens const SCREENS = [...new Set(LIB.patterns.filter((p) => p.screen).map((p) => p.screen))]; const SIGNATURES = LIB.patterns.filter((p) => p.signature); const signaturesFor = (brief = '') => { const b = brief.toLowerCase(); return SIGNATURES.filter((p) => (p.cues || []).some((c) => new RegExp('(^|[^a-z])' + c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i').test(b))); }; if (opt('list')) { const what = opt('list'); if (what === 'kinds') for (const [k, v] of Object.entries(LIB.kinds)) console.log(`${k.padEnd(14)} ${v.note}`); else if (what === 'screens') for (const s of SCREENS) console.log(`${s.padEnd(12)} ${LIB.patterns.filter((p) => p.screen === s).map((p) => p.id).join(', ')}`); else if (what === 'signatures') for (const p of SIGNATURES) console.log(`${p.id.padEnd(16)} ${p.name} — ${(p.cues || []).slice(0, 5).join(', ')}`); else for (const s of LIB.styles) console.log(`${s.id.padEnd(22)} ${s.name} — ${s.theme}, ${s.suits.join(', ')}`); process.exit(0); } let seed = opt('seed') !== undefined && opt('seed') !== true ? Number(opt('seed')) : randomInt(1, 2 ** 31 - 1); const startSeed = seed; const rnd = () => { seed |= 0; seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; const pick = (arr) => arr[Math.floor(rnd() * arr.length)]; const userHist = process.env.TASTE_SKILL_HISTORY || join(homedir(), '.taste-skill', 'history.json'); const readHist = () => { try { return JSON.parse(readFileSync(userHist, 'utf8')); } catch { return []; } }; const writeHist = (e) => { try { mkdirSync(dirname(userHist), { recursive: true }); const h = readHist(); h.unshift(e); writeFileSync(userHist, JSON.stringify(h.slice(0, 24), null, 1)); } catch {} }; const recentStyles = readHist().filter((h) => h.kind === 'app').slice(0, 3).map((h) => h.style); const saved = existsSync(sheetPath) ? JSON.parse(readFileSync(sheetPath, 'utf8')) : null; if (opt('show')) { if (!saved || saved.kind !== 'app') { console.error('No saved app sheet. Run draw.mjs --app first.'); process.exit(2); } console.log(render(saved)); process.exit(0); } function stylePool(input) { const pref = LIB.kinds[input.kind]?.styles || []; let pool = LIB.styles.filter((s) => !(input.avoid || []).includes(s.id)); if (input.theme) { const t = pool.filter((s) => s.theme === input.theme); if (t.length) pool = t; } const fit = pool.filter((s) => pref.includes(s.id) || s.suits.includes(input.kind)); if (fit.length >= 2) pool = fit; return pool; } function dealStyle(input, exclude = []) { let pool = stylePool(input).filter((s) => !exclude.includes(s.id)); if (!pool.length) pool = LIB.styles.filter((s) => !exclude.includes(s.id)); const fresh = pool.filter((s) => !recentStyles.includes(s.id)); return pick(fresh.length ? fresh : pool.length ? pool : LIB.styles); } // The style's own shell usually carries part of its move, so it is dealt about two times in three. function dealShell(style, input, exclude = []) { const own = LIB.shells.find((s) => s.id === style.shell && !exclude.includes(s.id)); const alts = LIB.shells.filter((s) => (style.shellAlternatives || []).includes(s.id) && !exclude.includes(s.id)); if (own && (!alts.length || rnd() < 0.66)) return own; if (alts.length) return pick(alts); const rest = LIB.shells.filter((s) => !exclude.includes(s.id)); return pick(rest.length ? rest : LIB.shells); } function dealScreen(name, style, input, exclude = []) { let pool = LIB.patterns.filter((p) => p.screen === name && !exclude.includes(p.id)); // a pattern written for another kind of product brings that product's furniture with it (a getting-started strip on a // leave planner): when none fits this kind, the sheet says to compose the screen from the chapter instead pool = pool.filter((p) => !p.kinds || p.kinds.includes(input.kind)); return pool.length ? pick(pool) : null; } function deal(input) { const style = input.style ? LIB.styles.find((s) => s.id === input.style) : dealStyle(input); if (!style) { console.error(`No app style ${input.style}. List them with --app --list styles.`); process.exit(2); } const s = { version: 1, kind: 'app', at: new Date().toISOString(), seed: startSeed, input, style, shell: dealShell(style, input), screens: [] }; for (const name of input.screens) s.screens.push({ name, pattern: dealScreen(name, style, input) }); return s; } let sheet; if (opt('redraw') && saved?.kind === 'app') { sheet = saved; sheet.redraws = (sheet.redraws || 0) + 1; const what = String(opt('redraw')); if (what === 'style') { sheet.style = dealStyle(sheet.input, [sheet.style.id]); } else if (what === 'shell') sheet.shell = dealShell(sheet.style, sheet.input, [sheet.shell.id]); else if (what.startsWith('screen:')) { const n = what.slice(7); const i = sheet.screens.findIndex((x) => x.name === n); if (i === -1) { console.error('No screen ' + n + ' on this sheet.'); process.exit(2); } sheet.screens[i].pattern = dealScreen(n, sheet.style, sheet.input, [sheet.screens[i].pattern?.id]) || sheet.screens[i].pattern; } else { console.error('Redraw style, shell or screen:.'); process.exit(2); } } else { const kind = String(opt('kind', '')); if (!KINDS.includes(kind)) { console.error(`--kind is required with --app. One of:\n ${KINDS.join(', ')}`); process.exit(2); } const screens = list(opt('screens')).length ? list(opt('screens')) : (LIB.kinds[kind].screens || ['overview']); const bad = screens.filter((x) => !SCREENS.includes(x)); if (bad.length) { console.error(`Unknown screen(s): ${bad.join(', ')}. One of: ${SCREENS.join(', ')}`); process.exit(2); } const input = { kind, brief: typeof opt('brief') === 'string' ? opt('brief') : '', theme: ['light', 'dark'].includes(opt('theme')) ? opt('theme') : null, screens, keep: list(opt('keep')), style: typeof opt('style') === 'string' ? opt('style') : null }; if (opt('options')) { const n = Math.max(2, Math.min(5, Number(opt('options')) || 3)); const pool = stylePool(input); const out = []; let rest = pool.filter((s) => !recentStyles.includes(s.id)); if (rest.length < n) rest = pool; while (out.length < n && rest.length) { const s = pick(rest); out.push(s); rest = rest.filter((x) => x !== s); } console.log(`# ${out.length} app styles dealt for this brief\n`); console.log('Choose the one that suits how this product is used (long sessions or a quick look, money or media, a team or one person), not the most familiar. Then deal it with the same command plus `--style ` (and without --options).\n'); for (const s of out) console.log(`## \`${s.id}\` — ${s.name} (${s.theme})\n${s.idea}\nThe move: ${s.signature}\n`); process.exit(0); } sheet = deal(input); } mkdirSync(outDir, { recursive: true }); writeFileSync(sheetPath, JSON.stringify(sheet, null, 1)); if (!opt('redraw')) writeHist({ at: sheet.at, kind: 'app', style: sheet.style.id, shell: sheet.shell.id }); console.log(render(sheet)); process.exit(0); function kebab(k) { return k.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()); } // A Google Fonts link for the style's faces (all of them are free there). function fontLink(f) { const fam = (name, w) => 'family=' + name.trim().replace(/ /g, '+') + ':wght@' + w.join(';'); const fams = [fam(f.family, f.weights || [400, 500, 600])]; const second = f.second && f.second.split(',')[0].trim(); if (second && !/^(system|inter\b)/i.test(second)) fams.push(fam(second, [400, 500])); return `\n`; } function render(s) { const st = s.style, t = st.tokens, o = []; const p = (x = '') => o.push(x); p(`# APP SHEET — ${st.name}`); p(`seed ${s.seed} · drawn ${s.at.slice(0, 10)}${s.redraws ? ` · redraws: ${s.redraws}` : ''} · kind ${s.input.kind} · ${st.theme}`); if (s.input.brief) p(`Brief: ${s.input.brief}`); p(); p('This is the style for this product. Adopt it whole and keep its move intact; everything the sheet does not fix comes from the'); p('shared defaults in references/app-screens.md. Redraw only for a reason you can state (it contradicts the brand, or the product is'); p('used in a way the style does not suit) and say why in your report. The values were measured on real product screens: use them'); p('as given rather than rounding them towards your habits.'); p(); p('## 1. The style'); p(st.idea); p(); p(`**The move:** ${st.signature}`); p(); if (st.colourBudget) p(`**Colour budget:** ${st.colourBudget}.`); if (st.avoid?.length) p('**Off-style here:** ' + st.avoid.join('; ') + '.'); if (st.basis) p(`Distilled from ${st.basis}.${st.notes ? ' ' + st.notes : ''}`); p(); // the style makes a screen correct; its face makes it this product's (references/app-screens.md, §2.3) if (st.identity?.length) { p('**Its face.** Choose one of these (or one of your own of the same weight) before you build, write it in your notes next to'); p('the screen\'s question, and check that it shows on the first screen. Without one the screen reads as a well-applied kit.'); for (const x of st.identity) p('- ' + x); p(); } const sigs = signaturesFor(s.input.brief); if (sigs.length) { p('**Signature elements this brief names** (an element only this kind of product has; build one of them as part of a screen):'); for (const g of sigs) p(`- **${g.name}** (\`${g.id}\`). ${g.blueprint}${g.phone ? ' On a phone: ' + g.phone : ''}`); p(); } const f = t.font || {}; if (!s.input.keep.includes('palette')) { p('## 2. Tokens'); p('```css'); p(':root {'); for (const [k, v] of Object.entries(t)) { if (k === 'font' || k === 'density') continue; if (typeof v === 'string') p(` --${kebab(k)}: ${v};`); else if (typeof v === 'number') p(` --${kebab(k)}: ${v}px;`); else if (k === 'chart') v.forEach((c, i) => p(` --chart-${i + 1}: ${c};`)); else if (k === 'pills') for (const [n, [bg, ink]] of Object.entries(v)) p(` --pill-${n}-bg: ${bg}; --pill-${n}-ink: ${ink};`); else if (k === 'radius') for (const [n, r] of Object.entries(v)) p(` --radius-${n}: ${r}px;`); } if (f.sizes) for (const [n, v] of Object.entries(f.sizes)) p(` --fs-${n}: ${v}px;`); p('}'); p('```'); p(`Density: ${t.density}. One gap value, horizontally and vertically. ${t.shadow ? 'The shadow belongs to this dark style\'s cards only; nothing else casts one.' : 'No shadow on anything at rest.'}`); p(); } else { p('## 2. Tokens'); p('Kept from the brand: map its colours onto this style\'s roles (ground, surface, raised, border, rule, control, text, muted, accent, status pills, chart) and keep its proportions: ' + (st.colourBudget || 'colour on data and status only') + '. Take the sizes, radii, row and control heights from the style as they are.'); p(); } if (!s.input.keep.includes('type') && f.family) { p(`## 3. Type — ${f.family}`); p('```html'); p(fontLink(f)); p('```'); p(`\`font-family: "${f.family}", ${f.fallback || 'system-ui, sans-serif'}\`. Weights ${(f.weights || []).join(', ')}. Sizes: ${Object.entries(f.sizes || {}).map(([k, v]) => `${k} ${v}px`).join(' · ')}.${f.note ? ' ' + f.note : ''}${f.second ? ' Second face: ' + f.second + '.' : ''}`); p('Numbers: `font-variant-numeric: tabular-nums` on every table, KPI and anything that updates. Sentence case; no tracked capitals.'); p(); } p(`## 4. Shell — ${s.shell.name}`); p(s.shell.blueprint); if (s.shell.numbers) p('Numbers: ' + Object.entries(s.shell.numbers).map(([k, v]) => `${k} ${v}`).join('; ') + '.'); if (s.shell.phone) p(`On a phone: ${s.shell.phone}`); p(); p('## 5. Screens'); for (const sc of s.screens) { p(); if (!sc.pattern) { p(`### ${sc.name}`); p('No pattern in the library for this screen. Compose it from the rules in references/app-screens.md.'); continue; } p(`### ${sc.name} — ${sc.pattern.name}`); p(sc.pattern.blueprint); if (sc.pattern.phone) p(`On a phone: ${sc.pattern.phone}`); } p(); p('## 6. Always'); p('Real data for this product, written before the markup: names, amounts that add up, dates in order, units on every figure,'); p('honest zeros and dashes. One chart answers one question. No gradients, glows, glass, icons in tinted squares, sparklines on KPIs,'); p('rainbow charts, emoji, lorem or Acme. `/assets/app.css` and `/assets/charts.js` are a neutral start set to these'); p('rules. Check with `node /scripts/lint.mjs ` and `node /scripts/inspect.mjs `: this sheet switches both'); p('to app rules.'); p(); p(`Saved to ${sheetPath.split('\\').join('/')} · reprint with \`node draw.mjs --show\``); return o.join('\n'); } ================ File: workspace/agents/opencode/engine/skills/design-taste-frontend/scripts/lib/cdp.mjs ================ // Zero-dependency Chrome DevTools Protocol client. // Drives an installed Chrome / Edge / Chromium in headless mode over Node's built-in WebSocket. // Requires Node 22+. No npm install. import { spawn, spawnSync } from 'node:child_process'; import { existsSync, mkdtempSync, rmSync, writeFileSync, readFileSync, readdirSync } from 'node:fs'; import { tmpdir, hostname } from 'node:os'; import { join } from 'node:path'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); export function findChrome() { const env = process.env.CHROME_PATH || process.env.PUPPETEER_EXECUTABLE_PATH; if (env && existsSync(env)) return env; const home = process.env.HOME || process.env.USERPROFILE || ''; const local = process.env.LOCALAPPDATA || ''; const pf = process.env.PROGRAMFILES || 'C:\\Program Files'; const pf86 = process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)'; const candidates = [ // Windows join(pf, 'Google/Chrome/Application/chrome.exe'), join(pf86, 'Google/Chrome/Application/chrome.exe'), join(local, 'Google/Chrome/Application/chrome.exe'), join(pf86, 'Microsoft/Edge/Application/msedge.exe'), join(pf, 'Microsoft/Edge/Application/msedge.exe'), join(pf, 'BraveSoftware/Brave-Browser/Application/brave.exe'), // macOS '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser', join(home, 'Applications/Google Chrome.app/Contents/MacOS/Google Chrome'), // Linux '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/snap/bin/chromium', '/usr/bin/microsoft-edge', ]; for (const c of candidates) if (c && existsSync(c)) return c; return null; } // Each profile carries an owner file naming the machine and the two processes that use it (the run and // its Chrome). A later run removes a profile left by a killed run only when that file names this machine // and neither process is alive. A profile without one (made before 2.1.4, or by another tool) is never touched. const OWNER = 'taste-skill-owner.json'; function alive(pid) { if (!Number.isInteger(pid) || pid <= 0) return false; try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } } export function sweepProfiles(base) { let names; try { names = readdirSync(base); } catch { return; } for (const name of names) { if (!name.startsWith('ad-chrome-')) continue; const p = join(base, name); try { const o = JSON.parse(readFileSync(join(p, OWNER), 'utf8')); if (o.host !== hostname() || !Number.isInteger(o.pid) || alive(o.pid) || alive(o.chrome)) continue; rmSync(p, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch {} } } export class Browser { static async launch({ width = 1440, height = 900, extraArgs = [] } = {}) { if (typeof WebSocket === 'undefined') { throw new Error('Node 22+ is required (built-in WebSocket missing). Current: ' + process.version); } const exe = findChrome(); if (!exe) { throw new Error('No Chrome/Edge/Chromium found. Install one or set CHROME_PATH to its executable.'); } // A throwaway profile per run, its cache capped (each run starts cold anyway), removed on exit. It is // claimed with an owner file before Chrome starts, so another run's sweep can tell when it was abandoned. const base = process.env.TASTE_SKILL_TMP || tmpdir(); sweepProfiles(base); const dir = mkdtempSync(join(base, 'ad-chrome-')); const claim = (chrome) => { try { writeFileSync(join(dir, OWNER), JSON.stringify({ host: hostname(), pid: process.pid, chrome })); } catch {} }; claim(); // On exit Chrome still holds the profile's files: stop it first, then remove the folder. let pid = 0; const drop = () => { try { if (pid) { if (process.platform === 'win32') spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' }); else process.kill(pid, 'SIGKILL'); } } catch {} try { rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } catch {} }; process.once('exit', drop); for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.once(sig, () => { drop(); process.exit(130); }); const args = [ '--headless=new', '--remote-debugging-port=0', `--user-data-dir=${dir}`, '--disk-cache-size=16777216', '--media-cache-size=16777216', '--no-first-run', '--no-default-browser-check', '--mute-audio', '--hide-scrollbars', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows', '--disable-features=Translate,MediaRouter', '--force-color-profile=srgb', '--autoplay-policy=no-user-gesture-required', '--enable-unsafe-swiftshader', `--window-size=${width},${height}`, ...extraArgs, 'about:blank', ]; const proc = spawn(exe, args, { stdio: ['ignore', 'ignore', 'pipe'] }); pid = proc.pid; claim(pid); const wsUrl = await new Promise((resolve, reject) => { let buf = ''; const t = setTimeout(() => reject(new Error('Chrome did not start within 20s')), 20000); proc.stderr.on('data', (d) => { buf += d.toString(); const m = buf.match(/DevTools listening on (ws:\/\/[^\s]+)/); if (m) { clearTimeout(t); resolve(m[1]); } }); proc.on('exit', (code) => { clearTimeout(t); reject(new Error('Chrome exited early, code ' + code + '\n' + buf.slice(-600))); }); }); const b = new Browser(); b.proc = proc; b.dir = dir; b.nextId = 1; b.pending = new Map(); b.listeners = new Map(); b.ws = new WebSocket(wsUrl); await new Promise((res, rej) => { b.ws.onopen = res; b.ws.onerror = () => rej(new Error('CDP socket failed')); }); b.ws.onmessage = (ev) => { const msg = JSON.parse(typeof ev.data === 'string' ? ev.data : Buffer.from(ev.data).toString()); if (msg.id && b.pending.has(msg.id)) { const { resolve, reject, timer } = b.pending.get(msg.id); clearTimeout(timer); b.pending.delete(msg.id); if (msg.error) reject(new Error(msg.error.message)); else resolve(msg.result); } else if (msg.method) { const fns = b.listeners.get((msg.sessionId || '') + '|' + msg.method); if (fns) for (const fn of [...fns]) fn(msg.params); } }; return b; } send(method, params = {}, sessionId, timeoutMs = 30000) { const id = this.nextId++; return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pending.delete(id); reject(new Error('CDP timeout: ' + method)); }, timeoutMs); this.pending.set(id, { resolve, reject, timer }); this.ws.send(JSON.stringify(sessionId ? { id, method, params, sessionId } : { id, method, params })); }); } on(sessionId, method, fn) { const k = (sessionId || '') + '|' + method; if (!this.listeners.has(k)) this.listeners.set(k, new Set()); this.listeners.get(k).add(fn); return () => this.listeners.get(k)?.delete(fn); } async newPage({ width = 1440, height = 900, dsf = 1, mobile = false, reducedMotion = false } = {}) { const { targetId } = await this.send('Target.createTarget', { url: 'about:blank', newWindow: true }); const { sessionId } = await this.send('Target.attachToTarget', { targetId, flatten: true }); const p = new Page(this, targetId, sessionId); await p.send('Page.enable'); await p.send('Runtime.enable'); await p.setViewport({ width, height, dsf, mobile }); if (reducedMotion) { await p.send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] }); } return p; } async close() { try { await this.send('Browser.close', {}, undefined, 3000); } catch {} try { this.ws.close(); } catch {} try { this.proc.kill(); } catch {} await sleep(300); try { rmSync(this.dir, { recursive: true, force: true, maxRetries: 3 }); } catch {} } } export class Page { constructor(browser, targetId, sessionId) { this.browser = browser; this.targetId = targetId; this.sessionId = sessionId; this.consoleErrors = []; browser.on(sessionId, 'Runtime.exceptionThrown', (p) => { this.consoleErrors.push((p.exceptionDetails?.exception?.description || p.exceptionDetails?.text || '').slice(0, 300)); }); // Never let a JS dialog block the page. browser.on(sessionId, 'Page.javascriptDialogOpening', () => { this.send('Page.handleJavaScriptDialog', { accept: false }).catch(() => {}); }); } send(method, params = {}, timeoutMs = 30000) { return this.browser.send(method, params, this.sessionId, timeoutMs); } async setViewport({ width, height, dsf = 1, mobile = false }) { this.viewport = { width, height, dsf, mobile }; await this.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: dsf, mobile }); await this.send('Emulation.setTouchEmulationEnabled', { enabled: !!mobile }); } // Navigates and resolves after the load event (or timeout), then waits `settleMs` for intros to finish. async goto(url, { timeoutMs = 25000, settleMs = 1500 } = {}) { let off; const loaded = new Promise((resolve) => { off = this.browser.on(this.sessionId, 'Page.loadEventFired', () => resolve('load')); }); const nav = await this.send('Page.navigate', { url }, timeoutMs).catch((e) => ({ errorText: e.message })); if (nav.errorText && !/ERR_ABORTED/.test(nav.errorText)) { off(); throw new Error('navigate: ' + nav.errorText); } const how = await Promise.race([loaded, sleep(timeoutMs).then(() => 'timeout')]); off(); await sleep(settleMs); return how; } // Evaluates a function (serialised) or an expression string in the page and returns its JSON value. async eval(fnOrExpr, arg) { const expression = typeof fnOrExpr === 'function' ? `(${fnOrExpr.toString()})(${arg === undefined ? '' : JSON.stringify(arg)})` : fnOrExpr; const r = await this.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }, 30000); if (r.exceptionDetails) { throw new Error('page eval: ' + (r.exceptionDetails.exception?.description || r.exceptionDetails.text)); } return r.result?.value; } async screenshot({ path, format = 'jpeg', quality = 72, fullPage = false, clip } = {}) { const params = { format, captureBeyondViewport: !!fullPage }; if (format !== 'png') params.quality = quality; if (fullPage) { const m = await this.send('Page.getLayoutMetrics'); const s = m.cssContentSize || m.contentSize; params.clip = { x: 0, y: 0, width: Math.ceil(s.width), height: Math.min(Math.ceil(s.height), 16000), scale: 1 }; } else if (clip) { params.clip = { scale: 1, ...clip }; } const { data } = await this.send('Page.captureScreenshot', params, 45000); if (path) writeFileSync(path, Buffer.from(data, 'base64')); return data; } // Real wheel input, so smooth-scroll libraries and scroll-driven reveals behave as they do for a visitor. async wheel(totalDeltaY, { step = 240, pauseMs = 45 } = {}) { const { width, height } = this.viewport; const dir = Math.sign(totalDeltaY) || 1; let left = Math.abs(totalDeltaY); while (left > 0) { const d = Math.min(step, left); await this.send('Input.dispatchMouseEvent', { type: 'mouseWheel', x: Math.round(width / 2), y: Math.round(height / 2), deltaX: 0, deltaY: d * dir }).catch(() => {}); left -= d; await sleep(pauseMs); } } async close() { try { await this.browser.send('Target.closeTarget', { targetId: this.targetId }, undefined, 5000); } catch {} } } export { sleep }; ================ File: workspace/agents/opencode/engine/skills/design-taste-frontend/scripts/lib/inspection-files.mjs ================ export const viewportClass = (width) => (width >= 1100 ? 'desktop' : width >= 600 ? 'tablet' : 'mobile'); export function screenshotLabel(width, widths) { const label = viewportClass(width); const sameClass = new Set(widths.filter((candidate) => viewportClass(candidate) === label)); return sameClass.size > 1 ? `${label}-${width}` : label; } export const isInspectionScreenshot = (file) => /^(desktop|tablet|mobile)(?:-\d+)?-(fold|page-\d+|film-\d+)\.jpg$/.test(file); ================ File: workspace/agents/opencode/engine/skills/design-taste-frontend/scripts/lib/probe.mjs ================ // probe() runs INSIDE the page (it is serialised and evaluated through cdp.mjs), so it must stay // self-contained: no imports, no closures over module scope. // It measures what a design reviewer looks at — headline line counts, the type system, colour, // surfaces, decoration — and the specific tells this skill bans. export function probe() { const W = innerWidth, H = innerHeight, SY = scrollY; const round = (n, d = 2) => Math.round(n * 10 ** d) / 10 ** d; // --- colour normalisation (handles oklch(), color(), named colours, etc.) const cvs = document.createElement('canvas'); cvs.width = cvs.height = 1; const ctx = cvs.getContext('2d', { willReadFrequently: true }); const colorCache = new Map(); const rgba = (c) => { if (colorCache.has(c)) return colorCache.get(c); ctx.clearRect(0, 0, 1, 1); ctx.fillStyle = 'rgba(0,0,0,0)'; ctx.fillStyle = c; ctx.fillRect(0, 0, 1, 1); const d = ctx.getImageData(0, 0, 1, 1).data; const v = { r: d[0], g: d[1], b: d[2], a: round(d[3] / 255) }; colorCache.set(c, v); return v; }; const hex = (v) => '#' + [v.r, v.g, v.b].map((n) => n.toString(16).padStart(2, '0')).join(''); const lum = (v) => { const f = (x) => { x /= 255; return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(v.r) + 0.7152 * f(v.g) + 0.0722 * f(v.b); }; const contrast = (a, b) => { const l1 = lum(a), l2 = lum(b); return round((Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05), 1); }; const hsl = (v) => { const r = v.r / 255, g = v.g / 255, b = v.b / 255, mx = Math.max(r, g, b), mn = Math.min(r, g, b); const l = (mx + mn) / 2; let h = 0, s = 0; if (mx !== mn) { const d = mx - mn; s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn); h = mx === r ? (g - b) / d + (g < b ? 6 : 0) : mx === g ? (b - r) / d + 2 : (r - g) / d + 4; h *= 60; } return { h: Math.round(h), s: round(s), l: round(l) }; }; // --- monospace detection by measurement, not by font name const mctx = document.createElement('canvas').getContext('2d'); const monoCache = new Map(); const isMono = (family) => { if (monoCache.has(family)) return monoCache.get(family); mctx.font = `40px ${family}`; const a = mctx.measureText('iiiiiiii').width, b = mctx.measureText('WWWWWWWW').width; const v = Math.abs(a - b) < 1; monoCache.set(family, v); return v; }; const firstFam = (ff) => ff.split(',')[0].replace(/["']/g, '').trim(); // Text kept for screen readers only (the usual .sr-only / .visually-hidden patterns) is not on the page. const srHidden = (el) => { for (let p = el, i = 0; p && p !== document.body && i < 4; p = p.parentElement, i++) { const cs = getComputedStyle(p); const r = p.getBoundingClientRect(); if (cs.clip === 'rect(0px, 0px, 0px, 0px)' || /inset\(50%\)/.test(cs.clipPath) || (r.width <= 2 && r.height <= 2 && cs.overflow !== 'visible')) return true; } return false; }; const GENERIC = /^(serif|sans-serif|monospace|system-ui|ui-sans-serif|ui-serif|ui-monospace|ui-rounded|cursive|fantasy|inherit|-apple-system|BlinkMacSystemFont|Segoe UI|Arial|Helvetica|Helvetica Neue|Times New Roman|Times|Georgia|Verdana|Tahoma|Courier New|Courier|Roboto|Apple Color Emoji)$/i; // --- collect elements const all = [...document.body.querySelectorAll('*')].slice(0, 9000); const texts = []; const big = []; for (const el of all) { const tag = el.tagName; if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT' || tag === 'TEMPLATE') continue; const r = el.getBoundingClientRect(); if (r.width < 1 || r.height < 1) continue; const cs = getComputedStyle(el); if (cs.display === 'none' || cs.visibility === 'hidden') continue; const area = r.width * r.height; if (area >= 60 * 30) big.push({ el, r, cs, area, top: r.top + SY }); let direct = ''; for (const n of el.childNodes) if (n.nodeType === 3) direct += n.nodeValue; direct = direct.replace(/\s+/g, ' ').trim(); if (!direct) continue; if (tag === 'OPTION' || el.closest('svg') || srHidden(el)) continue; const size = parseFloat(cs.fontSize); if (!size || size < 6) continue; const ls = cs.letterSpacing === 'normal' ? 0 : parseFloat(cs.letterSpacing) / size; const lh = cs.lineHeight === 'normal' ? 1.2 : parseFloat(cs.lineHeight) / size; texts.push({ el, tag, text: direct, chars: direct.length, fam: firstFam(cs.fontFamily), ff: cs.fontFamily, size: round(size, 1), weight: +cs.fontWeight || 400, ls: round(ls, 3), lh: round(lh, 2), upper: cs.textTransform === 'uppercase' || (direct.length > 2 && direct === direct.toUpperCase() && /[A-Z]{2}/.test(direct)), italic: cs.fontStyle === 'italic', align: cs.textAlign, color: cs.color, top: r.top + SY, bottom: r.bottom + SY, left: r.left, right: r.right, w: r.width, h: r.height, clipText: (cs.webkitBackgroundClip || cs.backgroundClip || '').includes('text'), inCode: !!el.closest('pre, code, kbd, samp, textarea'), inUI: !!el.closest('[data-ui]'), textShadow: cs.textShadow !== 'none', }); } // --- heading blocks: climb from a large text node to the block that holds the whole headline // An element that is a complete text unit by itself: separate links, list items or paragraphs set large // are separate lines of content, never the wrapped lines of one headline. const UNIT = 'h1, h2, h3, h4, h5, h6, [role="heading"], p, li, a, button, blockquote, dt, dd, figcaption, td, th, label, summary'; const blockOf = (t) => { let cur = t.el; // usable as the block when it holds only this size of type (a title with its own small description is not one block) const clean = (el) => (el.innerText || '').trim().length <= 320 && !texts.some((o) => o.el !== t.el && el.contains(o.el) && o.size < t.size * 0.72); let unit = t.el.closest(UNIT); if (unit && unit.matches('a, button')) { const outer = unit.parentElement?.closest('h1, h2, h3, h4, h5, h6, p, blockquote, li'); if (outer && clean(outer)) unit = outer; } if (unit && clean(unit)) return unit; while (cur.parentElement && cur.parentElement !== document.body) { if (cur.matches(UNIT)) break; const p = cur.parentElement; if (p.matches('ul, ol, dl, nav, menu, table, tbody')) break; const inner = (p.innerText || '').trim(); if (inner.length > 220) break; let ok = true; for (const o of texts) if (o.el !== t.el && p.contains(o.el) && o.size < t.size * 0.72) { ok = false; break; } if (!ok) break; cur = p; } return cur; }; // Count lines from the text itself: inline images or media set inside a sentence are not lines. const linesOf = (el, size) => { const tops = []; const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); const range = document.createRange(); for (let node = walker.nextNode(); node; node = walker.nextNode()) { if (!node.nodeValue.trim()) continue; range.selectNodeContents(node); for (const rc of range.getClientRects()) { if (rc.width < 1 || rc.height < size * 0.35) continue; const mid = rc.top + rc.height / 2; if (!tops.some((t) => Math.abs(t - mid) < size * 0.55)) tops.push(mid); } } return Math.max(1, tops.length); }; const describeBlock = (el, t) => { const r = el.getBoundingClientRect(); const text = (el.innerText || t.text).replace(/\s+/g, ' ').trim(); const cx = (r.left + r.right) / 2 / W; const fams = new Set(texts.filter((o) => el.contains(o.el)).map((o) => o.fam + (o.italic ? ' italic' : ''))); return { text: text.slice(0, 200), words: text.split(' ').filter(Boolean).length, chars: text.length, lines: linesOf(el, t.size), size: t.size, vw: round((t.size / W) * 100, 1), font: t.fam, weight: t.weight, tracking: t.ls, leading: t.lh, upper: t.upper, italic: t.italic, mono: isMono(t.ff), align: /center/.test(t.align) ? 'center' : /right|end/.test(t.align) ? 'right' : 'left', position: cx < 0.42 ? 'left' : cx > 0.58 ? 'right' : 'center', widthRatio: round(r.width / W), top: Math.round(r.top + SY), mixedFonts: fams.size > 1 ? [...fams] : undefined, gradientText: texts.some((o) => el.contains(o.el) && o.clipText) || undefined, tag: el.tagName.toLowerCase(), // a title (h1–h6, or unmarked large text) or a statement (a paragraph, quotation or list item set large) kind: el.closest('h1,h2,h3,h4,h5,h6,[role="heading"]') || el.querySelector('h1,h2,h3,h4,h5,h6,[role="heading"]') ? 'heading' : t.el.closest('p, blockquote, li, dd, q, figcaption') || text.length > 150 ? 'statement' : 'heading', }; }; const headingMin = W < 600 ? 22 : Math.max(26, W * 0.019); const seenBlocks = new Set(); const headings = []; for (const t of texts.filter((x) => x.size >= headingMin && !x.inCode).sort((a, b) => b.size - a.size)) { const blk = blockOf(t); if (seenBlocks.has(blk) || [...seenBlocks].some((b) => b.contains(blk) || blk.contains(b))) continue; seenBlocks.add(blk); const d = describeBlock(blk, t); if (d.kind === 'statement' && d.chars > 150 && d.size <= 30) continue; // a lead paragraph, not display type headings.push({ ...d, _el: blk }); } headings.sort((a, b) => a.top - b.top); // hero headline = largest heading block that starts inside the first viewport const inHero = headings.filter((h) => h.top < H * 0.95); let hero = inHero.slice().sort((a, b) => b.size - a.size || b.chars - a.chars)[0] || null; if (hero) hero.kind = 'heading'; // the main line of the first screen is a headline whatever its tag const h1el = document.querySelector('h1'); if (!hero) { // quiet pages: fall back to the h1, then to the largest text in the first viewport const h1r = h1el && h1el.getBoundingClientRect(); const pick = (h1r && h1r.top < H * 0.95 && h1r.width > 0 && texts.find((t) => h1el.contains(t.el))) || texts.filter((t) => t.top - SY < H * 0.95 && t.size >= 18 && t.chars >= 8).sort((a, b) => b.size - a.size)[0]; if (pick) { const blk = h1el && h1el.contains(pick.el) ? h1el : blockOf(pick); hero = { ...describeBlock(blk, pick), _el: blk }; } } let h1 = null; if (h1el && h1el.getBoundingClientRect().width > 0) { const cs = getComputedStyle(h1el); const size = parseFloat(cs.fontSize); const text = (h1el.innerText || '').replace(/\s+/g, ' ').trim(); h1 = { text: text.slice(0, 200), words: text.split(' ').filter(Boolean).length, lines: linesOf(h1el, size), size: round(size, 1), font: firstFam(cs.fontFamily) }; } // --- eyebrows: a small short label sitting directly above a heading block // Sticky and fixed layers overlap other content by design; their geometry says nothing about what labels what. const pinned = (el) => { for (let p = el; p && p !== document.body; p = p.parentElement) { const pos = getComputedStyle(p).position; if (pos === 'fixed' || pos === 'sticky') return true; } return false; }; const eyebrows = []; for (const h of headings) { const hr = h._el.getBoundingClientRect(); const htop = hr.top + SY; if (h._el.closest('[data-ui]')) continue; const cand = texts.filter((t) => !t.inUI && !h._el.contains(t.el) && t.size <= Math.min(18, h.size * 0.5) && t.chars <= 44 && t.chars >= 2 && t.bottom <= htop + 4 && htop - t.bottom <= Math.min(140, Math.max(72, h.size * 1.1)) && t.right > hr.left - 8 && t.left < hr.right + 8 && !t.el.closest('button') && !t.el.closest('nav, [role="navigation"], footer') && !(t.top < 120 && t.el.closest('header')) && !pinned(t.el) && pinned(h._el) === false) .sort((a, b) => b.bottom - a.bottom)[0]; if (!cand) continue; // must be alone on its row (a real paragraph above a heading is not an eyebrow) if (texts.some((t) => t !== cand && Math.abs(t.top - cand.top) < 6 && t.chars > 44)) continue; // a note that sits closer to the heading above it than to this one belongs to that heading const gapDown = htop - cand.bottom; if (headings.some((o) => o !== h && !o._el.contains(cand.el) && (() => { const or = o._el.getBoundingClientRect(); const gapUp = cand.top - (or.bottom + SY); return gapUp >= -2 && gapUp < gapDown && or.left < cand.right && or.right > cand.left; })())) continue; let pill = false; let p = cand.el; for (let i = 0; i < 3 && p; i++, p = p.parentElement) { const cs = getComputedStyle(p); const pr = p.getBoundingClientRect(); if (pr.width < 420 && pr.height < 56 && parseFloat(cs.borderTopLeftRadius) >= 10 && (rgba(cs.backgroundColor).a > 0.04 || parseFloat(cs.borderTopWidth) > 0)) pill = true; } eyebrows.push({ text: cand.text.slice(0, 50), above: h.text.slice(0, 40), upper: cand.upper, tracked: cand.ls >= 0.04, mono: isMono(cand.ff), pill, size: cand.size, top: Math.round(cand.top) }); } // --- decorative numbering: 01 / 02 / (03) / 04. / 001 / "Step 02" const numbered = texts.filter((t) => !t.inCode && !t.inUI && (/^[(\[\/#]?\s?0\d{1,2}\s?[)\]\/.:—–-]?$/.test(t.text) || /^0\d\s?[\/.—–-]\s/.test(t.text) || /^(step|phase|no\.?|n°|nº|chapter|part|fig\.?)\s?0\d{1,2}\b/i.test(t.text))); // --- type system const famChars = new Map(); const sizeChars = new Map(); let totalChars = 0; let monoChars = 0; let monoProse = 0; const monoFams = new Set(); const monoSamples = []; let microLabels = 0; let microLabelChars = 0; let upperChars = 0; let centerChars = 0; const microSamples = []; for (const t of texts) { totalChars += t.chars; famChars.set(t.fam, (famChars.get(t.fam) || 0) + t.chars); if (!t.inUI) { const s = Math.round(t.size); sizeChars.set(s, (sizeChars.get(s) || 0) + t.chars); } // interface fragments keep their own sizes if (isMono(t.ff)) { monoChars += t.chars; monoFams.add(t.fam); if (!t.inCode) { monoProse += t.chars; if (monoSamples.length < 6) monoSamples.push(t.text.slice(0, 40)); } } if (t.size <= 14 && t.upper && t.ls >= 0.05 && !t.inUI && !t.el.closest('nav, button, a[href]')) { microLabels++; microLabelChars += t.chars; if (microSamples.length < 6) microSamples.push(t.text.slice(0, 40)); } if (t.upper) upperChars += t.chars; if (/center/.test(t.align)) centerChars += t.chars; } const families = [...famChars.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6).map(([family, chars]) => ({ family, share: round(chars / totalChars), mono: isMono(`"${family}", serif`) && isMono(`"${family}", sans-serif`) })); const bodyTexts = texts.filter((t) => t.size >= 10 && t.size <= 24 && t.chars >= 60 && !t.inCode && !t.inUI); const bodyBy = new Map(); for (const t of bodyTexts) { const k = t.fam + '|' + Math.round(t.size); bodyBy.set(k, (bodyBy.get(k) || 0) + t.chars); } const bodyTop = [...bodyBy.entries()].sort((a, b) => b[1] - a[1])[0]; const bodySample = bodyTop ? bodyTexts.find((t) => t.fam + '|' + Math.round(t.size) === bodyTop[0]) : null; const longest = bodyTexts.slice().sort((a, b) => b.chars - a.chars)[0]; const sizes = [...sizeChars.entries()].sort((a, b) => a[0] - b[0]).map(([s]) => s); const maxText = texts.slice().sort((a, b) => b.size - a.size)[0]; const smallText = texts.filter((t) => t.size < 13 && t.chars >= 12 && !t.inUI && !t.inCode).length; // interface fragments keep their true sizes const loaded = new Set([...document.fonts].filter((f) => f.status === 'loaded').map((f) => f.family.replace(/["']/g, '').toLowerCase())); const declared = new Set([...document.fonts].map((f) => f.family.replace(/["']/g, '').toLowerCase())); const notLoaded = families.map((f) => f.family).filter((f) => !GENERIC.test(f) && !loaded.has(f.toLowerCase()) && (declared.has(f.toLowerCase()) || declared.size > 0)); // --- colours const bodyBg = (() => { for (const el of [document.body, document.documentElement]) { const v = rgba(getComputedStyle(el).backgroundColor); if (v.a > 0.5) return v; } return { r: 255, g: 255, b: 255, a: 1 }; })(); const sectionBgs = []; for (const b of big) { if (b.area < W * H * 0.3) continue; const v = rgba(b.cs.backgroundColor); if (v.a < 0.9) continue; const hx = hex(v); if (!sectionBgs.some((s) => s.color === hx)) sectionBgs.push({ color: hx, top: Math.round(b.top), hsl: hsl(v) }); } const textColorChars = new Map(); for (const t of texts) { const v = rgba(t.color); if (v.a < 0.3) continue; const hx = hex(v); textColorChars.set(hx, (textColorChars.get(hx) || 0) + t.chars); } const textColors = [...textColorChars.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([color, c]) => ({ color, share: round(c / totalChars) })); // effective background behind an element (null when an image or gradient sits behind it) const bgBehind = (el) => { for (let p = el; p && p !== document.documentElement; p = p.parentElement) { const cs = getComputedStyle(p); if (cs.backgroundImage !== 'none' || p.tagName === 'VIDEO' || p.tagName === 'CANVAS') return null; const v = rgba(cs.backgroundColor); if (v.a >= 0.9) return v; if (cs.position === 'absolute' || cs.position === 'fixed') return null; // may overlap media } return bodyBg; }; // media that may sit behind text without being its ancestor (an absolutely placed hero image, a video layer) const mediaRects = big.filter((b) => b.area >= 120 * 80 && (b.el.tagName === 'IMG' || b.el.tagName === 'VIDEO' || b.el.tagName === 'CANVAS' || b.el.tagName === 'PICTURE' || b.el.tagName === 'IFRAME' || b.el.tagName === 'svg' || b.cs.backgroundImage !== 'none')).map((b) => ({ el: b.el, l: b.r.left, r: b.r.right, t: b.r.top + SY, b: b.r.bottom + SY })); const overMedia = (t) => { const area = Math.max(1, t.w * t.h); for (const m of mediaRects) { const ox = Math.min(t.right, m.r) - Math.max(t.left, m.l), oy = Math.min(t.bottom, m.b) - Math.max(t.top, m.t); if (ox > 0 && oy > 0 && (ox * oy) / area >= 0.25) return true; } return false; }; const lowContrast = []; for (const t of texts) { if (t.chars < 4 || t.top - SY > H * 6) continue; const fg = rgba(t.color); if (fg.a < 0.99) continue; if (overMedia(t)) continue; // cannot know the pixels behind it; judge that from the screenshot // text blended with difference or exclusion inverts whatever lies under it; its colour alone says nothing let blended = false; for (let p = t.el, i = 0; p && p !== document.body && i < 6; p = p.parentElement, i++) if (/difference|exclusion/.test(getComputedStyle(p).mixBlendMode)) { blended = true; break; } if (blended) continue; const bg = bgBehind(t.el); if (!bg) continue; const c = contrast(fg, bg); const need = t.size >= 24 || (t.size >= 18.5 && t.weight >= 700) ? 3 : 4.5; if (c < need) lowContrast.push({ text: t.text.slice(0, 40), ratio: c, need, size: t.size, fg: hex(fg), bg: hex(bg) }); } lowContrast.sort((a, b) => a.ratio - b.ratio); // --- buttons const buttons = []; for (const el of document.querySelectorAll('a, button, [role="button"], input[type="submit"]')) { const r = el.getBoundingClientRect(); if (r.height < 28 || r.height > 84 || r.width < 64 || r.width > Math.min(W * 0.96, 900)) continue; const cs = getComputedStyle(el); let bg = rgba(cs.backgroundColor); const bw = parseFloat(cs.borderTopWidth) || 0; const grad = cs.backgroundImage.includes('gradient('); if (grad && bg.a < 0.5) { const first = cs.backgroundImage.match(/(rgba?\([^)]+\)|oklch\([^)]+\)|color\([^)]+\)|#[0-9a-f]{3,8})/i); if (first) bg = rgba(first[1]); } if (bg.a < 0.5 && bw < 1) continue; const rad = parseFloat(cs.borderTopLeftRadius) || 0; buttons.push({ text: (el.innerText || el.value || '').replace(/\s+/g, ' ').trim().slice(0, 40), filled: bg.a >= 0.5, bg: bg.a >= 0.5 ? hex(bg) : null, bgHsl: bg.a >= 0.5 ? hsl(bg) : null, fg: hex(rgba(cs.color)), radius: rad >= r.height / 2 - 1 ? 'pill' : Math.round(rad), h: Math.round(r.height), top: Math.round(r.top + SY), shadow: cs.boxShadow !== 'none' && !/^rgba?\(0, 0, 0, 0\)/.test(cs.boxShadow), gradient: grad, upper: cs.textTransform === 'uppercase', mono: isMono(cs.fontFamily), }); } const mode = (arr) => { const m = new Map(); for (const a of arr) m.set(JSON.stringify(a), (m.get(JSON.stringify(a)) || 0) + 1); const top = [...m.entries()].sort((a, b) => b[1] - a[1])[0]; return top ? JSON.parse(top[0]) : undefined; }; const filledBtns = buttons.filter((b) => b.filled); // --- surfaces and decoration const isOverlay = (el) => { for (let p = el, i = 0; p && i < 4; p = p.parentElement, i++) { const pos = getComputedStyle(p).position; if (pos === 'fixed' || pos === 'sticky') return true; if (p.matches('dialog, [role="dialog"], [role="menu"], [role="tooltip"], [popover]')) return true; } return false; }; // real tables, form fields and interface fragments (marked data-ui) keep their own lines const formish = (el) => el.matches('input, textarea, select, table, thead, tbody, tr, td, th, hr') || !!el.closest('table, [data-ui]'); let shadowStatic = 0, shadowOverlay = 0; const shadowSamples = []; let gradients = 0; let scrims = 0; const gradientSamples = []; let gradientText = 0; let hairlines = 0; const hairlineSamples = []; let outlinedBoxes = 0; let blurred = 0; let roundedBoxes = 0; let squareMedia = 0; let roundedMedia = 0; const mediaRadii = []; const boxRadii = []; let dashed = 0; const label = (el) => (el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (typeof el.className === 'string' && el.className ? '.' + el.className.trim().split(/\s+/).slice(0, 2).join('.') : '')).slice(0, 60); for (const b of big) { const { cs, r, el } = b; const sh = cs.boxShadow; if (sh !== 'none' && !/^rgba?\(0, 0, 0, 0\) 0px 0px 0px 0px$/.test(sh)) { const outer = sh.split(/,(?![^(]*\))/).some((part) => !/inset/.test(part) && !/ 0px 0px 0px 0px/.test(part) && !/rgba\(\d+, \d+, \d+, 0\)/.test(part)); if (outer) { if (isOverlay(el) || el.closest('[data-ui]')) shadowOverlay++; else { shadowStatic++; if (shadowSamples.length < 4) shadowSamples.push(label(el) + ' → ' + sh.slice(0, 80)); } } } if (cs.backgroundImage.includes('gradient(')) { if ((cs.webkitBackgroundClip || cs.backgroundClip || '').includes('text')) gradientText++; else if (b.area >= W * H * 0.02) { // neutral stops with changing alpha = a legibility scrim over media, not decoration const stops = [...cs.backgroundImage.matchAll(/rgba?\(([^)]+)\)/g)].map((mm) => mm[1].split(/[,\s/]+/).filter(Boolean).map(Number)); const exotic = /oklch|oklab|color\(|lab\(|lch\(|hsl/.test(cs.backgroundImage); const scrim = !exotic && stops.length > 0 && stops.every((c) => Math.max(c[0], c[1], c[2]) - Math.min(c[0], c[1], c[2]) <= 14) && stops.some((c) => c.length > 3 && c[3] < 1); if (scrim) scrims++; else { gradients++; if (gradientSamples.length < 4) gradientSamples.push(label(el) + ' → ' + cs.backgroundImage.slice(0, 110)); } } } if ((cs.backdropFilter && cs.backdropFilter !== 'none') || (cs.webkitBackdropFilter && cs.webkitBackdropFilter !== 'none')) blurred++; const bt = parseFloat(cs.borderTopWidth) || 0, bb = parseFloat(cs.borderBottomWidth) || 0, bl = parseFloat(cs.borderLeftWidth) || 0, br = parseFloat(cs.borderRightWidth) || 0; const thin = (w, c, style) => w > 0 && w <= 1.5 && style !== 'none' && rgba(c).a > 0.02; const topLine = thin(bt, cs.borderTopColor, cs.borderTopStyle), botLine = thin(bb, cs.borderBottomColor, cs.borderBottomStyle); const leftLine = thin(bl, cs.borderLeftColor, cs.borderLeftStyle), rightLine = thin(br, cs.borderRightColor, cs.borderRightStyle); if (!formish(el)) { if (topLine && botLine && leftLine && rightLine) { if (r.width >= 150 && r.height >= 80) outlinedBoxes++; } else if ((r.width >= 240 && (topLine || botLine)) || (r.height >= 240 && (leftLine || rightLine))) { hairlines++; if (hairlineSamples.length < 5) hairlineSamples.push(label(el) + ' ' + Math.round(r.width) + 'px'); } // thin dashed or dotted lines; a heavy dotted edge (3px or more) is a drawn perforation, not a hairline if ([[cs.borderTopStyle, bt], [cs.borderBottomStyle, bb], [cs.borderLeftStyle, bl], [cs.borderRightStyle, br]].some(([st, w]) => /dashed|dotted/.test(st) && w > 0 && w < 3)) dashed++; } const rad = parseFloat(cs.borderTopLeftRadius) || 0; const isMedia = el.tagName === 'IMG' || el.tagName === 'VIDEO' || el.tagName === 'CANVAS' || /url\(/.test(cs.backgroundImage); if (isMedia && r.width >= 200 && r.height >= 140) { let effective = rad; let p = el.parentElement; for (let i = 0; i < 3 && p && effective === 0; i++, p = p.parentElement) { const pcs = getComputedStyle(p); if (pcs.overflow !== 'visible') effective = parseFloat(pcs.borderTopLeftRadius) || 0; } if (effective > 1) { roundedMedia++; mediaRadii.push(Math.round(effective)); } else squareMedia++; } else if (rad >= 6 && r.width >= 150 && r.height >= 80 && (rgba(cs.backgroundColor).a > 0.04 || bt > 0)) { roundedBoxes++; boxRadii.push(Math.round(Math.min(rad, 99))); } } // elements that are themselves a thin rule (1px tall or 1px wide) for (const el of all) { const r = el.getBoundingClientRect(); const horiz = r.height > 0 && r.height <= 1.5 && r.width >= 160, vert = r.width > 0 && r.width <= 1.5 && r.height >= 160; if (!horiz && !vert) continue; if (formish(el) && (el.tagName !== 'HR' || el.closest('[data-ui]'))) continue; const cs = getComputedStyle(el); if (cs.display === 'none' || cs.visibility === 'hidden' || +cs.opacity < 0.03) continue; if (rgba(cs.backgroundColor).a > 0.02 || parseFloat(cs.borderTopWidth) > 0 || parseFloat(cs.borderLeftWidth) > 0 || cs.backgroundImage !== 'none') { hairlines++; if (hairlineSamples.length < 5) hairlineSamples.push(label(el) + ' ' + Math.round(Math.max(r.width, r.height)) + 'px rule'); } } // --- repeated card rows (three or more same-size boxed siblings) const cardRows = []; const seenParents = new Set(); for (const b of big) { const p = b.el.parentElement; if (!p || seenParents.has(p)) continue; seenParents.add(p); // cards carry text; a row of photographs or media tiles is not a card row const kids = [...p.children].filter((k) => { const r = k.getBoundingClientRect(); return r.width >= 150 && r.width < W * 0.9 && r.height >= 90 && !k.matches('img, picture, video, canvas, svg, figure:not(:has(figcaption))') && (k.innerText || '').trim().length >= 12; }); // full-width bands are sections, not cards if (kids.length < 3) continue; const boxed = kids.filter((k) => { const cs = getComputedStyle(k); return (rgba(cs.backgroundColor).a > 0.04 || parseFloat(cs.borderTopWidth) > 0 || cs.boxShadow !== 'none') && true; }); if (boxed.length < 3) continue; const w0 = boxed[0].getBoundingClientRect().width; const same = boxed.filter((k) => Math.abs(k.getBoundingClientRect().width - w0) < 3); if (same.length < 3) continue; const iconTop = same.filter((k) => { const first = k.querySelector('svg, img, i, [class*="icon" i]'); if (!first) return false; const r = first.getBoundingClientRect(); return r.width > 0 && r.width <= 64 && r.height <= 64; }).length; cardRows.push({ count: same.length, iconTopped: iconTop >= same.length - 1 && iconTop >= 3, rounded: (parseFloat(getComputedStyle(same[0]).borderTopLeftRadius) || 0) >= 6, top: Math.round(same[0].getBoundingClientRect().top + SY) }); } // --- hero media const heroMedia = []; for (const b of big) { const { el, r, cs } = b; if (r.top > H * 0.9 || r.bottom < 0) continue; const type = el.tagName === 'IMG' ? 'img' : el.tagName === 'VIDEO' ? 'video' : el.tagName === 'CANVAS' ? 'canvas' : el.tagName === 'svg' && r.width > 240 ? 'svg' : /url\(/.test(cs.backgroundImage) ? 'bg-image' : el.tagName === 'IFRAME' ? 'iframe' : null; if (!type) continue; const vw = Math.max(0, Math.min(r.right, W) - Math.max(r.left, 0)), vh = Math.max(0, Math.min(r.bottom, H) - Math.max(r.top, 0)); const ratio = (vw * vh) / (W * H); if (ratio >= 0.08) heroMedia.push({ type, ratio: round(ratio) }); } heroMedia.sort((a, b) => b.ratio - a.ratio); const brokenImages = [...document.images].filter((i) => i.complete && i.naturalWidth === 0 && i.getBoundingClientRect().width > 0 && (i.currentSrc || i.src)).map((i) => (i.currentSrc || i.src).slice(0, 120)).slice(0, 8); // --- navigation const navEl = document.querySelector('header nav, nav, header, [role="navigation"]'); let nav = null; if (navEl) { let host = navEl; let fixed = false; let p = navEl; for (let i = 0; i < 4 && p && p !== document.body; i++, p = p.parentElement) { const pos = getComputedStyle(p).position; if (pos === 'fixed' || pos === 'sticky') { fixed = true; host = p; break; } } const hr = host.getBoundingClientRect(); const hcs = getComputedStyle(host); const links = [...navEl.querySelectorAll('a')].filter((a) => { const r = a.getBoundingClientRect(); return r.width > 0 && r.top < 140; }); nav = { links: links.length, fixed, height: Math.round(hr.height), floatingPill: hr.width < W * 0.92 && parseFloat(hcs.borderTopLeftRadius) >= 16 && hr.top > 4, blur: (hcs.backdropFilter || 'none') !== 'none' }; } // data-ui is for cropped interface fragments, not for wrapping the page const uiAbuse = [...document.querySelectorAll('[data-ui]')].some((el) => el.querySelector('h1') || el.getBoundingClientRect().height > H * 1.6 || el === document.body); const uiFragments = document.querySelectorAll('[data-ui]').length; // pictures that were not drawn by the page's own code: photographs, footage, raster artwork. // Inline SVG and .svg files do not count; cropped interface fragments (data-ui) are counted apart. const svgSrc = (u) => /\.svg(\?|#|$)|^data:image\/svg/i.test(u || ''); let realPictures = 0, videoCount = 0; const pictureSamples = []; for (const el of all) { if (el.closest('[data-ui]') || el.closest('svg')) continue; const r = el.getBoundingClientRect(); if (r.width < 120 || r.height < 90 || r.width * r.height < 160 * 120) continue; if (el.tagName === 'IMG') { const src = el.currentSrc || el.src; if (el.naturalWidth > 0 && !svgSrc(src)) { realPictures++; if (pictureSamples.length < 8) pictureSamples.push(src.split('/').pop().slice(0, 48)); } continue; } if (el.tagName === 'VIDEO') { realPictures++; videoCount++; continue; } const bg = getComputedStyle(el).backgroundImage; if (bg && bg.includes('url(')) { const u = (bg.match(/url\(["']?([^"')]+)/) || [])[1]; if (!svgSrc(u)) realPictures++; } } const uiPictures = [...document.querySelectorAll('[data-ui]')].filter((el) => { const r = el.getBoundingClientRect(); return r.width >= 300 && r.height >= 200; }).length; // which elements push the page wider than the viewport (the deepest ones that are not clipped by an ancestor) let overflowCulprits = []; if (document.documentElement.scrollWidth > W + 2) { const clippedByAncestor = (el) => { for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) { if (getComputedStyle(p).overflowX !== 'visible' && p.getBoundingClientRect().right <= W + 2) return true; } return false; }; const wide = all.filter((el) => { const r = el.getBoundingClientRect(); return r.width > 0 && r.right > W + 2 && !clippedByAncestor(el); }); overflowCulprits = wide.filter((el) => !wide.some((o) => o !== el && el.contains(o))).slice(0, 3).map((el) => `${el.tagName.toLowerCase()}${el.id ? '#' + el.id : ''}${el.classList.length ? '.' + [...el.classList].slice(0, 2).join('.') : ''} (+${Math.round(el.getBoundingClientRect().right - W)}px)`); } // rows that scroll sideways with content past their edge (read as clipped in screenshots and by most visitors) const sideScrollers = all.filter((el) => { if (el.closest('[data-ui], table, pre, code')) return false; const cs = getComputedStyle(el); return /auto|scroll/.test(cs.overflowX) && el.scrollWidth > el.clientWidth + 24 && el.clientHeight > 40; }).length; const blendText = texts.filter((t) => { for (let p = t.el, i = 0; p && p !== document.body && i < 6; p = p.parentElement, i++) if (/difference|exclusion/.test(getComputedStyle(p).mixBlendMode)) return true; return false; }).length; const bodyText = document.body.innerText || ''; const emoji = (bodyText.match(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/gu) || []); const doc = document.documentElement; const pageH = Math.max(doc.scrollHeight, document.body.scrollHeight); const headingStats = headings.map(({ _el, ...h }) => h); const heroOut = hero ? (({ _el, ...h }) => h)(hero) : null; const below = headingStats.filter((h) => !heroOut || h.top !== heroOut.top); return { viewport: { w: W, h: H }, page: { height: pageH, screens: round(pageH / H, 1), words: bodyText.split(/\s+/).filter(Boolean).length, img: document.images.length, video: document.querySelectorAll('video').length, canvas: document.querySelectorAll('canvas').length, svg: document.querySelectorAll('svg').length, sections: document.querySelectorAll('section').length, horizontalOverflow: doc.scrollWidth > W + 2 ? doc.scrollWidth - W : 0, overflowCulprits, uiFragments, uiAbuse, realPictures, videoCount, uiPictures, pictureSamples, blendText, sideScrollers, brokenImages, emoji: [...new Set(emoji)].slice(0, 10), videosNoPoster: [...document.querySelectorAll('video')].filter((v) => !v.getAttribute('poster') && v.getBoundingClientRect().width >= 160).length }, hero: heroOut, h1, headings: headingStats.slice(0, 40), headingSummary: { count: headingStats.length, maxLines: Math.max(0, ...headingStats.map((h) => h.lines)), over2Lines: headingStats.filter((h) => h.lines > 2 && h.kind !== 'statement').length, statements: headingStats.filter((h) => h.kind === 'statement').length, centered: headingStats.filter((h) => h.align === 'center').length, medianWords: below.length ? below.map((h) => h.words).sort((a, b) => a - b)[Math.floor(below.length / 2)] : null, mixedFontHeadings: headingStats.filter((h) => h.mixedFonts).length }, type: { families, familyCount: famChars.size, sizes, sizeCount: sizes.length, notLoaded, body: bodySample ? { font: bodySample.fam, size: bodySample.size, leading: bodySample.lh, weight: bodySample.weight, tracking: bodySample.ls, contrast: (() => { const bg = bgBehind(bodySample.el); return bg ? contrast(rgba(bodySample.color), bg) : null; })() } : null, measureChars: longest ? Math.round(longest.w / (longest.size * 0.5)) : null, max: maxText ? { size: maxText.size, vw: round((maxText.size / W) * 100, 1), font: maxText.fam, text: maxText.text.slice(0, 40) } : null, monoShare: round(monoChars / Math.max(1, totalChars)), monoProseShare: round(monoProse / Math.max(1, totalChars)), monoFamilies: [...monoFams], monoSamples, microLabels, microLabelShare: round(microLabelChars / Math.max(1, totalChars), 3), microSamples, upperShare: round(upperChars / Math.max(1, totalChars)), centerShare: round(centerChars / Math.max(1, totalChars)), smallText, }, eyebrows, numbered: numbered.slice(0, 12).map((t) => ({ text: t.text.slice(0, 30), size: t.size, top: Math.round(t.top) })), color: { bodyBg: hex(bodyBg), bodyBgHsl: hsl(bodyBg), dark: lum(bodyBg) < 0.18, sectionBgs: sectionBgs.slice(0, 10), textColors, button: filledBtns.length ? { bg: mode(filledBtns.map((b) => b.bg)), hsl: mode(filledBtns.map((b) => b.bgHsl)), fg: mode(filledBtns.map((b) => b.fg)) } : null, lowContrast: lowContrast.slice(0, 6), }, buttons: { count: buttons.length, inHero: buttons.filter((b) => b.top < H).length, radius: mode(buttons.map((b) => b.radius)), height: mode(buttons.map((b) => b.h)), withShadow: buttons.filter((b) => b.shadow).length, withGradient: buttons.filter((b) => b.gradient).length, mono: buttons.filter((b) => b.mono).length, upper: buttons.filter((b) => b.upper).length, heroLabels: buttons.filter((b) => b.top < H).map((b) => b.text).filter(Boolean).slice(0, 5) }, surfaces: { shadowStatic, shadowOverlay, shadowSamples, gradients, scrims, gradientSamples, gradientText, hairlines, hairlineSamples, outlinedBoxes, dashed, blurred, roundedBoxes, boxRadius: mode(boxRadii) ?? 0, squareMedia, roundedMedia, mediaRadius: mode(mediaRadii) ?? 0, textShadow: texts.filter((t) => t.textShadow).length, cardRows: cardRows.slice(0, 8) }, heroMedia: heroMedia.slice(0, 4), nav, fontsLoaded: [...loaded].slice(0, 12), }; } // frame() runs at each scroll position the inspector photographs. It reports large text that something // else is lying on top of (a picture sliding over a headline), and how much of the screen holds anything. export function frame() { const W = innerWidth, H = innerHeight; const pinnedTop = (el) => { for (let p = el; p && p !== document.body; p = p.parentElement) { const cs = getComputedStyle(p); if ((cs.position === 'fixed' || cs.position === 'sticky') && p.getBoundingClientRect().top < 140 && p.getBoundingClientRect().height < 180) return true; } return false; }; // 1. covered text. Hit-testing skips elements with pointer-events: none (a pinned title usually has it), // which would make whatever lies beneath look as if it were on top; switch it on while sampling. const hitStyle = document.createElement('style'); hitStyle.textContent = '*, *::before, *::after { pointer-events: auto !important; }'; document.head.appendChild(hitStyle); const covered = [], clipped = [], pinnedOver = []; const label = (el, direct) => (el.innerText || direct).replace(/\s+/g, ' ').trim().slice(0, 48); const pinnedLow = (el) => { for (let p = el; p && p !== document.body; p = p.parentElement) { const pcs = getComputedStyle(p); if (pcs.position === 'fixed' || pcs.position === 'sticky') return !pinnedTop(el); } return false; }; // Does the element found on top actually paint anything there? A transparent layout box does not hide text. const paints = (hit, el) => { for (let p = hit; p && p !== document.body && !p.contains(el); p = p.parentElement) { if (/^(IMG|VIDEO|CANVAS|PICTURE|IFRAME|svg)$/.test(p.tagName) || p.closest('svg')) return true; const pcs = getComputedStyle(p); if (pcs.backgroundImage !== 'none') return true; const m = pcs.backgroundColor.match(/rgba?\(([^)]+)\)/); const parts = m ? m[1].split(/[,\s/]+/).filter(Boolean) : []; if (parts.length && (parts.length < 4 || parseFloat(parts[3]) > 0.05)) return true; if (p === hit) for (const n of p.childNodes) if (n.nodeType === 3 && n.nodeValue.trim()) return true; // its own text lies on ours } return false; }; const pictures = [...document.querySelectorAll('img, picture, svg, canvas, video')].filter((m) => !m.parentElement?.closest('svg')).map((m) => ({ m, r: m.getBoundingClientRect() })).filter(({ r }) => r.width >= 80 && r.height >= 80 && r.bottom > 0 && r.top < H); for (const el of document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, blockquote, li, a, button, span, div')) { let direct = ''; for (const n of el.childNodes) if (n.nodeType === 3) direct += n.nodeValue; if (direct.trim().length < 3) continue; const cs = getComputedStyle(el); const size = parseFloat(cs.fontSize); if (size < 24 || cs.visibility === 'hidden' || +cs.opacity < 0.3) continue; let srOnly = false; for (let p = el, i = 0; p && p !== document.body && i < 4; p = p.parentElement, i++) { const pcs = getComputedStyle(p); const pr = p.getBoundingClientRect(); if (pcs.clip === 'rect(0px, 0px, 0px, 0px)' || /inset\(50%\)/.test(pcs.clipPath) || (pr.width <= 2 && pr.height <= 2 && pcs.overflow !== 'visible')) { srOnly = true; break; } } if (srOnly) continue; // the boxes that clip this text (overflow other than visible, or a clip-path) const clips = []; for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) { const pcs = getComputedStyle(p); if (pcs.overflowY !== 'visible' || pcs.overflowX !== 'visible' || pcs.clipPath !== 'none') clips.push(p.getBoundingClientRect()); } // a scroll scene moves pictures across its own held stage by design; its text is judged by the scene rules const pinned = size >= 32 && pinnedLow(el) && !el.closest('.scene.is-live'); // lines of one heading set as separate spans overlap each other's glyph boxes; that is not a collision const unit = el.closest('h1, h2, h3, h4, h5, h6, p, blockquote'); const range = document.createRange(); let total = 0, hidden = 0, cutTop = false, area = 0, over = 0; for (const node of el.childNodes) { if (node.nodeType !== 3 || !node.nodeValue.trim()) continue; range.selectNodeContents(node); for (const r of range.getClientRects()) { if (r.width < 6 || r.bottom < 0 || r.top > H) continue; // sampled at two heights inside the line box (a display face's glyph box often spills past it and // under the next line), so a band lying over the top or the bottom of the letters is found too const lh = parseFloat(cs.lineHeight); const band = Math.min(r.height, Number.isFinite(lh) ? lh : r.height); for (const dy of [-0.2, 0.2]) for (const fx of [0.12, 0.37, 0.63, 0.88]) { const x = r.left + r.width * fx, y = r.top + r.height * 0.55 + band * dy; if (x < 0 || x >= W || y < 0 || y >= H) continue; total++; const hit = document.elementFromPoint(x, y); if (hit && hit !== el && !el.contains(hit) && !hit.contains(el) && !pinnedTop(hit) && !(unit && unit.contains(hit) && !/^(IMG|VIDEO|CANVAS|PICTURE|svg)$/.test(hit.tagName) && !hit.closest('svg')) && paints(hit, el)) hidden++; } // the glyph box starts above the capitals (ascender room), so only a cut well into it is a real crop if (size >= 40 && r.top >= -4 && clips.some((c) => c.top > r.top + r.height * 0.3 && c.top < r.bottom - r.height * 0.2)) cutTop = true; if (pinned) { area += r.width * r.height; for (const { m, r: pr } of pictures) { if (m.contains(el) || el.contains(m)) continue; const ox = Math.min(r.right, pr.right) - Math.max(r.left, pr.left), oy = Math.min(r.bottom, pr.bottom) - Math.max(r.top, pr.top); if (ox > 0 && oy > 0) over += ox * oy; } } } } if (total >= 4 && hidden / total >= 0.2) covered.push({ text: label(el, direct), share: Math.round((hidden / total) * 100) / 100 }); if (cutTop) clipped.push({ text: label(el, direct) }); if (pinned && area && over / area >= 0.15) pinnedOver.push({ text: label(el, direct), share: Math.min(1, Math.round((over / area) * 100) / 100) }); } hitStyle.remove(); // 2. how much of the screen holds anything: text glyph boxes, media, or a ground that differs from the page's const opaque = (c) => c && c !== 'transparent' && !/rgba\([^)]*,\s*0\)$/.test(c); const pageBg = [document.body, document.documentElement].map((e) => getComputedStyle(e).backgroundColor).find(opaque) || 'rgb(255, 255, 255)'; const textRects = []; const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); const rg = document.createRange(); for (let n = walker.nextNode(); n; n = walker.nextNode()) { if (!n.nodeValue.trim() || !n.parentElement) continue; const pcs = getComputedStyle(n.parentElement); if (pcs.visibility === 'hidden' || +pcs.opacity < 0.2) continue; rg.selectNodeContents(n); for (const r of rg.getClientRects()) if (r.bottom > 0 && r.top < H && r.width > 1) textRects.push(r); } let filled = 0, samples = 0; for (let gx = 0; gx < 24; gx++) for (let gy = 0; gy < 15; gy++) { const x = (gx + 0.5) * (W / 24), y = (gy + 0.5) * (H / 15); samples++; if (textRects.some((r) => x >= r.left - 6 && x <= r.right + 6 && y >= r.top - 4 && y <= r.bottom + 4)) { filled++; continue; } let el = document.elementFromPoint(x, y); for (let i = 0; el && i < 6 && el !== document.body && el !== document.documentElement; i++, el = el.parentElement) { if (/^(IMG|VIDEO|CANVAS|PICTURE|svg|IFRAME)$/.test(el.tagName) || el.closest('svg')) { filled++; break; } const cs = getComputedStyle(el); if (cs.backgroundImage !== 'none') { filled++; break; } const bg = cs.backgroundColor; if (bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') { if (bg !== pageBg) filled++; break; } } } // 3. the large text in view, to spot one statement held on screen for screen after screen const big = [...new Set([...document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, blockquote')].filter((el) => { const r = el.getBoundingClientRect(); return !el.closest('.scene.is-live') && parseFloat(getComputedStyle(el).fontSize) >= 32 && r.top >= -4 && r.bottom <= H + 4 && r.height > 0; }).map((el) => (el.innerText || '').replace(/\s+/g, ' ').trim().slice(0, 60)).filter((t) => t.length >= 4))]; return { covered, clipped, pinnedOver, filled: Math.round((filled / samples) * 100) / 100, big, y: Math.round(scrollY) }; } // layout() runs once, back at the top after the page has been scrolled through (so reveals have fired). // It measures the frame of the page rather than its details: the two outer margins, sections whose content // huddles on one side, blank stretches with nothing in them, the title, untitled sections, text pressed // against the edge of the screen and how many left edges the text uses. export function layout() { const W = innerWidth, H = innerHeight, SY = scrollY; const D = document.documentElement, B = document.body; const pageH = Math.max(D.scrollHeight, B.scrollHeight); const pct = (arr, p) => { if (!arr.length) return null; const s = [...arr].sort((a, b) => a - b); return s[Math.min(s.length - 1, Math.max(0, Math.round((s.length - 1) * p)))]; }; const shown = (el) => { for (let p = el, i = 0; p && p !== B && i < 30; p = p.parentElement, i++) { const cs = getComputedStyle(p); if (cs.display === 'none' || cs.visibility === 'hidden' || +cs.opacity < 0.05) return false; if (i < 4 && (cs.clip === 'rect(0px, 0px, 0px, 0px)' || /inset\(50%\)/.test(cs.clipPath))) return false; } return true; }; const layerOf = (el) => { for (let p = el; p && p !== B; p = p.parentElement) { const pos = getComputedStyle(p).position; if (pos === 'fixed') return 'fixed'; if (pos === 'sticky') return 'sticky'; } return null; }; const sel = (el) => (el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\s+/).slice(0, 2).join('.') : '')).slice(0, 60); const boxes = []; // A range is layout geometry: an ellipsised or scrolled line reaches past what the reader sees. Clip // each text box to the window its element's overflow allows (its own box and every clipping ancestor). const clipMemo = new Map(); const clipBox = (el) => { if (!el || el === B || el === document.documentElement) return null; if (clipMemo.has(el)) return clipMemo.get(el); const cs = getComputedStyle(el); let box = cs.position === 'fixed' ? null : clipBox(el.parentElement); if (cs.overflowX !== 'visible' || cs.overflowY !== 'visible') { const r = el.getBoundingClientRect(); const own = { l: cs.overflowX !== 'visible' ? r.left : -Infinity, r: cs.overflowX !== 'visible' ? r.right : Infinity, t: cs.overflowY !== 'visible' ? r.top : -Infinity, b: cs.overflowY !== 'visible' ? r.bottom : Infinity }; box = box ? { l: Math.max(box.l, own.l), r: Math.min(box.r, own.r), t: Math.max(box.t, own.t), b: Math.min(box.b, own.b) } : own; } clipMemo.set(el, box); return box; }; // text, one box per text node (its line boxes merged) const walker = document.createTreeWalker(B, NodeFilter.SHOW_TEXT); const rg = document.createRange(); for (let n = walker.nextNode(); n && boxes.length < 6000; n = walker.nextNode()) { const t = n.nodeValue.replace(/\s+/g, ' ').trim(); if (t.length < 2) continue; const el = n.parentElement; if (!el || /^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE|OPTION)$/.test(el.tagName) || el.closest('svg')) continue; rg.selectNodeContents(n); const r = rg.getBoundingClientRect(); if (r.width < 2 || r.height < 2) continue; if (!shown(el)) continue; const c = clipBox(el); const l = c ? Math.max(r.left, c.l) : r.left, rr = c ? Math.min(r.right, c.r) : r.right; const tp = c ? Math.max(r.top, c.t) : r.top, bt = c ? Math.min(r.bottom, c.b) : r.bottom; if (rr - l < 2 || bt - tp < 2) continue; const cs = getComputedStyle(el); boxes.push({ kind: 'text', el, l, r: rr, t: tp + SY, b: bt + SY, chars: t.length, text: t.slice(0, 50), size: parseFloat(cs.fontSize), align: cs.textAlign, layer: layerOf(el), tag: el.tagName }); } // pictures, drawings and controls for (const el of B.querySelectorAll('img, video, canvas, picture, iframe, svg, input, select, textarea, button, [role="button"], a, div, section, figure, span, li')) { const tag = el.tagName; let kind = null; if (/^(IMG|VIDEO|CANVAS|PICTURE|IFRAME)$/.test(tag)) kind = 'media'; else if (tag === 'svg') { if (el.parentElement && el.parentElement.closest('svg')) continue; kind = 'media'; } else if (/^(INPUT|SELECT|TEXTAREA|BUTTON)$/.test(tag) || el.getAttribute('role') === 'button') kind = 'control'; else { const bi = getComputedStyle(el).backgroundImage; if (bi && bi.includes('url(')) kind = 'media'; else continue; } const r = el.getBoundingClientRect(); if (r.width < 24 || r.height < 24) continue; if (kind === 'media' && r.width * r.height < 48 * 48) continue; if (!shown(el)) continue; boxes.push({ kind, el, l: r.left, r: r.right, t: r.top + SY, b: r.bottom + SY, chars: 0, size: 0, layer: layerOf(el), tag }); } const flow = boxes.filter((b) => b.layer !== 'fixed'); // 1. the frame: how far the page's content comes to each edge (full-bleed things say nothing about margins) // a row that runs off one edge (a carousel, a bled display line) says nothing about that side's margin const framed = flow.filter((b) => b.r - b.l < W * 0.97 && b.l >= -1 && b.r <= W + 1); const L = framed.length ? Math.max(0, pct(framed.map((b) => b.l), 0.02)) : null; const R = framed.length ? Math.max(0, W - pct(framed.map((b) => b.r), 0.98)) : null; // 2. sections: descend through single wrappers, then take the blocks const tall = (c) => { const r = c.getBoundingClientRect(); return r.height >= 120 && r.width >= W * 0.6 && getComputedStyle(c).position !== 'fixed' && shown(c); }; let root = B; for (let i = 0; i < 12; i++) { const kids = [...root.children].filter(tall); if (kids.length === 1) root = kids[0]; else break; } // a tall block is split into its parts; the parts of a block that has its own heading continue it let blocks = [...root.children].filter(tall).map((el) => ({ el, cont: false })); for (let pass = 0; pass < 2; pass++) blocks = blocks.flatMap((b) => { const k = [...b.el.children].filter(tall); if (b.el.getBoundingClientRect().height > H * 1.6 && k.length >= 2) { const titled = b.cont || !!b.el.querySelector('h1, h2, h3, h4, [role="heading"]'); return k.map((el) => ({ el, cont: titled })); } return [b]; }); const continues = new Set(blocks.filter((b) => b.cont).map((b) => b.el)); blocks = blocks.map((b) => b.el); const bodySize = (() => { const m = new Map(); for (const b of flow) if (b.kind === 'text' && b.chars >= 40) m.set(Math.round(b.size), (m.get(Math.round(b.size)) || 0) + b.chars); return [...m.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || 16; })(); const sections = blocks.slice(0, 40).map((s, i) => { const r = s.getBoundingClientRect(); const top = r.top + SY, bot = r.bottom + SY; const inside = flow.filter((b) => b.t >= top - 2 && b.b <= bot + 2 && s.contains(b.el)); const own = inside.filter((b) => b.r - b.l < W * 0.97); const text = inside.filter((b) => b.kind === 'text'); const media = inside.filter((b) => b.kind === 'media' && (b.r - b.l) * (b.b - b.t) >= 160 * 120); const words = text.reduce((a, b) => a + b.text.split(' ').length + Math.max(0, Math.round((b.chars - 50) / 6)), 0); const headRe = /^H[1-4]$/; const heading = text.filter((b) => (b.el.closest('h1,h2,h3,h4,[role="heading"]') || b.size >= Math.max(22, bodySize * 1.35)) && b.t <= top + Math.max(520, (bot - top) * 0.45)).sort((a, b) => b.size - a.size)[0]; const cs = getComputedStyle(s); // the widest stretch across the section's content band that nothing covers, edge to edge excluded let middle = 0; if (own.length >= 2) { const iv = own.map((b) => [Math.max(0, b.l), Math.min(W, b.r)]).sort((a, b) => a[0] - b[0]); let end = iv[0][1]; for (const [a, b2] of iv.slice(1)) { if (a > end) middle = Math.max(middle, a - end); end = Math.max(end, b2); } } return { sel: sel(s), tag: s.tagName.toLowerCase(), top: Math.round(top), height: Math.round(r.height), words, media: media.length, middle: Math.round(middle), heading: heading ? { text: heading.text, size: Math.round(heading.size), tag: (heading.el.closest('h1,h2,h3,h4,h5,h6') || heading.el).tagName.toLowerCase() } : null, left: own.length ? Math.round(Math.max(0, Math.min(...own.map((b) => b.l)))) : null, right: own.length ? Math.round(Math.max(0, W - Math.max(...own.map((b) => b.r)))) : null, textLeft: text.length ? Math.round(Math.min(...text.map((b) => b.l))) : null, textRight: text.length ? Math.round(W - Math.max(...text.map((b) => b.r))) : null, fullBleed: inside.some((b) => b.kind === 'media' && b.r - b.l >= W * 0.97), footer: s.tagName === 'FOOTER' || !!s.querySelector('footer') || i === blocks.length - 1, hero: top < H * 0.5, continues: continues.has(s), pinned: cs.position === 'sticky' || !!s.querySelector('.pin-spacer') || [...s.querySelectorAll('*')].slice(0, 400).some((e) => getComputedStyle(e).position === 'sticky' && e.getBoundingClientRect().height > H * 0.5), }; }); // 3. blank stretches: rows of the page with no text, picture or control, between the first and the last content. // A sticky element's track (its parent) and a pinning spacer are scroll distance, not blank page. const STEP = 4, rows = Math.ceil(pageH / STEP), occ = new Uint8Array(rows); const mark = (t, b, v) => { for (let y = Math.max(0, Math.floor(t / STEP)); y < Math.min(rows, Math.ceil(b / STEP)); y++) occ[y] = v; }; for (const b of flow) if (b.layer !== 'sticky') mark(b.t, b.b, 1); for (const el of B.querySelectorAll('.pin-spacer')) { const r = el.getBoundingClientRect(); mark(r.top + SY, r.bottom + SY, 2); } let scanned = 0; for (const el of B.querySelectorAll('*')) { if (++scanned > 9000) break; const cs = getComputedStyle(el); if (cs.position === 'sticky' && el.parentElement) { const r = el.parentElement.getBoundingClientRect(); mark(r.top + SY, r.bottom + SY, 2); } } let first = 0; while (first < rows && occ[first] !== 1) first++; let last = rows - 1; while (last > 0 && occ[last] !== 1) last--; const gaps = []; let run = 0; for (let y = first; y <= last + 1; y++) { if (y <= last && occ[y] === 0) run++; else { if (run * STEP >= H * 0.3) gaps.push({ top: (y - run) * STEP, height: run * STEP }); run = 0; } } // 4. the title const h1s = [...document.querySelectorAll('h1')].filter((h) => { const r = h.getBoundingClientRect(); return r.width > 0 && r.height > 0 && shown(h); }).map((h) => { const r = h.getBoundingClientRect(); return { text: (h.innerText || '').replace(/\s+/g, ' ').trim().slice(0, 80), size: Math.round(parseFloat(getComputedStyle(h).fontSize)), top: Math.round(r.top + SY), firstScreen: r.top + SY < H * 0.95 }; }); // 5. text pressed against the edge of the screen (type deliberately bled past the edge is a different device) // fixed headers and corner labels count here: that is where text most often creeps to the edge const edge = 16; const edgeText = boxes.filter((b) => b.kind === 'text' && b.chars >= 3 && b.size < 40 && b.l >= -1 && b.r <= W + 1 && (b.l < edge || b.r > W - edge) && !b.el.closest('[class*="marquee" i],[class*="ticker" i],marquee,[data-ui],table')).slice(0, 8).map((b) => ({ text: b.text, side: b.l < edge ? 'left' : 'right', px: Math.round(b.l < edge ? b.l : W - b.r) })); // display type that runs off one edge by a sliver while it starts inside the margin: a fit error, not a bleed const slivers = boxes.filter((b) => b.kind === 'text' && b.size >= 40 && b.chars >= 3 && ((b.l >= 8 && b.r > W + 1 && b.r - W < b.size * 0.25) || (b.r <= W - 8 && b.l < -1 && -b.l < b.size * 0.25))).slice(0, 4).map((b) => ({ text: b.text.slice(0, 30), px: Math.round(b.r > W ? b.r - W : -b.l), side: b.r > W ? 'right' : 'left' })); // the largest empty rectangle on the first screen (below a 90px band for the navigation), on a 24 x 15 grid let voidShare = 0, voidBox = null; if (W >= 1000) { const C = 24, Rn = 15, cw = W / C, ch = H / Rn, occ2 = Array.from({ length: Rn }, () => new Array(C).fill(0)); for (const b of boxes) { if (b.t - SY > H || b.b - SY < 0) continue; const c0 = Math.max(0, Math.floor(b.l / cw)), c1 = Math.min(C - 1, Math.floor((b.r - 1) / cw)), r0 = Math.max(0, Math.floor((b.t - SY) / ch)), r1 = Math.min(Rn - 1, Math.floor((b.b - SY - 1) / ch)); for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) occ2[r][c] = 1; } const top = Math.ceil(90 / ch), hgt = new Array(C).fill(0); for (let r = top; r < Rn; r++) { for (let c = 0; c < C; c++) hgt[c] = occ2[r][c] ? 0 : hgt[c] + 1; for (let c = 0; c < C; c++) { let mn = Infinity; for (let k = c; k < C && hgt[k]; k++) { mn = Math.min(mn, hgt[k]); const a = mn * (k - c + 1); if (a > voidShare) { voidShare = a; voidBox = { x: Math.round(c * cw), y: Math.round((r - mn + 1) * ch), w: Math.round((k - c + 1) * cw), h: Math.round(mn * ch) }; } } } } voidShare = Math.round((voidShare / (C * Rn)) * 100) / 100; } // a fixed picture layer that covers the screen (a canvas scene, a film) hides what the DOM cannot see const stage = boxes.some((b) => b.layer === 'fixed' && b.kind === 'media' && (b.r - b.l) * (b.b - b.t) >= W * H * 0.9); // the first screen: is the title a bare name, with nothing at reading size saying what this is? const firstText = flow.filter((b) => b.kind === 'text' && b.t < H * 0.95 && b.size >= 18 && !b.el.closest('h1, nav, header nav, button, a')); const explains = firstText.some((b) => b.text.split(' ').length >= 6 || b.chars >= 36); // 6. left edges used by running text and headings (left-aligned blocks only) // headings and paragraphs only: the items of a grid or a list each start their own column on purpose const lefts = flow.filter((b) => b.kind === 'text' && b.size >= 14 && !/center|right|end/.test(b.align) && b.l >= 0 && (b.el.closest('h1, h2, h3') || (b.chars >= 60 && b.el.closest('p, blockquote'))) && !b.el.closest('nav, footer, [data-ui], table, li, figcaption')).map((b) => b.l).sort((a, b) => a - b); const edges = []; for (const x of lefts) { const e = edges.find((g) => Math.abs(g.x - x) <= 10); if (e) e.n++; else edges.push({ x: Math.round(x), n: 1 }); } edges.sort((a, b) => b.n - a.n); return { viewport: { w: W, h: H }, pageH, margins: { left: L == null ? null : Math.round(L), right: R == null ? null : Math.round(R) }, sections, gaps, h1s, edgeText, edges: edges.slice(0, 12), leftBlocks: lefts.length, bodySize, stage, explains, slivers, voidShare, voidBox }; } // app() measures what an app screen (dashboard, admin, settings, inbox) gets right or wrong: the shell, // how much of the canvas is used, panels and their edges, numbers in tables and KPIs, chart colours, // the icon-in-a-tinted-square habit, placeholder data, and what happens to the sidebar on a phone. export function app() { const W = innerWidth, H = innerHeight, SY = scrollY, B = document.body; const cvs = document.createElement('canvas'); cvs.width = cvs.height = 1; const ctx = cvs.getContext('2d', { willReadFrequently: true }); const rgba = (c) => { ctx.clearRect(0, 0, 1, 1); ctx.fillStyle = 'rgba(0,0,0,0)'; ctx.fillStyle = c; ctx.fillRect(0, 0, 1, 1); const d = ctx.getImageData(0, 0, 1, 1).data; return { r: d[0], g: d[1], b: d[2], a: d[3] / 255 }; }; const hex = (v) => '#' + [v.r, v.g, v.b].map((n) => n.toString(16).padStart(2, '0')).join(''); const lum = (v) => { const f = (x) => { x /= 255; return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(v.r) + 0.7152 * f(v.g) + 0.0722 * f(v.b); }; const contrast = (a, b) => { const l1 = lum(a), l2 = lum(b); return Math.round(((Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05)) * 100) / 100; }; const hsl = (v) => { const r = v.r / 255, g = v.g / 255, b = v.b / 255, mx = Math.max(r, g, b), mn = Math.min(r, g, b); const l = (mx + mn) / 2; let h = 0, s = 0; if (mx !== mn) { const d = mx - mn; s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn); h = mx === r ? (g - b) / d + (g < b ? 6 : 0) : mx === g ? (b - r) / d + 2 : (r - g) / d + 4; h *= 60; } return { h: Math.round(h), s, l }; }; const shown = (el, cs) => { cs = cs || getComputedStyle(el); if (cs.display === 'none' || cs.visibility === 'hidden' || +cs.opacity < 0.05) return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; }; const groundOf = (el) => { for (let p = el; p; p = p.parentElement) { const v = rgba(getComputedStyle(p).backgroundColor); if (v.a >= 0.9) return v; } return { r: 255, g: 255, b: 255, a: 1 }; }; const pageBg = groundOf(B); const all = [...B.querySelectorAll('*')].slice(0, 12000); // 1. the shell: a tall column at one side (sidebar) and a bar across the top let sidebar = null, topbar = null; for (const el of all) { const r = el.getBoundingClientRect(); if (r.width < 40) continue; if (!sidebar && r.left <= 1 && r.top <= 80 && r.height >= H * 0.7 && r.width >= 48 && r.width <= Math.min(360, W * 0.45) && (el.querySelectorAll('a, button').length >= 3) && shown(el)) sidebar = { w: Math.round(r.width), tag: el.tagName.toLowerCase(), links: el.querySelectorAll('a, button').length, share: Math.round((r.width / W) * 100) / 100 }; if (!topbar && r.top <= 1 && r.left <= (sidebar ? sidebar.w + 2 : 2) && r.width >= W * 0.6 && r.height >= 36 && r.height <= 96 && shown(el)) topbar = { h: Math.round(r.height) }; if (sidebar && topbar) break; } // 2. how much of the canvas the content uses (right of the sidebar) const x0 = sidebar ? sidebar.w : 0; // the text itself (a block element's box runs to the edge whatever its text does), pictures, charts and controls const boxes = []; const tw = document.createTreeWalker(B, NodeFilter.SHOW_TEXT); const trg = document.createRange(); for (let n = tw.nextNode(); n && boxes.length < 5000; n = tw.nextNode()) { if (n.nodeValue.trim().length < 2 || !n.parentElement || n.parentElement.closest('svg')) continue; const cs = getComputedStyle(n.parentElement); if (cs.visibility === 'hidden' || cs.display === 'none' || +cs.opacity < 0.05) continue; trg.selectNodeContents(n); const r = trg.getBoundingClientRect(); if (r.width < 2 || r.left < x0 - 1 || (cs.position === 'fixed' && r.left > W * 0.5)) continue; boxes.push(r); } for (const el of B.querySelectorAll('svg, canvas, img, video, input, select, textarea, button, table')) { if (el.parentElement && el.parentElement.closest('svg')) continue; const r = el.getBoundingClientRect(); if (r.width < 8 || r.height < 8 || r.left < x0 - 1 || !shown(el)) continue; boxes.push(r); } const rights = boxes.map((r) => r.right).sort((a, b) => a - b), lefts = boxes.map((r) => r.left).sort((a, b) => a - b); const canvas = boxes.length ? { left: Math.round(lefts[Math.floor(lefts.length * 0.02)] - x0), right: Math.round(W - rights[Math.floor(rights.length * 0.98)]) } : null; // 3. panels: boxes that read as cards (a fill one step from the ground, a border, or a shadow) const panels = []; const radii = new Map(); let heavyBorders = 0, heavyShadows = 0, softShadows = 0, glass = 0; const borderSamples = [], shadowSamples = []; for (const el of all) { const r = el.getBoundingClientRect(); if (r.width < 140 || r.height < 72 || r.width > W * 0.98) continue; const cs = getComputedStyle(el); if (!shown(el, cs)) continue; const bg = rgba(cs.backgroundColor), bw = parseFloat(cs.borderTopWidth) || 0, bc = rgba(cs.borderTopColor); const ground = groundOf(el.parentElement || B); const filled = bg.a >= 0.9 && contrast(bg, ground) >= 1.04, bordered = bw >= 0.5 && cs.borderTopStyle !== 'none' && bc.a > 0.05; const sh = cs.boxShadow !== 'none' ? cs.boxShadow : ''; if (!filled && !bordered && !sh) continue; if (!(el.innerText || '').trim()) continue; panels.push({ el, w: Math.round(r.width), h: Math.round(r.height), top: Math.round(r.top + SY), left: Math.round(r.left), bottom: Math.round(r.bottom + SY), right: Math.round(r.right) }); const rad = Math.round(parseFloat(cs.borderTopLeftRadius) || 0); radii.set(rad, (radii.get(rad) || 0) + 1); if (bordered) { const c = contrast({ ...bc, a: 1 }, bg.a >= 0.9 ? bg : ground); if (c >= (lum(ground) > 0.4 ? 1.45 : 1.9)) { heavyBorders++; if (borderSamples.length < 3) borderSamples.push(`${hex(bc)} on ${hex(bg.a >= 0.9 ? bg : ground)} (${c}:1)`); } } if (sh) { for (const part of sh.split(/,(?![^(]*\))/)) { if (/inset/.test(part)) continue; const col = part.match(/rgba?\([^)]+\)/); const nums = part.replace(/rgba?\([^)]+\)/, '').trim().split(/\s+/).map(parseFloat); const a = col ? rgba(col[0]).a : 1; const blur = nums[2] || 0, y = Math.abs(nums[1] || 0); if (a <= 0.02 || (blur === 0 && y === 0 && !(nums[3] > 0))) continue; // on a dark ground a black shadow barely shows; there it only counts when it is near-opaque const darkGround = lum(ground) < 0.2; if (darkGround ? a >= 0.45 : ((a >= 0.12 && blur >= 12) || y >= 8 || a >= 0.25)) { heavyShadows++; if (shadowSamples.length < 3) shadowSamples.push(part.trim().slice(0, 70)); } else softShadows++; } } if (cs.backdropFilter && cs.backdropFilter !== 'none') glass++; } // top-level panels only (a card inside a panel is part of it): rows that end ragged, and how many // panels start on the first screen const panelEls = new Set(panels.map((p) => p.el)); const tops = panels.filter((p) => { for (let a = p.el.parentElement; a && a !== B; a = a.parentElement) if (panelEls.has(a)) return false; return p.w < W * 0.97 || p.h < H * 2; }); // a column of cards stacked with a gutter of 24px or less is one block: two cards on the left that end level with // one tall card on the right are not ragged. A pair is ragged only when the cards and their columns both end apart. const below = (p) => tops.find((q) => q !== p && Math.abs(q.left - p.left) <= 4 && Math.abs(q.w - p.w) <= 4 && q.top >= p.bottom - 2 && q.top - p.bottom <= 24); const columnBottom = (p) => { let b = p.bottom; for (let q = below(p), n = 0; q && n < 12; q = below(q), n++) b = q.bottom; return b; }; const ragged = []; for (let i = 0; i < tops.length; i++) for (let k = i + 1; k < tops.length; k++) { const a = tops[i], b = tops[k]; if (Math.abs(a.top - b.top) > 4 || a.w < 200 || b.w < 200 || (a.right > b.left - 4 && b.right > a.left - 4)) continue; // side by side, same row const diff = Math.min(Math.abs(a.bottom - b.bottom), Math.abs(columnBottom(a) - columnBottom(b))), tall = Math.max(a.h, b.h); if (diff > Math.max(24, tall * 0.12)) ragged.push({ diff, top: a.top }); } // the largest blank band inside a card: the vertical gap between consecutive pieces of its content (text, charts, // pictures, controls, rules, fills), or from the last piece to the card's padding edge. A legend pinned to the foot // of a stretched card, or four rows in a card kept at its neighbour's height, leave one. const voids = []; const seen = new Map(); const visibleIn = (el, root) => { if (seen.has(el)) return seen.get(el); let ok = true; for (let p = el; p && p !== root.parentElement; p = p.parentElement) { const cs = getComputedStyle(p); if (cs.display === 'none' || cs.visibility === 'hidden' || +cs.opacity < 0.05 || cs.position === 'fixed') { ok = false; break; } } seen.set(el, ok); return ok; }; for (const p of tops) { if (p.el.matches('nav, aside, header, footer, dialog, [role="navigation"], [role="dialog"]') || p.el.querySelector('nav') || (sidebar && p.left < sidebar.w - 4) || p.h < 120) continue; if (panels.filter((q) => q.el !== p.el && p.el.contains(q.el)).length >= 2) continue; // a frame of cards, not a card const cs = getComputedStyle(p.el), pr = p.el.getBoundingClientRect(); const inTop = pr.top + (parseFloat(cs.borderTopWidth) || 0) + (parseFloat(cs.paddingTop) || 0), inBottom = pr.bottom - (parseFloat(cs.borderBottomWidth) || 0) - (parseFloat(cs.paddingBottom) || 0); const bg = rgba(cs.backgroundColor), spans = []; const tw2 = document.createTreeWalker(p.el, NodeFilter.SHOW_TEXT), rg = document.createRange(); // text that opens a section (a heading, a summary, or text at 14px/600 or 15px/500 and up) may follow a wider, placed gap for (let n = tw2.nextNode(); n; n = tw2.nextNode()) { if (!n.nodeValue.trim() || !n.parentElement || n.parentElement.closest('svg') || !visibleIn(n.parentElement, p.el)) continue; rg.selectNodeContents(n); const r = rg.getBoundingClientRect(); if (r.height > 0 && r.width > 0) { const pcs = getComputedStyle(n.parentElement); const fs = parseFloat(pcs.fontSize), fw = +pcs.fontWeight; spans.push([r.top, r.bottom, !!n.parentElement.closest('h1, h2, h3, h4, h5, h6, [role="heading"], summary, legend, caption') || (fw >= 600 && fs >= 14) || (fw >= 500 && fs >= 15)]); } } for (const el of p.el.querySelectorAll('*')) { if (el.parentElement && el.parentElement.closest('svg')) continue; const r = el.getBoundingClientRect(); if (r.width < 2 || r.height < 1 || !visibleIn(el, p.el)) continue; if (/^(svg|canvas|img|video|input|select|textarea|button|hr|progress|meter|iframe)$/i.test(el.tagName)) { spans.push([r.top, r.bottom]); continue; } const ecs = getComputedStyle(el), fillC = rgba(ecs.backgroundColor); if (fillC.a >= 0.1 && (bg.a < 0.9 || contrast({ ...fillC, a: 1 }, bg) >= 1.03) && r.height >= 2) spans.push([r.top, r.bottom]); if ((parseFloat(ecs.borderTopWidth) || 0) >= 0.5 && ecs.borderTopStyle !== 'none' && rgba(ecs.borderTopColor).a > 0.05) spans.push([r.top, r.top + 1]); if ((parseFloat(ecs.borderBottomWidth) || 0) >= 0.5 && ecs.borderBottomStyle !== 'none' && rgba(ecs.borderBottomColor).a > 0.05) spans.push([r.bottom - 1, r.bottom]); } const ys = spans.filter(([a, b]) => b > inTop - 2 && a < inBottom + 2).sort((a, b) => a[0] - b[0]); if (!ys.length) continue; // a band counts when it is over 40px, or over 64px when the content after it opens a new section let end = ys[0][1], gap = 0, at = null; for (const [a, b] of ys.slice(1)) { const g = a - end, section = ys.some(([t, , hd]) => hd && Math.abs(t - a) < 6); if (g > (section ? 64 : 40) && g > gap) { gap = g; at = end; } end = Math.max(end, b); } if (inBottom - end > 40 && inBottom - end > gap) { gap = inBottom - end; at = end; } const head = (p.el.querySelector('h1, h2, h3, h4, [role="heading"], header') || p.el).innerText || ''; if (gap > 40) voids.push({ gap: Math.round(gap), at: Math.round(at + SY), panel: head.trim().split('\n')[0].slice(0, 40) }); } voids.sort((a, b) => b.gap - a.gap); const firstScreen = tops.filter((p) => p.top < H - 48).length; // the icon-in-a-tinted-square habit: small boxes with a coloured fill holding one icon, repeated const chips = []; let tiles = 0; for (const el of all) { const r = el.getBoundingClientRect(); if (r.width < 24 || r.width > 64 || Math.abs(r.width - r.height) > 4) continue; const cs = getComputedStyle(el); const bg = rgba(cs.backgroundColor); if (bg.a < 0.08) continue; const icon = el.querySelector('svg, i, img, [class*="icon" i]'); if (!icon || (el.innerText || '').trim().length > 2) continue; const c = hsl(bg); if (c.s < 0.18 && bg.a > 0.5) { if (r.width >= 28 && !el.closest('button, a, nav, aside, [role="navigation"]')) tiles++; continue; } // neutral: an avatar, a button, or a boxed icon chips.push({ hue: c.h, size: Math.round(r.width), radius: cs.borderTopLeftRadius }); } const chipHues = new Set(chips.map((c) => Math.round(c.hue / 30))); // 5. numbers: tables and KPI figures const numRe = /^[−\-+]?[$€£¥]?\s?\d[\d,.\s]*(%|[kKmMbB]|\s?(ms|s|h|min|GB|MB|kB|x))?$/; const cells = [...B.querySelectorAll('td, [role="cell"], [role="gridcell"]')].filter((el) => numRe.test((el.innerText || '').trim())); const leftNums = cells.filter((el) => { const a = getComputedStyle(el).textAlign; const inner = el.firstElementChild ? getComputedStyle(el.firstElementChild).textAlign : a; return /left|start/.test(a) && /left|start/.test(inner) && getComputedStyle(el).justifyContent !== 'flex-end'; }).length; const figures = []; for (const el of all) { if (el.children.length) continue; const t = (el.innerText || '').trim(); if (!numRe.test(t) || t.length < 2) continue; const cs = getComputedStyle(el); if (!shown(el, cs)) continue; figures.push({ size: parseFloat(cs.fontSize), tabular: /tabular-nums/.test(cs.fontVariantNumeric) || /"tnum"/.test(cs.fontFeatureSettings), weight: +cs.fontWeight }); } const tabularShare = figures.length ? Math.round((figures.filter((f) => f.tabular).length / figures.length) * 100) / 100 : null; const rows = [...B.querySelectorAll('tbody tr, [role="row"]')].map((tr) => Math.round(tr.getBoundingClientRect().height)).filter((h) => h > 0); // 6. chart colours: saturated fills and strokes inside SVG charts and canvas-free charts const hues = new Map(); for (const svg of B.querySelectorAll('svg')) { const r = svg.getBoundingClientRect(); if (r.width < 120 || r.height < 60) continue; for (const s of svg.querySelectorAll('path, rect, circle, line, polyline, polygon')) { const cs = getComputedStyle(s); for (const c of [cs.fill, cs.stroke]) { if (!c || c === 'none' || c.startsWith('url')) continue; const v = rgba(c); if (v.a < 0.3) continue; const h = hsl(v); if (h.s < 0.25 || h.l < 0.12 || h.l > 0.92) continue; const k = Math.round(h.h / 24); hues.set(k, (hues.get(k) || 0) + 1); } } } // bars and segments drawn with divs count too (a common way to fake a chart) // 7. placeholder data const text = B.innerText || ''; const placeholders = [...new Set((text.match(/\b(John|Jane) Doe\b|\bAcme( Inc| Corp)?\b|\bLorem ipsum\b|\bexample\.com\b|\bUser Name\b|\bFirstname Lastname\b|\b123 Main St\b|\bfoo(bar)?\b/gi) || []).map((s) => s.toLowerCase()))]; // 8. type at app sizes const sizes = new Map(); let tiny = 0; const tinySamples = []; const walker = document.createTreeWalker(B, NodeFilter.SHOW_TEXT); for (let n = walker.nextNode(); n; n = walker.nextNode()) { const t = n.nodeValue.trim(); if (t.length < 2 || !n.parentElement) continue; const cs = getComputedStyle(n.parentElement); if (cs.visibility === 'hidden' || cs.display === 'none') continue; const s = Math.round(parseFloat(cs.fontSize)); sizes.set(s, (sizes.get(s) || 0) + t.length); if (s < 11 && t.length >= 3) { tiny++; if (tinySamples.length < 4) tinySamples.push(t.slice(0, 24) + ' ' + s + 'px'); } } const total = [...sizes.values()].reduce((a, b) => a + b, 0) || 1; const under13 = Math.round(([...sizes.entries()].filter(([s]) => s < 13).reduce((a, [, c]) => a + c, 0) / total) * 100) / 100; const h1 = document.querySelector('h1'); const h1r = h1 && h1.getBoundingClientRect(); // the screen's hero: its h1 when that is on the first screen, else the largest text there. Text is read with a // space between element children, so a figure and its unit ("£55.94" and "at cost") do not run together. const spaced = (el) => { const out = []; const w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); for (let n = w.nextNode(); n; n = w.nextNode()) { const t = n.nodeValue.replace(/\s+/g, ' ').trim(); if (t && n.parentElement && !n.parentElement.closest('svg, script, style') && getComputedStyle(n.parentElement).display !== 'none') out.push(t); } return out.join(' ').replace(/\s+([,.;:!?%])/g, '$1').trim(); }; let heroEl = h1 && h1r && h1r.width > 0 && h1r.top + SY < H * 0.95 ? h1 : null; if (!heroEl) { let best = null; const hw = document.createTreeWalker(B, NodeFilter.SHOW_TEXT); for (let n = hw.nextNode(); n; n = hw.nextNode()) { const el = n.parentElement; if (!el || n.nodeValue.trim().length < 2 || el.closest('svg, nav, aside, [role="navigation"]')) continue; const r = el.getBoundingClientRect(); if (r.top + SY > H * 0.95 || r.width < 1 || (sidebar && r.left < sidebar.w - 2)) continue; const cs = getComputedStyle(el); if (cs.visibility === 'hidden' || +cs.opacity < 0.05) continue; const size = parseFloat(cs.fontSize); if (!best || size > best.size) best = { el, size }; } if (best) { heroEl = best.el; for (let p = heroEl; p && p !== B; p = p.parentElement) if (!/^inline/.test(getComputedStyle(p).display)) { heroEl = p; break; } } } let hero = null; if (heroEl) { const big = [...heroEl.querySelectorAll('*'), heroEl].map((e) => ({ e, s: parseFloat(getComputedStyle(e).fontSize) })).filter((o) => [...o.e.childNodes].some((c) => c.nodeType === 3 && c.nodeValue.trim())).sort((a, b) => b.s - a.s)[0]?.e || heroEl; const cs = getComputedStyle(big), size = parseFloat(cs.fontSize), txt = spaced(heroEl), r = heroEl.getBoundingClientRect(); const tops2 = []; const lw = document.createTreeWalker(heroEl, NodeFilter.SHOW_TEXT); const lr = document.createRange(); for (let n = lw.nextNode(); n; n = lw.nextNode()) { if (!n.nodeValue.trim()) continue; lr.selectNodeContents(n); for (const rc of lr.getClientRects()) { const mid = rc.top + rc.height / 2; if (rc.width >= 1 && !tops2.some((t) => Math.abs(t - mid) < size * 0.55)) tops2.push(mid); } } const lh = parseFloat(cs.lineHeight); hero = { text: txt.slice(0, 200), words: txt.split(' ').filter(Boolean).length, chars: txt.length, lines: Math.max(1, tops2.length), size: Math.round(size), vw: Math.round((size / W) * 1000) / 10, font: cs.fontFamily.split(',')[0].replace(/["']/g, '').trim(), weight: +cs.fontWeight, tracking: Math.round(((parseFloat(cs.letterSpacing) || 0) / size) * 100) / 100, leading: Number.isFinite(lh) ? Math.round((lh / size) * 100) / 100 : 1.2, top: Math.round(r.top + SY), tag: heroEl.tagName.toLowerCase(), kind: 'heading', align: 'left', position: 'left' }; } // charts and timelines on the screen (for the data-hue check): sizeable SVGs with marks, or blocks named as one const chartRe = /(^|[\s_-])(chart|plot|graph|timeline|calendar|cal|gantt|heatmap|matrix|strip)([\s_-]|$)/i; const chartCount = [...B.querySelectorAll('svg')].filter((s) => { if (s.parentElement && s.parentElement.closest('svg')) return false; const r = s.getBoundingClientRect(); return r.width >= 200 && r.height >= 80 && s.querySelectorAll('path, rect, line, polyline, circle').length >= 6 && shown(s); }).length + [...B.querySelectorAll('[class]')].filter((e) => typeof e.className === 'string' && chartRe.test(e.className) && !e.closest('svg') && (() => { const r = e.getBoundingClientRect(); return r.width >= 200 && r.height >= 80 && shown(e); })()).length; return { viewport: { w: W, h: H }, dark: lum(pageBg) < 0.2, ground: hex(pageBg), sidebar, topbar, canvas, hero, charts: chartCount, layout: { panels: tops.length, firstScreen, ragged: ragged.slice(0, 6), voids: voids.slice(0, 4) }, tiles, panels: { count: panels.length, radii: [...radii.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6), heavyBorders, borderSamples, heavyShadows, softShadows, shadowSamples, glass }, chips: { count: chips.length, hues: chipHues.size, samples: chips.slice(0, 4) }, numbers: { cells: cells.length, leftAligned: leftNums, figures: figures.length, tabularShare, kpiMax: figures.length ? Math.max(...figures.map((f) => f.size)) : null }, rows: rows.length ? { count: rows.length, median: rows.sort((a, b) => a - b)[Math.floor(rows.length / 2)] } : null, chartHues: hues.size, placeholders, type: { under13, tiny, tinySamples, sizes: [...sizes.keys()].sort((a, b) => a - b) }, title: h1 && h1r.width > 0 ? { text: (h1.innerText || '').trim().slice(0, 60), size: Math.round(parseFloat(getComputedStyle(h1).fontSize)), top: Math.round(h1r.top + SY) } : null, }; } // Share of a screenshot's pixels that are strongly coloured (saturated, neither near-black nor near-white). // The studied app screens keep it near 3%; pale tints barely count. Runs inside any page; takes a data: URL. export async function colourShare(dataUrl) { const img = new Image(); img.src = dataUrl; await img.decode(); const w = 240, h = Math.max(1, Math.round((240 * img.height) / img.width)); const c = document.createElement('canvas'); c.width = w; c.height = h; const x = c.getContext('2d', { willReadFrequently: true }); x.drawImage(img, 0, 0, w, h); const d = x.getImageData(0, 0, w, h).data; let n = 0; for (let i = 0; i < d.length; i += 4) { const r = d[i] / 255, g = d[i + 1] / 255, b = d[i + 2] / 255, mx = Math.max(r, g, b), mn = Math.min(r, g, b), l = (mx + mn) / 2; const s = mx === mn ? 0 : l > 0.5 ? (mx - mn) / (2 - mx - mn) : (mx - mn) / (mx + mn); if (s >= 0.4 && l >= 0.18 && l <= 0.82) n++; } return Math.round((n / (w * h)) * 1000) / 1000; } // Dominant colours of a screenshot. Runs inside any page; takes a data: URL. export async function palette(dataUrl) { const img = new Image(); img.src = dataUrl; await img.decode(); const w = 96, h = Math.max(1, Math.round((96 * img.height) / img.width)); const c = document.createElement('canvas'); c.width = w; c.height = h; const x = c.getContext('2d', { willReadFrequently: true }); x.drawImage(img, 0, 0, w, h); const d = x.getImageData(0, 0, w, h).data; const bins = new Map(); for (let i = 0; i < d.length; i += 4) { const k = (d[i] >> 4) << 8 | (d[i + 1] >> 4) << 4 | (d[i + 2] >> 4); const b = bins.get(k) || [0, 0, 0, 0]; b[0] += d[i]; b[1] += d[i + 1]; b[2] += d[i + 2]; b[3]++; bins.set(k, b); } const total = w * h; return [...bins.values()].sort((a, b) => b[3] - a[3]).slice(0, 6).map((b) => ({ color: '#' + [0, 1, 2].map((i) => Math.round(b[i] / b[3]).toString(16).padStart(2, '0')).join(''), share: Math.round((b[3] / total) * 100) / 100, })); } ================ File: workspace/agents/opencode/engine/skills/design-taste-frontend/scripts/lib/rules.mjs ================ // Turns probe() metrics into findings. Each finding: { level: 'FAIL'|'WARN', id, message, fix }. // FAIL = a banned tell or a broken page; the work is not done while one remains. // WARN = usually wrong; keep only with a reason you could say out loud to a designer. const inRange = (h, a, b) => (a <= b ? h >= a && h <= b : h >= a || h <= b); // Colour families this skill treats as generated-page signatures. export function colourFamily(hsl) { if (!hsl) return null; const { h, s, l } = hsl; if (inRange(h, 22, 62) && s >= 0.16 && l >= 0.88 && l < 0.995) return 'cream'; if (inRange(h, 6, 30) && s >= 0.38 && s <= 0.85 && l >= 0.42 && l <= 0.72) return 'terracotta'; if (inRange(h, 244, 295) && s >= 0.42 && l >= 0.3 && l <= 0.8) return 'ai-purple'; return null; } const hexToHsl = (hex) => { const n = parseInt(hex.slice(1), 16); const r = (n >> 16) / 255, g = ((n >> 8) & 255) / 255, b = (n & 255) / 255; const mx = Math.max(r, g, b), mn = Math.min(r, g, b); const l = (mx + mn) / 2; let h = 0, s = 0; if (mx !== mn) { const d = mx - mn; s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn); h = mx === r ? (g - b) / d + (g < b ? 6 : 0) : mx === g ? (b - r) / d + 2 : (r - g) / d + 4; h *= 60; } return { h: Math.round(h), s, l }; }; export function judge(m, { width = m.viewport.w, allow = [] } = {}) { const out = []; const add = (level, id, message, fix) => { if (!allow.includes(id)) out.push({ level, id, width, message, fix }); }; const mobile = width < 600; // --- headlines // A title holds to two lines. A statement (a paragraph, quotation or list item set large) may run // longer, but only at statement size; set at display size it is a headline again. In blind tests jurors // read any large sentence of three lines or more as a long headline, so statement size is kept modest. const stmtMaxPx = width < 600 ? 24 : width < 1100 ? 32 : 36; const stmtMaxLines = width < 600 ? 4 : 3; const isStatement = (h) => h.kind === 'statement' && h.size <= stmtMaxPx; const long = m.headings.filter((h) => h.lines > 2 && !isStatement(h)); if (long.length) { add('FAIL', 'headline-lines', `${long.length} headline(s) wrap past two lines at ${width}px: ` + long.slice(0, 4).map((h) => `"${h.text.slice(0, 48)}" → ${h.lines} lines (${h.words} words, ${h.size}px${h.kind === 'statement' ? ', a paragraph set above ' + stmtMaxPx + 'px' : ''})`).join('; '), 'Cut words first (a headline is a title, not a sentence). Then give it a wider measure. Lower the size only as a last resort and never below the scale you chose. A real statement paragraph belongs in a

or

at ' + stmtMaxPx + 'px or less.'); } const longStatements = m.headings.filter((h) => isStatement(h) && h.lines > stmtMaxLines); if (longStatements.length) { add('WARN', 'statement-length', `${longStatements.length} large-set statement(s) run past ${stmtMaxLines} lines at ${width}px: ` + longStatements.slice(0, 3).map((h) => `"${h.text.slice(0, 40)}…" → ${h.lines} lines at ${h.size}px`).join('; '), 'A statement set large is about 30 words. Cut it, or set the rest as ordinary body text.'); } if (m.hero && m.hero.words > 10) add('WARN', 'hero-words', `Hero headline is ${m.hero.words} words ("${m.hero.text.slice(0, 60)}"). Gallery median is 5.`, 'Say the one thing. Move the rest to the supporting line.'); if (m.headingSummary.mixedFontHeadings) add('WARN', 'mixed-font-headline', `${m.headingSummary.mixedFontHeadings} headline(s) switch typeface or to italic mid-line (the "one italic serif word" move).`, 'Set the whole headline in one face. Emphasis comes from the words.'); // --- banned furniture if (m.eyebrows.length) { add('FAIL', 'eyebrow', `${m.eyebrows.length} eyebrow/kicker label(s) above headings: ` + m.eyebrows.slice(0, 5).map((e) => `"${e.text}"${e.pill ? ' (pill)' : ''} above "${e.above}"`).join('; '), 'Delete them. If the label carried real information, fold it into the headline or the supporting sentence.'); } if (m.type.monoProseShare > 0) { add('FAIL', 'mono', `Monospace type outside code: ${m.type.monoFamilies.join(', ')} — e.g. ${m.type.monoSamples.slice(0, 4).map((s) => `"${s}"`).join(', ')}`, 'Set it in the text face. Monospace is for literal code inside
/ only.');
  }
  if (m.numbered.length) {
    add('FAIL', 'numbering', `Decorative index numbers: ${m.numbered.slice(0, 6).map((n) => `"${n.text}"`).join(', ')}`,
      'Remove them. If order truly matters, write it into the words ("First, …") or use a real 
    .'); } if (m.type.microLabels >= 3) add('FAIL', 'micro-label', `${m.type.microLabels} tiny uppercase letter-spaced labels (e.g. ${m.type.microSamples.slice(0, 4).map((s) => `"${s}"`).join(', ')}).`, 'Use sentence-case text at a readable size, or delete the label.'); else if (m.type.microLabels > 0) add('WARN', 'micro-label', `${m.type.microLabels} tiny uppercase letter-spaced label(s): ${m.type.microSamples.slice(0, 3).map((s) => `"${s}"`).join(', ')}`, 'Prefer sentence case at body size.'); if (m.page.emoji.length) add('FAIL', 'emoji', `Emoji in the page: ${m.page.emoji.join(' ')}`, 'Remove. Use words or real imagery.'); // --- surfaces const s = m.surfaces; if (s.gradients) add('FAIL', 'gradient', `${s.gradients} gradient fill(s): ${s.gradientSamples.slice(0, 3).join(' | ')}`, 'Use a flat colour or a real image. The only gradient allowed by default is a neutral scrim over a photo for legibility.'); if (s.gradientText) add('FAIL', 'gradient-text', `${s.gradientText} gradient-filled text element(s).`, 'Solid colour text.'); if (s.shadowStatic) add('FAIL', 'shadow', `${s.shadowStatic} box-shadow(s) on elements at rest: ${s.shadowSamples.slice(0, 3).join(' | ')}`, 'Remove. Separate surfaces with a tone step or space. Shadows are for things that really float (menus, dialogs, sticky bars).'); if (s.textShadow) add('FAIL', 'text-shadow', `${s.textShadow} text-shadow(s).`, 'Remove; fix legibility with a scrim or a different crop.'); if (m.buttons.withGradient || m.buttons.withShadow) add('FAIL', 'button-fx', `Buttons with ${m.buttons.withGradient ? 'gradient fill' : ''}${m.buttons.withGradient && m.buttons.withShadow ? ' and ' : ''}${m.buttons.withShadow ? 'shadow/glow' : ''}.`, 'Flat fill, one colour.'); if (s.hairlines) add(s.hairlines >= 2 ? 'FAIL' : 'WARN', 'hairline', `${s.hairlines} thin rule(s)/divider line(s): ${s.hairlineSamples.slice(0, 4).join(' | ')}`, 'Delete. Separate with space, a tone change, or scale. Lines are only for real tables and form fields.'); if (s.outlinedBoxes >= 3) add('FAIL', 'outlined-box', `${s.outlinedBoxes} boxes drawn with a 1px outline.`, 'Define a surface by its fill (one tone step from the page) or leave the content unboxed.'); else if (s.outlinedBoxes) add('WARN', 'outlined-box', `${s.outlinedBoxes} box(es) drawn with a 1px outline.`, 'Prefer a filled surface or no box.'); if (s.dashed) add('WARN', 'dashed', `${s.dashed} dashed/dotted border(s).`, 'Remove unless it is a real drop zone.'); if (s.blurred > 1) add('WARN', 'glass', `${s.blurred} backdrop-blur surfaces.`, 'One blurred sticky bar at most; glass cards are a tell.'); const iconRows = s.cardRows.filter((c) => c.iconTopped); if (iconRows.length) add('FAIL', 'icon-cards', `${iconRows.length} row(s) of identical cards each topped with a small icon (${iconRows.map((c) => c.count).join(', ')} cards).`, 'Replace with one composed section from the direction: a narrative split, a list set in large type, or real imagery per item.'); else if (s.cardRows.length >= 2) add('WARN', 'card-rows', `${s.cardRows.length} rows of identical boxed cards.`, 'Vary the composition; not every section is a card grid.'); if (m.nav?.floatingPill) add('WARN', 'pill-nav', 'Floating rounded pill navigation.', 'Use a plain bar: wordmark left, a few links, one action.'); // --- colour const fams = new Map(); const note = (label, hsl) => { const f = colourFamily(hsl); if (f) fams.set(f, [...(fams.get(f) || []), label]); }; note('page background ' + m.color.bodyBg, m.color.bodyBgHsl); for (const sb of m.color.sectionBgs) note('section ' + sb.color, sb.hsl || hexToHsl(sb.color)); if (m.color.button) note('button ' + m.color.button.bg, m.color.button.hsl || hexToHsl(m.color.button.bg)); for (const t of m.color.textColors) if (t.share >= 0.01) note('text ' + t.color, hexToHsl(t.color)); if (fams.has('terracotta') && fams.has('cream')) add('FAIL', 'palette-claude', `Cream + terracotta palette (${[...fams.get('cream'), ...fams.get('terracotta')].slice(0, 4).join(', ')}).`, 'Re-draw the palette from the library. This pairing reads as an assistant brand, not yours.'); else if (fams.has('terracotta')) add('FAIL', 'palette-terracotta', `Terracotta / rust-orange accent (${fams.get('terracotta').slice(0, 3).join(', ')}).`, 'Use the accent from the drawn palette.'); else if (fams.has('cream')) add('WARN', 'palette-cream', `Yellow-cream background (${fams.get('cream').slice(0, 3).join(', ')}).`, 'Use the drawn palette. If you want warmth, take a low-chroma stone or bone, not a yellow cream.'); if (fams.has('ai-purple')) add('FAIL', 'palette-purple', `Indigo / violet (${fams.get('ai-purple').slice(0, 3).join(', ')}).`, 'Use the accent from the drawn palette.'); if (m.color.textColors.some((t) => t.color === '#0000ee' || t.color === '#551a8b')) add('WARN', 'default-link', 'Browser-default blue/purple link colour is showing.', 'Style every link: inherit the text colour and mark it with weight, underline offset, or the accent.'); const veryLow = m.color.lowContrast.filter((c) => c.ratio < 2.6); if (veryLow.length) add('FAIL', 'contrast', `Unreadable text: ` + veryLow.slice(0, 4).map((c) => `"${c.text}" ${c.ratio}:1 (${c.fg} on ${c.bg})`).join('; '), 'Raise contrast to at least 4.5:1 for text, 3:1 for large display.'); else if (m.color.lowContrast.length) add('WARN', 'contrast', `Low-contrast text: ` + m.color.lowContrast.slice(0, 4).map((c) => `"${c.text}" ${c.ratio}:1 needs ${c.need}:1`).join('; '), 'Darken or lighten the text colour.'); // --- type system const realFams = m.type.families.filter((f) => f.share >= 0.02); if (realFams.length > 3) add('FAIL', 'font-count', `${realFams.length} typefaces in use (${realFams.map((f) => f.family).join(', ')}).`, 'Two at most: one display, one text. Often one is enough.'); else if (realFams.length === 3) add('WARN', 'font-count', `3 typefaces in use (${realFams.map((f) => f.family).join(', ')}).`, 'Two at most.'); if (m.type.notLoaded.length) add('WARN', 'font-fallback', `Typeface(s) declared but not loaded, so a fallback is rendering: ${m.type.notLoaded.join(', ')}`, 'Fix the font URL / family name, or the screenshots you are judging are not the design.'); if (m.type.sizeCount > 9) add('WARN', 'size-count', `${m.type.sizeCount} different font sizes (${m.type.sizes.join(', ')}).`, 'A page needs about five: display, heading, lead, body, small.'); // Tiny text is the fault the study's best sites share (about a quarter of them); it is not copied here. if (m.type.body && m.type.body.size < 15) add('FAIL', 'body-size', `Body text is ${m.type.body.size}px (the size most of the running text is set in).`, 'Body copy 16–20px. Map small sizes from blueprints onto your body step.'); else if (m.type.body && m.type.body.size < 16 && !mobile) add('WARN', 'body-size', `Body text is ${m.type.body.size}px.`, 'Body copy 16–20px.'); if (m.type.body?.contrast && m.type.body.contrast < 7) add('WARN', 'grey-body', `The running text is a soft grey on its ground (${m.type.body.contrast}:1).`, 'Set body copy in the full ink (7:1 or more). Keep the muted ink for secondary notes; pages read thin and unfinished when everything is grey.'); if (m.buttons.inHero === 0) add('WARN', 'first-action', `No visible button in the first ${mobile ? 'phone ' : ''}screen.`, mobile ? 'On a phone the first screen still holds the headline, the supporting sentence and the action; move navigation into one row or a menu so it does not push them down.' : 'Give the one primary action a real button in the first screen (a filled or solid-bordered control, 44px or taller), not only a link in the navigation.'); // Models skip pictures when nothing makes them: the page must carry real ones (see SKILL.md, imagery). if (width >= 1100 && m.page.realPictures != null) { const pics = m.page.realPictures + m.page.uiPictures; if (pics < 4) add('FAIL', 'few-pictures', `Only ${pics} picture(s) on the page (${m.page.realPictures} photographs, footage or raster images, ${m.page.uiPictures} interface fragments). Drawings in SVG do not count.`, 'Find real pictures of the subject\'s world with scripts/assets.mjs (photographs, archival images, maps, engravings, footage), download them to assets/ and give them room: a hero picture, one per section that has a subject, a full-width band. At least four, usually six to twelve.'); else if (m.page.realPictures < 2) add('WARN', 'no-real-pictures', `Only ${m.page.realPictures} real photograph(s) or clip(s); the rest are interface fragments.`, 'Add two or more real pictures of the customer\'s world from scripts/assets.mjs, not stock of people at laptops.'); if (!m.page.videoCount) add('WARN', 'no-footage', 'No video anywhere on the page.', 'Look for one honest clip with scripts/assets.mjs --video (the place, the material, the process) and use it as a muted loop with a poster frame. Defend its absence only if no clip fits the subject.'); } if (m.page.videosNoPoster) add('WARN', 'no-poster', `${m.page.videosNoPoster} clip(s) without a poster.`, 'Give every clip a poster frame: in the study of 100 Sites of the Day only 31% had one, and the blank or grey boxes before the clips played were recorded as faults (references/imagery.md §11).'); if (m.page.sideScrollers) add('WARN', 'side-scroll', `${m.page.sideScrollers} row(s) scroll sideways with content past their edge.`, 'Jurors and most visitors read a row cut at the edge as clipped, not as scrollable. Wrap it onto more lines, or end it inside the grid.'); if (m.page.blendText) add('WARN', 'blend-text', `${m.page.blendText} text element(s) use mix-blend-mode difference or exclusion.`, 'Over anything but black, white or grey, blended text takes random hues (blue on orange, lilac on green) that jurors read as a glitch. Use a solid ink chosen for the ground, or keep the blended text over neutral grounds only.'); if (m.type.measureChars && m.type.measureChars > 88 && !mobile) add('WARN', 'measure', `Longest paragraph runs about ${m.type.measureChars} characters per line.`, 'Cap paragraphs at 45–75 characters (max-width: 60ch).'); if (m.type.smallText > 3) add('FAIL', 'small-text', `${m.type.smallText} text blocks under 13px.`, 'Nothing meant to be read goes below 13px.'); else if (m.type.smallText > 0) add('WARN', 'small-text', `${m.type.smallText} text block(s) under 13px.`, 'Nothing meant to be read goes below 13px.'); if (m.type.centerShare > 0.72 && m.headings.length >= 4) add('WARN', 'all-centred', `${Math.round(m.type.centerShare * 100)}% of the text is centre-aligned.`, 'Centre one moment at most; anchor the rest to a shared left edge.'); // --- breakage if (m.page.uiAbuse) add('FAIL', 'ui-wrapper', 'A data-ui element wraps a heading or more than a screen and a half of the page.', 'data-ui marks a cropped interface fragment only. Remove it from page-level wrappers.'); if (m.page.horizontalOverflow) add('FAIL', 'overflow', `Page scrolls sideways by ${m.page.horizontalOverflow}px at ${width}px.${m.page.overflowCulprits?.length ? ' Widest offenders: ' + m.page.overflowCulprits.join(', ') + '.' : ''}`, 'Constrain the element that is wider than the viewport (max-width: 100%, or overflow: clip on its section).'); if (m.page.brokenImages.length) add('FAIL', 'broken-image', `Broken image(s): ${m.page.brokenImages.slice(0, 4).join(', ')}`, 'Fix the URL or replace the asset.'); if (!m.hero) add('WARN', 'no-hero', 'No headline found in the first viewport.', 'The first screen needs a clear focal statement or image.'); // One dominant thing per first screen: large type, or a picture that holds a third of it, or (for software) an interface fragment. else if (!mobile && m.hero.vw < 4.5 && (m.heroMedia[0]?.ratio || 0) < 0.3 && !m.page.uiFragments) { add('FAIL', 'focal-point', `The first screen has no dominant element: the largest type is ${m.hero.size}px (${m.hero.vw}vw) and no single picture holds a third of the viewport.`, 'Make one thing lead: the headline at display size, or one large picture. A grid of many small pictures is not one thing; in blind tests jurors marked a first screen without a headline down every time. On the strongest studied sites the largest type has a median of 8.4vw.'); } return out; } // The frame of the page, measured once per width by layout(): margins, balance, blank stretches, titles. // These catch the failures a reader names first: "no clear title", "random empty space", "it is all // pushed to one side", "text touching the edge". export function judgeLayout(L, { width = L.viewport.w, allow = [] } = {}) { const out = []; const add = (level, id, message, fix) => { if (!allow.includes(id)) out.push({ level, id, width, message, fix }); }; const mobile = width < 600, H = L.viewport.h; // the outer frame: the two margins match (asymmetry belongs inside the grid, never in the frame) const { left, right } = L.margins; if (left != null && right != null) { const diff = Math.abs(left - right), wide = Math.max(left, right); if (diff >= Math.max(mobile ? 14 : 40, width * 0.045) && wide >= (mobile ? 28 : width * 0.07)) { add('FAIL', 'off-centre', `The page sits off-centre: its content comes to ${left}px from the left edge but only to ${right}px from the right, at ${width}px.`, `Give the page one pair of equal margins. Check the wrapper for a max-width without margin-inline: auto, a grid with an empty last column, or padding on one side only. If one side is meant to hold air, it must still be closed by something that reaches the margin (a picture, the navigation's last item, a figure).`); } else if (diff >= (mobile ? 12 : 16)) { // strong sites hold their two margins within a few pixels; a 20-30px drift is the "shifted" look add('WARN', 'off-centre', `The margins differ: ${left}px on the left, ${right}px on the right, at ${width}px.`, 'Put the navigation and the content on one pair of margins; check for padding on one side only, a scrollbar gutter, or a header on another inset than the page.'); } } // sections whose content huddles on one side, leaving the other half of the screen empty if (!mobile && left != null) { const lop = L.sections.filter((s) => !s.hero && !s.footer && !s.fullBleed && !s.pinned && s.left != null && s.words >= 12 && s.height >= 180 && Math.min(s.left, s.right) <= Math.max(left, right) + 24 && Math.max(s.left, s.right) >= width * 0.42); if (lop.length >= 2) add('FAIL', 'lopsided', `${lop.length} sections keep all their content on one side and leave ${Math.round(Math.max(...lop.map((s) => Math.max(s.left, s.right))) / width * 100)}% of the width empty: ` + lop.slice(0, 4).map((s) => `${s.heading ? '"' + s.heading.text + '"' : s.sel} (${s.right > s.left ? 'right' : 'left'} ${Math.max(s.left, s.right)}px empty)`).join('; '), 'Use the whole width the page has set up: put the section\'s picture, its second column, its list or its figure in the empty half, or set the text larger across the grid. Empty space must be placed, with something on each side of it.'); else if (lop.length) add('WARN', 'lopsided', `A section keeps its content on one side and leaves ${Math.max(lop[0].left, lop[0].right)}px of the width empty: ${lop[0].heading ? '"' + lop[0].heading.text + '"' : lop[0].sel}.`, 'Fill the other side with what the section has (its picture, a second column, the figure), or defend the void in one sentence.'); // the same hole with the empty part in the middle: a card at the left, a lone button at the right const hollow = L.sections.filter((s) => !s.hero && !s.footer && !s.fullBleed && !s.pinned && s.words >= 12 && s.middle >= width * 0.45); if (hollow.length) add('WARN', 'empty-middle', `${hollow.length} section(s) have content at both edges and ${Math.max(...hollow.map((s) => s.middle))}px of nothing between: ` + hollow.slice(0, 3).map((s) => s.heading ? '"' + s.heading.text + '"' : s.sel).join('; '), 'Bring the two parts together on the grid, or put the picture or figure of the section in the middle. An empty middle third reads as a hole, the same as an empty half.'); } // blank stretches: nothing at all for a long run down the page const big = L.gaps.filter((g) => g.height >= H * 0.6), mid = L.gaps.filter((g) => g.height >= H * 0.42 && g.height < H * 0.6); if (big.length || mid.length >= 2) add(L.stage ? 'WARN' : 'FAIL', 'blank-gap', `${big.length + mid.length} stretch(es) of the page hold nothing at all: ` + [...big, ...mid].slice(0, 4).map((g) => `${g.height}px at ${g.top}px`).join('; ') + ` (the screen is ${H}px tall).`, (L.stage ? 'A fixed picture layer covers the screen here, so the DOM cannot tell whether the stretch is empty: check the screenshots. ' : '') + 'Close the gap: a section gap is 120–240px on desktop and 72–112px on a phone. Look for a min-height: 100vh on a short section, an empty grid row, a hidden element still holding its space, or a reveal that never fired. A deliberate rest is a placed picture or colour field, not nothing.'); else if (mid.length) add('WARN', 'blank-gap', `A ${mid[0].height}px stretch at ${mid[0].top}px holds nothing.`, 'Check that it is a deliberate rest (once per page at most); otherwise close it to the normal section gap.'); // the title: one h1, on the first screen, that says what this is if (!L.h1s.length) add('FAIL', 'no-title', 'The page has no visible h1.', 'Give the first screen a clear title in an h1 that says what this is (a name with what it does, the product with what it is for, the event with its date and place).'); else { const first = L.h1s[0]; if (!first.firstScreen) add('FAIL', 'no-title', `The h1 ("${first.text.slice(0, 50)}") is not on the first screen (it starts ${first.top}px down).`, 'The title belongs on the first screen, where a visitor decides whether to stay.'); else if (first.size < (mobile ? 26 : 32)) add('WARN', 'small-title', `The h1 is ${first.size}px ("${first.text.slice(0, 50)}").`, 'Set the title at display size, or make the large text of the first screen the h1.'); if (first.firstScreen && first.text.split(' ').length <= 3 && L.explains === false) add('WARN', 'name-only-title', `The h1 is a bare name ("${first.text.slice(0, 40)}") and nothing else on the first screen says what this is.`, 'Keep the name if it leads, and give the line under it (18px or more) the plain words: what it is, for whom, where or when. In the study of 97 Sites of the Day, first screens that named nothing lost usability and content points.'); if (L.h1s.length > 1) add('WARN', 'many-h1', `${L.h1s.length} h1 elements.`, 'One h1 per page; section titles are h2.'); } // sections with running text but no heading: the reader cannot tell what they are const untitled = L.sections.filter((s) => !s.hero && !s.footer && !s.continues && !s.heading && s.words >= 30 && s.height >= 200); if (untitled.length >= 2) add('FAIL', 'untitled-section', `${untitled.length} sections carry text but no heading: ` + untitled.slice(0, 4).map((s) => `${s.sel} at ${s.top}px (${s.words} words)`).join('; '), 'Give each section a short heading that names what it holds (the menu, the rooms, how it works, the price), in the heading face at the section size.'); else if (untitled.length) add('WARN', 'untitled-section', `A section carries text but no heading: ${untitled[0].sel} at ${untitled[0].top}px.`, 'Name it with a short heading unless it is the continuation of the section above.'); // the first screen: a large empty rectangle that nothing closes reads as "random empty space" if (!mobile && L.voidShare >= 0.28 && !L.stage) add('WARN', 'hero-void', `${Math.round(L.voidShare * 100)}% of the first screen is one empty block (${L.voidBox.w}×${L.voidBox.h}px at ${L.voidBox.x},${L.voidBox.y}).`, 'Place the void on purpose or fill it: move the picture, the film or one fact into it, grow the headline to meet it, or shrink the screen to its content. A void reads as placed only when something closes it on the far side and it is the screen\'s one rest.'); // display type that starts inside the margin but runs off the opposite edge by a sliver: a fit error if (L.slivers && L.slivers.length) add('WARN', 'one-sided-bleed', `${L.slivers.length} display line(s) start inside the margin but run off the ${L.slivers[0].side} edge by a few pixels: ` + L.slivers.map((t) => `"${t.text}" (${t.px}px)`).join('; '), 'Fit the line to the content box by measurement (a small fit script, or SVG text with textLength), or bleed it on purpose by a clear amount from the margin. A name sliced by a few pixels on one side reads as a mistake.'); // text pressed against the screen edge const tight = L.edgeText.filter((t) => t.px < (mobile ? 12 : 16)), near = mobile ? L.edgeText.filter((t) => t.px >= 12) : []; if (near.length && !tight.length) add('WARN', 'edge-text', `${near.length} text block(s) sit 12–15px from the phone's edge: ` + near.slice(0, 3).map((t) => `"${t.text.slice(0, 30)}" (${t.px}px)`).join('; '), 'The page margin is 16–20px on a phone; the header and the corner labels sit on it too.'); if (tight.length) add('FAIL', 'edge-text', `${tight.length} text block(s) sit within ${mobile ? 12 : 16}px of the screen edge: ` + tight.slice(0, 4).map((t) => `"${t.text.slice(0, 30)}" (${t.px}px from the ${t.side})`).join('; '), `Keep text inside the page margin (${mobile ? '16–20px' : '16–32px or more'}). Only display type deliberately bled past the edge may touch it.`); // too many left edges: nothing lines up if (!mobile && L.edges.length > 6 && L.leftBlocks >= 8) { const top3 = L.edges.slice(0, 3).reduce((a, e) => a + e.n, 0) / L.leftBlocks; if (top3 < 0.6) add('WARN', 'ragged-edges', `Left-aligned text starts at ${L.edges.length} different lines (${L.edges.slice(0, 7).map((e) => e.x + 'px').join(', ')}); the three most used hold only ${Math.round(top3 * 100)}% of it.`, 'Pick two or three anchor lines from the grid and start every heading and paragraph on one of them.'); } return out; } // App screens (dashboards, admin, settings, inboxes) keep conventions a marketing page does not: tables // and panels may have quiet borders, labels are small, cards repeat, there is no hero, no picture floor // and no motion floor. These page rules do not apply to them; references/app-screens.md has the reasons. export const APP_SKIP = new Set(['hero-words', 'eyebrow', 'mono', 'name-only-title', 'empty-middle', 'numbering', 'hairline', 'outlined-box', 'dashed', 'icon-cards', 'card-rows', 'pill-nav', 'grey-body', 'measure', 'first-action', 'few-pictures', 'no-real-pictures', 'no-footage', 'focal-point', 'no-hero', 'body-size', 'small-text', 'statement-length', 'shadow', 'glass', 'still-page', 'empty-screens', 'long-pin', 'side-scroll', 'pinned-over-picture', 'off-centre', 'lopsided', 'ragged-edges', 'untitled-section', 'size-count', 'all-centred']); export function judgeApp(A, m, { width = A.viewport.w, allow = [] } = {}) { const out = []; const add = (level, id, message, fix) => { if (!allow.includes(id)) out.push({ level, id, width, message, fix }); }; const mobile = width < 600; if (mobile && A.sidebar && A.sidebar.share > 0.3) add('FAIL', 'sidebar-phone', `The sidebar still takes ${Math.round(A.sidebar.share * 100)}% of the width on a ${width}px phone.`, 'On a phone the navigation folds into a top bar with a menu button or a bottom bar of four or five destinations; the screen\'s content gets the full width.'); if (!mobile && width >= 1100 && A.canvas && A.canvas.right > Math.max(120, width * 0.1) && A.canvas.right > A.canvas.left + 80) add('WARN', 'unused-canvas', `The content stops ${A.canvas.right}px short of the right edge while it starts ${A.canvas.left}px from the sidebar.`, 'Let the grid fill the canvas (columns in fr units), or centre a capped canvas with equal margins. A dead strip on one side reads as unfinished.'); if (A.panels.heavyShadows) add('FAIL', 'heavy-shadow', `${A.panels.heavyShadows} panel shadow(s) heavy enough to read as floating: ${A.panels.shadowSamples.join(' | ')}`, 'Panels sit on the ground: separate them with a fill one step from the ground or a quiet 1px border. A shadow, if any, is under 8% black with a small blur. Real overlays (menus, dialogs, popovers) may float.'); if (A.panels.glass) add('FAIL', 'glass', `${A.panels.glass} blurred glass panel(s).`, 'Opaque surfaces only.'); if (A.panels.heavyBorders >= 3) add('WARN', 'heavy-borders', `${A.panels.heavyBorders} panel borders are dark against their ground: ${A.panels.borderSamples.join(' | ')}`, 'A panel border is a whisper: about 1.1–1.3:1 against its ground on a light screen (#e5e7eb–#ededed on white), and about 1.2–1.5:1 on a dark one (#1a1a1a on #000, #2e2e2e on #111). Darker lines turn the screen into a grid of boxes; keep the stronger line for controls.'); const radii = A.panels.radii.filter(([, n]) => n >= 2).map(([r]) => r); if (radii.length > 3) add('WARN', 'radius-mix', `Panels use ${radii.length} corner radii (${radii.join(', ')}px).`, 'One radius for panels, one for controls, full round only for pills and avatars.'); if (A.chips.count >= 3) add(A.chips.hues >= 2 ? 'FAIL' : 'WARN', 'icon-chips', `${A.chips.count} icons sit in tinted squares or circles${A.chips.hues >= 2 ? ` in ${A.chips.hues} different colours` : ''}.`, 'This is the commonest tell of a generated dashboard. Drop the tinted square: a KPI is a label and a number; an icon, if it helps, is plain and in the text colour.'); if (A.numbers.cells >= 6 && A.numbers.leftAligned / A.numbers.cells >= 0.5) add('WARN', 'numeric-align', `${A.numbers.leftAligned} of ${A.numbers.cells} numeric table cells are left-aligned.`, 'Right-align numbers, amounts and percentages (and their column headers) so the digits line up.'); if (A.numbers.figures >= 6 && A.numbers.tabularShare != null && A.numbers.tabularShare < 0.5) add('WARN', 'tabular', `Only ${Math.round(A.numbers.tabularShare * 100)}% of the figures use tabular numerals.`, 'Set font-variant-numeric: tabular-nums on tables, KPIs and anything that updates, so digits keep their width.'); if (A.chartHues > 6) add('FAIL', 'rainbow-chart', `The charts use ${A.chartHues} different hues.`, 'One hue for the series that matters, a neutral for the rest, and at most four series colours in total; status colours only for status.'); else if (A.chartHues > 4) add('WARN', 'rainbow-chart', `The charts use ${A.chartHues} different hues.`, 'Four series colours at most; prefer one accent and neutrals.'); if (A.colourShare != null && A.colourShare > 0.12) add('WARN', 'colour-sprawl', `${Math.round(A.colourShare * 100)}% of the first screen is strongly coloured.`, 'Keep the screen neutral and spend colour on data and status: the studied screens sit near 3%, and only tinted styles reach 10–15%, as pale tints rather than saturated fills.'); if (A.placeholders.length) add('FAIL', 'placeholder-data', `Placeholder data on screen: ${A.placeholders.join(', ')}.`, 'Use believable data for this product: real-looking names, amounts that add up, dates in order, units on every figure.'); if (A.type.tiny > 2) add('FAIL', 'tiny-text', `${A.type.tiny} text blocks under 11px: ${A.type.tinySamples.join(', ')}.`, 'Nothing below 11px; secondary text 12px, data 13–14px.'); else if (A.type.under13 > 0.45) add('WARN', 'tiny-text', `${Math.round(A.type.under13 * 100)}% of the text is under 13px.`, 'Data and labels read at 13–14px; keep 11–12px for sublines and axis ticks.'); if (!mobile && A.layout) { if (A.layout.ragged.length) add('WARN', 'ragged-row', `${A.layout.ragged.length} pair(s) of cards in one row end at different heights (by ${A.layout.ragged.map((r) => r.diff + 'px').slice(0, 3).join(', ')}).`, 'Cards that share a row share a bottom edge: balance them by their content (the same number of rows, a block of similar height beside it), or stack them. In testing, ragged rows were marked down every time.'); if (width >= 1100 && A.layout.panels >= 4 && A.layout.firstScreen / A.layout.panels < 0.5) add('WARN', 'below-fold', `Only ${A.layout.firstScreen} of ${A.layout.panels} blocks start on the first ${width}px screen.`, 'Every block the brief names shows its header and first rows on the first desktop screen: size the headline block to its question, cap long lists with "View all", and put time-sensitive blocks before long tables.'); if (width >= 1100 && A.layout.voids?.length) add('WARN', 'panel-void', `${A.layout.voids.length} card(s) hold a blank band inside: ${A.layout.voids.slice(0, 3).map((v) => `${v.gap}px in "${v.panel}"`).join(', ')}.`, 'A card stretches to its neighbour only when it has content to fill. Keep a legend or a note directly under its chart (only an action or a total may sit at a stretched card\'s foot), let a short card keep its own height (align-items: start), and let a filtered block shrink to its rows.'); } // an achromatic screen with a chart or a timeline and no hue on any data reads as a kit: the one chart hue goes on // the main data element (today's line, the booked bars, the margin that breaks) if (!mobile && A.charts > 0 && A.chartHues === 0 && A.colourShare != null && A.colourShare < 0.002) add('WARN', 'no-data-hue', `The screen has ${A.charts === 1 ? 'a chart' : A.charts + ' charts or timelines'} but no hue on any of its data (${(A.colourShare * 100).toFixed(1)}% coloured pixels).`, 'Put the style\'s one chart hue (or the brand\'s) on the screen\'s main data element: today\'s line, the booked bars, the current period, the cover margin. Keep the chrome neutral; the hue still stays under the style\'s colour budget.'); // in app mode the screen's hero is its h1 or the largest text on the first screen, not the page probe's guess // (which can be a figure below the fold read without its spaces); the report and the MEASURED line use this one if (A.hero && m) m.hero = A.hero; if (A.title && A.title.size > (mobile ? 28 : 34)) add('WARN', 'app-title-size', `The h1 is ${A.title.size}px ("${A.title.text.slice(0, 40)}").`, 'An app screen is not a landing page: the title is 18-32px (20-24px on a phone).'); if (A.numbers.kpiMax && A.numbers.kpiMax >= 48) add('WARN', 'kpi-size', `A figure is set at ${Math.round(A.numbers.kpiMax)}px.`, 'Figures run 16-40px on app screens; none of the studied screens reaches 48px. Size and isolation make a number loud, not display type.'); if (A.type.sizes.length > 10) add('WARN', 'size-count', `${A.type.sizes.length} font sizes (${A.type.sizes.join(', ')}).`, 'App screens use 4-9 sizes (median 6): a scale such as 12/14/16/24 with one figure size.'); if (A.tiles >= 4) add('WARN', 'icon-tiles', `${A.tiles} icons sit in neutral boxes repeated down the screen.`, 'A boxed icon on every row or card is still the template habit, tinted or not. Keep one plain 16px icon where it helps the eye, or none.'); if (!A.title) add('FAIL', 'no-title', 'The screen has no h1.', 'Name the screen in an h1 at the top of the canvas (18–28px): "Overview", "Payouts", the project\'s name.'); if (m && m.type && m.type.monoProseShare > 0.15) add('WARN', 'mono', `Monospace is ${Math.round(m.type.monoProseShare * 100)}% of the text.`, 'Monospace for IDs, keys and code only; numbers use tabular figures in the text face.'); return out; } export const RULE_IDS = ['headline-lines', 'statement-length', 'focal-point', 'hero-words', 'mixed-font-headline', 'eyebrow', 'mono', 'numbering', 'micro-label', 'emoji', 'gradient', 'gradient-text', 'shadow', 'text-shadow', 'button-fx', 'hairline', 'outlined-box', 'dashed', 'glass', 'icon-cards', 'card-rows', 'pill-nav', 'palette-claude', 'palette-terracotta', 'palette-cream', 'palette-purple', 'default-link', 'contrast', 'font-count', 'font-fallback', 'size-count', 'body-size', 'measure', 'small-text', 'all-centred', 'ui-wrapper', 'overflow', 'broken-image', 'no-hero', 'first-action', 'grey-body', 'blend-text', 'covered-text', 'clipped-text', 'pinned-over-picture', 'empty-screens', 'long-pin', 'side-scroll', 'few-pictures', 'no-real-pictures', 'no-footage', 'still-page', 'off-centre', 'lopsided', 'blank-gap', 'no-title', 'small-title', 'many-h1', 'name-only-title', 'empty-middle', 'untitled-section', 'edge-text', 'ragged-edges', 'sidebar-phone', 'unused-canvas', 'heavy-shadow', 'heavy-borders', 'radius-mix', 'icon-chips', 'numeric-align', 'tabular', 'rainbow-chart', 'placeholder-data', 'tiny-text', 'colour-sprawl', 'ragged-row', 'below-fold', 'app-title-size', 'kpi-size', 'icon-tiles', 'late-title', 'reduced-hidden', 'no-poster', 'hero-void', 'one-sided-bleed', 'font-not-dealt', 'panel-void', 'no-data-hue', 'scene-not-started', 'scene-not-live', 'many-scenes', 'scene-heavy', 'dev-server']; ================ File: workspace/agents/opencode/engine/skills/design-taste-frontend/scripts/assets.mjs ================ #!/usr/bin/env node // tasteskill / assets: find real, openly licensed pictures of the subject's world, and keep their credits. // // A page without photographs of its own does not have to draw everything. Archives, museums and open // collections hold the subject's city and street, its maps, its materials, its trade's history and the // museum objects of its kind, free to use with a credit. This searches them without keys and prints what // an agent needs to use a picture honestly: a direct image URL, the page it comes from, its size, its // creator, its licence and a one-line credit. Then it downloads the chosen ones into the project and // records their credits, so the page works offline and credits every picture. // // node assets.mjs "" search every source, licences safe for commercial use // [--source all|openverse|commons|aic|met|museums] one or several, comma-separated // [--license safe|pd|all|cc0,pd,by,by-sa,...] safe = CC0, public domain, CC BY, CC BY-SA // [--orientation landscape|portrait|square] [--min-width 1000] [--width 1920] [--limit 5] // [--no-check] skip checking that each image loads (faster) // [--json] machine-readable results // node assets.mjs "" --video search for real footage instead (same options, plus // [--res 720] the largest rendition to pick, by its short side // [--max-mb 25] clips over this are flagged, and --get refuses them // node assets.mjs --get [,...] [--to assets] [--name ] [--width 1920] [--res 720] [--max-mb 25] // download into the project and record the credit // node assets.mjs --credits [--to assets] print the credits for the footer and the report // // Image sources: Openverse (api.openverse.org: Flickr, Wikimedia Commons, museums and libraries in one // index), Wikimedia Commons, the Art Institute of Chicago (public-domain works over IIIF) and The // Metropolitan Museum of Art (Open Access). Video sources: Wikimedia Commons (with its web-sized webm // transcodes), the Internet Archive (its curated Prelinger Archives of films, public domain or Creative // Commons) and the NASA Image and Video Library. Zero dependencies, no API keys. Node 18+ (built-in fetch). import { readFileSync, writeFileSync, existsSync, mkdirSync, createWriteStream, unlinkSync } from 'node:fs'; import { join, resolve, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; const UA = 'taste-skill-assets/2.0 (+https://github.com/Leonxlnx/taste-skill)'; const SELF = fileURLToPath(import.meta.url).split('\\').join('/'); const CMD = `node ${/\s/.test(SELF) ? `"${SELF}"` : SELF}`; const SOURCES = { openverse: { media: 'image', name: 'Openverse', note: 'one index over Flickr, Wikimedia Commons, museums and libraries' }, commons: { media: 'both', name: 'Wikimedia Commons', note: 'archival photographs, old maps, scans of prints and books, places as they look now', videoNote: 'video files with web-sized webm transcodes: places, crafts, processes, nature' }, aic: { media: 'image', name: 'Art Institute of Chicago', note: 'public-domain paintings, prints, photographs and textiles, served large' }, met: { media: 'image', name: 'The Metropolitan Museum of Art', note: 'public-domain objects, prints, drawings and photographs (Open Access)' }, archive: { media: 'video', name: 'Internet Archive', note: 'the Prelinger Archives: industrial, educational, city and home films from the 1920s to the 1970s, mostly long' }, nasa: { media: 'video', name: 'NASA Image and Video Library', note: 'footage of the Earth, space, aircraft, launches and laboratories' }, }; const SOURCE_ALIAS = { museums: ['aic', 'met'], wikimedia: ['commons'], artic: ['aic'], chicago: ['aic'], metmuseum: ['met'], ia: ['archive'], internetarchive: ['archive'] }; const VIDEO_TYPES = { 'video/mp4': 'mp4', 'video/webm': 'webm' }; // what every current browser plays; Ogg Theora and QuickTime are left out const LICENCES = ['cc0', 'pd', 'by', 'by-sa', 'by-nc', 'by-nd', 'by-nc-sa', 'by-nc-nd', 'other']; const SAFE = ['cc0', 'pd', 'by', 'by-sa']; const LICENCE_ALIAS = { pdm: 'pd', 'public-domain': 'pd', publicdomain: 'pd', 'cc-by': 'by', 'cc-by-sa': 'by-sa', 'cc-zero': 'cc0' }; const WEB_TYPES = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', 'image/gif': 'gif', 'image/avif': 'avif', 'image/svg+xml': 'svg' }; const PROVIDERS = { flickr: 'Flickr', wikimedia: 'Wikimedia Commons', met: 'The Metropolitan Museum of Art', nypl: 'New York Public Library', rawpixel: 'rawpixel', stocksnap: 'StockSnap', clevelandmuseum: 'Cleveland Museum of Art', brooklynmuseum: 'Brooklyn Museum', sciencemuseum: 'Science Museum Group', europeana: 'Europeana', geographorguk: 'Geograph Britain and Ireland', inaturalist: 'iNaturalist', wordpress: 'WordPress Photo Directory' }; const AIC_FIELDS = ['id', 'title', 'artist_title', 'artist_display', 'date_display', 'image_id', 'thumbnail', 'medium_display', 'artwork_type_title', 'is_public_domain']; const AIC_HEADERS = { 'AIC-User-Agent': UA }; const USAGE = `Find real, openly licensed pictures of the subject's world, download them, keep their credits. ${CMD} "" [options] --source all|openverse|commons|aic|met|museums comma-separated; default all --license safe|pd|all|cc0,pd,by,by-sa,... default safe: CC0, public domain, CC BY, CC BY-SA --orientation landscape|portrait|square --min-width smallest acceptable full width, default 1000 --width width of the image URL to use or download, default 1920 --limit results per source, default 5 (at most 20) --no-check do not check that every image loads (faster, less reliable) --json machine-readable results ${CMD} "" --video [options] real footage: Wikimedia Commons, Internet Archive, NASA --source all|commons|archive|nasa --res

    largest rendition to pick, by its short side, default 720 --max-mb clips over this are flagged and --get refuses them, default 25 --min-width default 640 for video ${CMD} --get [,...] [--to assets] [--name ] [--width 1920] [--res 720] [--max-mb 25] ${CMD} --credits [--to assets] Queries work best in the subject's own words and a medium: "ghent postcard", "ghent old map", "azulejo tiles", "botanical engraving fig", "rotterdam harbour 1920", "basalt texture". The museum sources match one or two words best ("fig", "harbour"). For footage, name the action or the process: "coffee roasting", "timber construction", "river", "city street night".`; // ---------- arguments ---------- const VALUED = new Set(['source', 'sources', 'license', 'licence', 'orientation', 'min-width', 'width', 'limit', 'get', 'to', 'name', 'type', 'res', 'max-mb']); const FLAGS = new Set(['no-check', 'json', 'credits', 'help', 'video']); const args = {}; const words = []; const argv = process.argv.slice(2); for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === '-h') { args.help = true; continue; } if (!a.startsWith('--')) { words.push(a); continue; } const eq = a.indexOf('='); const key = a.slice(2, eq === -1 ? undefined : eq); if (VALUED.has(key)) { const v = eq === -1 ? argv[++i] : a.slice(eq + 1); if (v === undefined || v === '' || (eq === -1 && v.startsWith('--'))) fail(`--${key} needs a value.`); args[key] = v; } else if (FLAGS.has(key)) args[key] = true; else fail(`Unknown option --${key}.`); } function fail(message) { console.error(`${message}\n\n${USAGE}`); process.exit(2); } function number(key, def, min, max) { if (args[key] === undefined) return def; const n = Number(args[key]); if (!Number.isFinite(n) || n < min || n > max) fail(`--${key} must be a number from ${min} to ${max}.`); return Math.round(n); } if (args.type !== undefined && !['image', 'images', 'video', 'videos'].includes(String(args.type).toLowerCase())) fail('--type is image or video.'); const VIDEO = Boolean(args.video) || /^videos?$/i.test(String(args.type || '')); function pickSources(value) { const media = VIDEO ? 'video' : 'image'; const usable = Object.keys(SOURCES).filter((k) => SOURCES[k].media === media || SOURCES[k].media === 'both'); const out = []; for (const s of String(value).toLowerCase().split(',').map((x) => x.trim()).filter(Boolean)) { const found = s === 'all' ? usable : SOURCE_ALIAS[s] || (SOURCES[s] ? [s] : null); if (!found) fail(`Unknown source "${s}". ${VIDEO ? 'Video' : 'Image'} sources: ${usable.join(', ')}${VIDEO ? '' : ', museums (aic and met)'}, all.`); const wrong = found.filter((x) => !usable.includes(x)); if (wrong.length) fail(`${wrong.join(', ')} has no ${media === 'video' ? 'video' : 'still images'} here. ${VIDEO ? 'Video' : 'Image'} sources: ${usable.join(', ')}.`); for (const x of found) if (!out.includes(x)) out.push(x); } return out.length ? out : usable; } function pickLicences(value) { const v = String(value).toLowerCase().trim(); if (v === 'safe') return SAFE; if (v === 'pd') return ['cc0', 'pd']; if (v === 'all' || v === 'any') return LICENCES; const out = v.split(',').map((x) => x.trim()).filter(Boolean).map((x) => LICENCE_ALIAS[x] || x); const bad = out.filter((x) => !LICENCES.includes(x)); if (bad.length || !out.length) fail(`Unknown licence "${bad.join(', ') || v}". Use safe, pd, all, or a list of: ${LICENCES.join(', ')}.`); return out; } function pickOrientation(value) { if (value === undefined) return null; const v = String(value).toLowerCase(); const o = { landscape: 'landscape', wide: 'landscape', horizontal: 'landscape', portrait: 'portrait', tall: 'portrait', vertical: 'portrait', square: 'square' }[v]; if (!o) fail('--orientation is landscape, portrait or square.'); return o; } const o = { sources: pickSources(args.source ?? args.sources ?? 'all'), licences: pickLicences(args.license ?? args.licence ?? 'safe'), orientation: pickOrientation(args.orientation), minWidth: number('min-width', VIDEO ? 640 : 1000, 0, 20000), width: number('width', 1920, 200, 4000), limit: number('limit', 5, 1, 20), video: VIDEO, res: number('res', 720, 144, 2160), maxMb: number('max-mb', 25, 1, 2000), check: !args['no-check'], to: resolve(String(args.to || 'assets')), toArg: args.to ? ` --to ${args.to}` : '', }; // ---------- small helpers ---------- const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const short = (s, n) => { const t = String(s || ''); return t.length > n ? t.slice(0, n - 1).replace(/[\s,;:.-]+$/, '') + '…' : t; }; const noQuery = (u) => (u ? String(u).split('?')[0] : u); const rel = (p) => relative(process.cwd(), p).split('\\').join('/') || '.'; const size = (bytes) => (bytes >= 1048576 ? `${(bytes / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(bytes / 1024))} KB`); const shape = (w, h) => (!w || !h ? null : w / h >= 1.15 ? 'landscape' : w / h <= 0.87 ? 'portrait' : 'square'); const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); function text(v) { if (v === undefined || v === null) return ''; return String(v).replace(/<(style|script)\b[\s\S]*?<\/\1>/gi, ' ').replace(/<[^>]*>/g, ' ').replace(/ /g, ' ').replace(/"/g, '"').replace(/�?39;|'/g, "'") .replace(/</g, '<').replace(/>/g, '>').replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n))).replace(/&/g, '&') .replace(/\s+/g, ' ').trim(); } function keyOf(u) { let s = noQuery(u) || ''; try { s = decodeURIComponent(s); } catch {} return s.toLowerCase().replace(/^https?:\/\//, '').replace(/ /g, '_'); } function slug(s) { return String(s || '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') .split('-').slice(0, 7).join('-').slice(0, 60).replace(/-+$/, ''); } // The words of a query that carry meaning, and whether a record's own description holds them // (all of them, or all but one of three or more). Used to keep the museum sources on the subject. function queryWords(q) { return String(q).toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((w) => w && !['the', 'a', 'an', 'of', 'in', 'on', 'at', 'and', 'or', 'for', 'with', 'from', 'to', 'by'].includes(w)); } function onSubject(wanted, description) { if (!wanted.length) return true; const loose = (s) => String(s).toLowerCase().normalize('NFKD').replace(/\p{M}/gu, '').replace(/our\b/g, 'or'); // harbour = harbor const hay = loose(description); const hits = wanted.filter((w) => { const x = loose(w); return hay.includes(x) || (x.length > 4 && hay.includes(x.replace(/(es|s)$/, ''))); }).length; return hits >= (wanted.length >= 3 ? wanted.length - 1 : wanted.length); } async function mapLimit(items, n, fn) { const out = new Array(items.length); let next = 0; await Promise.all(Array.from({ length: Math.min(n, items.length) }, async () => { while (next < items.length) { const k = next++; out[k] = await fn(items[k], k); } })); return out; } async function getJson(url, ms = 20000, headers = {}) { for (let attempt = 1; ; attempt++) { let r; try { r = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json', ...headers }, signal: AbortSignal.timeout(ms) }); } catch (e) { const timedOut = e.name === 'TimeoutError' || e.name === 'AbortError'; if (!timedOut && attempt < 2) { await sleep(800); continue; } throw new Error(timedOut ? `no answer within ${Math.round(ms / 1000)} s` : `could not connect (${e.cause?.code || e.message})`); } if ([502, 503, 504].includes(r.status) && attempt < 2) { await sleep(1200); continue; } if (r.status === 429) throw new Error('rate limited (HTTP 429): wait a minute, or search another --source'); if (r.status === 410) throw new Error('this API endpoint has been retired (HTTP 410); assets.mjs needs updating for it'); if (!r.ok) throw new Error(`HTTP ${r.status}${r.status >= 500 ? ', the service is having trouble' : ''}`); try { return await r.json(); } catch { throw new Error('the answer was not JSON'); } } } // Width and height from the first bytes of a JPEG, PNG, GIF or WebP file. function dims(b) { if (!b || b.length < 26) return {}; if (b[0] === 0x89 && b.toString('ascii', 1, 4) === 'PNG') return { w: b.readUInt32BE(16), h: b.readUInt32BE(20) }; if (b.toString('ascii', 0, 3) === 'GIF') return { w: b.readUInt16LE(6), h: b.readUInt16LE(8) }; if (b.toString('ascii', 0, 4) === 'RIFF' && b.toString('ascii', 8, 12) === 'WEBP' && b.length >= 30) { const f = b.toString('ascii', 12, 16); if (f === 'VP8X') return { w: 1 + b.readUIntLE(24, 3), h: 1 + b.readUIntLE(27, 3) }; if (f === 'VP8L') { const bits = b.readUInt32LE(21); return { w: 1 + (bits & 0x3fff), h: 1 + ((bits >>> 14) & 0x3fff) }; } if (f === 'VP8 ') return { w: b.readUInt16LE(26) & 0x3fff, h: b.readUInt16LE(28) & 0x3fff }; } if (b[0] === 0xff && b[1] === 0xd8) { let i = 2; while (i + 9 < b.length) { if (b[i] !== 0xff) { i++; continue; } const m = b[i + 1]; if (m === 0xff) { i++; continue; } if (m === 0x01 || (m >= 0xd0 && m <= 0xd8)) { i += 2; continue; } if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) return { w: b.readUInt16BE(i + 7), h: b.readUInt16BE(i + 5) }; i += 2 + b.readUInt16BE(i + 2); } } return {}; } // Width, height and duration from the head of an MP4 whose index comes first (what web renditions use). function mp4Info(b) { const out = {}; let i = -1; let area = 0; while ((i = b.indexOf('tkhd', i + 1)) > 3) { // one per track; the video track is the one with a size const end = i - 4 + b.readUInt32BE(i - 4); if (end <= b.length && end - (i - 4) >= 84) { const w = b.readUInt32BE(end - 8) >>> 16; const h = b.readUInt32BE(end - 4) >>> 16; if (w * h > area) { area = w * h; out.w = w; out.h = h; } } } const m = b.indexOf('mvhd'); if (m > 3 && m + 36 <= b.length) { const v1 = b[m + 4] === 1; const scale = b.readUInt32BE(m + (v1 ? 24 : 16)); const d = v1 ? Number(b.readBigUInt64BE(m + 28)) : b.readUInt32BE(m + 20); if (scale && d) out.duration = d / scale; } return out; } const clock = (s) => { if (!s && s !== 0) return null; const t = Math.round(s); return `${Math.floor(t / 60)}:${String(t % 60).padStart(2, '0')}`; }; // The Art Institute's image server turns away scripts that do not name themselves in this header. const imageHeaders = (url) => ({ 'User-Agent': UA, Accept: 'image/*', ...(/^https?:\/\/[^/]*artic\.edu\//.test(url) ? AIC_HEADERS : {}) }); // Does the file load, is it something a browser shows, and how large is it? Reads only the first // 128 KB of an image, or 512 KB of a video (enough for an MP4's size and duration). async function probe(url, ms = 15000, video = false, again = true) { const ac = new AbortController(); const timer = setTimeout(() => ac.abort(), ms); const want = video ? 524288 : 131072; try { const headers = video ? { 'User-Agent': UA, Accept: 'video/*' } : imageHeaders(url); const r = await fetch(url, { headers: { ...headers, Range: `bytes=0-${want - 1}` }, signal: ac.signal, redirect: 'follow' }); if (!r.ok) { r.body?.cancel().catch(() => {}); if (r.status >= 500 && again) { clearTimeout(timer); await sleep(1000); return probe(url, ms, video, false); } // file servers stumble now and then return { ok: false, why: `HTTP ${r.status}` }; } const type = (r.headers.get('content-type') || '').split(';')[0].trim().toLowerCase(); if (!(video ? VIDEO_TYPES : WEB_TYPES)[type]) { r.body?.cancel().catch(() => {}); return { ok: false, why: type ? `not a web ${video ? 'video' : 'image'} (${type})` : 'no content type' }; } const reader = r.body.getReader(); const chunks = []; let n = 0; while (n < want) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); n += value.length; } reader.cancel().catch(() => {}); const bytes = r.status === 206 ? Number((r.headers.get('content-range') || '').split('/')[1]) || null : Number(r.headers.get('content-length')) || null; const head = Buffer.concat(chunks); return { ok: true, type, bytes, ...(video ? (type === 'video/mp4' ? mp4Info(head) : {}) : dims(head)) }; } catch (e) { return { ok: false, why: e.name === 'AbortError' ? 'timed out' : e.cause?.code || e.message }; } finally { clearTimeout(timer); } } // ---------- licences and credits ---------- function licenceLabel(it) { if (it.licenseName) return it.licenseName; // set where the plain code would mislead: museums, informal statements, unusual licences if (it.license === 'cc0') return 'CC0'; if (it.license === 'pd') return 'public domain'; if (it.license === 'other') return 'licence on the source page'; return `CC ${it.license.toUpperCase()}${it.licenseVersion ? ' ' + it.licenseVersion : ''}`; } function licenceUrl(it) { if (it.licenseUrl) return String(it.licenseUrl).replace(/^http:/, 'https:'); if (it.license === 'cc0') return 'https://creativecommons.org/publicdomain/zero/1.0/'; if (it.license === 'pd') return 'https://creativecommons.org/publicdomain/mark/1.0/'; if (it.license.startsWith('by')) return `https://creativecommons.org/licenses/${it.license}/${it.licenseVersion || '4.0'}/`; return null; } const creditRequired = (it) => it.license !== 'cc0' && it.license !== 'pd'; function licenceNotes(it) { const n = []; if (/-sa$/.test(it.license)) n.push('Share-alike: an altered version (recoloured, combined with other pictures) must be shared under the same licence.'); if (/-nc/.test(it.license)) n.push('Non-commercial only: not for a business, a shop or a client site.'); if (/-nd$/.test(it.license)) n.push('No derivatives: show it whole and unaltered, no crops or treatments.'); if (it.license === 'other') n.push('A licence outside the usual set: read the source page before using it.'); return n; } function credit(it) { const by = it.creator && !/^(unknown|anonymous)\b/i.test(it.creator) ? ` by ${short(it.creator, 60)}` : ''; const date = (it.source === 'aic' || it.source === 'met') && it.date ? `, ${short(it.date, 30)}` : ''; return `"${short(it.title, 90)}"${by}${date}, ${licenceLabel(it)}, via ${it.via}`; } function base(source, id) { return { ref: `${source}:${id}`, source, media: 'image', via: SOURCES[source].name, title: '', creator: '', creatorUrl: null, date: null, kind: null, about: null, license: 'other', licenseVersion: null, licenseName: null, licenseUrl: null, page: null, image: null, original: null, smaller: null, width: null, height: null, imageWidth: null, imageHeight: null, bytes: null, estimated: false, duration: null, poster: null, alt: null, notes: [], hint: null }; } // Licence code and version from a licence URL (Creative Commons licences and marks). function licenceFromUrl(u) { const s = String(u || '').toLowerCase(); if (/publicdomain\/zero/.test(s)) return { code: 'cc0', version: '1.0' }; if (/publicdomain\/mark|publicdomain\/?$|\/publicdomain\b/.test(s)) return { code: 'pd', version: null }; const m = s.match(/creativecommons\.org\/licenses\/(by(?:-nc)?(?:-sa|-nd)?)\/(\d\.\d)/); return m ? { code: m[1], version: m[2] } : { code: 'other', version: null }; } // Of the renditions a source offers, the largest whose short side is at most `res` (720p by default), // in a format every browser plays. If all are larger, the smallest one. function pickRendition(list, res) { const playable = list.filter((r) => r.url && VIDEO_TYPES[r.type]); const side = (r) => (r.w && r.h ? Math.min(r.w, r.h) : Infinity); const fit = playable.filter((r) => side(r) <= res).sort((a, b) => side(b) - side(a) || (a.bytes ?? 1e15) - (b.bytes ?? 1e15)); return fit[0] || playable.sort((a, b) => side(a) - side(b) || (a.bytes ?? 1e15) - (b.bytes ?? 1e15))[0] || null; } function useRendition(it, pick, largest) { Object.assign(it, { media: 'video', image: pick.url, mime: pick.type, imageWidth: pick.w || null, imageHeight: pick.h || null, bytes: pick.bytes || null, estimated: Boolean(pick.estimated), duration: it.duration || pick.duration || null }); if (largest) { it.width = largest.w || it.width; it.height = largest.h || it.height; } return it; } // ---------- Openverse ---------- async function searchOpenverse(q, opts) { const lic = opts.licences.filter((l) => l !== 'other').map((l) => (l === 'pd' ? 'pdm' : l)); if (!lic.length) return { total: 0, items: [], note: 'Openverse filters by Creative Commons licences and marks only.' }; const call = (large) => { const p = new URLSearchParams({ q, license: lic.join(','), page_size: '20' }); if (opts.orientation) p.set('aspect_ratio', { landscape: 'wide', portrait: 'tall', square: 'square' }[opts.orientation]); if (large) p.set('size', 'large'); return getJson(`https://api.openverse.org/v1/images/?${p}`, 30000); }; // Its default order is led by 1024px Flickr files. Ask for the large ones first, and fill up from the rest only if needed. const first = await call(true); let rows = first.results || []; let total = first.result_count ?? null; if (rows.filter((r) => !r.width || r.width >= opts.minWidth).length < opts.limit + 2) { const rest = await call(false).catch(() => null); if (rest) { total = rest.result_count ?? total; rows = [...rows, ...(rest.results || []).filter((r) => !rows.some((x) => x.id === r.id))]; } } const items = rows.map(fromOpenverse).filter((it) => it.image); await wikimediaSizes(items, opts.width); return { total, items }; } function fromOpenverse(r) { const it = base('openverse', r.id); const via = r.source || r.provider; Object.assign(it, { via: PROVIDERS[via] || (String(via || '').startsWith('smithsonian') ? 'Smithsonian' : via ? String(via).replace(/_/g, ' ') : 'Openverse'), title: text(r.title).replace(/^File:/, '').replace(/\.(jpe?g|png|tiff?|gif|webp)$/i, '') || 'Untitled', creator: text(r.creator), creatorUrl: r.creator_url || null, license: r.license === 'pdm' ? 'pd' : String(r.license || 'other'), licenseVersion: r.license_version || null, licenseUrl: r.license_url || null, page: r.foreign_landing_url || r.detail_url, image: r.url, original: r.url, width: r.width || null, height: r.height || null, imageWidth: r.width || null, imageHeight: r.height || null, }); if (!LICENCES.includes(it.license)) it.license = 'other'; return it; } // Openverse points at Wikimedia originals, which can be 10 MB or a TIFF. Swap in a web-sized rendition. async function wikimediaSizes(items, width) { const wm = items.filter((it) => /^https?:\/\/upload\.wikimedia\.org\/wikipedia\/commons\/[0-9a-f]\/[0-9a-f]{2}\//.test(it.original || '')); if (!wm.length) return; const title = (it) => { let n = noQuery(it.original).split('/').pop(); try { n = decodeURIComponent(n); } catch {} return 'File:' + n.replace(/_/g, ' '); }; const j = await getJson(commonsApi({ titles: wm.map(title).join('|'), prop: 'imageinfo', iiprop: 'url|size|mime', iiurlwidth: String(width) })).catch(() => null); if (!j?.query) return; const normal = new Map((j.query.normalized || []).map((n) => [n.from, n.to])); const info = new Map((j.query.pages || []).map((pg) => [pg.title, pg.imageinfo?.[0]])); for (const it of wm) { const t = title(it); const ii = info.get(normal.get(t) || t); if (ii) applyThumb(it, ii); } } // ---------- Wikimedia Commons ---------- function commonsApi(params) { return `https://commons.wikimedia.org/w/api.php?${new URLSearchParams({ action: 'query', format: 'json', formatversion: '2', origin: '*', ...params })}`; } function commonsInfo(width) { return { prop: 'imageinfo', iiprop: 'url|size|mime|extmetadata', iiurlwidth: String(width), iiextmetadatalanguage: 'en', iiextmetadatafilter: 'ObjectName|Artist|LicenseShortName|License|LicenseUrl|Restrictions|DateTimeOriginal|ImageDescription' }; } async function searchCommons(q, opts) { let s = `${q} filetype:bitmap`; if (opts.minWidth > 1) s += ` filew:>${opts.minWidth - 1}`; const j = await getJson(commonsApi({ generator: 'search', gsrnamespace: '6', gsrsearch: s, gsrlimit: '30', ...commonsInfo(opts.width) })); if (j.error) throw new Error(j.error.info || 'the search failed'); const pages = (j.query?.pages || []).sort((a, b) => (a.index ?? 0) - (b.index ?? 0)); return { total: null, items: pages.map(fromCommons).filter(Boolean) }; } function fromCommons(pg) { const ii = pg?.imageinfo?.[0]; if (!ii) return null; const m = (k) => text(ii.extmetadata?.[k]?.value); const lic = commonsLicence(m('License'), m('LicenseShortName')); const it = base('commons', pg.pageid); Object.assign(it, { title: m('ObjectName').replace(/\s*\b(?:title|label) QS:.*$/s, '') || pg.title.replace(/^File:/, '').replace(/\.[a-z0-9]+$/i, ''), creator: m('Artist'), date: m('DateTimeOriginal').replace(/\s*\bdate QS:.*$/s, '').replace(/^(\d{4}-\d{2}-\d{2})[ T]\d{2}:\d{2}.*$/, '$1') || null, about: m('ImageDescription') || null, license: lic.code, licenseVersion: lic.version, licenseName: lic.name, licenseUrl: m('LicenseUrl') || null, page: ii.descriptionurl, }); applyThumb(it, ii); if (lic.informal) it.notes.push('Not a formal licence: the archive that published it states it knows of no copyright restrictions.'); if (it.about && it.about.toLowerCase().startsWith(it.title.toLowerCase()) && it.about.length < it.title.length + 12) it.about = null; const restrictions = m('Restrictions'); if (restrictions) it.notes.push(`Restrictions named on the source page (${restrictions}): personality rights, a trademark or similar. Read it before use.`); if (/^pd-us/i.test(m('License'))) it.notes.push('Public domain in the United States; check the source page before using it elsewhere.'); return it; } function commonsLicence(license, shortName) { const l = String(license || '').toLowerCase(); const s = String(shortName || '').toLowerCase(); const version = (shortName.match(/\b(\d\.\d)\b/) || l.match(/(\d\.\d)/) || [])[1] || null; if (/^cc0|^cc-zero/.test(l) || /^cc0/.test(s)) return { code: 'cc0', version: '1.0' }; if (/^pd|public domain/.test(l) || /public domain/.test(s)) return { code: 'pd', version: null }; // Flickr Commons and similar archives: no formal licence, the institution states it knows of no copyright if (/^no (known copyright )?restrictions/.test(s)) return { code: 'pd', version: null, name: 'no known copyright restrictions', informal: true }; const cc = l.match(/^cc-(by(?:-nc)?(?:-sa|-nd)?)(?:-|$)/) || s.match(/^cc (by(?:-nc)?(?:-sa|-nd)?)\b/); if (cc) return { code: cc[1], version }; return { code: 'other', version: null, name: shortName || license || null }; } // Commons serves thumbnails only at standard widths (1280, 1920, 3840...); the API picks the step. function applyThumb(it, ii) { it.width = ii.width || it.width; it.height = ii.height || it.height; it.original = noQuery(ii.url) || it.original; const tw = Number(String(ii.thumburl || '').split('?')[0].match(/(\d+)px-[^/]*$/)?.[1]) || 0; if (ii.thumburl && tw && ii.width && (tw < ii.width || (ii.mime && !WEB_TYPES[ii.mime]))) { it.image = noQuery(ii.thumburl); it.imageWidth = Math.min(tw, ii.width); it.imageHeight = ii.height ? Math.round((ii.height * it.imageWidth) / ii.width) : null; } else { it.image = it.original; it.imageWidth = ii.width || null; it.imageHeight = ii.height || null; } } // ---------- Art Institute of Chicago ---------- async function searchAic(q, opts) { const wanted = queryWords(q); const run = (match) => getJson(`https://api.artic.edu/api/v1/artworks/search?params=${encodeURIComponent(JSON.stringify({ query: { bool: { // the long description field is left out on purpose: it matches almost anything must: [{ multi_match: { query: wanted.join(' ') || q, type: 'cross_fields', fields: ['title^3', 'subject_titles^2', 'term_titles^2', 'classification_titles', 'medium_display', 'artist_title', 'place_of_origin', 'date_display'], ...match } }], filter: [{ term: { is_public_domain: true } }, { exists: { field: 'image_id' } }], } }, fields: AIC_FIELDS, limit: 30, }))}`, 20000, AIC_HEADERS); // every word must match; with three words or more, one may be missing. Looser than that returns noise. let j = await run({ operator: 'and' }); if (!(j.data || []).length && wanted.length >= 3) j = await run({ operator: 'or', minimum_should_match: wanted.length - 1 }); const iiif = j.config?.iiif_url || 'https://www.artic.edu/iiif/2'; return { total: j.pagination?.total ?? null, items: (j.data || []).map((d) => fromAic(d, iiif, opts.width)).filter(Boolean) }; } function fromAic(d, iiif, width) { if (!d?.image_id || d.is_public_domain === false) return null; const it = base('aic', d.id); const W = d.thumbnail?.width || null; const H = d.thumbnail?.height || null; const cap = W ? Math.min(W, 3000) : 3000; const w = Math.min(width, cap); Object.assign(it, { title: text(d.title) || 'Untitled', creator: text(d.artist_title) || text(String(d.artist_display || '').split('\n')[0]), date: text(d.date_display) || null, kind: [d.artwork_type_title, d.medium_display].filter(Boolean).map(text).join(', ') || null, license: 'cc0', licenseVersion: '1.0', licenseName: 'public domain (CC0)', licenseUrl: 'https://creativecommons.org/publicdomain/zero/1.0/', page: `https://www.artic.edu/artworks/${d.id}`, image: `${iiif}/${d.image_id}/full/${w},/0/default.jpg`, original: `${iiif}/${d.image_id}/full/${cap},/0/default.jpg`, width: W && H ? cap : null, height: W && H ? Math.round((H * cap) / W) : null, imageWidth: W && H ? w : null, imageHeight: W && H ? Math.round((H * w) / W) : null, alt: text(d.thumbnail?.alt_text) || null, }); it.hint = 'Download it with --get: the museum\'s image server answers browsers and this script, and refuses plain curl or wget.'; return it; } // ---------- The Met ---------- async function searchMet(q, opts) { const s = await getJson(`https://collectionapi.metmuseum.org/public/collection/v1.1/search?${new URLSearchParams({ q, hasImages: 'true', limit: String(Math.min(40, opts.limit * 4 + 8)), offset: '0' })}`); const ids = (s.objectIDs || []).slice(0, opts.limit * 4 + 8); const objects = await mapLimit(ids, 8, (id) => getJson(`https://collectionapi.metmuseum.org/public/collection/v1/objects/${id}`, 15000).catch(() => null)); // The museum's search reads every field of a record, so much of what it returns is off the subject. // Keep the objects whose own name, material, maker, place or tags carry the words of the query. const wanted = queryWords(q); const about = (x) => [x.title, x.objectName, x.medium, x.classification, x.culture, x.period, x.artistDisplayName, x.department, x.city, x.country, x.region, x.objectDate, ...(x.tags || []).map((t) => t.term)].filter(Boolean).join(' '); return { total: s.total ?? null, items: objects.filter((x) => x && onSubject(wanted, about(x))).map(fromMet).filter(Boolean) }; } function fromMet(x) { if (!x || !x.isPublicDomain || !x.primaryImage) return null; const it = base('met', x.objectID); const fix = (u) => (u ? encodeURI(u).replace(/%25([0-9a-f]{2})/gi, '%$1') : null); Object.assign(it, { title: text(x.title) || text(x.objectName) || 'Untitled', creator: text(x.artistDisplayName) || (x.culture ? `${text(x.culture)} maker` : ''), date: text(x.objectDate) || null, kind: [x.objectName || x.classification, x.medium].filter(Boolean).map(text).join(', ') || null, license: 'cc0', licenseVersion: '1.0', licenseName: 'public domain (CC0)', licenseUrl: 'https://creativecommons.org/publicdomain/zero/1.0/', page: x.objectURL || `https://www.metmuseum.org/art/collection/search/${x.objectID}`, image: fix(x.primaryImage), original: fix(x.primaryImage), smaller: x.primaryImageSmall && x.primaryImageSmall !== x.primaryImage ? fix(x.primaryImageSmall) : null, }); return it; } // ---------- video: Wikimedia Commons ---------- async function searchCommonsVideo(q, opts) { const j = await getJson(commonsApi({ generator: 'search', gsrnamespace: '6', gsrsearch: `${q} filetype:video`, gsrlimit: '30', ...commonsInfo(1280), prop: 'imageinfo|videoinfo', viprop: 'derivatives' })); if (j.error) throw new Error(j.error.info || 'the search failed'); const pages = (j.query?.pages || []).sort((a, b) => (a.index ?? 0) - (b.index ?? 0)); return { total: null, items: pages.map((pg) => fromCommonsVideo(pg, opts)).filter(Boolean) }; } function fromCommonsVideo(pg, opts) { const it = fromCommons(pg); const ii = pg?.imageinfo?.[0]; if (!it || !ii) return null; it.poster = it.image !== it.original ? it.image : null; // for a video file, the thumbnail is a still frame it.duration = Number(ii.duration) || null; // the original plus the transcodes Commons makes of every video (240p to 1080p webm); sizes from their bit rates const renditions = (pg.videoinfo?.[0]?.derivatives || []).map((d) => { const url = noQuery(d.src); const original = !/\/transcoded\//.test(url); return { url, type: String(d.type || '').split(';')[0].trim(), w: Number(d.width) || null, h: Number(d.height) || null, bytes: original ? Number(ii.size) || null : d.bandwidth && it.duration ? Math.round((d.bandwidth * it.duration) / 8) : null, estimated: !original }; }); const pick = pickRendition(renditions, opts.res); return pick ? useRendition(it, pick, { w: ii.width, h: ii.height }) : null; } // ---------- video: Internet Archive ---------- const first = (v) => (Array.isArray(v) ? v[0] : v); const seconds = (s) => { if (s === undefined || s === null || s === '') return null; if (/^\d+(\.\d+)?$/.test(String(s))) return Number(s); const p = String(s).split(':').map(Number); return p.length > 1 && p.every(Number.isFinite) ? p.reduce((a, x) => a * 60 + x, 0) : null; }; // Only the Prelinger Archives: curated industrial, educational, advertising and home films, mostly in the // public domain. The rest of the archive's films are uploads by anyone, and their licence claims go // unchecked (an upload called "... all rights reserved" can carry a public-domain mark), so they are left out. const ARCHIVE_LICENCE = { cc0: '*publicdomain\\/zero*', pd: '*publicdomain*', by: '*licenses\\/by\\/*', 'by-sa': '*licenses\\/by-sa\\/*', 'by-nc': '*licenses\\/by-nc\\/*', 'by-nd': '*licenses\\/by-nd\\/*', 'by-nc-sa': '*licenses\\/by-nc-sa\\/*', 'by-nc-nd': '*licenses\\/by-nc-nd\\/*' }; async function searchArchive(q, opts) { const words = queryWords(q); const licences = opts.licences.includes('other') ? '' : ` AND licenseurl:(${[...new Set(opts.licences.map((l) => ARCHIVE_LICENCE[l]))].join(' OR ')})`; const p = new URLSearchParams({ q: `(${(words.length ? words : [q]).join(' AND ')}) AND mediatype:(movies) AND collection:(prelinger)${licences}`, rows: '40', output: 'json' }); for (const f of ['identifier', 'licenseurl']) p.append('fl[]', f); const j = await getJson(`https://archive.org/advancedsearch.php?${p}`, 30000); if (j.error) throw new Error(`its search is failing right now (${short(text(j.error), 80)}); try again later`); const docs = j.response?.docs || []; const allowed = docs.filter((d) => opts.licences.includes(licenceFromUrl(first(d.licenseurl)).code)); const metas = await mapLimit(allowed.slice(0, opts.limit * 2 + 4), 6, (d) => getJson(`https://archive.org/metadata/${encodeURIComponent(d.identifier)}`, 20000).catch(() => null)); return { total: j.response?.numFound ?? null, skippedLicence: docs.length - allowed.length, items: metas.map((m) => fromArchive(m, opts)).filter(Boolean) }; } function fromArchive(m, opts) { const md = m?.metadata; if (!md?.identifier || !Array.isArray(m.files)) return null; const id = md.identifier; const fileUrl = (name) => `https://archive.org/download/${encodeURIComponent(id)}/${String(name).split('/').map(encodeURIComponent).join('/')}`; const isVideo = (f) => /\.(mp4|m4v|webm|mpe?g|avi|mov|ogv|mkv|wmv|flv)$/i.test(f.name || ''); // an item can hold several films: keep the first original and the web copies made from it const originals = m.files.filter((f) => f.source === 'original' && isVideo(f)); const stem = originals[0] ? originals[0].name.replace(/\.[^.]+$/, '') : null; const family = m.files.filter((f) => isVideo(f) && (!stem || f.name.startsWith(stem))); const renditions = family.filter((f) => /\.(mp4|webm)$/i.test(f.name)).map((f) => ({ url: fileUrl(f.name), type: /\.webm$/i.test(f.name) ? 'video/webm' : 'video/mp4', w: Number(f.width) || null, h: Number(f.height) || null, bytes: Number(f.size) || null, duration: seconds(f.length) })); const pick = pickRendition(renditions, opts.res); if (!pick) return null; const largest = family.reduce((a, f) => ((Number(f.width) || 0) > (a.w || 0) ? { w: Number(f.width), h: Number(f.height) } : a), { w: pick.w, h: pick.h }); const lic = licenceFromUrl(first(md.licenseurl)); const it = base('archive', id); Object.assign(it, { title: text(first(md.title)) || id, creator: text(first(md.creator)), date: text(first(md.date) || first(md.year)) || null, about: text(first(md.description)) || null, license: lic.code, licenseVersion: lic.version, licenseUrl: first(md.licenseurl) || null, page: `https://archive.org/details/${id}`, original: fileUrl((originals[0] || family[0]).name), duration: pick.duration || seconds(first(md.runtime)), poster: null, // the archive's own stills are about 160 pixels wide: too small to stand in for a clip }); // the same film is often stored in several formats; different names mean different films const films = new Set(originals.map((f) => f.name.split('/').pop().split('.')[0])).size; if (films > 1) it.notes.push(`This item holds ${films} films; the first is shown. The page lists the others.`); return useRendition(it, pick, largest); } // ---------- video: NASA Image and Video Library ---------- const nasaUrl = (u) => (u ? encodeURI(String(u).replace(/^http:/, 'https:')).replace(/%25([0-9a-f]{2})/gi, '%$1') : null); async function searchNasa(q, opts) { const j = await getJson(`https://images-api.nasa.gov/search?${new URLSearchParams({ q, media_type: 'video', page_size: String(Math.min(100, opts.limit * 3 + 6)) })}`); // its search matches any word anywhere; keep the clips whose own title, description or keywords carry the query const wanted = queryWords(q); const rows = (j.collection?.items || []).filter((x) => { const d = x.data?.[0] || {}; return onSubject(wanted, [d.title, d.description, ...(d.keywords || [])].join(' ')); }).slice(0, opts.limit * 2 + 4); const items = await mapLimit(rows, 6, async (x) => fromNasa(x, await getJson(nasaUrl(x.href), 15000).catch(() => null), opts)); return { total: j.collection?.metadata?.total_hits ?? null, items: items.filter(Boolean) }; } function fromNasa(x, files, opts) { const d = x?.data?.[0]; if (!d?.nasa_id || !Array.isArray(files)) return null; const find = (suffix) => files.find((f) => String(f).endsWith(suffix)); // NASA makes the same renditions of every video; these are their 16:9 sizes, and the check reads the real ones const renditions = [['~large.mp4', 1920, 1080], ['~medium.mp4', 1280, 720], ['~small.mp4', 640, 360], ['~mobile.mp4', 320, 180]] .filter(([s]) => find(s)).map(([s, w, h]) => ({ url: nasaUrl(find(s)), type: 'video/mp4', w, h })); const pick = pickRendition(renditions, opts.res); if (!pick) return null; const it = base('nasa', encodeURIComponent(d.nasa_id)); Object.assign(it, { title: text(d.title) || d.nasa_id, creator: text(d.photographer || d.secondary_creator) || `NASA${d.center ? ' ' + d.center : ''}`, date: String(d.date_created || '').slice(0, 10) || null, about: text(d.description) || null, license: 'pd', licenseName: 'public domain (NASA)', licenseUrl: 'https://www.nasa.gov/nasa-brand-center/images-and-media/', page: `https://images.nasa.gov/details/${encodeURIComponent(d.nasa_id)}`, original: nasaUrl(find('~orig.mp4')) || pick.url, poster: nasaUrl(find('~large.jpg') || find('~medium.jpg') || find('~thumb.jpg')), }); it.notes.push('NASA footage is generally free of copyright in the US. Never use NASA logos or suggest NASA endorses the client; check the page for third-party material.'); return useRendition(it, pick, renditions[0]); } function videoNotes(it, opts) { const n = []; const smaller = it.imageWidth && it.imageHeight && Math.min(it.imageWidth, it.imageHeight) > 480; if (it.bytes && it.bytes > opts.maxMb * 1048576) n.push(`Over ${opts.maxMb} MB (${it.estimated ? 'about ' : ''}${size(it.bytes)}): too heavy for a page, and --get refuses it unless you pass --max-mb. ${smaller ? 'Try --res 480, or a shorter clip.' : 'Choose a shorter clip.'}`); if (it.duration && it.duration > 60) n.push(`Long (${clock(it.duration)}): a loop on a page wants 5 to 20 seconds. Prefer a shorter clip, or trim one with ffmpeg if it is installed.`); return n; } // ---------- search ---------- function fits(it, opts, skipped) { const W = it.width || it.imageWidth; const H = it.height || it.imageHeight; if (W && W < opts.minWidth) { skipped.small++; return false; } if (opts.orientation && W && H && shape(W, H) !== opts.orientation) { skipped.shape++; return false; } return true; } async function search(query, opts) { const runners = opts.video ? { commons: searchCommonsVideo, archive: searchArchive, nasa: searchNasa } : { openverse: searchOpenverse, commons: searchCommons, aic: searchAic, met: searchMet }; // a slow service never holds the whole search: each source gets 45 s, the check 30 s const deadline = (ms, message) => new Promise((_, reject) => setTimeout(() => reject(new Error(message)), ms).unref()); const results = await Promise.all(opts.sources.map(async (key) => { try { return { key, ...(await Promise.race([runners[key](query, opts), deadline(45000, 'no complete answer within 45 s; try again or search another --source')])) }; } catch (e) { return { key, error: e.message, items: [] }; } })); // short clips first, then longer ones, then anything over the size cap; the source's own order within each const cap = opts.maxMb * 1048576; const rank = (it) => (it.media !== 'video' ? 0 : it.bytes && it.bytes > cap ? 4 : !it.duration ? 3 : it.duration <= 30 ? 0 : it.duration <= 90 ? 1 : it.duration <= 300 ? 2 : 3); const byRank = (a, b) => rank(a) - rank(b); const seen = new Set(); for (const r of results) { r.skipped = { licence: r.skippedLicence || 0, small: 0, shape: 0, dead: 0, alike: 0 }; const titles = new Map(); r.items = r.items.filter((it) => { if (!opts.licences.includes(it.license)) { r.skipped.licence++; return false; } const k = keyOf(it.original || it.image); if (seen.has(k)) return false; seen.add(k); if (!fits(it, opts, r.skipped)) return false; // one scanned book can fill a whole page of results: keep two pictures per title const t = it.title.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim().slice(0, 32); const n = (titles.get(t) || 0) + 1; titles.set(t, n); if (t && n > 2) { r.skipped.alike++; return false; } return true; }).sort(byRank).slice(0, opts.limit + 4); // a few spares for files that turn out not to load } if (opts.check) { await Promise.race([mapLimit(results.flatMap((r) => r.items), 8, async (it) => { const video = it.media === 'video'; const p = await probe(it.image, video ? 15000 : 12000, video); it.check = p; if (p.ok && p.w && p.h) { it.imageWidth = p.w; it.imageHeight = p.h; if (!it.width || it.image === it.original) { it.width = p.w; it.height = p.h; } } if (p.ok && p.bytes) { it.bytes = p.bytes; it.estimated = false; } if (p.ok && p.duration) it.duration = p.duration; }), new Promise((r) => setTimeout(r, 30000).unref())]); for (const r of results) r.items = r.items.filter((it) => (it.check?.ok ? fits(it, opts, r.skipped) : (r.skipped.dead++, (r.skipped.why = it.check?.why || 'not checked within 30 s'), false))).sort(byRank); } for (const r of results) r.items = r.items.slice(0, opts.limit); const shown = results.reduce((n, r) => n + r.items.length, 0); if (args.json) { console.log(JSON.stringify({ query, options: { media: opts.video ? 'video' : 'image', sources: opts.sources, licences: opts.licences, orientation: opts.orientation, minWidth: opts.minWidth, width: opts.width, res: opts.video ? opts.res : undefined, maxMb: opts.video ? opts.maxMb : undefined, limit: opts.limit, checked: opts.check }, sources: results.map((r) => ({ source: r.key, name: SOURCES[r.key].name, total: r.total ?? null, error: r.error || null, skipped: r.skipped, items: r.items.map(({ check, ...it }) => ({ ...it, licenseLabel: licenceLabel(it), licenseUrl: licenceUrl(it), creditRequired: creditRequired(it), credit: credit(it), notes: [...it.notes, ...licenceNotes(it), ...(it.media === 'video' ? videoNotes(it, opts) : [])] })) })) }, null, 1)); return shown ? 0 : 1; } const filters = opts.video ? [`licences ${opts.licences.join(', ')}`, `at least ${opts.minWidth}px wide`, opts.orientation, `renditions up to ${opts.res}p`, `flagged over ${opts.maxMb} MB`, opts.check ? 'every file checked' : 'files not checked'] : [`licences ${opts.licences.join(', ')}`, `at least ${opts.minWidth}px wide`, opts.orientation, `image URLs ${opts.width}px wide where the source can size them`, opts.check ? 'every image checked' : 'images not checked']; console.log(`ASSETS${opts.video ? ' · VIDEO' : ''} · "${query}" · ${filters.filter(Boolean).join(' · ')}`); for (const r of results) { const src = SOURCES[r.key]; console.log(''); if (r.error) { console.log(`## ${src.name}: could not search it (${r.error})`); continue; } const dropped = [r.skipped.small && `${r.skipped.small} narrower than ${opts.minWidth}px`, r.skipped.shape && `${r.skipped.shape} not ${opts.orientation}`, r.skipped.licence && `${r.skipped.licence} under other licences`, r.skipped.alike && `${r.skipped.alike} more with the same title`, r.skipped.dead && `${r.skipped.dead} did not load (${r.skipped.why})`].filter(Boolean); const count = r.total != null ? `${r.total} matches, ${r.items.length} shown` : `${r.items.length} shown`; console.log(`## ${src.name}: ${r.items.length ? count : 'nothing usable'}${dropped.length ? ` (dropped: ${dropped.join(', ')})` : ''}`); if (r.note) console.log(r.note); if (!r.items.length) continue; console.log(`${(opts.video && src.videoNote) || src.note}`); for (const it of r.items) { console.log(''); console.log(`${it.ref} ${short(it.title, 100)}${it.date ? ` (${short(it.date, 30)})` : ''}`); if (it.kind) console.log(` what ${short(it.kind, 110)}`); if (it.about) console.log(` about ${short(it.about, 150)}`); const pixels = it.imageWidth && it.imageHeight ? `${it.imageWidth} × ${it.imageHeight}` : 'size unknown'; const largest = it.width && it.height && it.width !== it.imageWidth ? `; largest ${it.width} × ${it.height}` : ''; const sh = shape(it.width || it.imageWidth, it.height || it.imageHeight); if (it.media === 'video') { const facts = [pixels, it.duration ? clock(it.duration) : 'length unknown', it.bytes ? `${it.estimated ? 'about ' : ''}${size(it.bytes)}` : null, VIDEO_TYPES[it.mime]]; console.log(` file ${it.image}`); console.log(` size ${facts.filter(Boolean).join(', ')}${largest}${sh ? `; ${sh}` : ''}`); if (it.poster) console.log(` poster ${it.poster}`); } else { console.log(` image ${it.image}`); console.log(` size ${pixels}${it.bytes ? `, ${size(it.bytes)}` : ''}${largest}${sh ? `; ${sh}` : ''}`); if (it.smaller) console.log(` smaller ${it.smaller}`); } console.log(` page ${it.page}`); const lu = licenceUrl(it); console.log(` licence ${licenceLabel(it)}${lu ? ` (${lu})` : ''}; ${creditRequired(it) ? 'credit required' : 'no credit required, credit anyway'}`); console.log(` credit ${credit(it)}`); for (const n of [...(it.media === 'video' ? videoNotes(it, opts) : []), ...it.notes, ...licenceNotes(it), ...(it.hint ? [it.hint] : [])]) console.log(` note ${n}`); } } console.log(''); if (results.every((r) => r.error)) { console.log('No source could be reached. Check the network and try again; if this machine has no internet access, draw the'); console.log('pictures instead (references/imagery.md) and say in your report that the open collections could not be searched.'); return 1; } if (!shown && opts.video) { console.log('Nothing usable came back. Name the action or the process ("coffee roasting", "timber framing", "river"); use fewer'); console.log('words; try a smaller --min-width or another --source. A page does not need video: a still picture or a drawing is fine.'); return 1; } if (!shown) { console.log('Nothing usable came back. Try fewer words; the place\'s own name for things ("azulejo", not "blue tile"); the place and a'); console.log('medium ("ghent postcard", "ghent old map", "ghent engraving"); a decade ("rotterdam harbour 1920"); a smaller --min-width;'); console.log('or another --source. The museum sources match one or two words best ("fig", "harbour"). If nothing honest exists, draw it.'); return 1; } if (opts.video) console.log('Next. Watch before you choose: --get downloads the clip and a poster frame into the project and records the credit.'); else console.log('Next. Look before you choose: --get downloads into the project and records the credit; open the file to see it.'); console.log(` ${CMD} --get [,...]${o.toArg}`); console.log(` ${CMD} --credits${o.toArg} the footer credit line and the list for your report`); console.log(`These ${opts.video ? 'clips' : 'pictures'} show the subject's world. Never present one as the client's own product, room, dish, person or team.`); return 0; } // ---------- get ---------- function creditsPath(opts) { return join(opts.to, 'credits.json'); } function readCredits(opts) { try { const j = JSON.parse(readFileSync(creditsPath(opts), 'utf8')); return Array.isArray(j) ? j : []; } catch { return []; } } // A file name in the target folder that is free, or already belongs to this same picture. function targetFile(opts, stem, ext, ref, credits) { let file = join(opts.to, `${stem}.${ext}`); for (let k = 2; existsSync(file) && credits.find((c) => c.file === rel(file))?.ref !== ref; k++) file = join(opts.to, `${stem}-${k}.${ext}`); return file; } function recordCredit(opts, credits, entry) { writeFileSync(creditsPath(opts), JSON.stringify([...credits.filter((c) => c.file !== entry.file), entry], null, 1)); } async function getOne(ref, opts) { const m = String(ref).match(/^(openverse|commons|aic|met|archive|nasa):(.+)$/); if (!m) throw new Error('a ref looks like openverse:, commons:, aic:, met:, archive: or nasa:, as a search prints it.'); const [, src, id] = m; let it; if (src === 'openverse') { it = fromOpenverse(await getJson(`https://api.openverse.org/v1/images/${encodeURIComponent(id)}/`, 30000)); await wikimediaSizes([it], opts.width); } else if (src === 'commons') { // the same ref form serves pictures and videos; the record says which const j = await getJson(commonsApi({ pageids: id, ...commonsInfo(opts.width), iiprop: 'url|size|mime|mediatype|extmetadata', prop: 'imageinfo|videoinfo', viprop: 'derivatives' })); const pg = j.query?.pages?.[0]; const ii = pg?.imageinfo?.[0]; it = ii && (ii.mediatype === 'VIDEO' || /^video\//.test(ii.mime || '')) ? fromCommonsVideo(pg, opts) : fromCommons(pg); } else if (src === 'aic') { const j = await getJson(`https://api.artic.edu/api/v1/artworks/${encodeURIComponent(id)}?fields=${AIC_FIELDS.join(',')}`, 20000, AIC_HEADERS); it = fromAic(j.data, j.config?.iiif_url || 'https://www.artic.edu/iiif/2', opts.width); } else if (src === 'archive') it = fromArchive(await getJson(`https://archive.org/metadata/${encodeURIComponent(id)}`, 30000), opts); else if (src === 'nasa') { const j = await getJson(`https://images-api.nasa.gov/search?${new URLSearchParams({ nasa_id: decodeURIComponent(id), media_type: 'video' })}`); const x = j.collection?.items?.[0]; it = x ? fromNasa(x, await getJson(nasaUrl(x.href), 15000), opts) : null; } else { it = fromMet(await getJson(`https://collectionapi.metmuseum.org/public/collection/v1/objects/${encodeURIComponent(id)}`)); if (it?.smaller) { const s = await probe(it.smaller); if (s.ok && s.w >= opts.width * 0.9) it.image = it.smaller; } } if (!it || !it.image) throw new Error(`no usable ${['archive', 'nasa'].includes(src) ? 'video (no rendition a browser plays, or the record is gone)' : 'image (not public domain, or the record has no picture)'}.`); if (!opts.licences.includes(it.license)) throw new Error(`its licence is ${licenceLabel(it)}, outside the ones allowed (${opts.licences.join(', ')}). Pass --license all to take it anyway, and say why in your report.`); if (it.media === 'video') return getVideo(it, src, id, opts); let r; try { r = await fetch(it.image, { headers: imageHeaders(it.image), signal: AbortSignal.timeout(90000) }); } catch (e) { throw new Error(`the image did not download (${e.name === 'TimeoutError' ? 'timed out' : e.cause?.code || e.message}).`); } if (!r.ok) throw new Error(`the image did not load (HTTP ${r.status}).`); const type = (r.headers.get('content-type') || '').split(';')[0].trim().toLowerCase(); const ext = WEB_TYPES[type]; if (!ext) throw new Error(`the file is ${type || 'of no stated type'}, not a web image.`); const buf = Buffer.from(await r.arrayBuffer()); if (!buf.length) throw new Error('the image was empty.'); const d = dims(buf); mkdirSync(opts.to, { recursive: true }); const credits = readCredits(opts); const stem = slug(args.name || it.title) || `${src}-${slug(id).slice(0, 12)}`; const file = targetFile(opts, stem, ext, it.ref, credits); writeFileSync(file, buf); const entry = { file: rel(file), media: 'image', ref: it.ref, title: it.title, creator: it.creator || null, creatorUrl: it.creatorUrl, date: it.date, license: licenceLabel(it), licenseCode: it.license, licenseUrl: licenceUrl(it), creditRequired: creditRequired(it), page: it.page, via: it.via, image: it.image, width: d.w || it.imageWidth, height: d.h || it.imageHeight, bytes: buf.length, credit: credit(it), notes: [...it.notes, ...licenceNotes(it)], saved: new Date().toISOString(), }; recordCredit(opts, credits, entry); const alt = it.alt || it.title; console.log(`Saved ${entry.file}${entry.width ? ` · ${entry.width} × ${entry.height}` : ''} · ${size(buf.length)}`); console.log(` page ${it.page}`); console.log(` credit ${entry.credit}`); console.log(` markup ${esc(short(alt, 120))}`); console.log(' (rewrite the alt to say what the picture shows; drop loading="lazy" on the hero image)'); for (const n of entry.notes) console.log(` note ${n}`); } // A clip: check its size against the cap first, stream it to disk (stopping at the cap), then save a still // frame from the source as its poster. async function getVideo(it, src, id, opts) { const cap = opts.maxMb * 1048576; const head = await probe(it.image, 20000, true); if (!head.ok) throw new Error(`the video did not load (${head.why}).`); const side = it.imageWidth && it.imageHeight ? Math.min(it.imageWidth, it.imageHeight) : 0; if (head.bytes && head.bytes > cap) throw new Error(`the clip is ${size(head.bytes)}, over the ${opts.maxMb} MB cap. ${side > 480 ? 'Try --res 480 for a smaller rendition, or a shorter clip' : 'Choose a shorter clip'}; pass --max-mb ${Math.ceil(head.bytes / 1048576) + 1} only if the page really needs this one.`); mkdirSync(opts.to, { recursive: true }); const credits = readCredits(opts); const stem = slug(args.name || it.title) || `${src}-${slug(decodeURIComponent(id)).slice(0, 12)}`; const file = targetFile(opts, stem, VIDEO_TYPES[head.type], it.ref, credits); let r; try { r = await fetch(it.image, { headers: { 'User-Agent': UA, Accept: 'video/*' }, signal: AbortSignal.timeout(600000) }); } catch (e) { throw new Error(`the video did not download (${e.name === 'TimeoutError' ? 'timed out' : e.cause?.code || e.message}).`); } if (!r.ok) throw new Error(`the video did not load (HTTP ${r.status}).`); const out = createWriteStream(file); const first512 = []; let kept = 0; let n = 0; try { const reader = r.body.getReader(); for (;;) { const { done, value } = await reader.read(); if (done) break; n += value.length; if (n > cap) { reader.cancel().catch(() => {}); throw new Error(`the clip passed the ${opts.maxMb} MB cap while downloading; choose a shorter clip or pass a larger --max-mb.`); } if (kept < 524288) { first512.push(value); kept += value.length; } if (!out.write(value)) await new Promise((res) => out.once('drain', res)); } await new Promise((res, rej) => out.end((e) => (e ? rej(e) : res()))); } catch (e) { out.destroy(); try { unlinkSync(file); } catch {} throw e; } if (!n) { try { unlinkSync(file); } catch {} throw new Error('the video was empty.'); } const info = head.type === 'video/mp4' ? mp4Info(Buffer.concat(first512)) : {}; const w = info.w || it.imageWidth; const h = info.h || it.imageHeight; const duration = info.duration || it.duration; let poster = null; if (it.poster) { try { const pr = await fetch(it.poster, { headers: imageHeaders(it.poster), signal: AbortSignal.timeout(60000) }); const pt = (pr.headers.get('content-type') || '').split(';')[0].trim().toLowerCase(); if (pr.ok && WEB_TYPES[pt]) { poster = file.replace(/\.[^.]+$/, `-poster.${WEB_TYPES[pt]}`); writeFileSync(poster, Buffer.from(await pr.arrayBuffer())); } } catch { poster = null; } } const entry = { file: rel(file), media: 'video', poster: poster ? rel(poster) : null, ref: it.ref, title: it.title, creator: it.creator || null, creatorUrl: it.creatorUrl, date: it.date, license: licenceLabel(it), licenseCode: it.license, licenseUrl: licenceUrl(it), creditRequired: creditRequired(it), page: it.page, via: it.via, video: it.image, width: w || null, height: h || null, duration: duration ? Math.round(duration * 10) / 10 : null, bytes: n, credit: credit(it), notes: [...it.notes, ...licenceNotes(it), ...videoNotes({ ...it, bytes: n, duration }, opts)], saved: new Date().toISOString(), }; recordCredit(opts, credits, entry); console.log(`Saved ${entry.file}${w ? ` · ${w} × ${h}` : ''}${duration ? ` · ${clock(duration)}` : ''} · ${size(n)}`); console.log(` poster ${entry.poster || 'none from the source: export a still frame yourself, or use a found picture'}`); console.log(` page ${it.page}`); console.log(` credit ${entry.credit}`); console.log(` markup `); console.log(' (a silent loop is decoration: pause it or show the poster under prefers-reduced-motion; references/imagery.md "Video")'); for (const x of entry.notes) console.log(` note ${x}`); } async function getAll(refs, opts) { let failed = 0; for (const ref of refs) { try { await getOne(ref, opts); } catch (e) { failed++; console.error(`Could not get ${ref}: ${e.message}`); } console.log(''); } if (failed < refs.length) { console.log(`Credits are recorded in ${rel(creditsPath(opts))}. Look at each picture and watch each clip before using it.`); console.log(`Print the footer credits and the report list with: ${CMD} --credits${opts.toArg}`); } return failed ? 1 : 0; } // ---------- credits ---------- function printCredits(opts) { const list = readCredits(opts); const uniq = list.filter((c, i) => list.findIndex((x) => x.ref === c.ref) === i && existsSync(resolve(c.file))); if (!uniq.length) { console.log(`No pictures recorded in ${rel(creditsPath(opts))} yet. Download them with --get first.`); return 1; } const html = (c) => { const by = c.creator && !/^(unknown|anonymous)\b/i.test(c.creator) ? ` by ${esc(short(c.creator, 60))}` : ''; const date = /^(aic|met):/.test(c.ref) && c.date ? `, ${esc(short(c.date, 30))}` : ''; const lic = c.licenseUrl ? `${esc(c.license)}` : esc(c.license); return `“${esc(short(c.title, 90))}”${by}${date}, ${lic}, via ${esc(c.via)}`; }; const media = new Set(uniq.map((c) => c.media || 'image')); const label = media.size > 1 ? 'Pictures and footage' : media.has('video') ? 'Footage' : 'Pictures'; console.log('Footer credits (HTML). Full contrast, keep the links, body size (one step smaller only on a page with plenty of'); console.log('running text). Remove anything the page no longer uses, here and in credits.json.\n'); console.log(`

    ${label}: ${uniq.map(html).join('; ')}.

    `); console.log('\nFor your report:\n'); for (const c of uniq) console.log(`- ${c.file}${c.media === 'video' && c.duration ? ` (video, ${clock(c.duration)})` : ''}: ${c.credit}. ${c.page}${c.creditRequired ? '' : ' (no credit required)'}`); return 0; } // ---------- run ---------- if (args.help) { console.log(USAGE); process.exit(0); } if (args.credits) process.exit(printCredits(o)); if (args.get) process.exit(await getAll(String(args.get).split(',').map((s) => s.trim()).filter(Boolean), o)); const query = words.join(' ').trim(); if (!query) fail('Give a search query, for example: node assets.mjs "ghent postcard"'); process.exit(await search(query, o)); ================ File: workspace/agents/opencode/engine/skills/design-taste-frontend/scripts/draw.mjs ================ #!/usr/bin/env node // tasteskill / draw — deal an art direction from the research library. // // The script chooses, not the model: a model asked to pick a direction returns the category // default every time. The draw is random within what fits the brief, and it remembers recent // draws so consecutive projects do not look alike. // // node draw.mjs --sector --brief "one line" [--sections hero,showcase,pricing,footer] // [--tone quiet|bold|warm|technical|playful|luxury|editorial] // [--have photography,product-photo,product-ui,illustration,video] assets you really have // [--keep palette,type] the brand already fixes these; draw the rest // [--avoid id,id] [--seed N] [--out DIR] // [--options 3] show three dealt directions to choose from, write nothing // [--direction ] deal the full sheet for the chosen direction // node draw.mjs --show reprint the saved direction sheet // node draw.mjs --redraw hero|type|palette|photos|scenes|section:|direction re-deal one slot // node draw.mjs --list sectors|directions|roles|tones|scenes // node draw.mjs --site print one studied reference site in full // node draw.mjs --app --kind ... deal a style for an app screen instead (see lib/app-draw.mjs) // // Zero dependencies. Node 18+. import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { randomInt } from 'node:crypto'; import { homedir } from 'node:os'; const HERE = dirname(fileURLToPath(import.meta.url)); const LIB = join(HERE, '..', 'library'); const ASSETS_PATH = join(HERE, 'assets.mjs').split('\\').join('/'); const ASSETS_CMD = `node ${/\s/.test(ASSETS_PATH) ? `"${ASSETS_PATH}"` : ASSETS_PATH}`; const load = (name) => JSON.parse(readFileSync(join(LIB, name + '.json'), 'utf8')); const argv = process.argv.slice(2); const opt = (name, def) => { const i = argv.indexOf('--' + name); if (i === -1) return def; const v = argv[i + 1]; return v === undefined || v.startsWith('--') ? true : v; }; const list = (v) => (typeof v === 'string' ? v.split(',').map((s) => s.trim()).filter(Boolean) : []); const outDir = resolve(String(opt('out', '.taste-skill'))); const sheetPath = join(outDir, 'direction.json'); const SECTORS = ['saas', 'dev-tool', 'ai-product', 'consumer-app', 'finance', 'agency', 'studio-portfolio', 'personal-portfolio', 'ecommerce', 'physical-product', 'fashion', 'hospitality', 'restaurant', 'real-estate', 'architecture', 'event', 'health', 'education', 'nonprofit', 'culture', 'music', 'other']; const TONES = ['quiet', 'bold', 'warm', 'technical', 'playful', 'luxury', 'editorial']; const DEFAULT_SECTIONS = { saas: ['hero', 'showcase', 'features', 'how-it-works', 'testimonial', 'pricing', 'faq', 'cta', 'footer'], 'dev-tool': ['hero', 'showcase', 'features', 'how-it-works', 'pricing', 'faq', 'cta', 'footer'], 'ai-product': ['hero', 'showcase', 'features', 'how-it-works', 'testimonial', 'pricing', 'cta', 'footer'], 'consumer-app': ['hero', 'showcase', 'features', 'testimonial', 'cta', 'footer'], finance: ['hero', 'showcase', 'features', 'stats', 'testimonial', 'faq', 'cta', 'footer'], agency: ['hero', 'intro', 'work', 'services', 'about', 'cta', 'footer'], 'studio-portfolio': ['hero', 'work', 'intro', 'services', 'about', 'cta', 'footer'], 'personal-portfolio': ['hero', 'work', 'about', 'cta', 'footer'], ecommerce: ['hero', 'gallery', 'features', 'intro', 'testimonial', 'cta', 'footer'], 'physical-product': ['hero', 'showcase', 'features', 'gallery', 'intro', 'pricing', 'cta', 'footer'], fashion: ['hero', 'gallery', 'intro', 'work', 'cta', 'footer'], hospitality: ['hero', 'intro', 'gallery', 'features', 'list', 'contact', 'footer'], restaurant: ['hero', 'intro', 'list', 'gallery', 'contact', 'footer'], 'real-estate': ['hero', 'intro', 'gallery', 'features', 'stats', 'contact', 'footer'], architecture: ['hero', 'work', 'intro', 'about', 'contact', 'footer'], event: ['hero', 'intro', 'list', 'features', 'pricing', 'faq', 'cta', 'footer'], health: ['hero', 'intro', 'features', 'how-it-works', 'testimonial', 'faq', 'cta', 'footer'], education: ['hero', 'intro', 'features', 'how-it-works', 'testimonial', 'pricing', 'faq', 'cta', 'footer'], nonprofit: ['hero', 'intro', 'stats', 'features', 'about', 'cta', 'footer'], culture: ['hero', 'intro', 'gallery', 'list', 'about', 'cta', 'footer'], music: ['hero', 'list', 'gallery', 'about', 'cta', 'footer'], other: ['hero', 'intro', 'features', 'showcase', 'cta', 'footer'], }; // requested role names → the role vocabulary used by library/sections.json const ROLE_ALIAS = { features: 'features', feature: 'features', benefits: 'features', services: 'features', showcase: 'showcase', product: 'showcase', demo: 'showcase', 'how-it-works': 'how-it-works', how: 'how-it-works', process: 'how-it-works', steps: 'how-it-works', testimonial: 'testimonial', testimonials: 'testimonial', quotes: 'testimonial', proof: 'proof', logos: 'proof', press: 'proof', stats: 'stats', numbers: 'stats', pricing: 'pricing', plans: 'pricing', faq: 'faq', cta: 'cta', footer: 'footer', intro: 'intro', manifesto: 'intro', about: 'about', team: 'about', work: 'work', projects: 'work', index: 'work', 'case-studies': 'work', gallery: 'gallery', images: 'gallery', list: 'list', menu: 'list', schedule: 'list', lineup: 'list', 'line-up': 'list', speakers: 'list', agenda: 'list', programme: 'list', program: 'list', timetable: 'list', rooms: 'list', tickets: 'pricing', price: 'pricing', contact: 'contact', booking: 'contact', newsletter: 'contact', signup: 'contact' }; // App screens (dashboards, admin, settings) have their own library and sheet: scripts/lib/app-draw.mjs. const savedKind = (() => { try { return JSON.parse(readFileSync(sheetPath, 'utf8')).kind; } catch { return null; } })(); if (opt('app') || ((opt('show') || opt('redraw')) && savedKind === 'app')) await import('./lib/app-draw.mjs'); if (opt('list')) { const what = opt('list'); if (what === 'sectors') console.log(SECTORS.join('\n')); else if (what === 'tones') console.log(TONES.join('\n')); else if (what === 'roles') console.log([...new Set(Object.values(ROLE_ALIAS))].join('\n')); else if (what === 'scenes') { const all = existsSync(join(LIB, 'scenes.json')) ? load('scenes') : []; for (const sc of all) console.log(`${sc.id.padEnd(34)} ${sc.name} — ${sc.family}; ${sc.summary.split(/(?<=\.)\s/)[0]}`); if (!all.length) console.log('No scene library yet.'); } else for (const d of load('directions')) console.log(`${d.id.padEnd(30)} ${d.name} — ${d.sectors.join(', ')}`); process.exit(0); } if (opt('photos')) { const lib = load('photos'); const q = String(opt('photos')); const line = (ph) => `- https://picsum.photos/id/${ph.id}/1600/1000 — ${ph.alt} · ${ph.tone} tone · ${(ph.colours || []).join(', ')}${ph.mono ? ' · black-and-white' : ''}${ph.calm && ph.calm !== 'none' ? ` · calm area: ${ph.calm}` : ''} · photo: ${ph.by}`; if (q === 'list' || q === 'true') { for (const [id, set] of Object.entries(lib.sets)) console.log(`${id.padEnd(22)} ${String(set.photos.length).padStart(3)} photos — ${set.note}`); } else if (lib.sets[q]) { console.log(`# ${lib.sets[q].title} — ${lib.sets[q].note}`); for (const ph of lib.sets[q].photos) console.log(line(ph)); } else { // free-text search across all descriptions const words = q.toLowerCase().split(/[^a-z]+/).filter((w) => w.length >= 3); const hits = Object.values(lib.sets).flatMap((s) => s.photos).filter((ph) => words.some((w) => (ph.alt + ' ' + (ph.colours || []).join(' ')).toLowerCase().includes(w))); if (!hits.length) console.log('No photograph matches. `--photos list` shows the sets.'); else for (const ph of hits.slice(0, 24)) console.log(line(ph)); } console.log('\nPath numbers are width/height; add ?grayscale to desaturate. Photographs are atmosphere, never a stand-in for a specific product or person.'); process.exit(0); } if (opt('site')) { const s = load('sites').find((x) => x.id === opt('site') || x.name.toLowerCase() === String(opt('site')).toLowerCase()); if (!s) { console.error('No studied site with that id. Ids appear in the direction sheet under "Real references".'); process.exit(2); } console.log(siteCard(s, true)); process.exit(0); } // ---------- randomness (seedable) ---------- let seed = opt('seed') !== undefined && opt('seed') !== true ? Number(opt('seed')) : randomInt(1, 2 ** 31 - 1); const startSeed = seed; const rnd = () => { seed |= 0; seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; const pick = (arr) => arr[Math.floor(rnd() * arr.length)]; const shuffle = (arr) => { const a = [...arr]; for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(rnd() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; }; // prefer items not used recently, without ever emptying the pool const fresh = (pool, recent) => { const f = pool.filter((x) => !recent.includes(x.id)); return f.length ? f : pool; }; // ---------- history (project + user level) ---------- const userHist = process.env.TASTE_SKILL_HISTORY || join(homedir(), '.taste-skill', 'history.json'); const readHist = () => { for (const p of [userHist]) { try { return JSON.parse(readFileSync(p, 'utf8')); } catch {} } return []; }; const writeHist = (entry) => { try { mkdirSync(dirname(userHist), { recursive: true }); const h = readHist(); h.unshift(entry); writeFileSync(userHist, JSON.stringify(h.slice(0, 24), null, 1)); } catch {} }; // ---------- show / redraw ---------- const saved = existsSync(sheetPath) ? JSON.parse(readFileSync(sheetPath, 'utf8')) : null; if (opt('show')) { if (!saved) { console.error('No saved direction. Run a draw first.'); process.exit(2); } console.log(render(saved)); process.exit(0); } const L = { directions: load('directions'), heroes: load('heroes'), sections: load('sections'), type: load('type'), palettes: load('palettes'), photos: load('photos'), sites: load('sites'), motions: load('motions'), scenes: existsSync(join(LIB, 'scenes.json')) ? load('scenes') : [] }; const hist = readHist(); const recent = (key, n) => hist.slice(0, n).map((h) => h[key]).filter(Boolean); let sheet; if (opt('alternatives')) { // The other blueprints of one role that suit the saved direction: for when the dealt one cannot hold // the real content (it assumes three plans and there is one; it needs five quotes and there are none). if (!saved) { console.error('Run a draw first.'); process.exit(2); } const role = ROLE_ALIAS[String(opt('alternatives'))] || String(opt('alternatives')); const dir = L.directions.find((d) => d.id === saved.direction.id); const current = saved.sections.find((s) => s.role === role)?.id; const pool = sectionPool(dir, role, saved.input); if (!pool.length) { console.error('No blueprints for role ' + role + '. Roles: --list roles'); process.exit(2); } const line = (s) => `- \`${s.id}\`${s.id === current ? ' (dealt)' : ''} — **${s.name}** (${s.shape}). ${s.blueprint.split(/(?<=\.)\s/).slice(0, 2).join(' ')}\n`; console.log(`# ${role}: blueprints that suit "${dir.name}"\n`); for (const s of pool) console.log(line(s)); const software = imageryMode(saved.input) === 'interface'; const others = L.sections.filter((s) => s.role === role && !pool.includes(s) && (software || !softwareOnly(s)) && (s.needs || []).every((n) => saved.input.have.includes(n) || (n === 'product-ui' && software))); if (others.length) { console.log(`# Other ${role} blueprints (written for other directions; if one fits the content better, take its geometry and set it in this direction's type, colour and manner)\n`); for (const s of others) console.log(line(s)); } console.log(`Choose by what the content really is (how many plans, quotes, items exist), then: node draw.mjs --redraw section:${role}= and say why in your report.`); process.exit(0); } if (opt('redraw')) { if (!saved) { console.error('Nothing to redraw. Run a draw first.'); process.exit(2); } sheet = saved; sheet.redraws = (sheet.redraws || 0) + 1; // the sheet keeps its original seed; slot redraws are counted const what = String(opt('redraw')); const dir = L.directions.find((d) => d.id === sheet.direction.id); if (what === 'direction') sheet = deal({ ...sheet.input, direction: null, avoid: [...(sheet.input.avoid || []), sheet.direction.id] }, sheet.redraws); else if (what === 'hero') sheet.hero = dealHero(dir, sheet.input, [sheet.hero?.id]); else if (what === 'type') sheet.type = dealType(dir, [sheet.type?.id]); else if (what === 'palette') sheet.palette = dealPalette(dir, sheet.input, [sheet.palette?.id]); else if (what === 'photos') sheet.photos = dealPhotos(dir, sheet.input); else if (what === 'scenes') sheet.scenes = dealScenes(dir, sheet.input, (sheet.scenes || []).map((x) => x.id)); else if (what.startsWith('section:')) { // section: re-deals at random; section:= takes a named alternative (see --alternatives) const [roleRaw, wanted] = what.slice(8).split('='); const role = ROLE_ALIAS[roleRaw] || roleRaw; const i = sheet.sections.findIndex((s) => s.role === role); if (i === -1) { console.error('No section with role ' + role + ' in this sheet.'); process.exit(2); } const used = sheet.sections.map((s) => s.id); if (wanted) { // a blueprint written for another role may be borrowed when its geometry fits the content better const chosen = L.sections.find((s) => s.id === wanted); if (!chosen) { console.error(`No blueprint with id ${wanted}. List them with --alternatives .`); process.exit(2); } sheet.sections[i] = chosen.role === role ? chosen : { ...chosen, role, name: `${chosen.name} (borrowed from ${chosen.role})` }; } else sheet.sections[i] = dealSection(dir, role, used, sheet.sections[i - 1]?.shape, sheet.sections[i + 1]?.shape, sheet.input) || sheet.sections[i]; } else { console.error('Unknown slot. Use hero, type, palette, photos, scenes, section:, section:= or direction.'); process.exit(2); } } else { const sector = String(opt('sector', '')); if (!SECTORS.includes(sector)) { console.error(`--sector is required. One of:\n ${SECTORS.join(', ')}\nPick the closest; use "other" only when nothing fits.`); process.exit(2); } const requested = list(opt('sections')); const input = { brief: typeof opt('brief') === 'string' ? opt('brief') : '', sector, tone: TONES.includes(opt('tone')) ? opt('tone') : null, have: list(opt('have')), keep: list(opt('keep')), avoid: list(opt('avoid')), sections: (requested.length ? requested : DEFAULT_SECTIONS[sector]).map((r) => ROLE_ALIAS[r] || r).filter((r) => r !== 'hero'), direction: typeof opt('direction') === 'string' ? opt('direction') : null, }; if (input.direction && !L.directions.some((d) => d.id === input.direction)) { console.error(`No direction with id ${input.direction}. List them with --list directions.`); process.exit(2); } if (opt('options')) { const n = Math.max(2, Math.min(5, Number(opt('options')) || 3)); const options = dealOptions(input, n); console.log(`# ${n} directions dealt for this brief\n`); console.log('Choose the one whose way of composing can best carry an idea from your inventory (SKILL.md steps 1 and 4), not the'); console.log('most familiar one. Then deal it in full with the same command plus `--direction ` (and without --options).\n'); for (const d of options) { console.log(`## \`${d.id}\` — ${d.name}`); console.log(`Idea engine: ${d.idea.split(/(?<=\.)\s/).slice(0, 2).join(' ')}`); console.log(`Composition: ${d.composition.split(/(?<=\.)\s/).slice(0, 2).join(' ')}`); if (d.needsPictures) console.log('Needs: many pictures. Without photographs of its own, each is a real picture of the subject\'s world found with scripts/assets.mjs, or a drawing you make.'); console.log(''); } process.exit(0); } sheet = deal(input, 0); } mkdirSync(outDir, { recursive: true }); writeFileSync(sheetPath, JSON.stringify(sheet, null, 1)); if (!opt('redraw') || String(opt('redraw')) === 'direction') writeHist({ at: sheet.at, direction: sheet.direction.id, type: sheet.type?.id, palette: sheet.palette?.id, hero: sheet.hero?.id, sector: sheet.input.sector, scenes: (sheet.scenes || []).map((x) => x.id) }); console.log(render(sheet)); // ---------- dealing ---------- // What kind of pictures this build can honestly have. It decides which blueprints are dealt and how the // sheet tells the agent to fill picture slots. function imageryMode(input) { if (input.have.includes('product-photo') || input.have.includes('photography')) return 'own'; if (['saas', 'dev-tool', 'ai-product', 'consumer-app', 'finance'].includes(input.sector)) return 'interface'; if (['physical-product', 'ecommerce'].includes(input.sector)) return 'no-product-photo'; return 'atmosphere'; } function pictureLed(x) { return (x.tags || []).includes('photo-led') || (x.tags || []).includes('object-led'); } function worksWithoutPictures(x) { return !pictureLed(x) || (x.tags || []).some((t) => ['ui-led', 'type-led', 'colour-field'].includes(t)); } // The directions that suit this brief and the assets it really has. function directionPool(input) { const has = (need) => input.have.includes(need) || need === 'photography'; // the photo library covers photography let pool = L.directions.filter((d) => (d.sectors.includes(input.sector) || d.sectors.includes('*')) && !input.avoid.includes(d.id)); const feasible = pool.filter((d) => (d.needs || []).every(has)); if (feasible.length) pool = feasible; // directions built on photographs of the subject are not dealt when the subject cannot be photographed honestly const mode = imageryMode(input); if (mode === 'interface' || mode === 'no-product-photo') { const plain = pool.filter((d) => !d.needsPictures); if (plain.length >= 3) pool = plain; } if (input.tone) { const toned = pool.filter((d) => (d.tones || []).includes(input.tone)); if (toned.length >= 2) pool = toned; } if (!pool.length) pool = L.directions.filter((d) => !input.avoid.includes(d.id)); return pool; } // Three directions dealt at random from the pool, for the builder to choose the one that carries its idea. function dealOptions(input, n) { const pool = directionPool(input); const out = []; let rest = fresh(pool, recent('direction', Math.min(5, pool.length - n))); while (out.length < n && pool.length > out.length) { if (!rest.length) rest = pool.filter((d) => !out.includes(d)); const d = pick(rest); out.push(d); rest = rest.filter((x) => x !== d); } return out; } function deal(input, redraws) { const pool = directionPool(input); const forced = input.direction ? L.directions.find((d) => d.id === input.direction) : null; const dir = forced || pick(fresh(pool, recent('direction', Math.min(5, pool.length - 1)))); const s = { version: 1, at: new Date().toISOString(), seed: startSeed, redraws, input, imagery: imageryMode(input), allow: dir.allow || [], direction: dir }; s.hero = dealHero(dir, input, []); s.type = input.keep.includes('type') ? null : dealType(dir, []); s.palette = input.keep.includes('palette') ? null : dealPalette(dir, input, []); s.photos = dealPhotos(dir, input); s.motion = L.motions.find((m) => m.id === (s.hero?.motion || dir.motion)) || L.motions.find((m) => m.id === dir.motion) || null; s.sections = []; for (const role of input.sections) { const sec = dealSection(dir, role, s.sections.map((x) => x.id), s.sections.at(-1)?.shape, null, input); if (sec) s.sections.push(sec); else s.sections.push({ id: null, role, shape: null, name: role, blueprint: null }); } s.references = dealReferences(dir, input); s.scenes = dealScenes(dir, input, []); return s; } // The page's big scroll moments (references/scenes.md): two or three tested scenes from different // families, each placed on a section of the argument it can carry. Expressive sectors and tones get three. function dealScenes(dir, input, exclude) { if (!L.scenes.length) return []; const has = (need) => input.have.includes(need) || need === 'photography'; const fits = (sc) => !exclude.includes(sc.id) && (sc.sectors || ['*']).some((x) => x === '*' || x === input.sector) && (sc.needs || []).every(has); let pool = L.scenes.filter(fits); if (pool.length < 2) pool = L.scenes.filter((sc) => !exclude.includes(sc.id) && (sc.needs || []).every(has)); if (!pool.length) return []; const loud = ['agency', 'studio-portfolio', 'personal-portfolio', 'fashion', 'culture', 'music', 'event', 'architecture', 'physical-product', 'real-estate', 'hospitality'].includes(input.sector) || ['bold', 'playful', 'luxury', 'editorial'].includes(input.tone); const quiet = ['quiet', 'technical'].includes(input.tone); const n = Math.min(pool.length, quiet ? 2 : loud ? 3 : 2); const recentIds = hist.slice(0, 4).flatMap((h) => h.scenes || []); let rest = fresh(pool, recentIds); const out = []; const families = new Set(); const usedRoles = new Set(); while (out.length < n) { let cands = rest.filter((sc) => !families.has(sc.family) && !(sc.position === 'opening' && out.some((o) => o.position === 'opening')) && !(sc.position === 'closing' && out.some((o) => o.position === 'closing'))); if (!cands.length) cands = pool.filter((sc) => !out.includes(sc) && !families.has(sc.family)); if (!cands.length) break; const sc = pick(cands); out.push(sc); families.add(sc.family); rest = rest.filter((x) => x !== sc); } // the section each scene carries: the first role of the argument it suits that no other scene has taken const roles = ['hero', ...input.sections]; return out.map((sc) => { const role = sc.position === 'opening' ? 'hero' : roles.find((r) => (sc.roles || []).includes(r) && !usedRoles.has(r) && r !== 'hero') || null; if (role) usedRoles.add(role); return { ...sc, carries: role }; }).sort((a, b) => (a.carries ? roles.indexOf(a.carries) : 99) - (b.carries ? roles.indexOf(b.carries) : 99)); } function dealHero(dir, input, exclude) { // the direction's own list, and any hero written for this direction since (the October 2026 study added some) let pool = L.heroes.filter((h) => (dir.heroes || []).includes(h.id) || (h.directions || []).includes(dir.id)); if (!pool.length) pool = L.heroes; if (imageryMode(input) !== 'interface' && !input.have.includes('product-ui')) { const plain = pool.filter((h) => !softwareOnly(h)); if (plain.length) pool = plain; } const ok = pool.filter((h) => !exclude.includes(h.id) && (h.needs || []).every((n) => n === 'photography' || input.have.includes(n))); return pick(fresh(ok.length ? ok : pool.filter((h) => !exclude.includes(h.id)).length ? pool.filter((h) => !exclude.includes(h.id)) : pool, recent('hero', 3))); } function dealType(dir, exclude) { let pool = L.type.filter((t) => (dir.type || []).includes(t.id) || (dir.typeClasses || []).includes(t.class)); if (!pool.length) pool = L.type; const p = pool.filter((t) => !exclude.includes(t.id)); return pick(fresh(p.length ? p : pool, recent('type', Math.min(6, pool.length - 1)))); } function dealPalette(dir, input, exclude) { let pool = L.palettes.filter((p) => (dir.palettes || []).includes(p.id) || (p.tags || []).some((t) => (dir.paletteTags || []).includes(t))); if (!pool.length) pool = L.palettes; const p = pool.filter((x) => !exclude.includes(x.id)); return pick(fresh(p.length ? p : pool, recent('palette', Math.min(6, pool.length - 1)))); } function dealPhotos(dir, input) { if (dir.photos === false) return null; // image-free directions const words = briefWords(input.brief); const mode = imageryMode(input); const SOFT_SETS = ['architecture', 'fog-and-quiet', 'sky-and-space', 'texture', 'water']; // subject first (sector + words of the brief), then the direction's preferred tone const scored = Object.entries(L.photos.sets).map(([id, set]) => { let score = (set.sectors || []).includes(input.sector) ? 3 : 0; if ((set.primary || []).includes(input.sector)) score += 5; // rooms and tables would be taken for this very house: for places to stay, deal the surroundings instead if (mode === 'atmosphere' && ['hospitality', 'real-estate'].includes(input.sector) && id === 'interior-and-table') score -= 6; score += words.filter((w) => (set.keywords || []).some((k) => k === w || (k.length >= 5 && w.startsWith(k)) || (w.length >= 5 && k.startsWith(w)))).length * 4; score += set.photos.filter((ph) => words.some((w) => ph.alt.toLowerCase().includes(w))).length; if (dir.photoTone) score += set.photos.filter((ph) => ph.tone === dir.photoTone).length / set.photos.length; return { id, set, score }; }).filter((x) => x.score > 0 && x.set.photos.length >= 5 && (mode !== 'interface' || SOFT_SETS.includes(x.id))).sort((a, b) => b.score - a.score); if (!scored.length) return null; // one set, so the photographs share a subject and a light; choose among the best-fitting few const top = scored.filter((x) => x.score >= scored[0].score * 0.6).slice(0, 4); const chosen = pick(top); let pool = chosen.set.photos; if (dir.photoTone) { const toned = pool.filter((ph) => ph.tone === dir.photoTone || ph.tone === 'mid'); if (toned.length >= 6) pool = toned; } // Without product photographs, a picture of the same kind of thing would pass for the product: deal // those last and mark them. Otherwise a picture that matches the brief is the better atmosphere. const things = words.filter((w) => w.length >= 5); const matches = (ph) => things.some((w) => ph.alt.toLowerCase().includes(w)); const bonus = mode === 'no-product-photo' ? -2 : 2; const ranked = shuffle(pool).sort((a, b) => (b.quality + (matches(b) ? bonus : 0)) - (a.quality + (matches(a) ? bonus : 0))) .map((ph) => (mode === 'no-product-photo' && matches(ph) ? { ...ph, lookalike: true } : ph)); return { set: chosen.id, title: chosen.set.title, note: chosen.set.note, treatment: dir.photoTreatment || null, others: scored.filter((x) => x.id !== chosen.id).slice(0, 4).map((x) => x.id), photos: ranked.slice(0, mode === 'interface' ? 4 : 8) }; } function briefStop() { return new Set(['with', 'that', 'this', 'from', 'page', 'site', 'website', 'landing', 'their', 'they', 'have', 'will', 'your', 'about', 'into', 'make', 'build', 'create', 'design', 'small', 'based', 'called', 'named', 'which', 'where', 'what', 'need', 'want', 'like', 'also', 'some', 'very', 'more', 'most', 'just', 'than', 'then', 'them', 'and', 'the', 'for', 'one', 'hand', 'handmade', 'made', 'batch', 'ship', 'ships', 'week', 'within', 'across', 'euro', 'euros', 'cost', 'price', 'open', 'only', 'each', 'every', 'there', 'images', 'photos', 'photograph', 'static', 'html']); } function briefWords(brief) { const stop = briefStop(); return [...new Set(String(brief || '').toLowerCase().split(/[^a-z]+/).filter((w) => w.length >= 4 && !stop.has(w)).map((w) => w.replace(/(ies|es|s)$/, '')))].filter((w) => w.length >= 4); } // First queries for scripts/assets.mjs, built from the brief: the place in old postcards and maps, then the // subject itself. In testing " postcard" and " old map" found archival material far more // reliably than a place and a year. A start only: the better queries come from the builder's inventory. function assetQueries(input) { const brief = String(input.brief || ''); const notPlace = /^(?:january|february|march|april|may|june|july|august|september|october|november|december|monday|tuesday|wednesday|thursday|friday|saturday|sunday)s?$/i; const placeRe = /(?:^|[\s,(])(?:in|at|near|from|on|above|below|beside|outside|off|over)\s+(\p{Lu}[\p{L}'’-]*(?:\s+(?:\p{Lu}[\p{L}'’-]*|de|da|do|dos|das|di|del|van|von|der|den))*)/gu; const place = [...brief.matchAll(placeRe)].map((m) => m[1].trim()).find((p) => !notPlace.test(p.split(/\s+/)[0])); const stop = briefStop(); const generic = new Set(['seat', 'seats', 'room', 'rooms', 'minute', 'minutes', 'hour', 'hours', 'night', 'nights', 'year', 'years', 'people', 'family', 'local', 'short', 'best', 'making', 'selling', 'serving', 'offering', 'business', 'company', 'brand', 'shop', 'store', 'studio', 'service', 'services', 'dinner', 'lunch', 'breakfast', 'included', 'online', 'modern']); // the subject: the last plain words of the first noun phrase ("a 38-seat Italian restaurant in ..." gives "restaurant") const words = brief.split(/\s+/).filter(Boolean); const phrase = []; for (let k = words.findIndex((w) => /^an?$/i.test(w)) + 1; k < words.length; k++) { if (/^(?:in|at|on|for|from|with|near|above|below|by|that|which|who|of|and|to)$/i.test(words[k])) break; phrase.push(words[k].replace(/[,.;:()!?]+$/, '')); if (/[,.;:()!?]$/.test(words[k])) break; } const plain = phrase.filter((w) => /^\p{Ll}{3,}$/u.test(w) && !stop.has(w) && !generic.has(w)); const subject = plain.slice(-2).join(' ') || (input.sector !== 'other' ? input.sector.replace(/-/g, ' ') : null); const out = []; if (place) out.push(`${place.toLowerCase()} postcard`, `${place.toLowerCase()} old map`); if (subject) out.push(subject.toLowerCase()); return [...new Set(out)]; } // Blueprints written for showing software (an interface fragment is their picture). function softwareOnly(x) { return (x.directions || [])[0] === 'product-performs' || (x.tags || []).includes('ui-led') || /data-ui|interface fragment/i.test(x.blueprint || ''); } // Blueprints were measured on real sites and carry that site's sizes, greys, faces, colours and corners. // This lists, for one blueprint, exactly where it disagrees with the house rules and the dealt system, // so the builder does not have to work it out section by section. function adjustments(bp, s) { const text = [bp.blueprint, bp.mobile, bp.headline].filter(Boolean).join(' '); const notes = []; const sizes = (re) => [...new Set([...text.matchAll(re)].map((m) => m[1] + 'px'))]; const notSpacing = String.raw`(?!\s*(?:gaps?|gutters?|margins?|padding|radius|corners?|apart|from|inset|offset|high|tall|wide|thick|square|borders?|stroke|rules?|line\b|dots?|circles?|bars?|of|between|beneath|below|above|under|down|up|across|deep|left|right|in\b|to\b|and\b|each))`; const tiny = sizes(new RegExp(String.raw`(? m[1].toLowerCase()))]; if (colours.length) notes.push(`It names colours (${colours.join(', ')}): map them onto this palette's roles (ground \`--bg\`, panels \`--surface\` and the fields, text \`--ink\`/\`--muted\`, the accent \`--accent\` on the primary action only).`); if (/(? (m[2] === 'px' && +m[1] > 36) || (m[2] === 'vw' && +m[1] > 2.6)); if (bigSentence) notes.push('It sets a sentence large over several lines: jurors read that as a long headline. Either cut it to a two-line headline at display size, or keep it a statement at 36px or less on three lines (24px, four lines on a phone).'); if (/(?:sticky|pinned|pins?\b|stays? (?:fixed|put|in place))/i.test(text) && /\b(?:three|four|five|six) (?:to (?:four|five|six) )?(?:viewports|screens)\b|\b[3-6]00svh\b/i.test(text)) notes.push('It pins something for three screens or more: hold a pin for two or three screens at most, give every screen under it its own content, and let nothing pass over the pinned words (the inspector checks all three).'); if (/mix-blend-mode:\s*(?:difference|exclusion)/i.test(text)) notes.push('It blends text with `difference`: over anything but black, white or grey that gives random hues. Use a solid ink chosen for each ground instead.'); if (/tops? of the (?:letters|word|name|wordmark)|(?:letters|wordmark|name|word)\b[^.;]{0,30}\b(?:sliced|cut|cropped|clipped)[^.;]{0,20}\btop/i.test(text)) notes.push('It crops letters along their top: the inspector fails that (jurors read it as broken). Crop at the bottom edge or a side instead.'); if (bp.archetype && /single action[^.;]{0,40}?\b(?:text link|underlined (?:text )?link)/i.test(text)) notes.push('Its action is a text link: the primary action on the first screen is a real button (filled or solid-bordered, 44px or taller), placed where the blueprint puts the link. A second action may stay a link.'); if (s.type?.class === 'condensed-heavy' && /\d+(?:\.\d+)?vw/.test(text)) notes.push('Its vw sizes assume a face of normal width: with this condensed display face, size the words to fill the measure the blueprint gives them instead of copying the vw value.'); if (/\b0[1-9]\b(?![:.,]\d)|numbered|index number/i.test(text)) notes.push('It numbers things 01, 02: drop the numbers unless they are real data (a price, a date, a count).'); return notes; } // Every blueprint of a role that suits this direction and this build's real assets. function sectionPool(dir, role, input) { let pool = L.sections.filter((s) => s.role === role); if (!pool.length) return []; const mode = imageryMode(input); const feasible = pool.filter((s) => (s.needs || []).every((n) => n === 'photography' || input.have.includes(n) || (n === 'product-ui' && mode === 'interface'))); if (feasible.length) pool = feasible; if (mode !== 'interface' && !input.have.includes('product-ui')) { const plain = pool.filter((s) => !softwareOnly(s)); if (plain.length) pool = plain; } if (mode === 'interface' || mode === 'no-product-photo') { // prefer compositions that do not hang on a photograph of the thing const honest = pool.filter((s) => (mode === 'interface' ? worksWithoutPictures(s) : !(s.tags || []).includes('object-led') || worksWithoutPictures(s))); if (honest.length >= 2) pool = honest; } const byDirection = pool.filter((s) => (s.directions || []).includes(dir.id)); if (byDirection.length) return byDirection; const byTag = pool.filter((s) => (s.tags || []).some((t) => (dir.sectionTags || []).includes(t))); return byTag.length ? byTag : pool; } function dealSection(dir, role, usedIds, prevShape, nextShape, input) { let pool = sectionPool(dir, role, input); if (!pool.length) return null; if (imageryMode(input) === 'interface') { const ui = pool.filter((s) => (s.tags || []).includes('ui-led')); if (ui.length >= 2 && rnd() < 0.6) pool = ui; } const unused = pool.filter((s) => !usedIds.includes(s.id)); if (unused.length) pool = unused; const varied = pool.filter((s) => s.shape !== prevShape && s.shape !== nextShape); if (varied.length) pool = varied; return pick(pool); } function dealReferences(dir, input) { const named = (dir.seenIn || []).map((n) => L.sites.find((s) => s.id === n || s.name === n)).filter(Boolean); // the October 2026 panel scored on a lower scale (its top band starts at 7.5, the first panel's at about 7.6) const good = (s, a, b) => s.score >= (s.panel === '2026-10' ? b : a); const sameKind = L.sites.filter((s) => s.kind === input.sector && good(s, 7.2, 7.3) && !named.includes(s)); const out = [...shuffle(named).slice(0, 2), ...shuffle(sameKind).slice(0, 1)]; return out.length ? out : shuffle(L.sites.filter((s) => good(s, 7.6, 7.5))).slice(0, 2); } // ---------- rendering ---------- function siteCard(s, full) { const lines = [`- **${s.name}** (${s.source}, studied score ${s.score}) \`${s.id}\``, ` Idea: ${s.idea}`, ` Hero: ${s.hero}`]; if (full) { lines.push(` Type: ${s.type}`, ` Colour: ${s.colour}`, ` Imagery: ${s.imagery}`, ` Layout: ${s.layout}`); if (s.media) lines.push(` Pictures and footage: ${s.media}`); if (s.motion) lines.push(` Motion: ${s.motion}`); for (const sec of s.sections || []) lines.push(` Section (${sec.role}): ${sec.description}`); for (const t of s.steal || []) lines.push(` Worth taking: ${t}`); lines.push(` URL: ${s.url}`); } else if (s.steal?.[0]) lines.push(` Worth taking: ${s.steal[0]}`); return lines.join('\n'); } function render(s) { const d = s.direction; const o = []; const p = (x = '') => o.push(x); p(`# DIRECTION SHEET — ${d.name}`); p(`seed ${s.seed} · drawn ${s.at.slice(0, 10)}${s.redraws ? ` · redraws: ${s.redraws}` : ''} · sector ${s.input.sector}${s.input.tone ? ' · tone ' + s.input.tone : ''}`); if (s.input.brief) p(`Brief: ${s.input.brief}`); p(); p('| | |'); p('| --- | --- |'); p(`| Direction | ${d.name} |`); p(`| Hero | ${s.hero.name} |`); p(`| Type | ${s.type ? s.type.name : 'kept from the brand'} |`); p(`| Colour | ${s.palette ? s.palette.name : 'kept from the brand'} |`); p(`| Sections | ${s.sections.map((x) => x.role + (x.name && x.name !== x.role ? ' (' + x.name + ')' : '')).join(' → ')} |`); p(`| Motion | ${s.motion ? s.motion.name : '—'} |`); p(`| Photographs | ${s.photos ? 'the "' + (s.photos.title || s.photos.set) + '" set, as atmosphere' : 'none'} |`); p(); p('This is the direction for this build. Adopt it. Do not blend it with the layout you would have made anyway,'); p('and do not redraw because it is unfamiliar: unfamiliar is the point. Redraw a slot only when it conflicts'); p('with a stated brand constraint or needs an asset that does not exist, and say why in your report.'); p(); p('The parts of this sheet were written separately and will disagree in small ways. Settle every disagreement in this order:'); p('**house rules** (including type no smaller than 13px and body text of 16px or more) → **the direction** (the idea, the composition habits, where the'); p('one accent goes) → **the type system and the palette** (which face does which job, the five size steps, which colour is the ground and where the'); p('accent goes) → **the hero blueprint** → **the section blueprints**. Under each blueprint below, "Adjusted for this page" lists where it disagrees.'); p('In practice: take geometry from blueprints (columns, proportions, what sits where) and take everything else from above them. Map every size a'); p('blueprint names onto one of your five type steps instead of adding a sixth. Use one button shape, one corner radius and one pair of page margins on'); p('the whole page, whatever individual blueprints say. If a blueprint names a kind of typeface (a light serif, a heavy grotesk), use the dealt face.'); p(); p('## 1. The idea'); p(d.idea); p(); p('Before anything else, write one sentence in your notes: "On this page, becomes ."'); p('Fill the first blank from your inventory of what only this client has (its name and what it means, its street, its numbers, its'); p('constraint), then put the nearest competitor in the sentence: if it still works, the idea is the category\'s, not the client\'s.'); p('Then name the one live thing the visitor can do on the page (SKILL.md step 4).'); p(); p('## 2. Composition'); p(d.composition); p(); if (s.type) { const t = s.type; p(`## 3. Type — ${t.name}`); p(t.note); p('```html'); for (const l of [...new Set([t.display.css, t.text.css].filter(Boolean))]) p(l); p('```'); p(`- Display: **${t.display.family}** — ${t.display.settings}`); p(`- Text: **${t.text.family}** — ${t.text.settings}`); if (t.scale) { p('```css'); p(':root {'); for (const [k, v] of Object.entries(t.scale)) p(` --${k}: ${v};`); p('}'); p('```'); } if (d.typography) p(d.typography); p('The direction decides sizes and how many of them; the type system decides how this face is set. Where the two give different tracking or leading, use the values of the type system: they are tuned to this face.'); p('The display size itself comes from the hero blueprint below.'); p(); } else { p('## 3. Type'); p('Kept from the brand. ' + (d.typography || '')); p(); } if (s.palette) { const c = s.palette; p(`## 4. Colour — ${c.name}`); p('```css'); p(':root {'); for (const k of ['bg', 'surface', 'ink', 'muted', 'accent', 'accentInk']) if (c[k]) p(` --${k.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase())}: ${c[k]};`); (c.fields || []).forEach((f, i) => p(` --field-${i + 1}: ${f.bg};${f.ink ? ` --field-${i + 1}-ink: ${f.ink};` : ''}`)); p('}'); p('```'); p(c.note); if (d.colour) p(d.colour); p(); } else { p('## 4. Colour'); p('Kept from the brand. ' + (d.colour || '')); p(); } p('## 5. Imagery'); // where to look for real pictures of the subject's world, with first queries built from the brief const qs = assetQueries(s.input); const find = qs.length ? `\`${ASSETS_CMD} "${qs[0]}"\`${qs.length > 1 ? `, then ${qs.slice(1).map((q) => `"${q}"`).join(' and ')}` : ''}, then queries from your inventory (its street, its materials, its trade's tools and history)` : `\`${ASSETS_CMD} ""\``; const MODE = { interface: '**This subject is software and there are no screenshots, so its pictures are its own interface.** Wherever this sheet says photograph, image or object, build a believable fragment of the product in HTML at real size (real labels, numbers that add up, one item hovered or selected) by the app-screen rules (references/app-screens.md: deal its style once with `node /scripts/draw.mjs --app --kind --keep palette,type --out .taste-skill/app`, and build it with assets/app.css and assets/charts.js), put the attribute `data-ui` on its root, and crop or scale it the way the blueprint treats a photograph: a full-bleed close crop, a tile, the fixed half of a split. Where a fragment makes no sense, use a flat colour field with type on it. Never a fake dashboard of grey bars, never a device frame. If the product works on something physical (ships, plants, buildings, a city), one real picture of that world can carry a section: find it with `' + ASSETS_CMD + ' ""`, download it with `--get`, credit it on the page, and never let it pass for the product or its customers.', 'no-product-photo': '**There are no photographs of the product and none can be faked.** Wherever this sheet calls for a picture of the object, draw the object: its pieces as flat shapes in inline SVG, large, in one graphic language and in the colours that make it worth buying (a glaze, a finish, a material), never grey placeholders (references/imagery.md §8). **Around the object, use real pictures of its world** from the open collections: its material up close, its town in old postcards, its craft in engravings, a museum piece of the same kind, never one a visitor could take for the product itself (references/imagery.md §3). Start with ' + find + '. Download what you use with `--get` and credit every one on the page (`--credits` prints the footer line). Type at scale and full colour fields carry the rest; the dealt photographs only where they plainly do not pretend to be the product. In your report, say that product photography is needed and which drawings it will replace, and list every found picture with its source and licence.', atmosphere: '**The project has no photographs of its own yet.** First choice: **real pictures of the subject\'s world from the open collections**: its city and street in old postcards and photographs, an old map of the neighbourhood, its materials up close, its trade in engravings, museum objects of its kind (references/imagery.md §3). Start with ' + find + '. Download what you use into the project with `--get` and credit every one on the page (`--credits` prints the footer line). They show the world around the client, never the client\'s own room, dish, product, person or team. Second, for what no honest picture exists of: draw the subject\'s own things in code, large, in one flat graphic language and the palette\'s real colours (its tiles, its windows, its objects; references/imagery.md §8). Third: the dealt photographs, only where an honestly generic picture fits (the sea, the weather, a material), never one a visitor could take for the thing itself and never an unrelated scene. One treatment for all of them. If this direction is built on photographs, plan the set before building (four to six pictures of one family) and reuse them at different crops and scales instead of finding or drawing a new picture for every slot; keep drawings as `` + `` or generate them with a small script so the page stays light. List every found picture (with its source and licence), every dealt photograph and every drawing that stands in for photography in your report.', own: '**Use the images that belong to the project in every picture slot.** For slots they cannot fill, real pictures of the subject\'s world from the open collections come next (start with ' + find + '; credit them on the page), then the dealt photographs.', }; p(MODE[s.imagery || 'atmosphere']); p(); p(d.imagery); p(); p('**Floor (the inspector checks it):** at least four real pictures on the page, usually six to twelve; SVG drawings do not count. One moving picture where an honest clip exists: `node /scripts/assets.mjs --video ""`, used as a muted loop with a poster frame. Whatever this direction says about pictures, it is carried out with real ones.'); if (s.photos) { p(); p(s.imagery === 'interface' ? `Optional atmosphere, two at most, from the "${s.photos.title || s.photos.set}" set (${s.photos.note}). The page does not need them.` : `Photographs dealt for this build, from the "${s.photos.title || s.photos.set}" set (${s.photos.note})`); p('Order of preference for imagery: (1) the project\'s own photographs and product shots; (2) images you generate, if an image tool is'); p('available; (3) real pictures of the subject\'s world found in the open collections (assets.mjs, downloaded and credited on the page);'); p('(4) drawings of the subject in code; (5) these. They are real photographs (Unsplash licence, served by picsum.photos) of places, light and'); p('texture. They are atmosphere, never a stand-in for a specific product, dish, person or interface. List the ones you use in your report so'); p('they can be replaced.'); if (s.photos.treatment) p(`Treatment: ${s.photos.treatment}`); for (const ph of s.photos.photos) p(`- \`https://picsum.photos/id/${ph.id}/1600/1000\` — ${ph.alt} · ${ph.tone} tone · ${(ph.colours || []).join(', ')}${ph.mono ? ' · black-and-white' : ''}${ph.lookalike ? ' · **could be taken for the product: never use it as the product**' : ''}${ph.calm && ph.calm !== 'none' ? ` · calm area: ${ph.calm}` : ''} · native ${ph.ratio >= 1.15 ? 'landscape' : ph.ratio <= 0.87 ? 'portrait' : 'square'} · photo: ${ph.by}`); p('The last two path numbers are width and height: request the exact crop you render (`/1200/1500` for a 4:5 portrait, `/2400/1350` for a hero) and'); p('add `?grayscale` to desaturate. ' + (s.imagery === 'interface' ? 'Use at most two.' : 'Use three to six of them, not all eight.') + ' Always set `width`, `height`, `alt` and `loading="lazy"` (not on the hero image).'); if (s.photos.others?.length) p(`Other sets that fit this brief: ${s.photos.others.join(', ')} — \`node draw.mjs --photos \` prints one in full; \`--photos list\` shows all sets. Taking photographs from another set is your call and is not a redraw; keep them to one light and one treatment.`); } else if (d.photos === false) { p(); p('This direction does not lead with photographs; it still carries the picture floor below, with real pictures placed where the direction allows them.'); } p(); const adjust = (bp) => { const n = adjustments(bp, s); if (n.length) { p(); p('Adjusted for this page:'); for (const x of n) p(`- ${x}`); } }; p(`## 6. Hero — ${s.hero.name}`); p(s.hero.blueprint); p(); p(`On a phone: ${s.hero.mobile}`); if (s.hero.headline) p(`Headline: ${s.hero.headline}`); adjust(s.hero); p(); p('## 7. Sections, in order'); p('Each is a composition to rebuild with this project\'s content. Keep the geometry; change the subject. Re-order or drop a'); p('section when the argument of the page needs it (see references/copy.md), never to fall back to a card grid.'); s.sections.forEach((sec, i) => { p(); if (!sec.blueprint) { p(`### ${i + 1}. ${sec.role}`); p('No blueprint in the library for this role. Compose it from the direction above, using a layout shape not used by its neighbours.'); return; } p(`### ${i + 1}. ${sec.role} — ${sec.name} (${sec.shape})`); p(sec.blueprint); if (sec.mobile) p(`On a phone: ${sec.mobile}`); adjust(sec); }); p(); if (s.motion) { p(`## 8. Motion — ${s.motion.name}`); p(s.motion.description); p('This is the signature move, the one loud moment. Around it the page still needs the rest of the motion layer (SKILL.md step 9): an entrance for the parts of every section, feedback on every control, and one moving picture. The inspector fails a page that barely moves. Recipes: references/motion.md.'); p(); } if (s.scenes?.length) { p('## 9. Scroll scenes'); p(`The page's big moments: ${s.scenes.length} scroll scenes, tested and ready to fill. They are part of the composition, so build them in step 6 with the section they carry, not as a motion afterthought. Each runs on the shared stage (assets/scenes/stage.js and stage.css, with GSAP and Lenis from the pinned CDN lines in stage.js) and ships its own finished composition for reduced motion and for phones that rest. How to put one on a page, fill it and check it: references/scenes.md. Check them with \`node /scripts/scenes.mjs \` and look at every sheet.`); for (const sc of s.scenes) { p(); p(`### ${sc.name} (\`${sc.id}\`)${sc.carries ? ` — carries the ${sc.carries === 'hero' ? 'first screen' : sc.carries + ' section'}` : ' — a transition between two sections'}`); p(sc.summary); if (sc.place) p(`Where: ${sc.place}`); if (sc.content) p(`Content: ${sc.content}`); if (sc.options) p(`Options (data-options): ${sc.options}`); p(`Files: \`/assets/scenes/${sc.id}.js\` and \`${sc.id}.css\`; a working example: \`/assets/scenes/demo/${sc.id}.html\`.${sc.phone ? ' On a phone: ' + sc.phone : ''}`); } p(); p('Leave a quiet section between two scenes so each one lands. A scene whose content you cannot fill honestly is redrawn (`--redraw scenes`), never faked.'); p(); } if (d.richness) { p('## 10. Where the richness comes from'); p(d.richness); p(); } if (d.pitfalls) { p('## 11. How this direction goes wrong'); p(d.pitfalls); p(); } if (s.references?.length) { p('## 12. Real references'); p('Studied sites that work this way. Take the principle, never the specifics: no names, copy, images or signature assets of theirs.'); for (const r of s.references) p(siteCard(r, false)); p('Full notes on any of them: `node draw.mjs --site `.'); p(); } p('## Always'); p('Headlines two lines or fewer at every width. No eyebrow labels, no monospace, no 01/02/03, no thin rules, no gradients, no shadows at rest,'); p('no cream-and-terracotta, no violet. The frame: one h1 on the first screen that says what this is, equal outer margins, no'); p('section huddled on one side with the other half empty, no blank stretch over half a screen, a heading on every section.'); p('`inspect.mjs` measures all of it.'); if (s.allow?.length) p(`This direction is allowed: ${s.allow.join(', ')} (the inspector reads this from the saved sheet).`); p(); p(`Saved to ${sheetPath.split('\\').join('/')} · reprint with \`node draw.mjs --show\``); return o.join('\n'); } ================ File: workspace/agents/opencode/engine/skills/design-taste-frontend/scripts/inspect.mjs ================ #!/usr/bin/env node // tasteskill / inspect — render the page in a real browser, measure it, and photograph it. // // node inspect.mjs [--out DIR] [--widths 1440,768,390] [--allow rule,rule] [--app] [--film] [--json] // // --film also photographs the scroll as a visitor sees it (a frame every third of a screen, real wheel // input) into desktop-film-NN.jpg, so a scroll scene is judged by its in-between states. A direction // that dealt scroll scenes switches it on by itself. // // --app judges an app screen (dashboard, admin, settings) by the rules in references/app-screens.md; a // direction dealt with draw.mjs --app switches it on by itself. // // Exit code 0 = no FAIL findings. Exit code 1 = at least one FAIL (the page is not done). // Zero dependencies: drives an installed Chrome/Edge/Chromium. Node 22+. // Widths sharing a viewport class receive width-qualified screenshot filenames. import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync, createReadStream, readdirSync, unlinkSync } from 'node:fs'; import { resolve, join, relative, dirname, extname, sep } from 'node:path'; import { createServer } from 'node:http'; import { Browser, sleep } from './lib/cdp.mjs'; import { probe, frame, layout, app, colourShare } from './lib/probe.mjs'; import { judge, judgeLayout, judgeApp, APP_SKIP } from './lib/rules.mjs'; import { viewportClass as name, screenshotLabel, isInspectionScreenshot } from './lib/inspection-files.mjs'; const args = process.argv.slice(2); const flag = (name, def) => { const i = args.indexOf('--' + name); if (i === -1) return def; const v = args[i + 1]; args.splice(i, 2); return v; }; const has = (name) => { const i = args.indexOf('--' + name); if (i === -1) return false; args.splice(i, 1); return true; }; const outDir = resolve(flag('out', '.taste-skill/inspect')); const widths = flag('widths', '1440,768,390').split(',').map(Number).filter(Boolean); let allow = flag('allow', '').split(',').filter(Boolean); const asJson = has('json'); let appMode = has('app'); let film = has('film'); let dealtFonts = []; // the faces the saved sheet dealt, to catch a build that quietly fell back to a default const target = args[0]; if (!target || target === '-h' || target.startsWith('--')) { console.error('usage: node inspect.mjs [--out DIR] [--widths 1440,768,390] [--allow rule,rule] [--app] [--film] [--json]'); process.exit(2); } // Allowances granted by the drawn direction (written by draw.mjs) apply automatically. for (const p of ['.taste-skill/direction.json', join(outDir, '..', 'direction.json')]) { if (existsSync(p)) { try { const d = JSON.parse(readFileSync(p, 'utf8')); allow = [...new Set([...allow, ...(d.allow || [])])]; if (d.kind === 'app') appMode = true; if (Array.isArray(d.scenes) && d.scenes.length) film = true; dealtFonts = (d.input?.keep || []).includes('type') ? [] : d.kind === 'app' ? [d.style?.tokens?.font?.family].filter(Boolean) : [d.type?.display?.family, d.type?.text?.family].filter(Boolean); } catch {} break; } } // A local file is served over http from a throwaway server on 127.0.0.1, so the browser never has to // open a file path (some sandboxes show Node and the browser different views of the disk) and relative // assets, fetch() and module scripts behave as they will when the page is hosted. let url = target; let server = null; if (!/^https?:\/\//.test(target)) { const file = resolve(target); if (!existsSync(file)) { console.error('No such file: ' + file); process.exit(2); } const cwd = resolve('.'); const root = file.startsWith(cwd + sep) ? cwd : dirname(file); const MIME = { '.html': 'text/html; charset=utf-8', '.htm': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.avif': 'image/avif', '.gif': 'image/gif', '.ico': 'image/x-icon', '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.otf': 'font/otf', '.mp4': 'video/mp4', '.webm': 'video/webm', '.txt': 'text/plain; charset=utf-8', '.xml': 'application/xml', '.webmanifest': 'application/manifest+json' }; server = createServer((req, res) => { try { let f = resolve(join(root, decodeURIComponent(new URL(req.url, 'http://x').pathname))); if (f !== root && !f.startsWith(root + sep)) { res.writeHead(403); return res.end(); } if (statSync(f).isDirectory()) f = join(f, 'index.html'); res.writeHead(200, { 'content-type': MIME[extname(f).toLowerCase()] || 'application/octet-stream', 'content-length': statSync(f).size, 'cache-control': 'no-store' }); createReadStream(f).pipe(res); } catch { res.writeHead(404); res.end('not found'); } }); await new Promise((r) => server.listen(0, '127.0.0.1', r)); url = `http://127.0.0.1:${server.address().port}/${relative(root, file).split(sep).map(encodeURIComponent).join('/')}`; } const HEIGHT = { 1440: 900, 1280: 800, 1024: 768, 768: 1024, 390: 844, 375: 812 }; mkdirSync(outDir, { recursive: true }); // Screenshots from an earlier run would sit beside the new ones when the page got shorter. for (const f of readdirSync(outDir)) if (isInspectionScreenshot(f)) unlinkSync(join(outDir, f)); const browser = await Browser.launch(); const helper = await browser.newPage({ width: 1600, height: 1000 }); const report = { url: server ? target : url, at: new Date().toISOString(), allow, viewports: [], findings: [], images: [] }; const MOTION_MIN = 8, MOTION_GOOD = 16; // distinct moving elements over load + one scroll through the page // Runs in the page before any of its own scripts: counts the distinct elements that actually move while // the page loads and is scrolled (CSS animations, CSS transitions, Web Animations, and script-driven // transform/opacity changes such as GSAP), so a page that never moves can be told from one that does. const MOTION_RECORDER = `(() => { const seen = new WeakSet(); const c = { elements: 0, css: 0, transitions: 0, scripted: 0 }; const note = (t, k) => { if (!(t instanceof Element) || seen.has(t)) return; seen.add(t); c.elements++; c[k]++; }; addEventListener('animationstart', (e) => note(e.target, 'css'), true); addEventListener('transitionrun', (e) => note(e.target, 'transitions'), true); const watch = () => new MutationObserver((list) => { for (const m of list) { const s = m.target.getAttribute && m.target.getAttribute('style') || ''; if (/transform|translate|opacity|scale|rotate|clip-path/.test(s)) note(m.target, 'scripted'); } }) .observe(document.documentElement, { attributes: true, attributeFilter: ['style'], subtree: true }); if (document.documentElement) watch(); else addEventListener('DOMContentLoaded', watch); window.__motionSample = () => { for (const a of document.getAnimations ? document.getAnimations() : []) { const t = a.effect && a.effect.target; if (t) note(t, a.constructor && a.constructor.name === 'CSSTransition' ? 'transitions' : 'css'); } }; window.__motion = () => { window.__motionSample(); const v = [...document.querySelectorAll('video')]; return { ...c, videos: v.length, playingVideos: v.filter((x) => x.autoplay || !x.paused).length }; }; })();`; // Is the page's title on screen and readable: in the viewport, not faded, not masked away? const TITLE_SEEN = `(() => { const h = document.querySelector('h1'); if (!h) return { found: false }; const r = h.getBoundingClientRect(); if (r.width < 2 || r.height < 2) return { found: true, visible: false, why: 'it has no size yet' }; if (r.bottom < 0 || r.top > innerHeight) return { found: false }; // a title below the first screen is the no-title check's business let o = 1; for (let p = h; p && p !== document.documentElement; p = p.parentElement) { const cs = getComputedStyle(p); if (cs.visibility === 'hidden' || cs.display === 'none') return { found: true, visible: false, why: 'it is hidden' }; o *= +cs.opacity; } if (o < 0.5) return { found: true, visible: false, why: 'it is at ' + Math.round(o * 100) + '% opacity' }; // text moved out of its own masked line, or a cover lying over it, shows up as nothing at its centre const range = document.createRange(); range.selectNodeContents(h); const rr = range.getBoundingClientRect(); const x = Math.min(innerWidth - 1, Math.max(0, rr.left + rr.width / 2)), y = Math.min(innerHeight - 1, Math.max(0, rr.top + Math.min(rr.height, 80) / 2)); const hit = document.elementFromPoint(x, y); if (hit && !h.contains(hit) && !hit.contains(h)) { const cs = getComputedStyle(hit); if (cs.backgroundColor !== 'rgba(0, 0, 0, 0)' || /^(IMG|VIDEO|CANVAS)$/.test(hit.tagName)) return { found: true, visible: false, why: 'something lies over it' }; } return { found: true, visible: true }; })()`; async function sheet(frames, cols, cellW, cellH, path) { const rows = Math.ceil(frames.length / cols); await helper.setViewport({ width: cols * cellW, height: rows * cellH, dsf: 1 }); await helper.eval((a) => { document.body.style.cssText = `margin:0;background:#888;display:grid;grid-template-columns:repeat(${a.cols},${a.cellW}px)`; document.body.innerHTML = a.frames.map((s) => ``).join(''); return Promise.all([...document.images].map((i) => i.decode().catch(() => {}))); }, { frames, cols, cellW, cellH }); await helper.screenshot({ path, quality: 76 }); } try { for (const w of widths) { const h = HEIGHT[w] || Math.round(w * 0.625); const label = name(w); const imageLabel = screenshotLabel(w, widths); const page = await browser.newPage({ width: w, height: h, dsf: 1, mobile: w < 600 }); await page.send('Page.addScriptToEvaluateOnNewDocument', { source: MOTION_RECORDER }).catch(() => {}); const how = await page.goto(url, { timeoutMs: 30000, settleMs: 600 }); await sleep(600); // about 1.2s after the load event: is the title there to read yet? const early = await page.eval(TITLE_SEEN).catch(() => null); // a dev server is not what ships: bundlers and minifiers change the CSS a visitor receives const devServer = await page.eval(`(() => { const s = [...document.scripts].map((x) => x.src || '').join(' '); if (/@vite\\/client|webpack-dev-server|\\/_next\\/static\\/development\\/|react-refresh|webpack-hmr/.test(s)) return true; try { return !!(window.__NEXT_DATA__ && window.__NEXT_DATA__.buildId === 'development'); } catch { return false; } })()`).catch(() => false); await page.eval('document.fonts.ready.then(() => true)').catch(() => {}); await sleep(800); // let the load choreography finish before judging the first screen const fold = join(outDir, `${imageLabel}-fold.jpg`); await page.screenshot({ path: fold, quality: 82 }); const metrics = await page.eval(probe); // walk the page one screen at a time so scroll reveals fire, photographing each screen const frames = [readFileSync(fold).toString('base64')]; const frameChecks = [await page.eval(frame).catch(() => null)]; const screens = Math.min(11, Math.max(0, Math.ceil(metrics.page.height / h) - 1)); for (let i = 1; i <= screens; i++) { await page.eval(`window.scrollTo({ top: ${i * h}, behavior: 'instant' })`).catch(() => {}); await page.wheel(2); await sleep(650); frames.push(await page.screenshot({ quality: 78 })); frameChecks.push(await page.eval(frame).catch(() => null)); await page.eval('window.__motionSample && window.__motionSample()').catch(() => {}); } const motion = await page.eval('window.__motion ? window.__motion() : null').catch(() => null); // re-measure after everything has revealed (catches content that only exists post-reveal) await page.eval(`window.scrollTo({ top: 0, behavior: 'instant' })`).catch(() => {}); await sleep(350); const after = await page.eval(probe).catch(() => metrics); const m = after.headings.length >= metrics.headings.length ? after : metrics; const lay = await page.eval(layout).catch(() => null); const appM = appMode ? await page.eval(app).catch(() => null) : null; if (appM) appM.colourShare = await helper.eval(colourShare, 'data:image/jpeg;base64,' + frames[0]).catch(() => null); // scroll scenes (assets/scenes): each must have started, run live on desktop, and leave the page room const sceneState = w >= 1100 && !appMode ? await page.eval(`[...document.querySelectorAll('section.scene, .scene[data-scene]')].map((s) => ({ id: s.dataset.scene || s.id || 'scene', live: s.classList.contains('is-live'), rest: s.classList.contains('is-rest'), h: Math.round(s.getBoundingClientRect().height) }))`).catch(() => []) : []; const sceneFindings = []; if (sceneState.length) { const idle = sceneState.filter((s) => !s.live && !s.rest); if (idle.length && !allow.includes('scene-not-started')) sceneFindings.push({ level: 'FAIL', id: 'scene-not-started', width: w, message: `${idle.length} scene(s) never started: ${idle.map((s) => s.id).join(', ')}.`, fix: 'Load assets/scenes/stage.js and the scene file for each data-scene, then call Stage.smooth(); Stage.init(); at the end of (references/scenes.md, "Putting a scene on a page").' }); const atRest = sceneState.filter((s) => s.rest); if (atRest.length && !allow.includes('scene-not-live')) sceneFindings.push({ level: 'FAIL', id: 'scene-not-live', width: w, message: `${atRest.length} scene(s) show their finished composition on desktop instead of running: ${atRest.map((s) => s.id).join(', ')}.`, fix: 'GSAP or ScrollTrigger did not load (check the pinned CDN lines and the console), or the scene threw while building. The scene must run where motion is allowed.' }); if (sceneState.length > 4 && !allow.includes('many-scenes')) sceneFindings.push({ level: 'WARN', id: 'many-scenes', width: w, message: `${sceneState.length} scroll scenes on one page.`, fix: 'Two or three scenes, with quiet sections between them, so each one lands. Cut the weakest.' }); const held = sceneState.reduce((a, s) => a + Math.max(0, s.h - h), 0); if (held > (metrics.page.height || 1) * 0.6 && !allow.includes('scene-heavy')) sceneFindings.push({ level: 'WARN', id: 'scene-heavy', width: w, message: `Scenes hold ${Math.round(held / h)} of the page's ${Math.round((metrics.page.height || 0) / h)} screens.`, fix: 'Shorten the holds (one screen per beat) or cut a scene; the reading sections need room between them.' }); } // --film: the scroll as a visitor sees it. Real wheel input and a frame every third of a screen, so a // scroll scene shows its in-between states, not only where each screen lands. const filmSheets = []; if (film && w >= 1100) { await page.eval(`window.scrollTo({ top: 0, behavior: 'instant' })`).catch(() => {}); await sleep(700); const shots = []; let lastY = -1, still = 0; for (let i = 0; i < 96; i++) { shots.push(await page.screenshot({ quality: 70 })); await page.wheel(Math.round(h / 3), { step: 100, pauseMs: 16 }); await sleep(420); const y = await page.eval('Math.round(scrollY || document.scrollingElement.scrollTop || 0)').catch(() => -1); if (y === lastY) { if (++still >= 2) break; } else still = 0; lastY = y; } for (let i = 0; i < shots.length; i += 24) { const p = join(outDir, `${imageLabel}-film-${String(i / 24 + 1).padStart(2, '0')}.jpg`); await sheet(shots.slice(i, i + 24), 6, 320, 200, p); filmSheets.push(p); } } const cols = w >= 1100 ? 2 : w >= 600 ? 3 : 4; const cellW = w >= 1100 ? 800 : w >= 600 ? 512 : 390; const cellH = Math.round((cellW * h) / w); const perSheet = w >= 1100 ? 4 : w >= 600 ? 6 : 8; const sheets = []; for (let i = 0; i < frames.length; i += perSheet) { const p = join(outDir, `${imageLabel}-page-${String(i / perSheet + 1).padStart(2, '0')}.jpg`); await sheet(frames.slice(i, i + perSheet), cols, cellW, cellH, p); sheets.push(p); } let findings = [...judge(m, { width: w, allow }), ...sceneFindings]; if (devServer && !allow.includes('dev-server')) findings.push({ level: 'WARN', id: 'dev-server', width: w, message: 'This page is served by a development server (Vite, webpack or Next in dev mode).', fix: 'Run the last gate on the production build too (vite build && vite preview, next build && next start, or the built index.html): minifiers and bundlers change the CSS a visitor receives.' }); if (dealtFonts.length && w >= 1100 && !allow.includes('font-not-dealt')) { const norm = (f) => String(f).toLowerCase().replace(/["']/g, '').replace(/\s+variable$/, '').trim(); const used = m.type.families.filter((f) => f.share >= 0.1).map((f) => norm(f.family)); const missing = dealtFonts.filter((f) => !used.some((u) => u === norm(f) || u.startsWith(norm(f)))); if (missing.length === dealtFonts.length) findings.push({ level: appMode ? 'FAIL' : 'WARN', id: 'font-not-dealt', width: w, message: `The sheet dealt ${dealtFonts.join(' and ')}, but the page renders ${m.type.families.slice(0, 2).map((f) => f.family).join(', ')}.`, fix: 'Load the dealt face (the link on the sheet) and put it first in font-family. A build that falls back to a default face lands on the default look.' }); } // a third of the studied award pages were still unreadable 4.5s after load; the house sequence is over by 1.2s if (!appMode && early && early.found && !early.visible && !allow.includes('late-title')) findings.push({ level: 'FAIL', id: 'late-title', width: w, message: `The title is not readable 1.2s after the page loaded (${early.why}).`, fix: 'Put the headline in the HTML, painted on the first frame; the load sequence only adds arrival and is settled by 1.2s (references/motion.md, "Load sequence in 1.2 seconds"). No preloader, no gate, no waiting for the load event.' }); if (lay) findings.push(...judgeLayout(lay, { width: w, allow })); if (appMode) { findings = findings.filter((f) => !APP_SKIP.has(f.id) && f.id !== 'no-title' && f.id !== 'small-title'); if (appM) findings.push(...judgeApp(appM, m, { width: w, allow })); } // what only shows up while scrolling: text hidden under something, and screens with almost nothing on them const coveredText = [...new Map(frameChecks.filter(Boolean).flatMap((f, i) => f.covered.map((c) => [c.text, { ...c, screen: i + 1 }]))).values()]; if (coveredText.length && !allow.includes('covered-text')) findings.push({ level: 'FAIL', id: 'covered-text', width: w, message: `Large text lies under another element at some scroll position: ` + coveredText.slice(0, 4).map((c) => `"${c.text}" (screen ${c.screen}, ${Math.round(c.share * 100)}% hidden)`).join('; '), fix: 'Nothing may sit on top of a headline or statement. Move the picture, shorten the pinned run, or raise the text above it (z-index) and give it a ground.' }); const clippedText = [...new Map(frameChecks.filter(Boolean).flatMap((f, i) => (f.clipped || []).map((c) => [c.text, { ...c, screen: i + 1 }]))).values()]; if (clippedText.length && !allow.includes('clipped-text')) findings.push({ level: 'FAIL', id: 'clipped-text', width: w, message: 'Large text is cut off along its top by the box that holds it: ' + clippedText.slice(0, 3).map((c) => `"${c.text}" (screen ${c.screen})`).join('; '), fix: 'Jurors read a wordmark sliced at the top as broken. Give the box room for the ascenders (line-height and padding), or crop at the bottom or a side edge on purpose.' }); const overPictures = [...new Map(frameChecks.filter(Boolean).flatMap((f, i) => (f.pinnedOver || []).map((c) => [c.text, { ...c, screen: i + 1 }]))).values()]; if (overPictures.length && !allow.includes('pinned-over-picture')) findings.push({ level: 'WARN', id: 'pinned-over-picture', width: w, message: 'Pinned large text slides across pictures while the page scrolls: ' + overPictures.slice(0, 3).map((c) => `"${c.text}" (screen ${c.screen}, ${Math.round(c.share * 100)}% over a picture)`).join('; '), fix: 'Jurors read a pinned title crossing drawings or photographs as a collision. Keep the pictures in the column the title does not occupy, or unpin it.' }); const empty = frameChecks.map((f, i) => ({ f, i })).filter(({ f }) => f && f.filled < 0.2); // the same large statement on screen for three screens or more in a row const runs = new Map(); frameChecks.forEach((f, i) => { for (const t of f?.big || []) { const r = runs.get(t) || { start: i, last: i - 1, best: 0 }; if (r.last === i - 1) r.last = i; else { r.start = i; r.last = i; } r.best = Math.max(r.best, r.last - r.start + 1); runs.set(t, r); } }); const longPins = [...runs.entries()].filter(([, r]) => r.best >= 3); if (w >= 1100 && longPins.length && !allow.includes('long-pin')) findings.push({ level: 'WARN', id: 'long-pin', width: w, message: `The same large text stays on screen for ${Math.max(...longPins.map(([, r]) => r.best))} screens in a row: ` + longPins.slice(0, 2).map(([t]) => `"${t}"`).join('; '), fix: 'A pinned element earns two screens, and something must change at each. Shorten the run or give every screen its own content.' }); // a page that barely moves: counted from what really animated while it loaded and was scrolled if (w >= 1100 && motion && !allow.includes('still-page')) { const moving = motion.elements + motion.playingVideos * 4; if (moving < MOTION_MIN) findings.push({ level: 'FAIL', id: 'still-page', width: w, message: `Only ${motion.elements} element(s) moved while the page loaded and scrolled${motion.playingVideos ? ` (plus ${motion.playingVideos} playing video)` : ''}.`, fix: 'Build the motion layer (SKILL.md step 9, references/motion.md): the signature move, an entrance for the parts of every section, feedback on every control, and one moving picture. Transform and opacity only, with a reduced-motion fallback.' }); else if (moving < MOTION_GOOD) findings.push({ level: 'WARN', id: 'still-page', width: w, message: `Little motion: ${motion.elements} element(s) moved while the page loaded and scrolled.`, fix: 'Give every section an entrance for its parts (not one fade of the whole block) and add one moving picture: a looping clip or a slow move on a photograph.' }); } if (w >= 1100 && empty.length >= 2 && !allow.includes('empty-screens')) findings.push({ level: 'FAIL', id: 'empty-screens', width: w, message: `${empty.length} of ${frameChecks.length} desktop screens are almost empty ground (under 20% of the screen holds text, a picture or a colour field): screens ${empty.map(({ i }) => i + 1).join(', ')}.`, fix: 'An empty screen is a rest only once per page. Elsewhere it means the section is underbuilt: set its content larger, give it the full information, put it on a colour field, or cut it.' }); report.viewports.push({ width: w, height: h, load: how, screens: frames.length, filled: frameChecks.map((f) => f?.filled ?? null), motion, metrics: m, layout: lay, app: appM, consoleErrors: page.consoleErrors.slice(0, 5) }); report.findings.push(...(appMode ? findings.filter((f) => !APP_SKIP.has(f.id)) : findings)); report.images.push({ viewport: label, width: w, fold, pages: sheets, film: filmSheets }); await page.close(); } // Under prefers-reduced-motion the page is the finished page: nothing may stay hidden waiting for an // animation that will not run. Two in three studied award pages had no reduced-motion rules at all. if (!appMode && widths.some((x) => x >= 1100) && !allow.includes('reduced-hidden')) { const rp = await browser.newPage({ width: 1440, height: 900, dsf: 1, reducedMotion: true }); try { await rp.goto(url, { timeoutMs: 30000, settleMs: 1500 }); const hidden = await rp.eval(`(() => { const out = []; const w = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); const rg = document.createRange(); for (let n = w.nextNode(); n; n = w.nextNode()) { const t = n.nodeValue.trim(); const el = n.parentElement; if (t.length < 3 || !el || el.closest('script, style, noscript, [aria-hidden="true"]')) continue; rg.selectNodeContents(n); const r = rg.getBoundingClientRect(); if (r.width < 2 || r.top > innerHeight || r.bottom < 0) continue; let o = 1, gone = false; for (let p = el; p && p !== document.documentElement; p = p.parentElement) { const cs = getComputedStyle(p); if (cs.visibility === 'hidden') { gone = true; break; } o *= +cs.opacity; } if (gone || o < 0.1) out.push(t.slice(0, 40)); } return out; })()`).catch(() => []); if (hidden.length >= 2) report.findings.push({ level: 'FAIL', id: 'reduced-hidden', width: 1440, message: `With reduced motion requested, ${hidden.length} text block(s) on the first screen stay invisible: ` + hidden.slice(0, 4).map((t) => `"${t}"`).join(', '), fix: 'Put every hidden starting state inside @media (prefers-reduced-motion: no-preference); the reduced page is the finished page (references/motion.md §8).' }); } finally { await rp.close(); } } } finally { await browser.close(); if (server) server.close(); } // de-duplicate findings that repeat across viewports (keep the widest occurrence, note the others) const merged = []; for (const f of report.findings) { const same = merged.find((x) => x.id === f.id && x.level === f.level); if (same && f.id !== 'headline-lines' && f.id !== 'overflow') { same.alsoAt = [...(same.alsoAt || []), f.width]; continue; } merged.push(f); } report.findings = merged; writeFileSync(join(outDir, 'report.json'), JSON.stringify(report, null, 1)); if (asJson) { console.log(JSON.stringify(report, null, 1)); process.exit(merged.some((f) => f.level === 'FAIL') ? 1 : 0); } const d = report.viewports[0].metrics; const line = (s = '') => console.log(s); line(`TASTESKILL · INSPECT ${report.url}`); line(''); line('MEASURED (first viewport listed)'); if (d.hero) line(` hero headline "${d.hero.text.slice(0, 70)}" ${d.hero.words} words · ${d.hero.lines} line(s) · ${d.hero.size}px (${d.hero.vw}vw) · ${d.hero.font} ${d.hero.weight} · tracking ${d.hero.tracking}em · leading ${d.hero.leading}`); for (const v of report.viewports.slice(1)) if (v.metrics.hero) line(` @${v.width}px ${v.metrics.hero.lines} line(s) · ${v.metrics.hero.size}px`); line(` typefaces ${d.type.families.filter((f) => f.share >= 0.02).map((f) => `${f.family} ${Math.round(f.share * 100)}%`).join(', ')}`); line(` sizes in use ${d.type.sizes.join(' / ')} px${d.type.body ? ` body ${d.type.body.size}px / ${d.type.body.leading}` : ''}`); line(` colour page ${d.color.bodyBg}${d.color.sectionBgs.length ? ' · sections ' + d.color.sectionBgs.map((s) => s.color).slice(0, 6).join(' ') : ''}${d.color.button ? ' · button ' + d.color.button.bg : ''}`); line(` page ${d.page.screens} screens · ${d.page.words} words · ${d.page.img} images · hero media ${d.heroMedia[0] ? Math.round(d.heroMedia[0].ratio * 100) + '% ' + d.heroMedia[0].type : 'none'}`); line(` headings ${d.headingSummary.count} · longest ${d.headingSummary.maxLines} line(s) · median ${d.headingSummary.medianWords ?? '-'} words`); line(''); const fails = merged.filter((f) => f.level === 'FAIL'), warns = merged.filter((f) => f.level === 'WARN'); if (!fails.length && !warns.length) line('FINDINGS none. Measurements are clean — now judge the pictures.'); for (const [title, list] of [['FAIL — fix every one of these', fails], ['WARN — fix, or be able to defend', warns]]) { if (!list.length) continue; line(title); for (const f of list) { line(` [${f.id}] @${f.width}px${f.alsoAt ? ' (+' + f.alsoAt.join(', ') + ')' : ''} ${f.message}`); line(` → ${f.fix}`); } line(''); } line('LOOK AT THE RENDER — open these with your image viewer / Read tool before saying anything about quality:'); const rel = (p) => relative(process.cwd(), p).split('\\').join('/'); for (const im of report.images) { line(` ${(im.viewport + ' ' + im.width + 'px').padEnd(15)} ${rel(im.fold)}`); for (const p of im.pages) line(` ${''.padEnd(15)} ${rel(p)}`); for (const p of im.film || []) line(` ${'scroll film'.padEnd(15)} ${rel(p)} (6 frames a row, a third of a screen apart)`); } line(''); line(`report: ${rel(join(outDir, 'report.json'))}`); line(fails.length ? `RESULT: ${fails.length} FAIL, ${warns.length} WARN — not done.` : `RESULT: 0 FAIL, ${warns.length} WARN — measurements pass. Passing the meter is the floor, not the grade: score the screenshots with references/critique.md.`); process.exit(fails.length ? 1 : 0); ================ File: workspace/agents/opencode/engine/skills/design-taste-frontend/scripts/lint.mjs ================ #!/usr/bin/env node // tasteskill / lint — static scan of source files for the tells this skill bans. // Catches them before a render, plus the ones a render cannot see (copy habits, motion values). // // node lint.mjs [files or directories ...] [--app] [--json] // // Exit code 1 when any FAIL is found. Zero dependencies. import { readFileSync, readdirSync, statSync } from 'node:fs'; import { join, extname, relative, resolve } from 'node:path'; const args = process.argv.slice(2); const asJson = args.includes('--json'); // App screens (references/app-screens.md) keep quiet table and panel lines, faint shadows, data labels // and their own typefaces; a sheet dealt with draw.mjs --app switches this on by itself. let appMode = args.includes('--app'); try { if (JSON.parse(readFileSync('.taste-skill/direction.json', 'utf8')).kind === 'app') appMode = true; } catch {} const APP_LEVEL = { hairline: null, 'outlined-box': null, 'default-font': null, numbering: 'WARN', mono: 'WARN', shadow: 'WARN', eyebrow: 'WARN', 'cta-copy': null, 'h-screen': null }; const roots = args.filter((a) => !a.startsWith('--')); if (!roots.length) roots.push('.'); const EXT = new Set(['.html', '.htm', '.css', '.scss', '.less', '.js', '.jsx', '.ts', '.tsx', '.vue', '.svelte', '.astro', '.mdx']); const SKIP = new Set(['node_modules', '.git', '.next', '.nuxt', '.svelte-kit', 'dist', 'build', 'out', '.vercel', '.taste-skill', 'coverage', '.turbo', 'vendor']); const files = []; const tooBig = []; const walk = (p) => { let st; try { st = statSync(p); } catch { return; } if (st.isDirectory()) { for (const f of readdirSync(p)) if (!SKIP.has(f) && !f.startsWith('.')) walk(join(p, f)); } else if (EXT.has(extname(p)) && !/\.min\.|\.d\.ts$|(^|[\\/])(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|[^\\/]+\.lock)$/.test(p)) { if (st.size < 4000000) files.push(p); else tooBig.push(p); } }; for (const r of roots) { try { statSync(resolve(r)); } catch { console.error(`Not found: ${r}`); process.exit(2); } walk(resolve(r)); } if (!files.length) { console.error(`No source files (${[...EXT].join(' ')}) under: ${roots.join(', ')}`); process.exit(2); } if (tooBig.length) console.error(`Skipped (over 4 MB, check by hand): ${tooBig.join(', ')}`); // Each rule: [level, id, regex, message]. Tested per line. `code` lines (inside
    /) skip the mono rule.
    const R = [
      ['FAIL', 'mono', /\bfont-mono\b|font-family\s*:[^;{}]*(mono\b|monospace|Courier|Consolas|Menlo|Monaco)|(JetBrains|Geist|IBM\+?Plex|Space|DM|Roboto|Fragment|Azeret|Red\+?Hat|Martian|Commit|Departure|Fira)[+_ ]?Mono|Geist_Mono|GeistMono|Fira[+ ]Code|ui-monospace/i, 'Monospace type. Only literal code may be monospace.'],
      ['FAIL', 'gradient', /(linear|radial|conic)-gradient\(|\bbg-(gradient|linear|radial|conic)-|\b(from|via)-(?!\[?0)[a-z]+-\d{2,3}\b/, 'Gradient fill. Use a flat colour or a real image (a neutral scrim over a photo is the one exception).'],
      ['FAIL', 'gradient-text', /bg-clip-text|background-clip\s*:\s*text|text-transparent/, 'Gradient / clipped text. Use solid colour.'],
      ['FAIL', 'shadow', /box-shadow\s*:\s*(?!none|0 0 #0000|var\(--tw)|\bshadow(-(sm|md|lg|xl|2xl|inner|\[[^\]]+\]))?(?=["'\s`])(?\s*[(\[]?0[1-9][)\]]?\.?\s*<|["'`]\s*0[1-9]\s*["'`]|decimal-leading-zero|>\s*(Step|Phase|No\.?|Chapter)\s*0?\d\s*\s*(Learn more|Get started|Click here|Read more|Discover more|Explore now|Submit)\s* {
        // track the CSS selector we are inside, so rules scoped to [data-ui] (interface fragments) keep their own lines
        if (line.includes('{')) { selector = pending + line.slice(0, line.indexOf('{')); pending = ''; } else if (!line.includes('}')) pending = (pending + ' ' + line).slice(-300);
        const inUi = /data-ui/.test(selector) || /data-ui/.test(line);
        if (line.includes('}') && !line.includes('{')) { selector = ''; pending = ''; }
        if (/<(pre|code)\b/i.test(line)) inCode = true;
        const codeLine = inCode || /<(pre|code|kbd|samp)\b/i.test(line);
        if (/<\/(pre|code)>/i.test(line)) inCode = false;
        if (line.length > 2000) return; // minified or data
        if (/taste-skill-ignore/.test(line)) return;
        if (!/^\s*(\/\/|\/\*|\*|/g, '').match(/—/g) || []).length; // comments are not copy
        for (const [level, id, re, message] of R) {
          if (!level) continue;
          if (id === 'mono' && (codeLine || /(^|[\s,>+~(])(pre|code|kbd|samp)\b|code/i.test(selector))) continue; // real code may be monospace
          if ((id === 'hairline' || id === 'outlined-box') && /(^|[\s,>+~(])(table|thead|tbody|tfoot|tr|td|th|input|textarea|select|fieldset)\b/i.test(selector)) continue; // real tables and form fields keep their lines
          if (inUi && (id === 'hairline' || id === 'outlined-box' || id === 'shadow')) continue;
          if (id === 'hairline' && /sr-only|visually-hidden|clip(-path)?\s*:|outline/i.test(line + selector)) continue; // screen-reader helpers and focus outlines are not rules
          if ((id === 'copy' || id === 'cta-copy' || id === 'emoji') && /^\s*(\/\/|\/\*|\*|import |@import|