atproto Thingiverse but good
12

Configure Feed

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

front-end design guidance: ingest impeccable, add frontend-design skill + Confluence reference

Orual (Jun 29, 2026, 7:06 PM EDT) 0649aa68 b91002bb

+17419 -1
+3 -1
.gitignore
··· 17 17 # Local databases (SQLite projection + Hydrant fjall store). 18 18 /data/ 19 19 /hydrant.db/ 20 - rustc-ice-*.txt 20 + rustc-ice-*.txt 21 + # Impeccable design detector scratch (ignore lists, design sidecar, critique snapshots). 22 + .impeccable/
+162
.polytoken/skills/frontend-design/SKILL.md
··· 1 + --- 2 + description: Use when designing, building, reviewing, critiquing, or polishing any Polymodel front-end surface (Dioxus RSX components, routes, CSS, layout, color, type, motion, states, copy). Carries the adapted Impeccable design method for Polymodel's blueprint identity and product register. 3 + polytoken: 4 + tags: [frontend, design, ui] 5 + --- 6 + # Polymodel front-end design 7 + 8 + Use this whenever you touch a Polymodel UI surface: a Dioxus component, a route, `assets/styling/*.css`, or any decision about layout, color, type, motion, state, or UX copy. It exists so front-end work ships with intentional craft instead of generic AI-generated defaults. 9 + 10 + Announce: "using the frontend-design skill for Polymodel UI work". 11 + 12 + **Source and companions.** This skill adapts [Impeccable](https://github.com/pbakaus/impeccable) (Paul Bakaus, Apache-2.0), which builds on Anthropic's `frontend-design` skill — distilled to Polymodel's stack and identity rather than vendored wholesale. Companions: 13 + 14 + - **`DESIGN.md`** (repo root) — the blueprint design system as machine-readable tokens (Stitch format) mirroring `assets/styling/theme.css`. Source of truth for the deterministic detector's design-system checks. Keep it in sync with `theme.css`. 15 + - **`tools/design-detector/`** — the vendored deterministic linter (see Detector below). 16 + - **Confluence**, space `PM`, [*Front-end design guidance*](https://radiant-industries.atlassian.net/wiki/spaces/PM/pages/655362) (child of the Polymodel Overall Design Plan) — the durable human-facing version of this method, with credit and links. 17 + 18 + Read `DESIGN.md` and the relevant `assets/styling/*.css` before non-trivial UI work. 19 + 20 + --- 21 + 22 + ## 1. Setup: orient before you design 23 + 24 + 1. **Read the tokens and the existing vocabulary.** `theme.css` (color, fonts, spacing, radius, dark mode), then `base.css` and `cards.css` (cards, buttons, inputs, tabs, status pills, skeletons, the drafting-grid media). Reuse what exists; do not invent a parallel system. `DESIGN.md` documents these as a system with named rules. 25 + 2. **Know the register.** Polymodel is **product UI** — a tool people browse and publish with — not a marketing/brand surface. Design *serves* the task. 26 + 3. **Preserve the blueprint identity** (§3). It is committed and distinctive. Do not "fix" it toward generic flat-SaaS or dark-terminal defaults. 27 + 28 + --- 29 + 30 + ## 2. The register: product, not brand 31 + 32 + Every design task is either **brand** (marketing/landing/portfolio — design *is* the product) or **product** (app UI, tools, dashboards — design *serves* the product). Polymodel is product. 33 + 34 + The product test is not "would someone say AI made this" — familiarity is a feature here. The test is: **would a user fluent in the category's best tools (Printables, Linear, Figma, Stripe) sit down and trust this, or pause at every subtly-off component?** Product UI's failure mode isn't flatness, it's *strangeness without purpose*: over-decorated buttons, mismatched controls, gratuitous motion, display fonts where labels belong, invented affordances for standard tasks. The bar is **earned familiarity**; the tool should disappear into the task. 35 + 36 + Product can afford things brand can't: familiar sans defaults, standard nav patterns, density when users need it, consistency over surprise. Save delight for moments, not pages. 37 + 38 + --- 39 + 40 + ## 3. The blueprint identity (committed — preserve it) 41 + 42 + Polymodel's north star is **"The Drafting Table"**: a well-kept set of engineering blueprints rendered for the web. These are intentional choices, not slop to clean up. 43 + 44 + - **Paper base, not "AI cream."** `--color-base` (`#f5f1e8` light) is a drafting-paper tone tinted toward the brand — the blueprint substrate. This is a *committed* identity choice; identity-preservation beats the generic "never warm-neutral body bg" rule. Never rename it `--paper`/`--cream`/`--sand`; never swap it for a flat off-white. 45 + - **Teal primary, one warm-orange action.** `--color-primary` teal carries structure (active/selected/focus, grid lines). `--color-action` orange is the single warm accent, reserved for the *one* primary action per view. Accent marks action and state, never decoration. 46 + - **Near-square corners.** `--radius-panel: 2px`; controls render at 0px (`base.css`). Never round cards/inputs to 12px+; full pill only for tags. Over-rounding breaks the drafting feel. 47 + - **Drafting-grid media.** Empty/preview surfaces use the grid background (`--color-grid-line`), and missing media draws nested registration rectangles — never a flat gray box, never fake content. 48 + - **Mono for metadata.** `--font-mono` (Ioskeley Mono) carries dimensions, formats, DIDs/handles, counts, status pills. `--font-ui`/`--font-heading` (Proza Libre) carries everything else — one humanist sans for body, headings, labels, buttons. 49 + - **Both schemes first-class.** Every token has a dark variant; verify light *and* dark. 50 + 51 + --- 52 + 53 + ## 4. General rules 54 + 55 + ### Color 56 + - **Verify contrast.** Body ≥ 4.5:1; large/bold-large (≥18px / bold ≥14px) ≥ 3:1; placeholders ≥ 4.5:1 (not muted-gray). The single most common AI failure is muted gray body text on a tinted near-white — exactly our paper base. When a value is even close, move toward `--color-text`/`--color-emphasis`, not `--color-muted`. 57 + - **No gray on color.** Gray text on a colored background looks washed out — use a darker shade of that hue, or a transparency of the text color. 58 + - **Restrained palette.** Tinted neutrals + the action accent. Heavy/full-saturation color does not belong on inactive states. A single surface may earn a committed color, but restrained is the product floor. 59 + - **State vocabulary, standardized:** hover, focus, active, disabled, selected, loading, error, warning, success, info. Accent is for primary action / current selection / state indicators only. 60 + 61 + ### Typography 62 + - **One UI family.** Proza Libre for headings, labels, body, buttons. No display/body pairing. Mono only for the metadata roles in §3. Never a display font in labels, buttons, or data. 63 + - **Fixed rem scale, not fluid `clamp()` headings.** Product UI is viewed at consistent DPI; a heading that shrinks in a panel looks worse. Tight step ratio (~1.125–1.2). The card-title `clamp()` is the one sanctioned exception. 64 + - **Prose line length 65–75ch.** Dense data/tables may run wider. 65 + - **`text-wrap: balance`** on headings; `pretty` on long prose. 66 + 67 + ### Layout 68 + - **Flexbox for 1D, Grid for 2D.** Don't reach for Grid where `flex-wrap` is simpler. Responsive card grids without breakpoints: `repeat(auto-fit, minmax(280px, 1fr))`. **Repo trap (AGENTS.md):** a Grid container + `min-height: 100vh` stretches rows and opens large gaps — use a flex column for vertical page shells. 69 + - **Cards are the lazy answer; nested cards are always wrong.** Use a card only when it's genuinely the best affordance. Thing cards are legitimate; a card wrapping cards is not. 70 + - **Vary spacing for rhythm.** Build a semantic z-index scale (dropdown → sticky → modal → toast → tooltip); never `999`/`9999`. 71 + - **Responsive behavior is structural** (collapse nav, responsive tables, breakpoint columns), not fluid typography. 72 + 73 + ### Motion 74 + - **Conveys state, not decoration.** 150–250 ms, ease-out (quart/quint/expo) — no bounce, no elastic. Reserve it for state change, feedback, loading, upload progress, publish-state, viewer readiness. No orchestrated page-load sequences; users load into a task. 75 + - **`prefers-reduced-motion` is not optional** — every animation needs a reduce fallback (crossfade or instant). 76 + - **Don't animate layout properties** or `<img>` on hover; animate background/border/shadow instead. 77 + 78 + ### Components & states 79 + - **Every interactive component ships all states:** default, hover, focus, active, disabled, loading, error. Don't ship half. Focus must be visible (the input `:focus` outline in `cards.css`). 80 + - **Skeletons, not center spinners,** for content loading (`.skeleton-*` exist). 81 + - **Empty states teach** the interface ("publish your first thing" + CTA), never "nothing here." 82 + - **Consistent affordances** across browse/search/profile/thing/publish: same button shape, same control vocabulary, same icon style. If "save" looks different in two places, one is wrong. 83 + 84 + ### Interaction 85 + - Dropdowns with `position: absolute` inside `overflow: hidden/auto` get clipped — use `position: fixed`, the popover/`<dialog>` API, or a portal. 86 + - Exhaust inline/progressive alternatives before a modal; modal-as-first-thought is usually laziness. 87 + 88 + --- 89 + 90 + ## 5. Anti-pattern catalog (rewrite if you catch yourself writing these) 91 + 92 + Cross-register slop tells: 93 + 94 + - **Side-stripe borders** — `border-left/right` >1px as a colored accent. Use full borders or background tints. 95 + - **Gradient text** (`background-clip: text` + gradient). Solid color; emphasis via weight/size. 96 + - **Glassmorphism as default** decoration. 97 + - **The hero-metric template** (big number + label + gradient) and **identical icon-card grids**. 98 + - **An uppercase tracked eyebrow above every section**, or numbered `01/02/03` markers as default scaffolding. (`.eyebrow`/`.product-kicker` are for *deliberate, occasional* use — the ban is reflexive repetition.) 99 + - **Text that overflows its container** at any breakpoint — test thing names/titles at mobile/tablet. 100 + - **Ghost-card**: a 1px border + a wide (≥16px) soft shadow on the same element. Pick one. 101 + - **Over-rounding** (24px+ on cards), **sketchy/hand-drawn SVG**, **repeating-stripe backgrounds**. 102 + 103 + Product-specific bans: 104 + 105 + - **Decorative motion** that doesn't convey state. 106 + - **Inconsistent component vocabulary** across screens. 107 + - **Display fonts in UI** labels/buttons/data. 108 + - **Reinvented standard affordances** for flavor (custom scrollbars beyond our thin styling, weird form controls, non-standard modals). 109 + - **Heavy/full-saturation accents on inactive states.** 110 + 111 + ### The slop test (two altitudes) 112 + 1. **First-order:** could someone guess the theme + palette from the category alone ("atproto 3D-model site")? If yes, it's the training-data reflex. 113 + 2. **Second-order:** could they guess it from category-plus-anti-reference ("not flat-SaaS → must be dark-terminal")? If yes, it's the trap one tier deeper. 114 + 115 + Polymodel's answer to both is the **blueprint identity** — lean into it deliberately rather than landing on flat-SaaS or dark-terminal. 116 + 117 + --- 118 + 119 + ## 6. Evaluation method (review / critique / audit) 120 + 121 + When reviewing a surface rather than building one, run two passes and synthesize. 122 + 123 + ### Design critique (judgment) 124 + Score Nielsen's 10 heuristics 0–4 (be honest — most real UIs land 20–32/40): system-status visibility, match to real world, user control/freedom, consistency, error prevention, recognition-over-recall, flexibility/efficiency, aesthetic/minimalist, error recovery, help. Then: 125 + - **Cognitive load:** ≤4 items per decision point (Miller/Cowan); chunk, group by proximity, progressive disclosure; one primary action + 1–2 secondary, rest in a menu. Flag any decision point with >4 visible options. 126 + - **Personas:** walk the primary action as 2–3 relevant archetypes (e.g. the impatient power user who wants keyboard/bulk paths; the first-timer who needs visible labels and plain-language errors). Report what *broke* for each, specifically. 127 + - **AI-slop verdict** against §5. 128 + 129 + Tag each issue P0 (blocks task) / P1 (major, fix before release) / P2 (minor) / P3 (polish). Be specific ("the publish button," not "some elements") and say why it matters to users. 130 + 131 + ### Technical audit (measurable) 132 + Score 0–4 across: **accessibility** (contrast, ARIA, keyboard nav/focus, semantic HTML, alt text, labeled inputs), **performance** (layout thrash, expensive/unbounded animations, missing lazy-load/memoization), **theming** (hard-coded colors vs tokens, dark-mode parity), **responsive** (fixed widths, ≥44px touch targets, no horizontal overflow), **anti-patterns** (§5). Run the detector (§7) as the deterministic half of this pass. 133 + 134 + --- 135 + 136 + ## 7. The detector 137 + 138 + `tools/design-detector/` is the vendored deterministic linter (44 rules; Apache-2.0; see its README): 139 + 140 + ```sh 141 + node tools/design-detector/detect.mjs --json assets/styling/ # static CSS scan (plain Node) 142 + node tools/design-detector/detect.mjs --json http://localhost:8080 # rendered route (real browser; `just serve` running) 143 + ``` 144 + 145 + Exit `0` clean / `2` findings. It scans **CSS and rendered HTML, not `.rs` RSX** — so it covers `theme.css`/`*.css` and live routes; rely on the §8 checklist for RSX-level structure. `design-system-*` findings (radius/color outside scale) read repo-root `DESIGN.md`, so keep that synced with `theme.css`. Scratch lands in `.impeccable/` (gitignored). Treat `advisory` findings as judgment calls; fix `warning` findings or justify them. 146 + 147 + --- 148 + 149 + ## 8. Workflow & checklist 150 + 151 + Before review or handoff: run `just fix` and the narrowest relevant tests (`just test` / e2e; `just test-all` for substantial UI changes). Verify in-browser with Playwright MCP — screenshots **plus** accessibility snapshots with boxes and computed-style inspection (screenshots show symptoms; boxes/computed styles find the offending rule). Check **both schemes** and **mobile + desktop**. Don't commit `.playwright-mcp/` scratch. 152 + 153 + - [ ] Reused existing tokens/components; no parallel system invented. 154 + - [ ] Blueprint identity preserved (paper base, near-square corners, teal/one-orange-action, mono metadata, grid-line media). 155 + - [ ] Body/placeholder contrast ≥ 4.5:1 in both schemes; no gray-on-tint mud. 156 + - [ ] All interactive states present (default/hover/focus/active/disabled/loading/error); focus visible. 157 + - [ ] Skeleton loading + teaching empty state + actionable error state. 158 + - [ ] One UI family + mono for metadata only; no display fonts in UI; fixed rem heading scale. 159 + - [ ] No §5 anti-patterns; motion conveys state, quick, ease-out, with reduced-motion fallback. 160 + - [ ] Headings don't overflow at any breakpoint. 161 + - [ ] Detector run on changed CSS / affected route; findings fixed or justified. 162 + - [ ] Verified light/dark + mobile/desktop in-browser.
+1
AGENTS.md
··· 65 65 66 66 ### Front-end implementation notes 67 67 68 + - **Design guidance: use the `frontend-design` skill for any UI work** (Dioxus RSX, routes, `assets/styling/*.css`, layout/color/type/motion/state/copy). It carries the adapted Impeccable design method for Polymodel's committed blueprint identity and product register. Companions: `DESIGN.md` (repo root, the blueprint design system as machine-readable tokens mirroring `theme.css`), `tools/design-detector/` (deterministic no-install linter — `node tools/design-detector/detect.mjs --json assets/styling/` or a rendered route; scans CSS/HTML, not `.rs`), and Confluence [*Front-end design guidance*](https://radiant-industries.atlassian.net/wiki/spaces/PM/pages/655362) in space `PM` (durable human-facing version). 68 69 - Dioxus asset declarations use source paths, but served asset URLs are flattened into `/assets/`. For example, declare a font with `asset!("/assets/fonts/proza-libre/ProzaLibre-Regular.woff2", ...)`, but reference it from CSS as `url("/assets/ProzaLibre-Regular.woff2")`. Verify font and model asset paths in browser network output when changing asset declarations. 69 70 - Keep generated lexicon/API code out of routine formatting churn. `crates/polymodel-api` is generated and is ignored by `rustfmt.toml`; do not include generated reformatting in ordinary feature slices unless the task is explicitly about regeneration or codegen output. 70 71 - Do not commit Playwright MCP scratch output. `.playwright-mcp/` contains local browser logs/snapshots from manual checks and should stay ignored/untracked.
+202
DESIGN.md
··· 1 + --- 2 + name: Polymodel 3 + description: Blueprint/technical-drawing design system for an AT Protocol model-sharing app. 4 + colors: 5 + base: "#f5f1e8" 6 + surface: "#fffdf7" 7 + overlay: "#e8edf0" 8 + ink: "#17252f" 9 + emphasis: "#0c1f2c" 10 + subtle: "#445461" 11 + muted: "#6a747b" 12 + primary: "#1f6d8a" 13 + secondary: "#5a8797" 14 + tertiary: "#0f4159" 15 + action: "#c5652a" 16 + link: "#a24d1f" 17 + border: "#a9b8bf" 18 + highlight: "#d8e5e9" 19 + error: "#a13f32" 20 + warning: "#b36a16" 21 + success: "#2d7358" 22 + typography: 23 + heading: 24 + fontFamily: "Proza Libre, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" 25 + fontWeight: 600 26 + lineHeight: 1.3 27 + body: 28 + fontFamily: "Proza Libre, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" 29 + fontWeight: 400 30 + fontSize: "16px" 31 + lineHeight: 1.55 32 + mono: 33 + fontFamily: "Ioskeley Mono, IBM Plex Mono, Consolas, monospace" 34 + fontSize: "0.72rem" 35 + letterSpacing: "0.04em" 36 + rounded: 37 + panel: "2px" 38 + square: "0px" 39 + spacing: 40 + base: "16px" 41 + components: 42 + button: 43 + backgroundColor: "{colors.surface}" 44 + textColor: "{colors.ink}" 45 + rounded: "{rounded.panel}" 46 + padding: "0.55rem 0.8rem" 47 + button-primary: 48 + backgroundColor: "{colors.action}" 49 + textColor: "{colors.base}" 50 + rounded: "{rounded.panel}" 51 + padding: "0.55rem 0.8rem" 52 + button-secondary: 53 + backgroundColor: "{colors.surface}" 54 + textColor: "{colors.tertiary}" 55 + rounded: "{rounded.panel}" 56 + input: 57 + backgroundColor: "{colors.base}" 58 + textColor: "{colors.ink}" 59 + rounded: "{rounded.panel}" 60 + padding: "0.75rem 0.85rem" 61 + card: 62 + backgroundColor: "{colors.surface}" 63 + textColor: "{colors.ink}" 64 + rounded: "{rounded.panel}" 65 + status-pill: 66 + backgroundColor: "{colors.surface}" 67 + textColor: "{colors.subtle}" 68 + rounded: "{rounded.panel}" 69 + typography: "{typography.mono}" 70 + --- 71 + 72 + # Design System: Polymodel 73 + 74 + ## 1. Overview 75 + 76 + **Creative North Star: "The Drafting Table"** 77 + 78 + Polymodel looks like a set of well-kept engineering blueprints rendered for the web. The page is drafting paper; content sits on crisp bordered panels with near-square corners; technical metadata is set in monospace the way a drawing carries its measurements; empty media reads as a faint drafting grid awaiting geometry. The teal primary is drawing-ink blue; a single warm orange is the one action accent, used like a redline. It is a **product** surface: design serves the task of browsing, judging, downloading, and publishing printable things. The tool should disappear into the work. 79 + 80 + This system explicitly rejects the flat-SaaS dashboard look (rounded cards everywhere, purple/blue gradients, soft shadows, Inter-for-everything) and the reflexive dark-terminal "developer tool" aesthetic. Warmth comes from the paper tone, the humanist type, and the orange accent — not from decoration. Familiarity is a feature: a maker fluent in Printables should trust every control on sight. 81 + 82 + **Key Characteristics:** 83 + - Drafting-paper base, drawing-ink teal, single warm-orange action accent. 84 + - Near-square corners (2px panels, 0px controls); crisp 1px borders over shadow. 85 + - Monospace for all technical metadata (dimensions, formats, DIDs/handles, counts, status). 86 + - Drafting-grid placeholders instead of gray boxes. 87 + - First-class light **and** dark schemes; restrained, state-driven color and motion. 88 + 89 + ## 2. Colors 90 + 91 + A restrained palette: a tinted drafting-paper neutral ramp, one drawing-ink teal that carries structure, and a single warm orange reserved for action. Every token has a dark-scheme value (see `assets/styling/theme.css`; tonal/dark values live in the `.impeccable/design.json` sidecar). 92 + 93 + ### Primary 94 + - **Drawing-Ink Teal** (#1f6d8a): The structural brand color — active tabs, selected state, focus rings, section emphasis, grid lines (at low alpha). Carries identity without being the action color. 95 + 96 + ### Secondary 97 + - **Slate Teal** (#5a8797): Supporting teal for secondary controls and quieter structural accents. 98 + - **Deep Teal** (#0f4159): High-emphasis teal for hover ink and strong labels against light surfaces. 99 + 100 + ### Tertiary 101 + - **Redline Orange** (#c5652a): The single warm action accent — primary buttons and the one element that should pull the eye to "do this." **Action only, never decoration.** 102 + - **Rust Link** (#a24d1f): Inline text links, distinct from the action button so links don't read as buttons. 103 + 104 + ### Neutral 105 + - **Drafting Paper** (#f5f1e8): The body background. A warm paper tone tinted toward the brand — the blueprint substrate, not a generic off-white. 106 + - **Sheet** (#fffdf7): Raised panel/card/surface background. 107 + - **Blueprint Overlay** (#e8edf0): Cool inset background for media wells and grid placeholders. 108 + - **Ink** (#17252f): Body text. 109 + - **Emphasis** (#0c1f2c) / **Subtle** (#445461) / **Muted** (#6a747b): The text-emphasis ramp; pick by importance, never drop body to muted-gray on the paper base. 110 + - **Border** (#a9b8bf): The 1px line on every panel, control, and pill. 111 + - **Highlight** (#d8e5e9): Selection/skeleton shimmer tint. 112 + 113 + ### Status 114 + - **Error** (#a13f32) / **Warning** (#b36a16) / **Success** (#2d7358): Semantic state only, paired with shape/label, never color-alone. 115 + 116 + ### Named Rules 117 + **The One Redline Rule.** Redline Orange (#c5652a) is the action accent. It marks the single primary action in a view and nothing decorative. If two things on a screen are orange, one is wrong. 118 + 119 + **The Paper-Is-Identity Rule.** The body background is drafting paper (#f5f1e8), a deliberate tinted neutral — not the AI warm-cream default. Never rename it `--paper`/`--cream`/`--sand` and never swap it for a flat #fff/off-white; the paper tone carries the blueprint metaphor. 120 + 121 + **The Ink-Not-Gray Rule.** Body text reaches for the ink end of the ramp. Light-gray "for elegance" on the tinted paper is the contrast trap; when a value is close to 4.5:1, move toward Ink/Emphasis, not Muted. 122 + 123 + ## 3. Typography 124 + 125 + **Heading Font:** Proza Libre (with system-ui, -apple-system, Segoe UI, sans-serif) 126 + **Body Font:** Proza Libre (with system-ui, -apple-system, Segoe UI, sans-serif) 127 + **Label/Mono Font:** Ioskeley Mono (with IBM Plex Mono, Consolas, monospace) 128 + 129 + **Character:** One humanist sans (Proza Libre) carries headings, body, labels, and buttons — warm and legible, the workshop-manual voice. A distinctive monospace (Ioskeley Mono) carries everything technical, like dimensions stamped on a drawing. The contrast axis is sans-vs-mono, not two near-identical sans faces. 130 + 131 + ### Hierarchy 132 + - **Heading** (Proza Libre, 600, ~1rem–1.18rem, line-height 1.3): Card titles, section and page headings. Fixed rem scale, tight ratio. 133 + - **Body** (Proza Libre, 400, 16px, line-height 1.55): Paragraphs and descriptions; cap prose at 65–75ch. 134 + - **Title (card)** (Proza Libre, 600, clamp(1rem, 1.8vw, 1.18rem)): The one tolerated small fluid step, for thing-card titles in a fluid grid. 135 + - **Meta/Label** (Ioskeley Mono, ~0.72rem, letter-spacing 0.04–0.08em, often uppercase): Dimensions, file formats, DIDs/handles, counts, status pills, eyebrows/kickers. 136 + 137 + ### Named Rules 138 + **The Mono-Is-Metadata Rule.** Monospace is reserved for technical metadata (measurements, formats, identifiers, counts, status). Prose and UI labels are never mono; metadata is never set in the body sans. 139 + 140 + **The Fixed-Scale Rule.** Product headings use a fixed rem scale, not fluid `clamp()` — a heading that shrinks inside a panel looks worse, not designed. The card-title clamp is the only sanctioned exception. 141 + 142 + **The One-Eyebrow Rule.** The uppercase tracked mono kicker (`.eyebrow`/`.product-kicker`) is part of the voice for *deliberate, occasional* use — never an eyebrow stacked above every section. 143 + 144 + ## 4. Elevation 145 + 146 + Flat by default. Depth comes from crisp 1px borders and tonal layering (paper → sheet → overlay), not shadows. The only shadow in the system is a 1px hairline under panels that reads as a sheet of paper lifting off the table, not a floating SaaS card. 147 + 148 + ### Shadow Vocabulary 149 + - **Paper hairline** (`box-shadow: 0 1px 0 color-mix(in srgb, var(--color-text) 8%, transparent)`): The single resting elevation on cards/panels. Borders do the structural work; this only seats the sheet. 150 + 151 + ### Named Rules 152 + **The Border-Over-Shadow Rule.** Structure is drawn with 1px borders and background-tone steps, never with soft drop shadows. Never pair a 1px border with a wide (≥16px) soft shadow on the same element (the ghost-card tell). 153 + 154 + ## 5. Components 155 + 156 + ### Buttons 157 + - **Shape:** Near-square (2px radius); base controls render at 0px corners. Never round to pill (except deliberate tags) or 12px+. 158 + - **Default:** Sheet background, 1px border, ink text. Hover shifts border to teal and text to deep teal. 159 + - **Primary:** Redline Orange fill, paper-colored text — the one action. Hover brightens slightly (`filter: brightness(1.04)`), no transform bounce. 160 + - **Secondary / Ghost:** Teal border + deep-teal text (secondary); transparent background (ghost). 161 + 162 + ### Chips / Tabs / Status pills 163 + - **Style:** 1px border, near-square, mono text, ~0.72rem. Transparent at rest. 164 + - **State:** Active tab / selected pill take a teal border, deep-teal text, and an 8% teal wash. Muted and error variants recolor the border + text + faint wash to match the semantic role. 165 + 166 + ### Cards / Containers 167 + - **Corner Style:** 2px (panel radius). 168 + - **Background:** Sheet (#fffdf7) over the paper base. 169 + - **Border:** 1px Border (#a9b8bf) — the defining line. 170 + - **Shadow Strategy:** Paper hairline only (see Elevation). 171 + - **Internal Padding:** ~1rem; metadata rows in mono, subordinate to title/creator. 172 + - **Media:** Drafting-grid background (20px grid, grid-line color) for previews and placeholders — never a flat gray box; missing media draws nested registration rectangles, not fake content. 173 + 174 + ### Inputs / Fields 175 + - **Style:** Paper-base fill, 1px border, 2px radius, body sans. 176 + - **Focus:** Border shifts to teal with a 2px translucent teal outline (`outline: 2px solid color-mix(... primary 25% ...)`). Focus is always visible. 177 + - **Error / Disabled:** Error recolors border+text to Error and adds a faint wash; disabled drops contrast and removes the pointer affordance. 178 + 179 + ### Navigation 180 + - Product nav (wordmark, Browse, Search, Publish, session control), not demo/workflow copy. Standard top-bar pattern; active state uses the tab treatment. Reachable search globally. 181 + 182 + ### Signature: the blueprint preview / skeleton 183 + The drafting-grid media well (`.blueprint-media`) and the shimmer skeletons (`.skeleton-*`, `blueprint-skeleton` keyframes) are the system's signature. Content loads through skeletons over a stable grid, never a centered spinner. 184 + 185 + ## 6. Do's and Don'ts 186 + 187 + ### Do: 188 + - **Do** reuse the tokens in `assets/styling/theme.css` and the component vocabulary in `base.css`/`cards.css`; extend, don't fork. 189 + - **Do** set all technical metadata (dimensions, formats, DIDs/handles, counts, status) in Ioskeley Mono. 190 + - **Do** keep body/placeholder contrast ≥ 4.5:1 in both schemes; move text toward Ink, not Muted, when close. 191 + - **Do** ship every interactive state — default, hover, focus, active, disabled, loading, error — with visible keyboard focus. 192 + - **Do** use drafting-grid placeholders and skeletons for loading/empty media; write empty states that teach (publish-your-first-thing). 193 + - **Do** keep motion to 150–250ms, ease-out, for state/feedback/loading/viewer-readiness only, with a `prefers-reduced-motion` fallback. 194 + 195 + ### Don't: 196 + - **Don't** swap the drafting-paper base for a flat off-white, or rename it `--paper`/`--cream`/`--sand`. 197 + - **Don't** round cards/inputs to 12px+ or reach for full pills outside tags; the near-square corner is the identity. 198 + - **Don't** use more than one Redline-Orange action per view, or use the action color for decoration. 199 + - **Don't** use gradient text, glassmorphism-by-default, hero-metric templates, identical icon-card grids, or an uppercase eyebrow above every section. 200 + - **Don't** pair a 1px border with a wide soft drop shadow (ghost-card), use `border-left` >1px as a colored stripe, or animate `<img>` on hover. 201 + - **Don't** put a display font — or any non-mono face — in technical metadata, or drop body text to muted gray on the paper base. 202 + - **Don't** land on generic flat-SaaS or generic dark-terminal looks; the answer to "what is this" is the blueprint identity.
+27
tools/design-detector/README.md
··· 1 + # Design detector 2 + 3 + Deterministic linter for AI-generated front-end design tells (gray-on-color, low contrast, side-stripe borders, bounce/elastic easing, gradient text, overused fonts, ghost-card shadows, over-rounding, …) plus design-system drift against our `DESIGN.md`. 4 + 5 + Vendored from **[Impeccable](https://github.com/pbakaus/impeccable)** by Paul Bakaus, Apache-2.0 (SPDX headers preserved in source). This is the detector engine only — the upstream skill/command workflow is **not** vendored. Our adapted design guidance lives in the `frontend-design` skill (`.polytoken/skills/frontend-design/`) and Confluence (space PM, *Front-end design guidance*). 6 + 7 + ## Usage 8 + 9 + ```sh 10 + # Static scan of our CSS (plain Node, no browser) 11 + node tools/design-detector/detect.mjs --json assets/styling/ 12 + 13 + # Scan a rendered route (drives a real browser; needs `just serve` running) 14 + node tools/design-detector/detect.mjs --json http://localhost:8080 15 + ``` 16 + 17 + Exit code `0` = clean, `2` = findings. JSON entries carry `antipattern`, `severity` (advisory/warning), `file`, `line`, `snippet`. 18 + 19 + ## Coverage notes 20 + 21 + - Scans **CSS and rendered HTML** — it does **not** parse `.rs` RSX. Treat a clean CSS scan plus a rendered-route scan as the coverage; rely on the `frontend-design` skill checklist for RSX-level structure. 22 + - `design-system-*` findings (radius/color outside our scale) are powered by the token frontmatter in repo-root `DESIGN.md`. Keep `DESIGN.md` in sync with `assets/styling/theme.css` for those checks to stay meaningful. 23 + - Scratch/config (`.impeccable/`) is gitignored. 24 + 25 + ## Updating 26 + 27 + Re-vendor from upstream by copying `skill/scripts/detector/`, `skill/scripts/lib/`, `skill/scripts/context.mjs`, and `skill/scripts/detect.mjs` (the detector's transitive closure) over this directory. Don't hand-edit the engine.
+961
tools/design-detector/context.mjs
··· 1 + /** 2 + * Context loader: prints PRODUCT.md (and DESIGN.md if present) as one 3 + * markdown block on stdout, or exits with empty stdout when no PRODUCT.md 4 + * is found anywhere. The skill keys off "empty stdout" to branch into the 5 + * init flow. 6 + * 7 + * Path resolution (first match wins): 8 + * 1. Active project root, if PRODUCT.md or DESIGN.md is there 9 + * 2. Active project .agents/context/ then docs/ 10 + * 3. Monorepo root context, using the same order, as a per-file fallback 11 + * 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user 12 + * escape hatch, only consulted when defaults are empty 13 + * 5. Active project root as a "nothing found" default 14 + * 15 + * `resolveContextDir()` and `loadContext()` are also exported for the 16 + * server-side scripts (live.mjs, live-server.mjs) that need the structured 17 + * shape rather than the markdown block. 18 + */ 19 + import fs from 'node:fs'; 20 + import os from 'node:os'; 21 + import path from 'node:path'; 22 + import { fileURLToPath } from 'node:url'; 23 + import { parseTargetOptions } from './lib/target-args.mjs'; 24 + 25 + const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md']; 26 + const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md']; 27 + const FALLBACK_DIRS = ['.agents/context', 'docs']; 28 + const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json']; 29 + const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages']; 30 + const WORKSPACE_DISCOVERY_IGNORED_DIRS = new Set([ 31 + 'node_modules', 32 + '.git', 33 + 'dist', 34 + 'build', 35 + '.next', 36 + '.nuxt', 37 + '.svelte-kit', 38 + '.turbo', 39 + '.cache', 40 + 'coverage', 41 + ]); 42 + 43 + // ─── Update check ────────────────────────────────────────────────────────── 44 + // Piggyback a lightweight skill-version check on the once-per-session boot. 45 + // When a newer skill ships, append an UPDATE_AVAILABLE directive so the agent 46 + // can offer `npx impeccable update`. Everything here is best-effort and 47 + // silent on failure: a network problem, sandbox, or missing cache must never 48 + // block context output or print an error. 49 + 50 + const UPDATE_HOST = (process.env.IMPECCABLE_UPDATE_HOST || 'https://impeccable.style').replace(/\/$/, ''); 51 + const UPDATE_CACHE_PATH = 52 + process.env.IMPECCABLE_UPDATE_CACHE || path.join(os.homedir(), '.impeccable', 'update-check.json'); 53 + const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to once a day 54 + const RENOTIFY_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // don't re-surface the same version for a week 55 + const FETCH_TIMEOUT_MS = 1200; 56 + 57 + export function resolveContextDir(cwd = process.cwd(), options = {}) { 58 + return resolveContext(cwd, options).contextDir; 59 + } 60 + 61 + export function loadContext(cwd = process.cwd(), options = {}) { 62 + const resolved = resolveContext(cwd, options); 63 + const absCwd = path.resolve(cwd); 64 + const productPath = resolved.productPath; 65 + const designPath = resolved.designPath; 66 + const product = productPath ? safeRead(productPath) : null; 67 + const design = designPath ? safeRead(designPath) : null; 68 + return { 69 + hasProduct: !!product, 70 + product, 71 + productPath: productPath ? path.relative(absCwd, productPath) : null, 72 + hasDesign: !!design, 73 + design, 74 + designPath: designPath ? path.relative(absCwd, designPath) : null, 75 + contextDir: resolved.contextDir, 76 + productContextDir: productPath ? path.dirname(productPath) : null, 77 + designContextDir: designPath ? path.dirname(designPath) : null, 78 + projectRoot: resolved.projectRoot, 79 + repoRoot: resolved.repoRoot, 80 + isMonorepo: resolved.isMonorepo, 81 + }; 82 + } 83 + 84 + function resolveContext(cwd = process.cwd(), options = {}) { 85 + const absCwd = path.resolve(cwd); 86 + const project = resolveProject(absCwd, options); 87 + const projectContextDir = resolveLocalContextDir(project.projectRoot); 88 + const rootContextDir = project.isMonorepo && project.repoRoot !== project.projectRoot 89 + ? resolveLocalContextDir(project.repoRoot) 90 + : null; 91 + 92 + let productPath = 93 + (projectContextDir ? firstExisting(projectContextDir, PRODUCT_NAMES) : null) 94 + || (rootContextDir ? firstExisting(rootContextDir, PRODUCT_NAMES) : null); 95 + let designPath = 96 + (projectContextDir ? firstExisting(projectContextDir, DESIGN_NAMES) : null) 97 + || (rootContextDir ? firstExisting(rootContextDir, DESIGN_NAMES) : null); 98 + 99 + let envContextDir = null; 100 + if (!productPath && !designPath) { 101 + envContextDir = resolveEnvContextDir(absCwd); 102 + if (envContextDir) { 103 + productPath = firstExisting(envContextDir, PRODUCT_NAMES); 104 + designPath = firstExisting(envContextDir, DESIGN_NAMES); 105 + } 106 + } 107 + 108 + return { 109 + contextDir: productPath 110 + ? path.dirname(productPath) 111 + : designPath 112 + ? path.dirname(designPath) 113 + : envContextDir || project.projectRoot, 114 + productPath, 115 + designPath, 116 + projectRoot: project.projectRoot, 117 + repoRoot: project.repoRoot, 118 + isMonorepo: project.isMonorepo, 119 + targetDir: project.targetDir, 120 + }; 121 + } 122 + 123 + export function resolveProjectRoot(cwd = process.cwd(), options = {}) { 124 + return resolveProject(cwd, options).projectRoot; 125 + } 126 + 127 + export function resolveTargetSelection(cwd = process.cwd(), options = {}) { 128 + if (hasTargetOption(options)) return null; 129 + const project = resolveProject(cwd); 130 + if ( 131 + !project.isMonorepo 132 + || !project.projectRoot 133 + || !project.repoRoot 134 + || path.resolve(project.projectRoot) !== path.resolve(project.repoRoot) 135 + ) { 136 + return null; 137 + } 138 + const targetCandidates = discoverTargetCandidates(project.repoRoot); 139 + // No discoverable child apps (e.g. `workspaces: ["."]`, a root-only workspace, 140 + // or a marker file with no apps/packages children): there is nothing to choose, 141 + // so treat the repo root as the active project rather than blocking on an empty 142 + // selection prompt that the user cannot answer. 143 + if (targetCandidates.length === 0) return null; 144 + return { 145 + targetPath: null, 146 + projectRoot: project.projectRoot, 147 + repoRoot: project.repoRoot, 148 + targetCandidates, 149 + }; 150 + } 151 + 152 + function resolveProject(cwd = process.cwd(), options = {}) { 153 + const absCwd = path.resolve(cwd); 154 + const targetDir = resolveTargetDir(absCwd, options); 155 + let repoRoot = findMonorepoRoot(targetDir); 156 + if (!repoRoot && targetDir !== absCwd) { 157 + const cwdRepoRoot = findMonorepoRoot(absCwd); 158 + if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) { 159 + repoRoot = cwdRepoRoot; 160 + } 161 + } 162 + if (!repoRoot) { 163 + return { 164 + targetDir, 165 + projectRoot: absCwd, 166 + repoRoot: absCwd, 167 + isMonorepo: false, 168 + }; 169 + } 170 + return { 171 + targetDir, 172 + projectRoot: resolveWorkspaceProjectRoot(repoRoot, targetDir) || repoRoot, 173 + repoRoot, 174 + isMonorepo: true, 175 + }; 176 + } 177 + 178 + function isPathInside(candidate, root) { 179 + const rel = path.relative(root, candidate); 180 + return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel); 181 + } 182 + 183 + function resolveLocalContextDir(root) { 184 + if (firstExisting(root, [...PRODUCT_NAMES, ...DESIGN_NAMES])) { 185 + return root; 186 + } 187 + for (const rel of FALLBACK_DIRS) { 188 + const candidate = path.resolve(root, rel); 189 + if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) { 190 + return candidate; 191 + } 192 + } 193 + return null; 194 + } 195 + 196 + function resolveEnvContextDir(cwd) { 197 + const envDir = process.env.IMPECCABLE_CONTEXT_DIR; 198 + if (!envDir || !envDir.trim()) return null; 199 + const trimmed = envDir.trim(); 200 + return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed); 201 + } 202 + 203 + function resolveTargetDir(cwd, options = {}) { 204 + const targetPath = options && typeof options === 'object' ? options.targetPath : null; 205 + if (!targetPath || !String(targetPath).trim()) return cwd; 206 + const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath); 207 + try { 208 + const stat = fs.statSync(abs); 209 + return stat.isDirectory() ? abs : path.dirname(abs); 210 + } catch { 211 + return path.extname(abs) ? path.dirname(abs) : abs; 212 + } 213 + } 214 + 215 + function findMonorepoRoot(startDir) { 216 + let dir = path.resolve(startDir); 217 + const homeDir = path.resolve(os.homedir()); 218 + while (true) { 219 + if (dir === homeDir) return null; 220 + // isMonorepoRoot is checked before hasGitBoundary on purpose: a workspace 221 + // root that also carries its own .git is still recognized. The trade-off is 222 + // deliberate — a directory with a monorepo *marker* but no workspace patterns 223 + // and no apps/packages children is not a monorepo root, so its .git stops 224 + // traversal and a further-up root is not searched. The nested .git is treated 225 + // as an independent project boundary, which is the intended isolation. 226 + if (isMonorepoRoot(dir)) return dir; 227 + if (hasGitBoundary(dir)) return null; 228 + const parent = path.dirname(dir); 229 + if (parent === dir) return null; 230 + dir = parent; 231 + } 232 + } 233 + 234 + function isMonorepoRoot(dir) { 235 + if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true; 236 + if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false; 237 + return hasFallbackWorkspaceChildren(dir); 238 + } 239 + 240 + function hasGitBoundary(dir) { 241 + return fs.existsSync(path.join(dir, '.git')); 242 + } 243 + 244 + function hasFallbackWorkspaceChildren(dir) { 245 + for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) { 246 + const base = path.join(dir, name); 247 + let entries; 248 + try { 249 + entries = fs.readdirSync(base, { withFileTypes: true }); 250 + } catch { 251 + continue; 252 + } 253 + if (entries.some((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))) return true; 254 + } 255 + return false; 256 + } 257 + 258 + function discoverTargetCandidates(repoRoot) { 259 + const roots = new Map(); 260 + const patterns = readWorkspacePatterns(repoRoot); 261 + for (const pattern of patterns) { 262 + for (const root of discoverRootsForPattern(repoRoot, pattern)) { 263 + roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root); 264 + } 265 + } 266 + if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) { 267 + for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) { 268 + const base = path.join(repoRoot, name); 269 + let entries; 270 + try { 271 + entries = fs.readdirSync(base, { withFileTypes: true }); 272 + } catch { 273 + continue; 274 + } 275 + for (const entry of entries) { 276 + if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue; 277 + const root = path.join(base, entry.name); 278 + roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root); 279 + } 280 + } 281 + } 282 + return [...roots.entries()] 283 + .filter(([rel]) => rel && !rel.startsWith('..')) 284 + // Honor negated workspace patterns (e.g. "!packages/internal"). resolveWorkspaceProjectRoot 285 + // sends an excluded package back to the repo root, so an excluded folder must not appear as a 286 + // selectable target — choosing it would silently resolve to the root instead. 287 + .filter(([rel]) => !isExcludedByWorkspacePattern(rel.split('/').filter(Boolean), patterns)) 288 + .sort(([a], [b]) => a.localeCompare(b)) 289 + .map(([rel, root]) => { 290 + const targetExample = findTargetExample(repoRoot, root); 291 + return { 292 + name: path.basename(root), 293 + path: rel, 294 + targetExample, 295 + ...resolveCandidateContextSummary(repoRoot, root, targetExample), 296 + }; 297 + }); 298 + } 299 + 300 + function resolveCandidateContextSummary(repoRoot, projectRoot, targetPath) { 301 + const ctx = resolveContext(repoRoot, { targetPath }); 302 + return { 303 + productStatus: contextSourceStatus(ctx.productPath, repoRoot, projectRoot), 304 + productPath: contextSourcePath(ctx.productPath, repoRoot), 305 + designStatus: contextSourceStatus(ctx.designPath, repoRoot, projectRoot), 306 + designPath: contextSourcePath(ctx.designPath, repoRoot), 307 + }; 308 + } 309 + 310 + // Selection candidates surface one of four statuses: 'child' (a canonical 311 + // PRODUCT.md/DESIGN.md directly in the app root), 'inherited' (resolved from the 312 + // repo root in a monorepo), 'missing' (no file found), and 'fallback'. 'fallback' 313 + // intentionally covers two non-canonical locations: a file inside the project 314 + // root but in a subdirectory (FALLBACK_DIRS, e.g. `.agents/context/`), and a file 315 + // outside both the project and repo roots (IMPECCABLE_CONTEXT_DIR override). 316 + function contextSourceStatus(filePath, repoRoot, projectRoot) { 317 + if (!filePath) return 'missing'; 318 + const absPath = path.resolve(filePath); 319 + const absProjectRoot = path.resolve(projectRoot); 320 + const absRepoRoot = path.resolve(repoRoot); 321 + if (isPathInsideOrEqual(absPath, absProjectRoot)) { 322 + return path.dirname(absPath) === absProjectRoot ? 'child' : 'fallback'; 323 + } 324 + if (absProjectRoot !== absRepoRoot && isPathInsideOrEqual(absPath, absRepoRoot)) { 325 + return 'inherited'; 326 + } 327 + return 'fallback'; 328 + } 329 + 330 + function contextSourcePath(filePath, repoRoot) { 331 + if (!filePath) return null; 332 + const rel = path.relative(repoRoot, filePath); 333 + if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) { 334 + return rel.split(path.sep).join('/'); 335 + } 336 + return filePath; 337 + } 338 + 339 + function discoverRootsForPattern(repoRoot, rawPattern) { 340 + const pattern = normalizeWorkspacePattern(rawPattern); 341 + if (!pattern || pattern.startsWith('!')) return []; 342 + const segments = pattern.split('/').filter(Boolean); 343 + if (!segments.length) return []; 344 + const firstGlobIndex = segments.findIndex((segment) => segment.includes('*')); 345 + const literalPrefix = firstGlobIndex === -1 ? segments : segments.slice(0, firstGlobIndex); 346 + const base = path.join(repoRoot, ...literalPrefix); 347 + if (!fs.existsSync(base)) return []; 348 + if (segments.includes('**')) { 349 + const packageRoots = []; 350 + walkDirs(base, (dir) => { 351 + if (dir !== base && isCandidateProjectRoot(dir)) packageRoots.push(dir); 352 + }); 353 + if (packageRoots.length) return packageRoots; 354 + return directChildDirs(base); 355 + } 356 + return expandSimplePattern(repoRoot, segments); 357 + } 358 + 359 + function expandSimplePattern(repoRoot, patternSegments, index = 0, current = repoRoot) { 360 + if (index >= patternSegments.length) return fs.existsSync(current) ? [current] : []; 361 + const segment = patternSegments[index]; 362 + if (!segment.includes('*')) { 363 + return expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, segment)); 364 + } 365 + let entries; 366 + try { 367 + entries = fs.readdirSync(current, { withFileTypes: true }); 368 + } catch { 369 + return []; 370 + } 371 + const roots = []; 372 + for (const entry of entries) { 373 + if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue; 374 + if (!segmentMatches(segment, entry.name)) continue; 375 + roots.push(...expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, entry.name))); 376 + } 377 + return roots; 378 + } 379 + 380 + function directChildDirs(dir) { 381 + try { 382 + return fs.readdirSync(dir, { withFileTypes: true }) 383 + .filter((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name)) 384 + .map((entry) => path.join(dir, entry.name)); 385 + } catch { 386 + return []; 387 + } 388 + } 389 + 390 + function walkDirs(root, visit) { 391 + let entries; 392 + try { 393 + entries = fs.readdirSync(root, { withFileTypes: true }); 394 + } catch { 395 + return; 396 + } 397 + for (const entry of entries) { 398 + if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue; 399 + const dir = path.join(root, entry.name); 400 + visit(dir); 401 + walkDirs(dir, visit); 402 + } 403 + } 404 + 405 + function isCandidateProjectRoot(dir) { 406 + return !!( 407 + fs.existsSync(path.join(dir, 'package.json')) 408 + || firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES]) 409 + || fs.existsSync(path.join(dir, 'src')) 410 + || fs.existsSync(path.join(dir, 'app')) 411 + || fs.existsSync(path.join(dir, 'pages')) 412 + || fs.existsSync(path.join(dir, 'public')) 413 + ); 414 + } 415 + 416 + function isIgnoredWorkspaceDiscoveryDir(name) { 417 + return name.startsWith('.') || WORKSPACE_DISCOVERY_IGNORED_DIRS.has(name); 418 + } 419 + 420 + function findTargetExample(repoRoot, projectRoot) { 421 + const examples = [ 422 + 'src/App.jsx', 423 + 'src/App.tsx', 424 + 'src/main.jsx', 425 + 'src/main.tsx', 426 + 'src/index.jsx', 427 + 'src/index.ts', 428 + 'app/page.tsx', 429 + 'pages/index.tsx', 430 + 'public/index.html', 431 + ]; 432 + for (const rel of examples) { 433 + const abs = path.join(projectRoot, rel); 434 + if (fs.existsSync(abs)) return path.relative(repoRoot, abs).split(path.sep).join('/'); 435 + } 436 + return path.relative(repoRoot, projectRoot).split(path.sep).join('/'); 437 + } 438 + 439 + function resolveWorkspaceProjectRoot(repoRoot, targetDir) { 440 + const rel = path.relative(repoRoot, targetDir); 441 + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot; 442 + const relSegments = rel.split(path.sep).filter(Boolean); 443 + const patterns = readWorkspacePatterns(repoRoot); 444 + const excluded = isExcludedByWorkspacePattern(relSegments, patterns); 445 + if (!excluded) { 446 + for (const pattern of patterns) { 447 + const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); 448 + if (projectRoot) return projectRoot; 449 + } 450 + } 451 + if (excluded) return repoRoot; 452 + if ( 453 + relSegments.length >= 2 454 + && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]) 455 + ) { 456 + return path.join(repoRoot, relSegments[0], relSegments[1]); 457 + } 458 + const nearest = nearestProjectLikeRoot(repoRoot, targetDir); 459 + if (nearest) return nearest; 460 + return repoRoot; 461 + } 462 + 463 + function isExcludedByWorkspacePattern(relSegments, patterns) { 464 + return patterns.some((rawPattern) => { 465 + const pattern = normalizeWorkspacePattern(rawPattern); 466 + if (!pattern.startsWith('!')) return false; 467 + return workspacePatternMatchesRel(pattern.slice(1), relSegments); 468 + }); 469 + } 470 + 471 + function nearestProjectLikeRoot(repoRoot, targetDir) { 472 + let dir = path.resolve(targetDir); 473 + const stop = path.resolve(repoRoot); 474 + while (dir && dir !== stop) { 475 + if ( 476 + firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES]) 477 + || fs.existsSync(path.join(dir, 'package.json')) 478 + ) { 479 + return dir; 480 + } 481 + const parent = path.dirname(dir); 482 + if (parent === dir) break; 483 + dir = parent; 484 + } 485 + return null; 486 + } 487 + 488 + function nearestPackageRootBetween(repoRoot, targetDir, stopDir) { 489 + let dir = path.resolve(targetDir); 490 + const stop = path.resolve(stopDir || repoRoot); 491 + const root = path.resolve(repoRoot); 492 + while (dir && dir !== stop && isPathInsideOrEqual(dir, root)) { 493 + if (fs.existsSync(path.join(dir, 'package.json'))) return dir; 494 + const parent = path.dirname(dir); 495 + if (parent === dir) break; 496 + dir = parent; 497 + } 498 + return null; 499 + } 500 + 501 + function isPathInsideOrEqual(candidate, root) { 502 + return path.resolve(candidate) === path.resolve(root) || isPathInside(candidate, root); 503 + } 504 + 505 + function workspacePatternMatchesRel(pattern, relSegments) { 506 + const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean); 507 + if (!patternSegments.length) return false; 508 + if (patternSegments.includes('**')) { 509 + const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*')); 510 + const literalPrefix = firstGlobIndex === -1 511 + ? patternSegments 512 + : patternSegments.slice(0, firstGlobIndex); 513 + if (relSegments.length < literalPrefix.length + 1) return false; 514 + for (let i = 0; i < literalPrefix.length; i++) { 515 + if (!segmentMatches(literalPrefix[i], relSegments[i])) return false; 516 + } 517 + return true; 518 + } 519 + if (relSegments.length < patternSegments.length) return false; 520 + for (let i = 0; i < patternSegments.length; i++) { 521 + if (!segmentMatches(patternSegments[i], relSegments[i])) return false; 522 + } 523 + return true; 524 + } 525 + 526 + function readWorkspacePatterns(repoRoot) { 527 + return [ 528 + ...readPackageWorkspaces(repoRoot), 529 + ...readPnpmWorkspaces(repoRoot), 530 + ...readLernaWorkspaces(repoRoot), 531 + ].filter(Boolean); 532 + } 533 + 534 + function readPackageWorkspaces(repoRoot) { 535 + const pkg = readJson(path.join(repoRoot, 'package.json')); 536 + const workspaces = pkg?.workspaces; 537 + if (Array.isArray(workspaces)) return workspaces; 538 + if (Array.isArray(workspaces?.packages)) return workspaces.packages; 539 + return []; 540 + } 541 + 542 + function readLernaWorkspaces(repoRoot) { 543 + const lerna = readJson(path.join(repoRoot, 'lerna.json')); 544 + return Array.isArray(lerna?.packages) ? lerna.packages : []; 545 + } 546 + 547 + function readPnpmWorkspaces(repoRoot) { 548 + try { 549 + const body = fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf-8'); 550 + const patterns = []; 551 + let inPackages = false; 552 + for (const line of body.split(/\r?\n/)) { 553 + const trimmed = stripYamlInlineComment(line).trim(); 554 + if (!trimmed || trimmed.startsWith('#')) continue; 555 + const flowMatch = trimmed.match(/^packages:\s*\[(.*)\]\s*$/); 556 + if (flowMatch) { 557 + patterns.push(...parseYamlFlowList(flowMatch[1])); 558 + inPackages = false; 559 + continue; 560 + } 561 + if (/^packages:\s*$/.test(trimmed)) { 562 + inPackages = true; 563 + continue; 564 + } 565 + if (inPackages && /^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break; 566 + if (inPackages) { 567 + const match = trimmed.match(/^-\s*(.+)$/); 568 + if (match) patterns.push(unquoteYamlValue(match[1])); 569 + } 570 + } 571 + return patterns; 572 + } catch { 573 + return []; 574 + } 575 + } 576 + 577 + function stripYamlInlineComment(line) { 578 + let quote = null; 579 + for (let i = 0; i < line.length; i++) { 580 + const ch = line[i]; 581 + if ((ch === '"' || ch === "'") && line[i - 1] !== '\\') { 582 + quote = quote === ch ? null : quote || ch; 583 + continue; 584 + } 585 + if (ch === '#' && !quote) return line.slice(0, i); 586 + } 587 + return line; 588 + } 589 + 590 + function parseYamlFlowList(body) { 591 + const items = []; 592 + let quote = null; 593 + let current = ''; 594 + for (let i = 0; i < body.length; i++) { 595 + const ch = body[i]; 596 + if ((ch === '"' || ch === "'") && body[i - 1] !== '\\') { 597 + quote = quote === ch ? null : quote || ch; 598 + current += ch; 599 + continue; 600 + } 601 + if (ch === ',' && !quote) { 602 + const value = unquoteYamlValue(current); 603 + if (value) items.push(value); 604 + current = ''; 605 + continue; 606 + } 607 + current += ch; 608 + } 609 + const value = unquoteYamlValue(current); 610 + if (value) items.push(value); 611 + return items; 612 + } 613 + 614 + function unquoteYamlValue(value) { 615 + return String(value || '') 616 + .trim() 617 + .replace(/^['"]|['"]$/g, ''); 618 + } 619 + 620 + function readJson(filePath) { 621 + try { 622 + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); 623 + } catch { 624 + return null; 625 + } 626 + } 627 + 628 + function projectRootFromWorkspacePattern(repoRoot, relSegments, rawPattern) { 629 + const pattern = normalizeWorkspacePattern(rawPattern); 630 + if (!pattern || pattern.startsWith('!')) return null; 631 + const patternSegments = pattern.split('/').filter(Boolean); 632 + if (!patternSegments.length) return null; 633 + if (patternSegments.includes('**')) { 634 + return projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments); 635 + } 636 + if (relSegments.length < patternSegments.length) return null; 637 + for (let i = 0; i < patternSegments.length; i++) { 638 + if (!segmentMatches(patternSegments[i], relSegments[i])) return null; 639 + } 640 + return path.join(repoRoot, ...relSegments.slice(0, patternSegments.length)); 641 + } 642 + 643 + function projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments) { 644 + const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*')); 645 + const literalPrefix = firstGlobIndex === -1 646 + ? patternSegments 647 + : patternSegments.slice(0, firstGlobIndex); 648 + if (relSegments.length < literalPrefix.length + 1) return null; 649 + for (let i = 0; i < literalPrefix.length; i++) { 650 + if (!segmentMatches(literalPrefix[i], relSegments[i])) return null; 651 + } 652 + const prefixDir = path.join(repoRoot, ...literalPrefix); 653 + const targetDir = path.join(repoRoot, ...relSegments); 654 + const packageRoot = nearestPackageRootBetween(repoRoot, targetDir, prefixDir); 655 + if (packageRoot) return packageRoot; 656 + return path.join(repoRoot, ...relSegments.slice(0, literalPrefix.length + 1)); 657 + } 658 + 659 + function normalizeWorkspacePattern(pattern) { 660 + return String(pattern || '') 661 + .trim() 662 + .replace(/^['"]|['"]$/g, '') 663 + .replace(/^\.\//, '') 664 + .replace(/\/+$/, ''); 665 + } 666 + 667 + function segmentMatches(patternSegment, relSegment) { 668 + if (patternSegment === '*') return true; 669 + if (!patternSegment.includes('*')) return patternSegment === relSegment; 670 + const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`); 671 + return re.test(relSegment); 672 + } 673 + 674 + function firstExisting(dir, names) { 675 + for (const name of names) { 676 + const abs = path.join(dir, name); 677 + if (fs.existsSync(abs)) return abs; 678 + } 679 + return null; 680 + } 681 + 682 + function safeRead(p) { 683 + try { 684 + return fs.readFileSync(p, 'utf-8'); 685 + } catch { 686 + return null; 687 + } 688 + } 689 + 690 + function escapeRegExp(value) { 691 + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 692 + } 693 + 694 + /** 695 + * Pull the register (`brand` or `product`) out of PRODUCT.md by looking 696 + * for a `## Register` section and reading the first non-empty line that 697 + * follows it. Returns null when the file is legacy / register-less. 698 + */ 699 + export function extractRegister(product) { 700 + if (!product) return null; 701 + const lines = product.split('\n'); 702 + for (let i = 0; i < lines.length; i++) { 703 + if (/^##\s+Register\b/i.test(lines[i].trim())) { 704 + for (let j = i + 1; j < lines.length; j++) { 705 + const next = lines[j].trim(); 706 + if (!next) continue; 707 + const word = next.toLowerCase(); 708 + if (word === 'brand' || word === 'product') return word; 709 + return null; 710 + } 711 + } 712 + } 713 + return null; 714 + } 715 + 716 + /** 717 + * Read the installed skill's own version from the sibling SKILL.md frontmatter 718 + * (this file lives at `<skill>/scripts/context.mjs`). Returns null when the 719 + * frontmatter is missing or unreadable. 720 + */ 721 + function readLocalSkillVersion() { 722 + try { 723 + const here = path.dirname(fileURLToPath(import.meta.url)); 724 + const skillMd = path.join(here, '..', 'SKILL.md'); 725 + const content = fs.readFileSync(skillMd, 'utf-8'); 726 + const match = content.match(/^version:\s*(.+)$/m); 727 + return match ? match[1].trim().replace(/^["']|["']$/g, '') : null; 728 + } catch { 729 + return null; 730 + } 731 + } 732 + 733 + function readUpdateCache() { 734 + try { 735 + return JSON.parse(fs.readFileSync(UPDATE_CACHE_PATH, 'utf-8')); 736 + } catch { 737 + return {}; 738 + } 739 + } 740 + 741 + function writeUpdateCache(cache) { 742 + try { 743 + fs.mkdirSync(path.dirname(UPDATE_CACHE_PATH), { recursive: true }); 744 + fs.writeFileSync(UPDATE_CACHE_PATH, JSON.stringify(cache)); 745 + } catch { 746 + // Best-effort: a read-only home dir just means we re-poll next session. 747 + } 748 + } 749 + 750 + /** Compare dotted numeric versions. Returns >0 when a is newer than b. */ 751 + function compareSemver(a, b) { 752 + const pa = String(a).split('.').map(n => parseInt(n, 10) || 0); 753 + const pb = String(b).split('.').map(n => parseInt(n, 10) || 0); 754 + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { 755 + const diff = (pa[i] || 0) - (pb[i] || 0); 756 + if (diff !== 0) return diff; 757 + } 758 + return 0; 759 + } 760 + 761 + async function fetchLatestSkillVersion() { 762 + try { 763 + const res = await fetch(`${UPDATE_HOST}/api/version`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); 764 + if (!res.ok) return null; 765 + const data = await res.json(); 766 + return typeof data?.skills === 'string' ? data.skills : null; 767 + } catch { 768 + return null; // offline, sandboxed, timed out, or bad JSON: all non-fatal 769 + } 770 + } 771 + 772 + function buildUpdateDirective(localVersion, latestVersion) { 773 + return ( 774 + `UPDATE_AVAILABLE: A newer Impeccable skill is available ` + 775 + `(installed v${localVersion}, latest v${latestVersion}). ` + 776 + `Before continuing, ask the user once: "A newer Impeccable (v${latestVersion}) is available. ` + 777 + `Update now? It runs \`npx impeccable update\`." ` + 778 + `If they agree, run \`npx impeccable update\` (the update applies to the next session, not this one). ` + 779 + `Either way, continue the current task without waiting, and do not raise this again.` 780 + ); 781 + } 782 + 783 + /** 784 + * Best-effort update directive for the boot output. Returns a string to append 785 + * or null. Polls the version endpoint at most once per day (cached globally in 786 + * the user's home dir) and re-surfaces a given version at most once per week so 787 + * the agent never nags. Opt out entirely with IMPECCABLE_NO_UPDATE_CHECK=1. 788 + */ 789 + // Read the unified config's top-level `updateCheck` (local overrides shared). 790 + // Inlined rather than importing hook-lib so the boot path stays lightweight. 791 + function updateCheckDisabledByConfig(cwd = process.cwd()) { 792 + let value; 793 + for (const name of ['config.json', 'config.local.json']) { 794 + try { 795 + const raw = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', name), 'utf-8')); 796 + if (raw && typeof raw === 'object' && typeof raw.updateCheck === 'boolean') value = raw.updateCheck; 797 + } catch { /* missing or malformed: ignore */ } 798 + } 799 + return value === false; 800 + } 801 + 802 + async function computeUpdateDirective(now = Date.now()) { 803 + try { 804 + if (process.env.IMPECCABLE_NO_UPDATE_CHECK) return null; 805 + if (updateCheckDisabledByConfig()) return null; 806 + const localVersion = readLocalSkillVersion(); 807 + if (!localVersion) return null; 808 + 809 + const cache = readUpdateCache(); 810 + 811 + // Poll the network only when the throttle window has elapsed. Stamp 812 + // lastCheck even on failure so an offline machine doesn't poll every boot. 813 + if (!cache.lastCheck || now - cache.lastCheck > CHECK_INTERVAL_MS) { 814 + const latest = await fetchLatestSkillVersion(); 815 + cache.lastCheck = now; 816 + if (latest) cache.latestVersion = latest; 817 + writeUpdateCache(cache); 818 + } 819 + 820 + const latest = cache.latestVersion; 821 + if (!latest || compareSemver(latest, localVersion) <= 0) return null; 822 + 823 + // Anti-nag: surface a given version at most once per RENOTIFY window. 824 + if (cache.notifiedVersion === latest && cache.notifiedAt && now - cache.notifiedAt < RENOTIFY_INTERVAL_MS) { 825 + return null; 826 + } 827 + cache.notifiedVersion = latest; 828 + cache.notifiedAt = now; 829 + writeUpdateCache(cache); 830 + 831 + return buildUpdateDirective(localVersion, latest); 832 + } catch { 833 + return null; 834 + } 835 + } 836 + 837 + async function cli() { 838 + let cliOptions; 839 + try { 840 + cliOptions = parseCliOptions(process.argv.slice(2)); 841 + } catch (err) { 842 + if (err?.name === 'TargetArgError') { 843 + process.stderr.write(`${err.message}\n`); 844 + process.exit(1); 845 + } 846 + throw err; 847 + } 848 + const targetProvided = hasTargetOption(cliOptions); 849 + const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null; 850 + const selection = resolveTargetSelection(process.cwd(), cliOptions); 851 + if (selection) { 852 + process.stdout.write(buildTargetSelectionDirective(selection) + '\n'); 853 + process.exit(0); 854 + } 855 + const ctx = loadContext(process.cwd(), cliOptions); 856 + const updateDirective = await computeUpdateDirective(); 857 + 858 + if (!ctx.hasProduct) { 859 + // Direct stdout message instead of relying on empty output as a signal 860 + // — cheap models miss the empty case more often than the explicit one. 861 + const parts = [ 862 + 'NO_PRODUCT_MD: This project has no PRODUCT.md yet. ' + 863 + 'Stop the current task, load reference/init.md, and follow its ' + 864 + 'instructions to write PRODUCT.md before resuming.', 865 + ]; 866 + parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists })); 867 + if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) { 868 + parts.push(buildMissingTargetDirective()); 869 + } 870 + if (updateDirective) parts.push(updateDirective); 871 + process.stdout.write(parts.join('\n\n---\n\n') + '\n'); 872 + process.exit(0); 873 + } 874 + const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`]; 875 + if (ctx.hasDesign) { 876 + parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`); 877 + } 878 + parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists })); 879 + if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) { 880 + parts.push(buildMissingTargetDirective()); 881 + } 882 + const register = extractRegister(ctx.product); 883 + const next = register 884 + ? `NEXT STEP: This project's register is \`${register}\`. You MUST now read \`reference/${register}.md\` before producing any design output.` 885 + : `NEXT STEP: You MUST now read the matching register reference (\`reference/brand.md\` or \`reference/product.md\`) before producing any design output. Pick based on PRODUCT.md above.`; 886 + parts.push(next); 887 + if (updateDirective) parts.push(updateDirective); 888 + process.stdout.write(parts.join('\n\n---\n\n') + '\n'); 889 + } 890 + 891 + function parseCliOptions(args) { 892 + return parseTargetOptions(args, { strict: true }); 893 + } 894 + 895 + function hasTargetOption(options) { 896 + return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim()); 897 + } 898 + 899 + function pathExistsForTarget(cwd, targetPath) { 900 + const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath); 901 + return fs.existsSync(abs); 902 + } 903 + 904 + function buildResolvedContextDirective(ctx, options, { targetExists = null } = {}) { 905 + const targetPath = hasTargetOption(options) ? options.targetPath : null; 906 + return `RESOLVED_CONTEXT:\n${JSON.stringify({ 907 + targetPath, 908 + ...(targetPath ? { targetExists } : {}), 909 + projectRoot: ctx.projectRoot, 910 + repoRoot: ctx.repoRoot, 911 + productPath: ctx.productPath, 912 + designPath: ctx.designPath, 913 + }, null, 2)}`; 914 + } 915 + 916 + function shouldWarnMissingTarget(ctx, targetProvided, targetExists = null) { 917 + if (ctx.isMonorepo && targetProvided && targetExists === false) return true; 918 + return !!( 919 + ctx.isMonorepo 920 + && (!targetProvided || targetExists === false) 921 + && ctx.projectRoot 922 + && ctx.repoRoot 923 + && path.resolve(ctx.projectRoot) === path.resolve(ctx.repoRoot) 924 + ); 925 + } 926 + 927 + function buildMissingTargetDirective() { 928 + const script = process.argv[1] || 'context.mjs'; 929 + return ( 930 + 'MONOREPO_TARGET_REQUIRED: This is a monorepo and context.mjs ran without --target. ' + 931 + 'If the user named a file, route, or child app, do not answer from this output. ' + 932 + `Rerun \`node ${script} --target <path>\` and answer from that run's RESOLVED_CONTEXT fields.` 933 + ); 934 + } 935 + 936 + function buildTargetSelectionDirective(selection) { 937 + return ( 938 + `TARGET_SELECTION_REQUIRED:\n${JSON.stringify(selection, null, 2)}\n\n` + 939 + 'Show each app with its productStatus/productPath and designStatus/designPath so the user can see child overrides, inherited root files, fallback files, or missing files before choosing. ' + 940 + 'Ask the user which app Impeccable should use, then rerun Impeccable helper commands from that child app cwd using this same scripts directory. ' + 941 + 'Use `--target <path>` only as a fallback when changing cwd is not possible, or when the user explicitly named a file/path.' 942 + ); 943 + } 944 + 945 + // Run cli() only when this module is the entry point. Compare realpaths 946 + // rather than endsWith(): a loose suffix match also fires for unrelated 947 + // scripts like `load-context.mjs`, and realpath tolerates symlinked 948 + // invocation (the test harness symlinks the skill dir). 949 + function invokedAsScript() { 950 + const arg = process.argv[1]; 951 + if (!arg) return false; 952 + try { 953 + return fs.realpathSync(arg) === fs.realpathSync(fileURLToPath(import.meta.url)); 954 + } catch { 955 + return false; 956 + } 957 + } 958 + 959 + if (invokedAsScript()) { 960 + cli(); 961 + }
+21
tools/design-detector/detect.mjs
··· 1 + #!/usr/bin/env node 2 + 3 + import fs from 'node:fs'; 4 + import path from 'node:path'; 5 + import { pathToFileURL, fileURLToPath } from 'node:url'; 6 + 7 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 8 + const candidates = [ 9 + path.join(__dirname, 'detector', 'detect-antipatterns.mjs'), 10 + path.join(__dirname, '..', '..', 'cli', 'engine', 'detect-antipatterns.mjs'), 11 + ]; 12 + const detectorPath = candidates.find(p => fs.existsSync(p)); 13 + 14 + if (!detectorPath) { 15 + process.stderr.write('Error: bundled detector not found.\n'); 16 + process.exit(1); 17 + } 18 + 19 + const { detectCli } = await import(pathToFileURL(detectorPath)); 20 + 21 + await detectCli();
+1937
tools/design-detector/detector/browser/injected/index.mjs
··· 1 + const IS_BROWSER = typeof window !== 'undefined'; 2 + 3 + // ─── Section 7: Browser UI (IS_BROWSER only) ──────────────────────────────── 4 + 5 + if (IS_BROWSER) { 6 + // Detect extension mode via the script tag's data attribute or the document element fallback. 7 + // currentScript is reliable for synchronously-executing scripts (which our IIFE is). 8 + const _myScript = document.currentScript; 9 + const EXTENSION_MODE = (_myScript && _myScript.dataset.impeccableExtension === 'true') 10 + || document.documentElement.dataset.impeccableExtension === 'true'; 11 + 12 + // Kinpaku gold — pinned to the site's brand token (see 13 + // site/styles/kinpaku-tokens.css --ks-kinpaku). Keep this in sync with 14 + // the picker's C.brand in skill/scripts/live-browser.js and the kit's 15 + // picker section in site/styles/kinpaku-kit.css. 16 + // 17 + // One color across both light and dark host pages. The outline is a 18 + // 2px gesture pointing at an element + a labeled tag — it's a marker, 19 + // not body text, so it doesn't need WCAG AA against the page. The 20 + // label text inside the gold tag is dark (LABEL_INK) which has ~16:1 21 + // against the leaf gold, so reading the rule name is solid in both 22 + // modes. Hover deepens the gold (preserves chroma — never drops it, 23 + // dropping chroma washes the gold into a sand/olive tone). 24 + const BRAND_COLOR = 'oklch(84% 0.19 80.46)'; 25 + const BRAND_COLOR_HOVER = 'oklch(74% 0.18 80)'; 26 + const LABEL_INK = 'oklch(4% 0.004 95)'; 27 + const LABEL_BG = BRAND_COLOR; 28 + const OUTLINE_COLOR = BRAND_COLOR; 29 + 30 + // Inject hover styles via CSS (more reliable than JS event listeners) 31 + const styleEl = document.createElement('style'); 32 + styleEl.textContent = ` 33 + @keyframes impeccable-reveal { 34 + from { opacity: 0; } 35 + to { opacity: 1; } 36 + } 37 + .impeccable-overlay:not(.impeccable-banner) { 38 + pointer-events: none; 39 + outline: 2px solid ${OUTLINE_COLOR}; 40 + border-radius: 4px; 41 + transition: outline-color 0.15s ease; 42 + animation: impeccable-reveal 0.4s cubic-bezier(0.16, 1, 0.3, 1) both; 43 + animation-play-state: paused; 44 + border-top-left-radius: 0; 45 + } 46 + .impeccable-overlay.impeccable-visible { 47 + animation-play-state: running; 48 + } 49 + .impeccable-overlay.impeccable-hover { 50 + outline-color: ${BRAND_COLOR_HOVER}; 51 + z-index: 100001 !important; 52 + } 53 + .impeccable-overlay.impeccable-hover .impeccable-label { 54 + background: ${BRAND_COLOR_HOVER}; 55 + } 56 + .impeccable-overlay.impeccable-spotlight { 57 + z-index: 100002 !important; 58 + } 59 + .impeccable-overlay.impeccable-spotlight-dimmed { 60 + opacity: 0.15 !important; 61 + animation: none !important; 62 + filter: blur(3px); 63 + } 64 + .impeccable-spotlight-backdrop { 65 + position: fixed; 66 + top: 0; left: 0; right: 0; bottom: 0; 67 + backdrop-filter: blur(3px) brightness(0.6); 68 + -webkit-backdrop-filter: blur(3px) brightness(0.6); 69 + pointer-events: none; 70 + z-index: 99998; 71 + opacity: 0; 72 + outline: none !important; 73 + animation: none !important; 74 + } 75 + .impeccable-spotlight-backdrop.impeccable-visible { 76 + opacity: 1; 77 + } 78 + .impeccable-hidden .impeccable-overlay${EXTENSION_MODE ? '' : ':not(.impeccable-banner)'} { 79 + display: none !important; 80 + } 81 + `; 82 + (document.head || document.documentElement).appendChild(styleEl); 83 + 84 + // Spotlight backdrop element (created lazily on first use) 85 + let spotlightBackdrop = null; 86 + let spotlightTarget = null; 87 + 88 + function getSpotlightBackdrop() { 89 + if (!spotlightBackdrop) { 90 + spotlightBackdrop = document.createElement('div'); 91 + spotlightBackdrop.className = 'impeccable-spotlight-backdrop'; 92 + document.body.appendChild(spotlightBackdrop); 93 + } 94 + return spotlightBackdrop; 95 + } 96 + 97 + function updateSpotlightClipPath() { 98 + if (!spotlightBackdrop || !spotlightTarget) return; 99 + const r = spotlightTarget.getBoundingClientRect(); 100 + // Match the overlay's outer edge: element rect + 4px (2px overlay offset + 2px outline width) 101 + const inset = 4; 102 + const radius = 6; // outline border-radius (4) + outline width (2) 103 + const x1 = r.left - inset; 104 + const y1 = r.top - inset; 105 + const x2 = r.right + inset; 106 + const y2 = r.bottom + inset; 107 + const vw = window.innerWidth; 108 + const vh = window.innerHeight; 109 + // Outer rect + rounded inner rect (evenodd creates a hole) 110 + const path = `M0 0H${vw}V${vh}H0Z M${x1 + radius} ${y1}H${x2 - radius}A${radius} ${radius} 0 0 1 ${x2} ${y1 + radius}V${y2 - radius}A${radius} ${radius} 0 0 1 ${x2 - radius} ${y2}H${x1 + radius}A${radius} ${radius} 0 0 1 ${x1} ${y2 - radius}V${y1 + radius}A${radius} ${radius} 0 0 1 ${x1 + radius} ${y1}Z`; 111 + spotlightBackdrop.style.clipPath = `path(evenodd, "${path}")`; 112 + } 113 + 114 + function showSpotlight(target) { 115 + if (!target || !target.getBoundingClientRect) return; 116 + // Respect the spotlightBlur setting: if disabled, don't show the backdrop 117 + if (window.__IMPECCABLE_CONFIG__?.spotlightBlur === false) { 118 + spotlightTarget = target; 119 + return; 120 + } 121 + spotlightTarget = target; 122 + const bd = getSpotlightBackdrop(); 123 + updateSpotlightClipPath(); 124 + bd.classList.add('impeccable-visible'); 125 + } 126 + 127 + function hideSpotlight() { 128 + spotlightTarget = null; 129 + if (spotlightBackdrop) spotlightBackdrop.classList.remove('impeccable-visible'); 130 + } 131 + 132 + function isInViewport(el) { 133 + const r = el.getBoundingClientRect(); 134 + return r.top >= 0 && r.left >= 0 && r.bottom <= window.innerHeight && r.right <= window.innerWidth; 135 + } 136 + 137 + // Reposition spotlight on scroll/resize 138 + window.addEventListener('scroll', () => { 139 + if (spotlightTarget) updateSpotlightClipPath(); 140 + }, { passive: true }); 141 + window.addEventListener('resize', () => { 142 + if (spotlightTarget) updateSpotlightClipPath(); 143 + }); 144 + 145 + const overlays = []; 146 + const TYPE_LABELS = {}; 147 + const RULE_CATEGORY = {}; 148 + for (const ap of ANTIPATTERNS) { 149 + TYPE_LABELS[ap.id] = ap.name.toLowerCase(); 150 + RULE_CATEGORY[ap.id] = ap.category || 'quality'; 151 + } 152 + 153 + function isInFixedContext(el) { 154 + let p = el; 155 + while (p && p !== document.body) { 156 + if (getComputedStyle(p).position === 'fixed') return true; 157 + p = p.parentElement; 158 + } 159 + return false; 160 + } 161 + 162 + function positionOverlay(overlay) { 163 + const el = overlay._targetEl; 164 + if (!el) return; 165 + const rect = el.getBoundingClientRect(); 166 + if (overlay._isFixed) { 167 + // Viewport-relative coords for fixed targets 168 + overlay.style.top = `${rect.top - 2}px`; 169 + overlay.style.left = `${rect.left - 2}px`; 170 + } else { 171 + // Document-relative coords for normal targets 172 + overlay.style.top = `${rect.top + scrollY - 2}px`; 173 + overlay.style.left = `${rect.left + scrollX - 2}px`; 174 + } 175 + overlay.style.width = `${rect.width + 4}px`; 176 + overlay.style.height = `${rect.height + 4}px`; 177 + } 178 + 179 + function repositionOverlays() { 180 + for (const o of overlays) { 181 + if (!o._targetEl || o.classList.contains('impeccable-banner')) continue; 182 + // Skip overlays whose target is currently hidden (display: none on the overlay) 183 + if (o.style.display === 'none') continue; 184 + positionOverlay(o); 185 + } 186 + } 187 + 188 + let resizeRAF; 189 + const onResize = () => { 190 + cancelAnimationFrame(resizeRAF); 191 + resizeRAF = requestAnimationFrame(repositionOverlays); 192 + }; 193 + window.addEventListener('resize', onResize); 194 + // Reposition on scroll too -- catches sticky/parallax shifts 195 + window.addEventListener('scroll', onResize, { passive: true }); 196 + // Reposition when body resizes (lazy-loaded images, dynamic content, fonts loading) 197 + if (typeof ResizeObserver !== 'undefined') { 198 + const bodyResizeObserver = new ResizeObserver(onResize); 199 + bodyResizeObserver.observe(document.body); 200 + } 201 + 202 + // Track target element visibility via IntersectionObserver. 203 + // Uses a huge rootMargin so all *rendered* elements count as intersecting, 204 + // while display:none / closed <details> / hidden modals etc. do not. 205 + // This is event-driven -- no polling needed. 206 + let overlayIndex = 0; 207 + const visibilityObserver = new IntersectionObserver((entries) => { 208 + for (const entry of entries) { 209 + const overlay = entry.target._impeccableOverlay; 210 + if (!overlay) continue; 211 + if (entry.isIntersecting) { 212 + overlay.style.display = ''; 213 + positionOverlay(overlay); 214 + if (!overlay._revealed) { 215 + overlay._revealed = true; 216 + if (firstScanDone) { 217 + // Subsequent reveals (re-scans, scroll-into-view): instant, no animation 218 + overlay.style.animation = 'none'; 219 + } else { 220 + // Initial scan: staggered cascade reveal 221 + overlay.style.animationDelay = `${Math.min((overlay._staggerIndex || 0) * 60, 600)}ms`; 222 + } 223 + requestAnimationFrame(() => { 224 + overlay.classList.add('impeccable-visible'); 225 + if (overlay._checkLabel) overlay._checkLabel(); 226 + }); 227 + } 228 + } else { 229 + overlay.style.display = 'none'; 230 + } 231 + } 232 + }, { rootMargin: '99999px' }); 233 + 234 + function detachOverlay(overlay) { 235 + if (!overlay) return; 236 + if (typeof overlay._cleanup === 'function') { 237 + try { overlay._cleanup(); } catch { /* best effort overlay teardown */ } 238 + } 239 + if (overlay._targetEl && overlay._targetEl._impeccableOverlay === overlay) { 240 + visibilityObserver.unobserve(overlay._targetEl); 241 + delete overlay._targetEl._impeccableOverlay; 242 + } 243 + const idx = overlays.indexOf(overlay); 244 + if (idx >= 0) overlays.splice(idx, 1); 245 + overlay.remove(); 246 + } 247 + 248 + // Reposition overlays after CSS transitions end (e.g. reveal animations). 249 + // Listens at document level so it catches transitions on ancestor elements 250 + // (the transform may be on a parent, not the flagged element itself). 251 + document.addEventListener('transitionend', (e) => { 252 + if (e.propertyName !== 'transform') return; 253 + for (const o of overlays) { 254 + if (!o._targetEl || o.classList.contains('impeccable-banner') || o.style.display === 'none') continue; 255 + if (e.target === o._targetEl || e.target.contains(o._targetEl)) { 256 + positionOverlay(o); 257 + } 258 + } 259 + }); 260 + 261 + const highlight = function(el, findings) { 262 + if (el._impeccableOverlay) detachOverlay(el._impeccableOverlay); 263 + const hasSlop = findings.some(f => RULE_CATEGORY[f.type || f.id] === 'slop'); 264 + 265 + const fixed = isInFixedContext(el); 266 + const rect = el.getBoundingClientRect(); 267 + const outline = document.createElement('div'); 268 + outline.className = 'impeccable-overlay'; 269 + outline._targetEl = el; 270 + outline._isFixed = fixed; 271 + Object.assign(outline.style, { 272 + position: fixed ? 'fixed' : 'absolute', 273 + top: fixed ? `${rect.top - 2}px` : `${rect.top + scrollY - 2}px`, 274 + left: fixed ? `${rect.left - 2}px` : `${rect.left + scrollX - 2}px`, 275 + width: `${rect.width + 4}px`, height: `${rect.height + 4}px`, 276 + zIndex: '99999', boxSizing: 'border-box', 277 + }); 278 + 279 + // Build per-finding label entries: ✦ prefix for slop 280 + const entries = findings.map(f => { 281 + const name = TYPE_LABELS[f.type || f.id] || f.type || f.id; 282 + const prefix = RULE_CATEGORY[f.type || f.id] === 'slop' ? '\u2726 ' : ''; 283 + return { name: prefix + name, detail: f.detail || f.snippet }; 284 + }); 285 + const allText = entries.map(e => e.name).join(', '); 286 + 287 + const label = document.createElement('div'); 288 + label.className = 'impeccable-label'; 289 + Object.assign(label.style, { 290 + position: 'absolute', bottom: '100%', left: '-2px', 291 + display: 'flex', alignItems: 'center', 292 + whiteSpace: 'nowrap', 293 + fontSize: '11px', fontWeight: '600', letterSpacing: '0.02em', 294 + color: LABEL_INK, lineHeight: '14px', 295 + background: LABEL_BG, 296 + fontFamily: 'system-ui, sans-serif', 297 + borderRadius: '4px 4px 0 0', 298 + }); 299 + 300 + const textSpan = document.createElement('span'); 301 + textSpan.style.padding = '3px 8px'; 302 + textSpan.textContent = allText; 303 + label.appendChild(textSpan); 304 + 305 + // State for cycling mode 306 + let cycleMode = false; 307 + let cycleIndex = 0; 308 + let isHovered = false; 309 + let prevBtn, nextBtn; 310 + 311 + function updateCycleText() { 312 + const e = entries[cycleIndex]; 313 + textSpan.textContent = isHovered ? e.detail : e.name; 314 + } 315 + 316 + function enableCycleMode() { 317 + if (cycleMode || entries.length < 2) return; 318 + cycleMode = true; 319 + 320 + const btnStyle = { 321 + background: 'none', border: 'none', color: 'rgba(255,255,255,0.7)', 322 + fontSize: '11px', cursor: 'pointer', padding: '3px 4px', 323 + fontFamily: 'system-ui, sans-serif', lineHeight: '14px', 324 + pointerEvents: 'auto', 325 + }; 326 + 327 + const navGroup = document.createElement('span'); 328 + Object.assign(navGroup.style, { 329 + display: 'inline-flex', alignItems: 'center', flexShrink: '0', 330 + }); 331 + 332 + prevBtn = document.createElement('button'); 333 + prevBtn.textContent = '\u2039'; 334 + Object.assign(prevBtn.style, btnStyle); 335 + prevBtn.style.paddingLeft = '6px'; 336 + prevBtn.addEventListener('click', (e) => { 337 + e.stopPropagation(); 338 + cycleIndex = (cycleIndex - 1 + entries.length) % entries.length; 339 + updateCycleText(); 340 + }); 341 + 342 + nextBtn = document.createElement('button'); 343 + nextBtn.textContent = '\u203A'; 344 + Object.assign(nextBtn.style, btnStyle); 345 + nextBtn.style.paddingRight = '2px'; 346 + nextBtn.addEventListener('click', (e) => { 347 + e.stopPropagation(); 348 + cycleIndex = (cycleIndex + 1) % entries.length; 349 + updateCycleText(); 350 + }); 351 + 352 + navGroup.appendChild(prevBtn); 353 + navGroup.appendChild(nextBtn); 354 + label.insertBefore(navGroup, textSpan); 355 + textSpan.style.padding = '3px 8px 3px 4px'; 356 + updateCycleText(); 357 + } 358 + 359 + outline.appendChild(label); 360 + 361 + // Start hidden; the IntersectionObserver will show it once the target is rendered 362 + outline.style.display = 'none'; 363 + outline._staggerIndex = overlayIndex++; 364 + el._impeccableOverlay = outline; 365 + visibilityObserver.observe(el); 366 + 367 + // After first paint, check label width vs outline 368 + outline._checkLabel = () => { 369 + if (entries.length > 1 && label.offsetWidth > outline.offsetWidth) { 370 + enableCycleMode(); 371 + } 372 + }; 373 + 374 + // Hover: show detail text, darken 375 + const onMouseEnter = () => { 376 + isHovered = true; 377 + outline.classList.add('impeccable-hover'); 378 + outline.style.outlineColor = BRAND_COLOR_HOVER; 379 + label.style.background = BRAND_COLOR_HOVER; 380 + if (cycleMode) { 381 + updateCycleText(); 382 + } else { 383 + textSpan.textContent = entries.map(e => e.detail).join(' | '); 384 + } 385 + }; 386 + const onMouseLeave = () => { 387 + isHovered = false; 388 + outline.classList.remove('impeccable-hover'); 389 + outline.style.outlineColor = ''; 390 + label.style.background = LABEL_BG; 391 + if (cycleMode) { 392 + updateCycleText(); 393 + } else { 394 + textSpan.textContent = allText; 395 + } 396 + }; 397 + el.addEventListener('mouseenter', onMouseEnter); 398 + el.addEventListener('mouseleave', onMouseLeave); 399 + outline._cleanup = () => { 400 + el.removeEventListener('mouseenter', onMouseEnter); 401 + el.removeEventListener('mouseleave', onMouseLeave); 402 + }; 403 + 404 + document.body.appendChild(outline); 405 + overlays.push(outline); 406 + }; 407 + 408 + const showPageBanner = function(findings) { 409 + if (!findings.length) return; 410 + const banner = document.createElement('div'); 411 + banner.className = 'impeccable-overlay impeccable-banner'; 412 + Object.assign(banner.style, { 413 + position: 'fixed', top: '0', left: '0', right: '0', zIndex: '100000', 414 + background: LABEL_BG, color: LABEL_INK, 415 + fontFamily: 'system-ui, sans-serif', fontSize: '13px', 416 + display: 'flex', alignItems: 'center', pointerEvents: 'auto', 417 + height: '36px', overflow: 'hidden', maxWidth: '100vw', 418 + transform: 'translateY(-100%)', 419 + transition: 'transform 0.4s cubic-bezier(0.16, 1, 0.3, 1)', 420 + }); 421 + requestAnimationFrame(() => requestAnimationFrame(() => { 422 + banner.style.transform = 'translateY(0)'; 423 + })); 424 + 425 + // Scrollable findings area 426 + const scrollArea = document.createElement('div'); 427 + Object.assign(scrollArea.style, { 428 + flex: '1', minWidth: '0', overflowX: 'auto', overflowY: 'hidden', 429 + display: 'flex', gap: '8px', alignItems: 'center', 430 + padding: '0 12px', scrollSnapType: 'x mandatory', 431 + scrollbarWidth: 'none', 432 + }); 433 + for (const f of findings) { 434 + const prefix = RULE_CATEGORY[f.type] === 'slop' ? '\u2726 ' : ''; 435 + const tag = document.createElement('span'); 436 + tag.textContent = `${prefix}${TYPE_LABELS[f.type] || f.type}: ${f.detail}`; 437 + Object.assign(tag.style, { 438 + background: 'rgba(255,255,255,0.15)', padding: '2px 8px', 439 + borderRadius: '3px', fontSize: '12px', fontFamily: 'ui-monospace, monospace', 440 + whiteSpace: 'nowrap', flexShrink: '0', scrollSnapAlign: 'start', 441 + }); 442 + scrollArea.appendChild(tag); 443 + } 444 + banner.appendChild(scrollArea); 445 + 446 + // Controls area (only in standalone mode, not extension) 447 + if (!EXTENSION_MODE) { 448 + const controls = document.createElement('div'); 449 + Object.assign(controls.style, { 450 + display: 'flex', alignItems: 'center', gap: '2px', 451 + padding: '0 8px', flexShrink: '0', 452 + }); 453 + 454 + // Toggle visibility button 455 + const toggle = document.createElement('button'); 456 + toggle.textContent = '\u25C9'; // circle with dot (visible state) 457 + toggle.title = 'Toggle overlay visibility'; 458 + Object.assign(toggle.style, { 459 + background: 'none', border: 'none', 460 + color: 'white', fontSize: '16px', cursor: 'pointer', padding: '0 4px', 461 + opacity: '0.85', transition: 'opacity 0.15s', 462 + }); 463 + let overlaysVisible = true; 464 + toggle.addEventListener('click', () => { 465 + overlaysVisible = !overlaysVisible; 466 + document.body.classList.toggle('impeccable-hidden', !overlaysVisible); 467 + toggle.textContent = overlaysVisible ? '\u25C9' : '\u25CB'; // filled vs empty circle 468 + toggle.style.opacity = overlaysVisible ? '0.85' : '0.5'; 469 + }); 470 + controls.appendChild(toggle); 471 + 472 + // Close button 473 + const close = document.createElement('button'); 474 + close.textContent = '\u00d7'; 475 + close.title = 'Dismiss banner'; 476 + Object.assign(close.style, { 477 + background: 'none', border: 'none', 478 + color: 'white', fontSize: '18px', cursor: 'pointer', padding: '0 4px', 479 + }); 480 + close.addEventListener('click', () => banner.remove()); 481 + controls.appendChild(close); 482 + 483 + banner.appendChild(controls); 484 + } 485 + document.body.appendChild(banner); 486 + overlays.push(banner); 487 + }; 488 + 489 + // Heuristic for skipping CSS-in-JS hashed class names like "css-1a2b3c" or "_2x4hG_". 490 + // These change between builds and produce brittle, ugly selectors. 491 + function isLikelyHashedClass(c) { 492 + if (!c) return true; 493 + if (/^(css|sc|emotion|jsx|module)-[\w-]{4,}$/i.test(c)) return true; 494 + if (/^_[\w-]{5,}$/.test(c)) return true; 495 + if (/^[a-z0-9]{6,}$/i.test(c) && /\d/.test(c)) return true; 496 + return false; 497 + } 498 + 499 + function buildSelectorSegment(el) { 500 + const tag = el.tagName.toLowerCase(); 501 + let sel = tag; 502 + 503 + if (el.classList && el.classList.length > 0) { 504 + const classes = [...el.classList] 505 + .filter(c => !c.startsWith('impeccable-') && !isLikelyHashedClass(c)) 506 + .slice(0, 2); 507 + if (classes.length > 0) { 508 + sel += '.' + classes.map(c => CSS.escape(c)).join('.'); 509 + } 510 + } 511 + 512 + // Disambiguate among siblings only if the parent has multiple matches 513 + const parent = el.parentElement; 514 + if (parent) { 515 + try { 516 + const matching = parent.querySelectorAll(':scope > ' + sel); 517 + if (matching.length > 1) { 518 + const sameType = [...parent.children].filter(c => c.tagName === el.tagName); 519 + const idx = sameType.indexOf(el) + 1; 520 + sel += `:nth-of-type(${idx})`; 521 + } 522 + } catch { 523 + const idx = [...parent.children].indexOf(el) + 1; 524 + sel = `${tag}:nth-child(${idx})`; 525 + } 526 + } 527 + return sel; 528 + } 529 + 530 + function generateSelector(el) { 531 + if (el === document.body) return 'body'; 532 + if (el === document.documentElement) return 'html'; 533 + if (el.id) return '#' + CSS.escape(el.id); 534 + 535 + const parts = []; 536 + let current = el; 537 + let depth = 0; 538 + const MAX_DEPTH = 10; 539 + 540 + while (current && current !== document.body && current !== document.documentElement && depth < MAX_DEPTH) { 541 + parts.unshift(buildSelectorSegment(current)); 542 + 543 + // Anchor on an ancestor's ID and stop walking up 544 + if (current.id) { 545 + parts[0] = '#' + CSS.escape(current.id); 546 + break; 547 + } 548 + 549 + // Stop as soon as the partial selector uniquely identifies the target 550 + const trySelector = parts.join(' > '); 551 + try { 552 + const matches = document.querySelectorAll(trySelector); 553 + if (matches.length === 1 && matches[0] === el) { 554 + return trySelector; 555 + } 556 + } catch { /* invalid selector — keep walking */ } 557 + 558 + current = current.parentElement; 559 + depth++; 560 + } 561 + 562 + return parts.join(' > '); 563 + } 564 + 565 + function getDirectText(el) { 566 + return [...el.childNodes] 567 + .filter(n => n.nodeType === 3) 568 + .map(n => n.textContent || '') 569 + .join(''); 570 + } 571 + 572 + function getDirectTextRect(el) { 573 + const rects = []; 574 + for (const node of el.childNodes) { 575 + if (node.nodeType !== 3 || !(node.textContent || '').trim()) continue; 576 + const range = document.createRange(); 577 + range.selectNodeContents(node); 578 + for (const rect of range.getClientRects()) { 579 + if (rect.width >= 1 && rect.height >= 1) rects.push(rect); 580 + } 581 + range.detach?.(); 582 + } 583 + if (rects.length === 0) return null; 584 + const left = Math.min(...rects.map(r => r.left)); 585 + const top = Math.min(...rects.map(r => r.top)); 586 + const right = Math.max(...rects.map(r => r.right)); 587 + const bottom = Math.max(...rects.map(r => r.bottom)); 588 + return { 589 + left, 590 + top, 591 + right, 592 + bottom, 593 + width: right - left, 594 + height: bottom - top, 595 + x: left, 596 + y: top, 597 + }; 598 + } 599 + 600 + function collectVisualContrastReasons(el, style) { 601 + const reasons = new Set(); 602 + const bgClip = style.webkitBackgroundClip || style.backgroundClip || ''; 603 + const ownBgImage = style.backgroundImage || ''; 604 + if (bgClip === 'text' && ownBgImage && ownBgImage !== 'none') { 605 + reasons.add('background-clip text'); 606 + } 607 + if (style.textShadow && style.textShadow !== 'none') reasons.add('text shadow'); 608 + 609 + let current = el; 610 + while (current && current.nodeType === 1) { 611 + const tag = current.tagName?.toLowerCase(); 612 + const currentStyle = getComputedStyle(current); 613 + const bgImage = currentStyle.backgroundImage || ''; 614 + const isDocumentSurface = tag === 'body' || tag === 'html'; 615 + 616 + if (!isDocumentSurface && bgImage && bgImage !== 'none') { 617 + if (/url\s*\(/i.test(bgImage)) reasons.add('image background'); 618 + if (/gradient/i.test(bgImage)) reasons.add('gradient background'); 619 + } 620 + if (parseFloat(currentStyle.opacity) < 0.99) reasons.add('opacity stack'); 621 + if (currentStyle.mixBlendMode && currentStyle.mixBlendMode !== 'normal') reasons.add('blend mode'); 622 + if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter'); 623 + if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter'); 624 + 625 + const solidBg = parseRgb(currentStyle.backgroundColor); 626 + if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break; 627 + current = current.parentElement; 628 + } 629 + 630 + const sampleRect = getDirectTextRect(el) || el.getBoundingClientRect(); 631 + if (sampleRect && document.elementsFromPoint) { 632 + const points = [ 633 + [sampleRect.left + sampleRect.width / 2, sampleRect.top + sampleRect.height / 2], 634 + [sampleRect.left + Math.min(sampleRect.width - 1, Math.max(1, sampleRect.width * 0.25)), sampleRect.top + sampleRect.height / 2], 635 + [sampleRect.left + Math.min(sampleRect.width - 1, Math.max(1, sampleRect.width * 0.75)), sampleRect.top + sampleRect.height / 2], 636 + ]; 637 + for (const [x, y] of points) { 638 + if (x < 0 || y < 0 || x > window.innerWidth || y > window.innerHeight) continue; 639 + const stack = document.elementsFromPoint(x, y); 640 + const selfIndex = stack.findIndex(node => node === el || el.contains(node) || node.contains?.(el)); 641 + if (selfIndex < 0) continue; 642 + for (const node of stack.slice(selfIndex + 1)) { 643 + const nodeTag = node.tagName?.toLowerCase(); 644 + if (nodeTag === 'img' || nodeTag === 'picture' || nodeTag === 'video' || nodeTag === 'canvas' || nodeTag === 'svg') { 645 + reasons.add(`${nodeTag} underlay`); 646 + break; 647 + } 648 + } 649 + } 650 + } 651 + 652 + return [...reasons]; 653 + } 654 + 655 + function collectVisualContrastCandidates(options = {}) { 656 + const maxCandidates = Number.isFinite(options.maxCandidates) ? options.maxCandidates : 12; 657 + const candidates = []; 658 + for (const el of document.querySelectorAll('*')) { 659 + if (candidates.length >= maxCandidates) break; 660 + if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; 661 + if (el.closest('[id^="impeccable-live-"]')) continue; 662 + if (el === document.body || el === document.documentElement) continue; 663 + if (!isRenderedForBrowserRule(el)) continue; 664 + 665 + const tag = el.tagName.toLowerCase(); 666 + const style = getComputedStyle(el); 667 + if (style.display === 'none' || style.visibility === 'hidden') continue; 668 + const directText = getDirectText(el); 669 + const hasDirectText = directText.trim().length > 0; 670 + if (!hasDirectText || isEmojiOnlyText(directText)) continue; 671 + 672 + const bgColor = readOwnBackgroundColor(el, style); 673 + const isStyledButton = (tag === 'a' || tag === 'button') 674 + && bgColor && bgColor.a > 0.5; 675 + if (SAFE_TAGS.has(tag) && !isStyledButton) continue; 676 + 677 + const rect = getDirectTextRect(el) || el.getBoundingClientRect(); 678 + if (!rect || rect.width < 4 || rect.height < 4) continue; 679 + 680 + const reasons = collectVisualContrastReasons(el, style); 681 + if (reasons.length === 0) continue; 682 + 683 + const textColor = parseRgb(style.color); 684 + const fontSize = parseFloat(style.fontSize) || 16; 685 + const fontWeight = parseInt(style.fontWeight) || 400; 686 + const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700); 687 + const threshold = isLargeText ? 3.0 : 4.5; 688 + const clip = { 689 + x: Math.max(0, Math.floor(rect.left + window.scrollX - 2)), 690 + y: Math.max(0, Math.floor(rect.top + window.scrollY - 2)), 691 + width: Math.max(1, Math.ceil(rect.width + 4)), 692 + height: Math.max(1, Math.ceil(rect.height + 4)), 693 + }; 694 + 695 + candidates.push({ 696 + selector: generateSelector(el), 697 + tagName: tag, 698 + text: directText.trim().replace(/\s+/g, ' ').slice(0, 80), 699 + threshold, 700 + reasons, 701 + clip, 702 + textColor, 703 + preferRenderedForeground: !textColor || textColor.a < 0.99 || reasons.some(reason => 704 + reason === 'opacity stack' || 705 + reason === 'blend mode' || 706 + reason === 'filter' || 707 + reason === 'backdrop filter' || 708 + reason === 'background-clip text' 709 + ), 710 + backgroundClipText: reasons.includes('background-clip text'), 711 + }); 712 + } 713 + return candidates; 714 + } 715 + 716 + const visualContrastImageCache = new Map(); 717 + const visualContrastRasterCache = new WeakMap(); 718 + 719 + function clampByte(value) { 720 + return Math.max(0, Math.min(255, Math.round(value))); 721 + } 722 + 723 + function blendRgba(fg, bg) { 724 + if (!fg) return bg || null; 725 + if (!bg || fg.a == null || fg.a >= 0.999) { 726 + return { r: clampByte(fg.r), g: clampByte(fg.g), b: clampByte(fg.b), a: fg.a == null ? 1 : fg.a }; 727 + } 728 + const alpha = Math.max(0, Math.min(1, fg.a)); 729 + return { 730 + r: clampByte(fg.r * alpha + bg.r * (1 - alpha)), 731 + g: clampByte(fg.g * alpha + bg.g * (1 - alpha)), 732 + b: clampByte(fg.b * alpha + bg.b * (1 - alpha)), 733 + a: 1, 734 + }; 735 + } 736 + 737 + function pickWorstContrastColor(textColor, colors) { 738 + const usable = (colors || []).filter(Boolean); 739 + if (!usable.length) return null; 740 + let worst = usable[0]; 741 + let worstRatio = contrastRatio(textColor, worst); 742 + for (const color of usable.slice(1)) { 743 + const ratio = contrastRatio(textColor, color); 744 + if (ratio < worstRatio) { 745 + worst = color; 746 + worstRatio = ratio; 747 + } 748 + } 749 + return worst; 750 + } 751 + 752 + function firstCssUrl(value) { 753 + const match = String(value || '').match(/url\((?:"([^"]+)"|'([^']+)'|([^)]*))\)/i); 754 + if (!match) return ''; 755 + return (match[1] || match[2] || match[3] || '').trim(); 756 + } 757 + 758 + function getLayerValue(value, index = 0) { 759 + return String(value || '').split(',')[index]?.trim() || ''; 760 + } 761 + 762 + function parsePositionToken(token, container, painted) { 763 + if (!token || token === 'center') return (container - painted) / 2; 764 + if (token === 'left' || token === 'top') return 0; 765 + if (token === 'right' || token === 'bottom') return container - painted; 766 + if (/%$/.test(token)) { 767 + const pct = parseFloat(token) / 100; 768 + return (container - painted) * pct; 769 + } 770 + if (/px$/.test(token)) return parseFloat(token) || 0; 771 + return (container - painted) / 2; 772 + } 773 + 774 + function parsePositionPair(positionValue) { 775 + const tokens = String(positionValue || '50% 50%').trim().split(/\s+/).filter(Boolean); 776 + const first = tokens[0] || '50%'; 777 + if (tokens.length < 2) { 778 + if (first === 'top' || first === 'bottom') return ['50%', first]; 779 + return [first, '50%']; 780 + } 781 + return [first, tokens[1] || '50%']; 782 + } 783 + 784 + function resolvePaintedImageRect(containerRect, image, sizeValue, positionValue) { 785 + const intrinsicWidth = image.naturalWidth || image.videoWidth || image.width || 1; 786 + const intrinsicHeight = image.naturalHeight || image.videoHeight || image.height || 1; 787 + let paintedWidth = intrinsicWidth; 788 + let paintedHeight = intrinsicHeight; 789 + const size = String(sizeValue || 'auto').trim(); 790 + 791 + if (size === 'cover' || size === 'contain') { 792 + const scale = size === 'cover' 793 + ? Math.max(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight) 794 + : Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight); 795 + paintedWidth = intrinsicWidth * scale; 796 + paintedHeight = intrinsicHeight * scale; 797 + } else if (size && size !== 'auto') { 798 + const parts = size.split(/\s+/); 799 + const widthToken = parts[0]; 800 + const heightToken = parts[1] || 'auto'; 801 + if (/%$/.test(widthToken)) paintedWidth = containerRect.width * (parseFloat(widthToken) / 100); 802 + else if (/px$/.test(widthToken)) paintedWidth = parseFloat(widthToken) || paintedWidth; 803 + if (heightToken === 'auto') paintedHeight = paintedWidth * (intrinsicHeight / intrinsicWidth); 804 + else if (/%$/.test(heightToken)) paintedHeight = containerRect.height * (parseFloat(heightToken) / 100); 805 + else if (/px$/.test(heightToken)) paintedHeight = parseFloat(heightToken) || paintedHeight; 806 + } 807 + 808 + const [xToken, yToken] = parsePositionPair(positionValue); 809 + const positionX = parsePositionToken(xToken, containerRect.width, paintedWidth); 810 + const positionY = parsePositionToken(yToken, containerRect.height, paintedHeight); 811 + return { 812 + left: containerRect.left + positionX, 813 + top: containerRect.top + positionY, 814 + width: paintedWidth, 815 + height: paintedHeight, 816 + intrinsicWidth, 817 + intrinsicHeight, 818 + }; 819 + } 820 + 821 + function parseObjectPosition(positionValue) { 822 + return parsePositionPair(positionValue); 823 + } 824 + 825 + function resolveObjectImageRect(containerRect, image, style) { 826 + const intrinsicWidth = image.naturalWidth || image.videoWidth || image.width || 1; 827 + const intrinsicHeight = image.naturalHeight || image.videoHeight || image.height || 1; 828 + const fit = style.objectFit || 'fill'; 829 + let paintedWidth = containerRect.width; 830 + let paintedHeight = containerRect.height; 831 + if (fit === 'contain' || fit === 'cover') { 832 + const scale = fit === 'cover' 833 + ? Math.max(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight) 834 + : Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight); 835 + paintedWidth = intrinsicWidth * scale; 836 + paintedHeight = intrinsicHeight * scale; 837 + } else if (fit === 'none') { 838 + paintedWidth = intrinsicWidth; 839 + paintedHeight = intrinsicHeight; 840 + } else if (fit === 'scale-down') { 841 + const containScale = Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight, 1); 842 + paintedWidth = intrinsicWidth * containScale; 843 + paintedHeight = intrinsicHeight * containScale; 844 + } 845 + const [xToken, yToken] = parseObjectPosition(style.objectPosition); 846 + return { 847 + left: containerRect.left + parsePositionToken(xToken, containerRect.width, paintedWidth), 848 + top: containerRect.top + parsePositionToken(yToken, containerRect.height, paintedHeight), 849 + width: paintedWidth, 850 + height: paintedHeight, 851 + intrinsicWidth, 852 + intrinsicHeight, 853 + }; 854 + } 855 + 856 + function pointToImageSource(point, paintedRect) { 857 + if ( 858 + point.x < paintedRect.left || 859 + point.y < paintedRect.top || 860 + point.x > paintedRect.left + paintedRect.width || 861 + point.y > paintedRect.top + paintedRect.height 862 + ) { 863 + return null; 864 + } 865 + return { 866 + x: Math.max(0, Math.min(paintedRect.intrinsicWidth - 1, ((point.x - paintedRect.left) / paintedRect.width) * paintedRect.intrinsicWidth)), 867 + y: Math.max(0, Math.min(paintedRect.intrinsicHeight - 1, ((point.y - paintedRect.top) / paintedRect.height) * paintedRect.intrinsicHeight)), 868 + }; 869 + } 870 + 871 + async function loadVisualContrastImage(src) { 872 + if (!src) return null; 873 + if (visualContrastImageCache.has(src)) return visualContrastImageCache.get(src); 874 + const promise = new Promise(resolve => { 875 + const img = new Image(); 876 + let settled = false; 877 + const finish = value => { 878 + if (settled) return; 879 + settled = true; 880 + clearTimeout(timer); 881 + resolve(value); 882 + }; 883 + const timer = setTimeout(() => finish(null), 800); 884 + try { 885 + const absolute = new URL(src, location.href); 886 + if (absolute.origin !== location.origin && absolute.protocol !== 'data:' && absolute.protocol !== 'blob:') { 887 + img.crossOrigin = 'anonymous'; 888 + } 889 + } catch { 890 + // Let the browser resolve unusual URLs itself. 891 + } 892 + img.onload = () => finish(img); 893 + img.onerror = () => finish(null); 894 + img.src = src; 895 + }); 896 + visualContrastImageCache.set(src, promise); 897 + return promise; 898 + } 899 + 900 + function sampleDrawablePixel(drawable, sourcePoint) { 901 + if (visualContrastRasterCache.has(drawable)) { 902 + const cached = visualContrastRasterCache.get(drawable); 903 + if (!cached || !cached.ctx) return { status: 'unresolved', reason: cached?.reason || 'image sample failed' }; 904 + try { 905 + const x = Math.max(0, Math.min(cached.width - 1, Math.floor(sourcePoint.x * cached.scaleX))); 906 + const y = Math.max(0, Math.min(cached.height - 1, Math.floor(sourcePoint.y * cached.scaleY))); 907 + const data = cached.ctx.getImageData(x, y, 1, 1).data; 908 + return { 909 + status: 'sampled', 910 + color: { r: data[0], g: data[1], b: data[2], a: data[3] / 255 }, 911 + }; 912 + } catch (err) { 913 + return { 914 + status: 'unresolved', 915 + reason: /taint|cross-origin|Security/i.test(err?.message || '') ? 'tainted image' : 'image sample failed', 916 + }; 917 + } 918 + } 919 + 920 + const canvas = document.createElement('canvas'); 921 + const intrinsicWidth = drawable.naturalWidth || drawable.videoWidth || drawable.width || 1; 922 + const intrinsicHeight = drawable.naturalHeight || drawable.videoHeight || drawable.height || 1; 923 + const maxRasterSide = 640; 924 + const scale = Math.min(1, maxRasterSide / Math.max(intrinsicWidth, intrinsicHeight)); 925 + canvas.width = Math.max(1, Math.round(intrinsicWidth * scale)); 926 + canvas.height = Math.max(1, Math.round(intrinsicHeight * scale)); 927 + const ctx = canvas.getContext('2d', { willReadFrequently: true }); 928 + if (!ctx) return { status: 'unresolved', reason: 'canvas unavailable' }; 929 + try { 930 + ctx.drawImage(drawable, 0, 0, canvas.width, canvas.height); 931 + const cached = { 932 + ctx, 933 + width: canvas.width, 934 + height: canvas.height, 935 + scaleX: canvas.width / intrinsicWidth, 936 + scaleY: canvas.height / intrinsicHeight, 937 + }; 938 + visualContrastRasterCache.set(drawable, cached); 939 + const x = Math.max(0, Math.min(cached.width - 1, Math.floor(sourcePoint.x * cached.scaleX))); 940 + const y = Math.max(0, Math.min(cached.height - 1, Math.floor(sourcePoint.y * cached.scaleY))); 941 + const data = ctx.getImageData(x, y, 1, 1).data; 942 + return { 943 + status: 'sampled', 944 + color: { r: data[0], g: data[1], b: data[2], a: data[3] / 255 }, 945 + }; 946 + } catch (err) { 947 + const reason = /taint|cross-origin|Security/i.test(err?.message || '') ? 'tainted image' : 'image sample failed'; 948 + visualContrastRasterCache.set(drawable, { ctx: null, reason }); 949 + return { 950 + status: 'unresolved', 951 + reason, 952 + }; 953 + } 954 + } 955 + 956 + async function sampleCssBackground(el, style, point, textColor) { 957 + const rect = el.getBoundingClientRect(); 958 + const bgImage = style.backgroundImage || ''; 959 + if (bgImage && bgImage !== 'none') { 960 + if (/gradient/i.test(bgImage)) { 961 + const color = pickWorstContrastColor(textColor, parseGradientColors(bgImage)); 962 + if (color) return { status: 'sampled', color, method: 'analytic-gradient' }; 963 + } 964 + if (/url\s*\(/i.test(bgImage)) { 965 + const img = await loadVisualContrastImage(firstCssUrl(bgImage)); 966 + if (!img) return { status: 'unresolved', reason: 'image unavailable' }; 967 + const paintedRect = resolvePaintedImageRect( 968 + rect, 969 + img, 970 + getLayerValue(style.backgroundSize) || 'auto', 971 + getLayerValue(style.backgroundPosition) || '50% 50%', 972 + ); 973 + const sourcePoint = pointToImageSource(point, paintedRect); 974 + if (!sourcePoint) return { status: 'unresolved', reason: 'point outside background image' }; 975 + const sample = sampleDrawablePixel(img, sourcePoint); 976 + if (sample.status === 'sampled') return { ...sample, method: 'canvas-background-image' }; 977 + return sample; 978 + } 979 + } 980 + const bg = parseRgb(style.backgroundColor); 981 + if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' }; 982 + return { status: 'unresolved', reason: 'no readable background' }; 983 + } 984 + 985 + async function sampleImageElement(img, point) { 986 + const rect = img.getBoundingClientRect(); 987 + const style = getComputedStyle(img); 988 + const paintedRect = resolveObjectImageRect(rect, img, style); 989 + const sourcePoint = pointToImageSource(point, paintedRect); 990 + if (!sourcePoint) return { status: 'unresolved', reason: 'point outside image' }; 991 + const sample = sampleDrawablePixel(img, sourcePoint); 992 + if (sample.status === 'sampled') return { ...sample, method: 'canvas-img-underlay' }; 993 + 994 + if (img.currentSrc || img.src) { 995 + const loaded = await loadVisualContrastImage(img.currentSrc || img.src); 996 + if (loaded) { 997 + const loadedRect = { ...paintedRect, intrinsicWidth: loaded.naturalWidth || loaded.width || paintedRect.intrinsicWidth, intrinsicHeight: loaded.naturalHeight || loaded.height || paintedRect.intrinsicHeight }; 998 + const loadedPoint = pointToImageSource(point, loadedRect); 999 + if (loadedPoint) { 1000 + const loadedSample = sampleDrawablePixel(loaded, loadedPoint); 1001 + if (loadedSample.status === 'sampled') return { ...loadedSample, method: 'canvas-img-underlay' }; 1002 + } 1003 + } 1004 + } 1005 + return sample; 1006 + } 1007 + 1008 + function textSamplePoints(rect) { 1009 + const insetX = Math.min(12, Math.max(1, rect.width * 0.12)); 1010 + const insetY = Math.min(8, Math.max(1, rect.height * 0.22)); 1011 + const xs = rect.width < 28 1012 + ? [rect.left + rect.width / 2] 1013 + : [rect.left + insetX, rect.left + rect.width / 2, rect.right - insetX]; 1014 + const ys = rect.height < 22 1015 + ? [rect.top + rect.height / 2] 1016 + : [rect.top + insetY, rect.top + rect.height / 2, rect.bottom - insetY]; 1017 + const points = []; 1018 + for (const y of ys) { 1019 + for (const x of xs) { 1020 + if (x >= 0 && y >= 0 && x <= window.innerWidth && y <= window.innerHeight) points.push({ x, y }); 1021 + } 1022 + } 1023 + return points; 1024 + } 1025 + 1026 + async function sampleVisualBackgroundAtPoint(el, point, textColor, depth = 0) { 1027 + if (depth > 8) { 1028 + return { status: 'unresolved', reason: 'background stack too deep' }; 1029 + } 1030 + const stack = typeof document.elementsFromPoint === 'function' 1031 + ? document.elementsFromPoint(point.x, point.y) 1032 + : []; 1033 + const selfIndex = stack.findIndex(node => node === el || el.contains(node)); 1034 + const nodes = selfIndex >= 0 ? stack.slice(selfIndex) : [el, ...stack]; 1035 + const unresolved = []; 1036 + 1037 + for (const node of nodes) { 1038 + if (!node || node.nodeType !== 1) continue; 1039 + if (node.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; 1040 + const tag = node.tagName?.toLowerCase(); 1041 + if (tag === 'img') { 1042 + const sample = await sampleImageElement(node, point); 1043 + if (sample.status === 'sampled') return sample; 1044 + unresolved.push(sample.reason); 1045 + continue; 1046 + } 1047 + if (tag === 'canvas' || tag === 'video') { 1048 + const rect = node.getBoundingClientRect(); 1049 + const sourcePoint = pointToImageSource(point, { 1050 + left: rect.left, 1051 + top: rect.top, 1052 + width: rect.width, 1053 + height: rect.height, 1054 + intrinsicWidth: node.width || node.videoWidth || rect.width, 1055 + intrinsicHeight: node.height || node.videoHeight || rect.height, 1056 + }); 1057 + if (sourcePoint) { 1058 + const sample = sampleDrawablePixel(node, sourcePoint); 1059 + if (sample.status === 'sampled') return { ...sample, method: `canvas-${tag}-underlay` }; 1060 + unresolved.push(sample.reason); 1061 + } 1062 + continue; 1063 + } 1064 + const style = getComputedStyle(node); 1065 + const sample = await sampleCssBackground(node, style, point, textColor); 1066 + if (sample.status === 'sampled') { 1067 + if (!sample.color || sample.color.a == null || sample.color.a >= 0.95) return sample; 1068 + const under = await sampleVisualBackgroundAtPoint(node.parentElement || document.body, point, textColor, depth + 1); 1069 + if (under.status === 'sampled') { 1070 + return { 1071 + status: 'sampled', 1072 + color: blendRgba(sample.color, under.color), 1073 + method: `${sample.method}+alpha`, 1074 + }; 1075 + } 1076 + return sample; 1077 + } 1078 + unresolved.push(sample.reason); 1079 + } 1080 + 1081 + return { 1082 + status: 'unresolved', 1083 + reason: [...new Set(unresolved.filter(Boolean))].slice(0, 3).join(', ') || 'no readable visual background', 1084 + }; 1085 + } 1086 + 1087 + async function analyzeVisualContrastCandidate(candidate) { 1088 + let el; 1089 + try { 1090 + el = document.querySelector(candidate.selector); 1091 + } catch { 1092 + return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' }; 1093 + } 1094 + if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' }; 1095 + if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' }; 1096 + 1097 + const blockingReason = (candidate.reasons || []).find(reason => 1098 + reason === 'background-clip text' || 1099 + reason === 'blend mode' || 1100 + reason === 'filter' || 1101 + reason === 'backdrop filter' || 1102 + reason === 'opacity stack' || 1103 + reason === 'text shadow' 1104 + ); 1105 + if (blockingReason) { 1106 + return { ...candidate, status: 'unresolved', confidence: 'none', reason: `${blockingReason} needs screenshot pixels` }; 1107 + } 1108 + 1109 + const style = getComputedStyle(el); 1110 + const textColor = parseRgb(style.color) || candidate.textColor; 1111 + if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' }; 1112 + 1113 + const rect = getDirectTextRect(el) || el.getBoundingClientRect(); 1114 + if (!rect || rect.width < 4 || rect.height < 4) { 1115 + return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing text rect' }; 1116 + } 1117 + 1118 + const points = textSamplePoints(rect); 1119 + if (points.length === 0) { 1120 + return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'text outside viewport' }; 1121 + } 1122 + 1123 + const ratios = []; 1124 + const methods = new Set(); 1125 + const unresolved = []; 1126 + for (const point of points) { 1127 + const sample = await sampleVisualBackgroundAtPoint(el, point, textColor); 1128 + if (sample.status !== 'sampled' || !sample.color) { 1129 + unresolved.push(sample.reason); 1130 + continue; 1131 + } 1132 + const fg = blendRgba(textColor, sample.color); 1133 + ratios.push(contrastRatio(fg, sample.color)); 1134 + if (sample.method) methods.add(sample.method); 1135 + } 1136 + 1137 + if (ratios.length < Math.min(3, points.length)) { 1138 + return { 1139 + ...candidate, 1140 + status: 'unresolved', 1141 + confidence: 'none', 1142 + samples: ratios.length, 1143 + reason: [...new Set(unresolved.filter(Boolean))].slice(0, 3).join(', ') || 'not enough readable samples', 1144 + }; 1145 + } 1146 + 1147 + ratios.sort((a, b) => a - b); 1148 + const pick = pct => ratios[Math.min(ratios.length - 1, Math.max(0, Math.floor((pct / 100) * ratios.length)))]; 1149 + const measuredRatio = pick(10); 1150 + const medianRatio = pick(50); 1151 + const status = measuredRatio < candidate.threshold ? 'fail' : 'pass'; 1152 + const method = [...methods].sort().join(', ') || 'browser-visual'; 1153 + const textLabel = candidate.text ? ` "${candidate.text}"` : ''; 1154 + const detail = `browser contrast ${measuredRatio.toFixed(1)}:1 median ${medianRatio.toFixed(1)}:1 (need ${candidate.threshold}:1) via ${method}${textLabel}`; 1155 + return { 1156 + ...candidate, 1157 + status, 1158 + confidence: method.includes('canvas-') ? 'high' : 'medium', 1159 + method, 1160 + ratio: measuredRatio, 1161 + medianRatio, 1162 + samples: ratios.length, 1163 + finding: status === 'fail' ? { id: 'low-contrast', snippet: detail } : null, 1164 + }; 1165 + } 1166 + 1167 + function waitForVisualPaint() { 1168 + return new Promise(resolve => { 1169 + requestAnimationFrame(() => requestAnimationFrame(resolve)); 1170 + }); 1171 + } 1172 + 1173 + async function analyzeVisualContrast(options = {}) { 1174 + const candidates = collectVisualContrastCandidates(options); 1175 + const results = []; 1176 + const shouldScrollOffscreen = options.scrollOffscreen === true; 1177 + const restoreScroll = { x: window.scrollX, y: window.scrollY }; 1178 + for (const candidate of candidates) { 1179 + if (shouldScrollOffscreen && (window.scrollX !== restoreScroll.x || window.scrollY !== restoreScroll.y)) { 1180 + window.scrollTo(restoreScroll.x, restoreScroll.y); 1181 + await waitForVisualPaint(); 1182 + } 1183 + let result = await analyzeVisualContrastCandidate(candidate); 1184 + if (shouldScrollOffscreen && result.status === 'unresolved' && result.reason === 'text outside viewport') { 1185 + let el = null; 1186 + try { 1187 + el = document.querySelector(candidate.selector); 1188 + } catch { 1189 + el = null; 1190 + } 1191 + if (el && typeof el.scrollIntoView === 'function') { 1192 + el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' }); 1193 + await waitForVisualPaint(); 1194 + result = await analyzeVisualContrastCandidate(candidate); 1195 + } 1196 + } 1197 + results.push(result); 1198 + } 1199 + if (shouldScrollOffscreen && (window.scrollX !== restoreScroll.x || window.scrollY !== restoreScroll.y)) { 1200 + window.scrollTo(restoreScroll.x, restoreScroll.y); 1201 + } 1202 + return results; 1203 + } 1204 + 1205 + function isElementHidden(el) { 1206 + if (!el || el === document.body || el === document.documentElement) return false; 1207 + if (typeof el.checkVisibility === 'function') return !el.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true }); 1208 + // Fallback: zero size or no offsetParent (covers display:none and detached subtrees) 1209 + return el.offsetWidth === 0 && el.offsetHeight === 0; 1210 + } 1211 + 1212 + function serializeFindings(allFindings) { 1213 + return allFindings.map(({ el, findings }) => ({ 1214 + selector: generateSelector(el), 1215 + tagName: el.tagName?.toLowerCase() || 'unknown', 1216 + rect: (el !== document.body && el !== document.documentElement && el.getBoundingClientRect) 1217 + ? el.getBoundingClientRect().toJSON() : null, 1218 + isPageLevel: el === document.body || el === document.documentElement, 1219 + isHidden: isElementHidden(el), 1220 + findings: findings.map(f => { 1221 + const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id)); 1222 + return { 1223 + type: f.type || f.id, 1224 + category: ap ? ap.category : 'quality', 1225 + severity: ap?.severity || 'warning', 1226 + detail: f.detail || f.snippet, 1227 + ignoreValue: f.ignoreValue || f.value || '', 1228 + name: ap ? ap.name : (f.type || f.id), 1229 + description: ap ? ap.description : '', 1230 + }; 1231 + }), 1232 + })); 1233 + } 1234 + 1235 + const printSummary = function(allFindings) { 1236 + if (allFindings.length === 0) { 1237 + console.log('%c[impeccable] No anti-patterns found.', 'color: #22c55e; font-weight: bold'); 1238 + return; 1239 + } 1240 + console.group( 1241 + `%c[impeccable] ${allFindings.length} anti-pattern${allFindings.length === 1 ? '' : 's'} found`, 1242 + 'color: oklch(84% 0.19 80.46); font-weight: bold' 1243 + ); 1244 + for (const { el, findings } of allFindings) { 1245 + for (const f of findings) { 1246 + console.log(`%c${f.type || f.id}%c ${f.detail || f.snippet}`, 1247 + 'color: oklch(84% 0.19 80.46); font-weight: bold', 'color: inherit', el); 1248 + } 1249 + } 1250 + console.groupEnd(); 1251 + }; 1252 + 1253 + function addBrowserFindings(groupMap, el, findings) { 1254 + if (!findings || findings.length === 0) return; 1255 + const existing = groupMap.get(el); 1256 + if (existing) existing.push(...findings); 1257 + else groupMap.set(el, [...findings]); 1258 + } 1259 + 1260 + function browserFindingsFromMap(groupMap) { 1261 + return [...groupMap.entries()].map(([el, findings]) => ({ el, findings })); 1262 + } 1263 + 1264 + const DESIGN_COLOR_TOLERANCE = 6; 1265 + const DESIGN_RADIUS_TOLERANCE_PX = 0.5; 1266 + const DESIGN_SKIP_TAGS = new Set(['head', 'title', 'meta', 'link', 'style', 'script', 'noscript', 'template', 'source']); 1267 + 1268 + function normalizeBrowserFontName(value) { 1269 + return String(value || '') 1270 + .trim() 1271 + .replace(/^["']|["']$/g, '') 1272 + .replace(/\+/g, ' ') 1273 + .replace(/\s+/g, ' ') 1274 + .toLowerCase(); 1275 + } 1276 + 1277 + function browserPrimaryFont(stack) { 1278 + if (!stack || /var\(/i.test(stack)) return ''; 1279 + return String(stack || '') 1280 + .split(',') 1281 + .map(normalizeBrowserFontName) 1282 + .find(font => font && !GENERIC_FONTS.has(font)) || ''; 1283 + } 1284 + 1285 + function browserDesignSystemConfig() { 1286 + const raw = window.__IMPECCABLE_CONFIG__?.designSystem; 1287 + if (!raw?.present) return null; 1288 + const allowedFonts = new Set((raw.allowedFonts || []).map(normalizeBrowserFontName).filter(Boolean)); 1289 + const allowedColors = (raw.allowedColors || []) 1290 + .filter(color => color && Number.isFinite(color.r) && Number.isFinite(color.g) && Number.isFinite(color.b)) 1291 + .map(color => ({ r: color.r, g: color.g, b: color.b })); 1292 + const allowedRadii = (raw.allowedRadii || []) 1293 + .map(Number) 1294 + .filter(px => Number.isFinite(px)); 1295 + return { 1296 + present: true, 1297 + hasFonts: raw.hasFonts === true && allowedFonts.size > 0, 1298 + allowedFonts, 1299 + hasColors: raw.hasColors === true && allowedColors.length > 0, 1300 + allowedColors, 1301 + hasRadii: raw.hasRadii === true && allowedRadii.length > 0, 1302 + allowedRadii, 1303 + hasPillRadius: raw.hasPillRadius === true, 1304 + }; 1305 + } 1306 + 1307 + function browserColorsClose(a, b) { 1308 + if (!a || !b) return false; 1309 + return Math.max( 1310 + Math.abs(a.r - b.r), 1311 + Math.abs(a.g - b.g), 1312 + Math.abs(a.b - b.b), 1313 + ) <= DESIGN_COLOR_TOLERANCE; 1314 + } 1315 + 1316 + function isBrowserDesignColorAllowed(raw, designSystem) { 1317 + if (!designSystem?.hasColors) return true; 1318 + const text = String(raw || '').trim().toLowerCase(); 1319 + if (!text || text === 'transparent' || text === 'currentcolor' || text === 'inherit' || text === 'initial') return true; 1320 + if (text.includes('var(')) return true; 1321 + const parsed = parseAnyColor(text); 1322 + if (!parsed) return true; 1323 + if ((parsed.a ?? 1) <= 0.05) return true; 1324 + return designSystem.allowedColors.some(color => browserColorsClose(parsed, color)); 1325 + } 1326 + 1327 + function isBrowserTransparentCss(value) { 1328 + const text = String(value || '').trim().toLowerCase(); 1329 + if (!text || text === 'transparent') return true; 1330 + const parsed = parseAnyColor(text); 1331 + return parsed ? (parsed.a ?? 1) <= 0.05 : false; 1332 + } 1333 + 1334 + function isBrowserDesignRadiusAllowed(raw, designSystem) { 1335 + if (!designSystem?.hasRadii) return true; 1336 + const text = String(raw || '').trim().toLowerCase(); 1337 + if (!text || text === '0' || text === 'none' || text === 'initial' || text === 'inherit') return true; 1338 + if (text.includes('var(') || text.includes('%')) return true; 1339 + const px = resolveLengthPx(text, 16); 1340 + if (px == null || !Number.isFinite(px) || px <= DESIGN_RADIUS_TOLERANCE_PX) return true; 1341 + if (designSystem.hasPillRadius && px >= 99) return true; 1342 + return designSystem.allowedRadii.some(allowed => Math.abs(allowed - px) <= DESIGN_RADIUS_TOLERANCE_PX); 1343 + } 1344 + 1345 + function browserRadiusTokens(value) { 1346 + return String(value || '') 1347 + .replace(/\s*\/\s*/g, ' ') 1348 + .split(/\s+/) 1349 + .map(token => token.trim()) 1350 + .filter(Boolean); 1351 + } 1352 + 1353 + function browserHasDirectText(el) { 1354 + return [...(el.childNodes || [])].some(node => node.nodeType === 3 && node.textContent.trim().length > 0); 1355 + } 1356 + 1357 + function browserSampleText(el) { 1358 + const text = String(el.textContent || '').replace(/\s+/g, ' ').trim(); 1359 + return text ? ` "${text.slice(0, 40)}"` : ''; 1360 + } 1361 + 1362 + function shouldSkipDesignElement(el) { 1363 + const tag = el.tagName?.toLowerCase?.() || ''; 1364 + return DESIGN_SKIP_TAGS.has(tag) || isElementHidden(el); 1365 + } 1366 + 1367 + function checkElementDesignSystemDOM(el, designSystem, seen) { 1368 + if (!designSystem?.present || shouldSkipDesignElement(el)) return []; 1369 + const findings = []; 1370 + const tag = el.tagName?.toLowerCase?.() || 'unknown'; 1371 + const style = getComputedStyle(el); 1372 + 1373 + if (designSystem.hasFonts && browserHasDirectText(el)) { 1374 + const font = browserPrimaryFont(style.fontFamily || ''); 1375 + if (font && !designSystem.allowedFonts.has(font) && !seen.fonts.has(font)) { 1376 + seen.fonts.add(font); 1377 + findings.push({ 1378 + type: 'design-system-font', 1379 + detail: `${tag}${browserSampleText(el)} uses ${font}; not declared in DESIGN.md typography`, 1380 + ignoreValue: font, 1381 + }); 1382 + } 1383 + } 1384 + 1385 + if (designSystem.hasColors) { 1386 + const colorChecks = []; 1387 + if (browserHasDirectText(el)) colorChecks.push(['text color', style.color]); 1388 + if (!isBrowserTransparentCss(style.backgroundColor)) colorChecks.push(['background', style.backgroundColor]); 1389 + for (const side of ['Top', 'Right', 'Bottom', 'Left']) { 1390 + if ((parseFloat(style[`border${side}Width`]) || 0) > 0) { 1391 + colorChecks.push([`border-${side.toLowerCase()}`, style[`border${side}Color`]]); 1392 + } 1393 + } 1394 + if ((parseFloat(style.outlineWidth) || 0) > 0) colorChecks.push(['outline', style.outlineColor]); 1395 + 1396 + for (const [kind, raw] of colorChecks) { 1397 + const label = String(raw || '').trim().replace(/\s+/g, ' '); 1398 + if (isBrowserDesignColorAllowed(label, designSystem)) continue; 1399 + const key = `${kind}:${label}`; 1400 + if (seen.colors.has(key)) continue; 1401 + seen.colors.add(key); 1402 + findings.push({ 1403 + type: 'design-system-color', 1404 + detail: `${kind} ${label} on ${tag}${browserSampleText(el)} is outside DESIGN.md colors`, 1405 + ignoreValue: label, 1406 + }); 1407 + } 1408 + } 1409 + 1410 + if (designSystem.hasRadii) { 1411 + for (const token of browserRadiusTokens(style.borderRadius || '')) { 1412 + if (isBrowserDesignRadiusAllowed(token, designSystem)) continue; 1413 + if (seen.radii.has(token)) continue; 1414 + seen.radii.add(token); 1415 + findings.push({ 1416 + type: 'design-system-radius', 1417 + detail: `border-radius ${token} on ${tag}${browserSampleText(el)} is outside the DESIGN.md rounded scale`, 1418 + ignoreValue: token, 1419 + }); 1420 + } 1421 + } 1422 + 1423 + return findings; 1424 + } 1425 + 1426 + function decodeBrowserGoogleFamily(value) { 1427 + const family = String(value || '').split(':')[0].replace(/\+/g, ' '); 1428 + try { 1429 + return decodeURIComponent(family); 1430 + } catch { 1431 + return family; 1432 + } 1433 + } 1434 + 1435 + function checkBrowserDesignSystemSources(designSystem, seen) { 1436 + if (!designSystem?.hasFonts) return []; 1437 + const findings = []; 1438 + for (const link of document.querySelectorAll('link[href*="fonts.googleapis.com/css"]')) { 1439 + const href = link.getAttribute('href') || ''; 1440 + for (const match of href.matchAll(/[?&]family=([^&]+)/g)) { 1441 + const display = decodeBrowserGoogleFamily(match[1]); 1442 + const font = normalizeBrowserFontName(display); 1443 + if (!font || designSystem.allowedFonts.has(font) || seen.fonts.has(font)) continue; 1444 + seen.fonts.add(font); 1445 + findings.push({ 1446 + type: 'design-system-font', 1447 + detail: `Google Fonts: ${display} is not declared in DESIGN.md typography`, 1448 + ignoreValue: display, 1449 + }); 1450 + } 1451 + } 1452 + return findings; 1453 + } 1454 + 1455 + function collectBrowserFindings() { 1456 + const groupMap = new Map(); 1457 + const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; 1458 + const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); 1459 + const designSystem = browserDesignSystemConfig(); 1460 + const designSeen = { fonts: new Set(), colors: new Set(), radii: new Set() }; 1461 + // Note: provider-gated rules (--gpt / --gemini) are NOT filtered here. In a 1462 + // real browser env (detector page, live overlay, extension) running every 1463 + // check is free, so we always surface them; the gating is purely a CLI 1464 + // output concern, applied in the Node engines' detect* return paths. 1465 + 1466 + for (const el of document.querySelectorAll('*')) { 1467 + // Skip impeccable's own elements and any descendants (overlays, labels, banner, nav buttons) 1468 + if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; 1469 + // Skip browser extension elements (Claude, etc.) 1470 + const elId = el.id || ''; 1471 + if (elId.startsWith('claude-') || elId.startsWith('cic-')) continue; 1472 + // Skip the impeccable live-mode overlay (highlight, tooltip, bar, picker, toast). 1473 + // These are inspector chrome, not part of the user's design. 1474 + if (el.closest('[id^="impeccable-live-"]')) continue; 1475 + // Skip html/body -- page-level findings go in the banner, not a full-page overlay 1476 + if (el === document.body || el === document.documentElement) continue; 1477 + 1478 + const findings = [ 1479 + ...checkElementBordersDOM(el).map(f => ({ type: f.id, detail: f.snippet })), 1480 + ...checkElementColorsDOM(el).map(f => ({ type: f.id, detail: f.snippet })), 1481 + ...checkElementMotionDOM(el).map(f => ({ type: f.id, detail: f.snippet })), 1482 + ...checkElementGlowDOM(el).map(f => ({ type: f.id, detail: f.snippet })), 1483 + ...checkElementAIPaletteDOM(el).map(f => ({ type: f.id, detail: f.snippet })), 1484 + ...checkElementIconTileDOM(el).map(f => ({ type: f.id, detail: f.snippet })), 1485 + ...checkElementItalicSerifDOM(el).map(f => ({ type: f.id, detail: f.snippet })), 1486 + ...checkElementQualityDOM(el).map(f => ({ type: f.id, detail: f.snippet })), 1487 + ...checkElementOversizedH1DOM(el).map(f => ({ type: f.id, detail: f.snippet })), 1488 + ...checkElementClippedOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })), 1489 + ...checkElementGptBorderShadowDOM(el).map(f => ({ type: f.id, detail: f.snippet })), 1490 + ...checkElementTextOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })), 1491 + ...checkElementDesignSystemDOM(el, designSystem, designSeen), 1492 + ].filter(f => _ruleOk(f.type)); 1493 + 1494 + addBrowserFindings(groupMap, el, findings); 1495 + 1496 + // Hero eyebrow: the offending element is the eyebrow above the heading, 1497 + // not the heading itself — highlight the previous sibling instead. 1498 + const eyebrowFindings = checkElementHeroEyebrowDOM(el) 1499 + .map(f => ({ type: f.id, detail: f.snippet })) 1500 + .filter(f => _ruleOk(f.type)); 1501 + if (eyebrowFindings.length > 0 && el.previousElementSibling) { 1502 + addBrowserFindings(groupMap, el.previousElementSibling, eyebrowFindings); 1503 + } 1504 + } 1505 + 1506 + const pageLevelFindings = []; 1507 + 1508 + const designSourceFindings = checkBrowserDesignSystemSources(designSystem, designSeen) 1509 + .filter(f => _ruleOk(f.type)); 1510 + if (designSourceFindings.length > 0) { 1511 + pageLevelFindings.push(...designSourceFindings); 1512 + addBrowserFindings(groupMap, document.body, designSourceFindings); 1513 + } 1514 + 1515 + const typoFindings = checkTypography().filter(f => _ruleOk(f.type)); 1516 + if (typoFindings.length > 0) { 1517 + pageLevelFindings.push(...typoFindings); 1518 + addBrowserFindings(groupMap, document.body, typoFindings); 1519 + } 1520 + 1521 + const sectionKickerFindings = checkRepeatedSectionKickersDOM() 1522 + .map(f => ({ type: f.id, detail: f.snippet })) 1523 + .filter(f => _ruleOk(f.type)); 1524 + if (sectionKickerFindings.length > 0) { 1525 + pageLevelFindings.push(...sectionKickerFindings); 1526 + addBrowserFindings(groupMap, document.body, sectionKickerFindings); 1527 + } 1528 + 1529 + const layoutFindings = checkLayout().filter(f => _ruleOk(f.type)); 1530 + for (const f of layoutFindings) { 1531 + const el = f.el || document.body; 1532 + addBrowserFindings(groupMap, el, [{ type: f.type, detail: f.detail || f.snippet }]); 1533 + } 1534 + 1535 + // Page-level quality checks (headings, etc.) 1536 + const qualityFindings = checkPageQualityDOM().filter(f => _ruleOk(f.type)); 1537 + if (qualityFindings.length > 0) { 1538 + pageLevelFindings.push(...qualityFindings); 1539 + addBrowserFindings(groupMap, document.body, qualityFindings); 1540 + } 1541 + 1542 + const creamFindings = checkCreamPalette(document) 1543 + .map(f => ({ type: f.id, detail: f.snippet })) 1544 + .filter(f => _ruleOk(f.type)); 1545 + if (creamFindings.length > 0) { 1546 + pageLevelFindings.push(...creamFindings); 1547 + addBrowserFindings(groupMap, document.body, creamFindings); 1548 + } 1549 + 1550 + // Regex-on-HTML checks (shared with Node) 1551 + // Clone the document and strip impeccable-live overlay nodes before the 1552 + // regex scan, so the inspector's own inline styles (transitions on top/ 1553 + // left/width/height, etc.) don't register as page anti-patterns. 1554 + const docClone = document.documentElement.cloneNode(true); 1555 + for (const node of docClone.querySelectorAll('[id^="impeccable-live-"]')) { 1556 + node.remove(); 1557 + } 1558 + const htmlPatternFindings = checkHtmlPatterns(docClone.outerHTML); 1559 + if (htmlPatternFindings.length > 0) { 1560 + const mapped = htmlPatternFindings.map(f => ({ type: f.id, detail: f.snippet })).filter(f => _ruleOk(f.type)); 1561 + pageLevelFindings.push(...mapped); 1562 + addBrowserFindings(groupMap, document.body, mapped); 1563 + } 1564 + 1565 + return { 1566 + groupMap, 1567 + allFindings: browserFindingsFromMap(groupMap), 1568 + pageLevelFindings, 1569 + }; 1570 + } 1571 + 1572 + function shouldRunVisualContrast(options = {}) { 1573 + return options.visualContrast === true || window.__IMPECCABLE_CONFIG__?.visualContrast === true; 1574 + } 1575 + 1576 + function visualContrastOptions(options = {}) { 1577 + const config = window.__IMPECCABLE_CONFIG__ || {}; 1578 + const scrollOffscreen = typeof options.scrollOffscreen === 'boolean' 1579 + ? options.scrollOffscreen 1580 + : typeof options.visualContrastScrollOffscreen === 'boolean' 1581 + ? options.visualContrastScrollOffscreen 1582 + : typeof config.visualContrastScrollOffscreen === 'boolean' 1583 + ? config.visualContrastScrollOffscreen 1584 + : false; 1585 + return { 1586 + ...options, 1587 + maxCandidates: Number.isFinite(options.visualContrastMaxCandidates) 1588 + ? options.visualContrastMaxCandidates 1589 + : Number.isFinite(options.maxCandidates) 1590 + ? options.maxCandidates 1591 + : Number.isFinite(config.visualContrastMaxCandidates) 1592 + ? config.visualContrastMaxCandidates 1593 + : undefined, 1594 + scrollOffscreen, 1595 + }; 1596 + } 1597 + 1598 + let lastVisualContrastAnalyses = []; 1599 + let lazyVisualContrastObserver = null; 1600 + let lazyVisualContrastPending = new WeakMap(); 1601 + const lazyVisualContrastResolving = new WeakSet(); 1602 + let scanGeneration = 0; 1603 + 1604 + function rememberVisualContrastAnalysis(result) { 1605 + if (!result?.selector) { 1606 + lastVisualContrastAnalyses.push(result); 1607 + return; 1608 + } 1609 + const idx = lastVisualContrastAnalyses.findIndex(item => item.selector === result.selector); 1610 + if (idx >= 0) lastVisualContrastAnalyses[idx] = result; 1611 + else lastVisualContrastAnalyses.push(result); 1612 + } 1613 + 1614 + function disconnectLazyVisualContrastObserver() { 1615 + if (lazyVisualContrastObserver) { 1616 + lazyVisualContrastObserver.disconnect(); 1617 + lazyVisualContrastObserver = null; 1618 + } 1619 + lazyVisualContrastPending = new WeakMap(); 1620 + } 1621 + 1622 + function addVisualContrastResult(groupMap, result, options = {}) { 1623 + if (result.status !== 'fail' || !result.finding || !result.selector) return false; 1624 + let el = null; 1625 + try { 1626 + el = document.querySelector(result.selector); 1627 + } catch { 1628 + el = null; 1629 + } 1630 + if (!el) return false; 1631 + const findingType = result.finding.type || result.finding.id || 'low-contrast'; 1632 + const existing = groupMap.get(el) || []; 1633 + if (existing.some(f => (f.type || f.id) === findingType)) return false; 1634 + addBrowserFindings(groupMap, el, [{ 1635 + type: findingType, 1636 + detail: result.finding.detail || result.finding.snippet, 1637 + }]); 1638 + if (options.decorate && el !== document.body && el !== document.documentElement) { 1639 + highlight(el, groupMap.get(el) || []); 1640 + } 1641 + return true; 1642 + } 1643 + 1644 + function scanResultMeta(options = {}) { 1645 + const scanId = options.scanId; 1646 + if (typeof scanId !== 'string' && typeof scanId !== 'number') return {}; 1647 + return { scanId: String(scanId) }; 1648 + } 1649 + 1650 + function postSerializedFindings(groupMap, options = {}) { 1651 + if (!EXTENSION_MODE) return; 1652 + const allFindings = browserFindingsFromMap(groupMap); 1653 + window.postMessage({ 1654 + source: 'impeccable-results', 1655 + findings: serializeFindings(allFindings), 1656 + count: allFindings.length, 1657 + ...scanResultMeta(options), 1658 + }, '*'); 1659 + } 1660 + 1661 + function postExtensionError(err) { 1662 + if (!EXTENSION_MODE) return; 1663 + window.postMessage({ 1664 + source: 'impeccable-error', 1665 + message: err?.message || String(err), 1666 + }, '*'); 1667 + } 1668 + 1669 + function reportVisualContrastError(err, detail = {}) { 1670 + window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-error', { 1671 + detail: { 1672 + ...detail, 1673 + message: err?.message || String(err), 1674 + }, 1675 + })); 1676 + if (EXTENSION_MODE) { 1677 + postExtensionError(err); 1678 + } else { 1679 + console.warn('[impeccable] visual contrast scan failed', err); 1680 + } 1681 + } 1682 + 1683 + function scheduleLazyVisualContrast(groupMap, analyses, options = {}, runtime = {}) { 1684 + disconnectLazyVisualContrastObserver(); 1685 + if (options.visualContrastLazy === false || options.scrollOffscreen !== false) return; 1686 + if (typeof IntersectionObserver === 'undefined') return; 1687 + const unresolved = (analyses || []).filter(result => 1688 + result?.status === 'unresolved' && 1689 + result.reason === 'text outside viewport' && 1690 + result.selector 1691 + ); 1692 + if (unresolved.length === 0) return; 1693 + const generation = runtime.generation || scanGeneration; 1694 + 1695 + lazyVisualContrastObserver = new IntersectionObserver((entries) => { 1696 + for (const entry of entries) { 1697 + if (!entry.isIntersecting) continue; 1698 + const el = entry.target; 1699 + const candidate = lazyVisualContrastPending.get(el); 1700 + if (!candidate || lazyVisualContrastResolving.has(el)) continue; 1701 + lazyVisualContrastObserver?.unobserve(el); 1702 + lazyVisualContrastPending.delete(el); 1703 + lazyVisualContrastResolving.add(el); 1704 + waitForVisualPaint() 1705 + .then(() => analyzeVisualContrastCandidate(candidate)) 1706 + .then(result => { 1707 + if (generation !== scanGeneration) return; 1708 + rememberVisualContrastAnalysis(result); 1709 + const added = addVisualContrastResult(groupMap, result, { decorate: true }); 1710 + if (added) { 1711 + postSerializedFindings(groupMap, options); 1712 + window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-resolved', { 1713 + detail: { 1714 + selector: result.selector, 1715 + status: result.status, 1716 + finding: result.finding || null, 1717 + }, 1718 + })); 1719 + } 1720 + }) 1721 + .catch(err => { 1722 + reportVisualContrastError(err, { selector: candidate.selector }); 1723 + }) 1724 + .finally(() => { 1725 + lazyVisualContrastResolving.delete(el); 1726 + }); 1727 + } 1728 + }, { threshold: 0.5 }); 1729 + 1730 + for (const candidate of unresolved) { 1731 + let el = null; 1732 + try { 1733 + el = document.querySelector(candidate.selector); 1734 + } catch { 1735 + el = null; 1736 + } 1737 + if (!el) continue; 1738 + lazyVisualContrastPending.set(el, candidate); 1739 + lazyVisualContrastObserver.observe(el); 1740 + } 1741 + } 1742 + 1743 + async function addVisualContrastFindings(groupMap, options = {}, runtime = {}) { 1744 + if (!shouldRunVisualContrast(options)) { 1745 + lastVisualContrastAnalyses = []; 1746 + disconnectLazyVisualContrastObserver(); 1747 + return []; 1748 + } 1749 + const resolvedOptions = visualContrastOptions(options); 1750 + const analyses = await analyzeVisualContrast(resolvedOptions); 1751 + if (runtime.generation && runtime.generation !== scanGeneration) return analyses; 1752 + lastVisualContrastAnalyses = analyses; 1753 + for (const result of analyses) { 1754 + addVisualContrastResult(groupMap, result, { decorate: runtime.decorate }); 1755 + } 1756 + if (runtime.decorate || runtime.scheduleLazy) scheduleLazyVisualContrast(groupMap, analyses, resolvedOptions, runtime); 1757 + return analyses; 1758 + } 1759 + 1760 + async function collectBrowserFindingsAsync(options = {}, runtime = {}) { 1761 + const collected = collectBrowserFindings(); 1762 + await addVisualContrastFindings(collected.groupMap, options, runtime); 1763 + return { 1764 + ...collected, 1765 + allFindings: browserFindingsFromMap(collected.groupMap), 1766 + visualContrastAnalyses: lastVisualContrastAnalyses, 1767 + }; 1768 + } 1769 + 1770 + function clearOverlays() { 1771 + scanGeneration += 1; 1772 + disconnectLazyVisualContrastObserver(); 1773 + for (const o of [...overlays]) detachOverlay(o); 1774 + overlays.length = 0; 1775 + visibilityObserver.disconnect(); 1776 + overlayIndex = 0; 1777 + } 1778 + 1779 + function renderBrowserFindings(collected, options = {}) { 1780 + const { allFindings, pageLevelFindings } = collected; 1781 + 1782 + for (const { el, findings } of allFindings) { 1783 + if (el === document.body || el === document.documentElement) continue; 1784 + highlight(el, findings); 1785 + } 1786 + 1787 + if (pageLevelFindings.length > 0) { 1788 + showPageBanner(pageLevelFindings); 1789 + } 1790 + 1791 + if (!EXTENSION_MODE) printSummary(allFindings); 1792 + 1793 + // In extension mode, post serialized results for the DevTools panel 1794 + if (EXTENSION_MODE) { 1795 + window.postMessage({ 1796 + source: 'impeccable-results', 1797 + findings: serializeFindings(allFindings), 1798 + count: allFindings.length, 1799 + ...scanResultMeta(options), 1800 + }, '*'); 1801 + } 1802 + 1803 + // After this scan completes, all subsequent reveals are instant (no stagger, no animation) 1804 + setTimeout(() => { firstScanDone = true; }, 1000); 1805 + 1806 + return allFindings; 1807 + } 1808 + 1809 + let firstScanDone = false; 1810 + const scan = function(options = {}) { 1811 + clearOverlays(); 1812 + const generation = scanGeneration; 1813 + const collected = collectBrowserFindings(); 1814 + const allFindings = renderBrowserFindings(collected, options); 1815 + if (shouldRunVisualContrast(options)) { 1816 + addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) 1817 + .then(() => { 1818 + if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); 1819 + }) 1820 + .catch(err => { 1821 + reportVisualContrastError(err); 1822 + }); 1823 + } 1824 + return allFindings; 1825 + }; 1826 + 1827 + const scanAsync = async function(options = {}) { 1828 + clearOverlays(); 1829 + const generation = scanGeneration; 1830 + if (shouldRunVisualContrast(options)) { 1831 + const collected = await collectBrowserFindingsAsync(options, { generation, scheduleLazy: true }); 1832 + if (generation !== scanGeneration) return []; 1833 + return renderBrowserFindings(collected, options); 1834 + } 1835 + lastVisualContrastAnalyses = []; 1836 + return renderBrowserFindings(collectBrowserFindings(), options); 1837 + }; 1838 + 1839 + const detect = function(options = {}) { 1840 + lastVisualContrastAnalyses = []; 1841 + const { allFindings } = collectBrowserFindings(); 1842 + return options.serialize === false ? allFindings : serializeFindings(allFindings); 1843 + }; 1844 + 1845 + const detectAsync = async function(options = {}) { 1846 + if (shouldRunVisualContrast(options)) { 1847 + const { allFindings } = await collectBrowserFindingsAsync(options); 1848 + return options.serialize === false ? allFindings : serializeFindings(allFindings); 1849 + } 1850 + lastVisualContrastAnalyses = []; 1851 + const { allFindings } = collectBrowserFindings(); 1852 + return options.serialize === false ? allFindings : serializeFindings(allFindings); 1853 + }; 1854 + 1855 + if (EXTENSION_MODE) { 1856 + // Extension mode: listen for commands, don't auto-scan 1857 + window.addEventListener('message', (e) => { 1858 + if (e.source !== window || !e.data || e.data.source !== 'impeccable-command') return; 1859 + if (e.data.action === 'scan') { 1860 + if (e.data.config) window.__IMPECCABLE_CONFIG__ = e.data.config; 1861 + try { 1862 + scan(e.data.config || {}); 1863 + } catch (err) { 1864 + postExtensionError(err); 1865 + } 1866 + } 1867 + if (e.data.action === 'toggle-overlays') { 1868 + const visible = !document.body.classList.contains('impeccable-hidden'); 1869 + document.body.classList.toggle('impeccable-hidden', visible); 1870 + window.postMessage({ source: 'impeccable-overlays-toggled', visible: !visible }, '*'); 1871 + } 1872 + if (e.data.action === 'remove') { 1873 + clearOverlays(); 1874 + styleEl.remove(); 1875 + if (spotlightBackdrop) { spotlightBackdrop.remove(); spotlightBackdrop = null; } 1876 + document.body.classList.remove('impeccable-hidden'); 1877 + } 1878 + if (e.data.action === 'highlight') { 1879 + try { 1880 + const target = e.data.selector ? document.querySelector(e.data.selector) : null; 1881 + if (target) { 1882 + // Scroll first so positionOverlay reads the post-scroll rect 1883 + if (!isInViewport(target) && target.scrollIntoView) { 1884 + target.scrollIntoView({ behavior: 'instant', block: 'center' }); 1885 + } 1886 + for (const o of overlays) { 1887 + if (o.classList.contains('impeccable-banner')) continue; 1888 + const isMatch = o._targetEl === target; 1889 + o.classList.toggle('impeccable-spotlight', isMatch); 1890 + o.classList.toggle('impeccable-spotlight-dimmed', !isMatch); 1891 + if (isMatch) { 1892 + // Force the matching overlay visible immediately, don't wait for IntersectionObserver 1893 + o.style.display = ''; 1894 + o.style.animation = 'none'; 1895 + o.classList.add('impeccable-visible'); 1896 + o._revealed = true; 1897 + positionOverlay(o); 1898 + } 1899 + } 1900 + showSpotlight(target); 1901 + } 1902 + } catch { /* invalid selector */ } 1903 + } 1904 + if (e.data.action === 'unhighlight') { 1905 + hideSpotlight(); 1906 + for (const o of overlays) { 1907 + o.classList.remove('impeccable-spotlight'); 1908 + o.classList.remove('impeccable-spotlight-dimmed'); 1909 + } 1910 + } 1911 + }); 1912 + window.postMessage({ source: 'impeccable-ready' }, '*'); 1913 + } else { 1914 + if (window.__IMPECCABLE_CONFIG__?.autoScan !== false) { 1915 + const runAutoScan = () => { 1916 + try { 1917 + scan(); 1918 + } catch (err) { 1919 + console.warn('[impeccable] scan failed', err); 1920 + } 1921 + }; 1922 + if (document.readyState === 'loading') { 1923 + document.addEventListener('DOMContentLoaded', () => setTimeout(runAutoScan, 100)); 1924 + } else { 1925 + setTimeout(runAutoScan, 100); 1926 + } 1927 + } 1928 + } 1929 + 1930 + window.impeccableDetect = detect; 1931 + window.impeccableDetectAsync = detectAsync; 1932 + window.impeccableScan = scan; 1933 + window.impeccableScanAsync = scanAsync; 1934 + window.impeccableCollectVisualContrastCandidates = collectVisualContrastCandidates; 1935 + window.impeccableAnalyzeVisualContrast = analyzeVisualContrast; 1936 + window.impeccableGetLastVisualContrastAnalyses = () => lastVisualContrastAnalyses.slice(); 1937 + }
+290
tools/design-detector/detector/cli/main.mjs
··· 1 + import fs from 'node:fs'; 2 + import path from 'node:path'; 3 + 4 + import { loadDesignSystemForCwd } from '../design-system.mjs'; 5 + import { createBrowserDetector, detectUrl } from '../engines/browser/detect-url.mjs'; 6 + import { detectHtml } from '../engines/static-html/detect-html.mjs'; 7 + import { detectText } from '../engines/regex/detect-text.mjs'; 8 + import { 9 + filterDetectionFindings, 10 + readDetectionConfig, 11 + shouldIgnoreDetectionFile, 12 + } from '../../lib/impeccable-config.mjs'; 13 + import { 14 + HTML_EXTENSIONS, 15 + buildImportGraph, 16 + detectFrameworkConfig, 17 + isPortListening, 18 + walkDir, 19 + } from '../node/file-system.mjs'; 20 + 21 + // --------------------------------------------------------------------------- 22 + // Output formatting 23 + // --------------------------------------------------------------------------- 24 + 25 + function formatFindingSummary(count) { 26 + return `${count} anti-pattern${count === 1 ? '' : 's'} found.`; 27 + } 28 + 29 + function formatFindings(findings, jsonMode) { 30 + if (jsonMode) return JSON.stringify(findings, null, 2); 31 + 32 + const grouped = {}; 33 + for (const f of findings) { 34 + if (!grouped[f.file]) grouped[f.file] = []; 35 + grouped[f.file].push(f); 36 + } 37 + const out = []; 38 + for (const [file, items] of Object.entries(grouped)) { 39 + const importNote = items[0]?.importedBy?.length ? ` (imported by ${items[0].importedBy.join(', ')})` : ''; 40 + out.push(`\n${file}${importNote}`); 41 + for (const item of items) { 42 + out.push(` ${item.line ? `line ${item.line}: ` : ''}[${item.antipattern}] ${item.snippet}`); 43 + out.push(` → ${item.description}`); 44 + } 45 + } 46 + out.push(`\n${formatFindingSummary(findings.length)}`); 47 + return out.join('\n'); 48 + } 49 + 50 + // --------------------------------------------------------------------------- 51 + // Stdin handling 52 + // --------------------------------------------------------------------------- 53 + 54 + async function handleStdin(options = {}) { 55 + const chunks = []; 56 + for await (const chunk of process.stdin) chunks.push(chunk); 57 + const input = Buffer.concat(chunks).toString('utf-8'); 58 + try { 59 + const parsed = JSON.parse(input); 60 + const fp = parsed?.tool_input?.file_path; 61 + if (fp && fs.existsSync(fp)) { 62 + return HTML_EXTENSIONS.has(path.extname(fp).toLowerCase()) 63 + ? detectHtml(fp, options) : detectText(fs.readFileSync(fp, 'utf-8'), fp, options); 64 + } 65 + } catch { /* not JSON */ } 66 + return detectText(input, '<stdin>', options); 67 + } 68 + 69 + 70 + // --------------------------------------------------------------------------- 71 + // CLI 72 + // --------------------------------------------------------------------------- 73 + 74 + async function confirm(question) { 75 + const rl = (await import('node:readline')).default.createInterface({ 76 + input: process.stdin, output: process.stderr, 77 + }); 78 + return new Promise((resolve) => { 79 + rl.question(`${question} [Y/n] `, (answer) => { 80 + rl.close(); 81 + resolve(!answer || /^y(es)?$/i.test(answer.trim())); 82 + }); 83 + }); 84 + } 85 + 86 + function printUsage() { 87 + console.log(`Usage: impeccable detect [options] [file-or-dir-or-url...] 88 + 89 + Scan files or URLs for UI anti-patterns and design quality issues. 90 + 91 + Options: 92 + --json Output results as JSON 93 + --quiet In text mode, only print the final findings count 94 + --gpt Also report GPT-specific provider tells (off by default) 95 + --gemini Also report Gemini-specific provider tells (off by default) 96 + --no-config Do not apply project config, detector ignores, inline 97 + ignore comments, or DESIGN.md 98 + --no-inline-ignores Do not honor in-file impeccable-disable* ignore comments 99 + --no-design-system Do not load local DESIGN.md / .impeccable/design.json context 100 + --help Show this help message 101 + 102 + Project config: 103 + Respects .impeccable/config.json and .impeccable/config.local.json detector 104 + settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues, 105 + and detector.designSystem.enabled. 106 + 107 + Inline ignores: 108 + In-file comments waive a finding where it lives and travel with the file: 109 + <!-- impeccable-disable overused-font -- exported brand doc --> 110 + .brand { font-family: Inter } /* impeccable-disable-line overused-font */ 111 + // impeccable-disable-next-line bounce-easing: intentional bounce 112 + impeccable-disable applies to the whole file; -line / -next-line are scoped. 113 + List one or more rule ids (comma-separated), or omit them / use * for all. 114 + 115 + Detection modes: 116 + HTML files Static HTML/CSS analysis (default, catches linked CSS) 117 + Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.) 118 + URLs Puppeteer full browser rendering (auto-detected) 119 + 120 + Examples: 121 + impeccable detect src/ 122 + impeccable detect index.html 123 + impeccable detect https://example.com 124 + impeccable detect --json . 125 + impeccable detect --no-config src/`); 126 + } 127 + 128 + async function detectCli() { 129 + let args = process.argv.slice(2).map(arg => { 130 + if (arg === '-json') return '--json'; 131 + if (arg === '-fast') return '--fast'; 132 + return arg; 133 + }); 134 + if (args[0] === 'detect') args = args.slice(1); 135 + const jsonMode = args.includes('--json'); 136 + const quietMode = args.includes('--quiet'); 137 + const helpMode = args.includes('--help'); 138 + // --fast (regex-only) is deprecated: since the jsdom removal, the static 139 + // HTML/CSS analysis is fast and covers every rule, so the regex-only path 140 + // only loses coverage for no real speed win. Accept the flag for back-compat 141 + // but ignore it and run the full scan. 142 + if (args.includes('--fast')) { 143 + process.stderr.write( 144 + 'Note: --fast is deprecated and ignored. The full scan is fast now and runs every rule.\n', 145 + ); 146 + } 147 + const configEnabled = !args.includes('--no-config'); 148 + const detectionConfig = configEnabled 149 + ? readDetectionConfig(process.cwd()) 150 + : { ignoreRules: [], ignoreFiles: [], ignoreValues: [] }; 151 + const providers = []; 152 + if (args.includes('--gpt')) providers.push('gpt'); 153 + if (args.includes('--gemini')) providers.push('gemini'); 154 + const designSystemEnabled = configEnabled && !args.includes('--no-design-system') && detectionConfig.designSystem?.enabled !== false; 155 + const designSystem = designSystemEnabled ? loadDesignSystemForCwd(process.cwd()) : null; 156 + // Inline `impeccable-disable*` waivers are part of the scanned file, so they 157 + // apply by default. `--no-config` (raw scan) and the dedicated 158 + // `--no-inline-ignores` both turn them off. 159 + const inlineIgnoresEnabled = configEnabled && !args.includes('--no-inline-ignores'); 160 + const scanOptions = { providers, inlineIgnores: inlineIgnoresEnabled }; 161 + if (designSystem) scanOptions.designSystem = designSystem; 162 + const targets = args.filter(a => !a.startsWith('--')); 163 + 164 + if (helpMode) { printUsage(); process.exit(0); } 165 + 166 + let allFindings = []; 167 + 168 + if (!process.stdin.isTTY && targets.length === 0) { 169 + allFindings = await handleStdin(scanOptions); 170 + } else { 171 + const paths = targets.length > 0 ? targets : [process.cwd()]; 172 + const urlTargetCount = paths.filter(target => /^https?:\/\//i.test(target)).length; 173 + const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null; 174 + 175 + try { 176 + for (const target of paths) { 177 + if (/^https?:\/\//i.test(target)) { 178 + try { 179 + const scanner = browserDetector 180 + ? (url) => browserDetector.detectUrl(url, scanOptions) 181 + : (url) => detectUrl(url, scanOptions); 182 + allFindings.push(...await scanner(target)); 183 + } catch (e) { process.stderr.write(`Error: ${e.message}\n`); } 184 + continue; 185 + } 186 + 187 + const resolved = path.resolve(target); 188 + let stat; 189 + try { stat = fs.statSync(resolved); } 190 + catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; } 191 + 192 + if (stat.isDirectory()) { 193 + // Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output) 194 + if (!jsonMode && !quietMode) { 195 + const fwConfig = detectFrameworkConfig(resolved); 196 + if (fwConfig) { 197 + const probe = await isPortListening(fwConfig.port, fwConfig.fingerprint); 198 + if (probe.listening && probe.matched) { 199 + process.stderr.write( 200 + `\n${fwConfig.name} dev server detected on localhost:${fwConfig.port}.\n` + 201 + `For more accurate results, scan the running site:\n` + 202 + ` npx impeccable detect http://localhost:${fwConfig.port}\n\n` 203 + ); 204 + } else if (probe.listening && !probe.matched) { 205 + process.stderr.write( 206 + `\n${fwConfig.name} project detected (${path.basename(fwConfig.configPath)}).\n` + 207 + `Port ${fwConfig.port} is in use by another service. Start the ${fwConfig.name} dev server and scan via URL for best results.\n\n` 208 + ); 209 + } else { 210 + process.stderr.write( 211 + `\n${fwConfig.name} project detected (${path.basename(fwConfig.configPath)}).\n` + 212 + `Start the dev server and scan via URL for best results:\n` + 213 + ` npx impeccable detect http://localhost:${fwConfig.port}\n\n` 214 + ); 215 + } 216 + } 217 + } 218 + 219 + const files = walkDir(resolved) 220 + .filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig)); 221 + const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length; 222 + 223 + // Warn and confirm if scanning many files (static HTML/CSS processes each HTML file) 224 + if (files.length > 50 && process.stdin.isTTY && !jsonMode && !quietMode) { 225 + process.stderr.write( 226 + `\nFound ${files.length} files (${htmlCount} HTML) in ${target}.\n` + 227 + `Scanning may take a while${htmlCount > 10 ? ' (static HTML/CSS processes each HTML file individually)' : ''}.\n` + 228 + `Target a specific subdirectory to narrow scope.\n` 229 + ); 230 + const ok = await confirm('Continue?'); 231 + if (!ok) { process.stderr.write('Aborted.\n'); process.exit(0); } 232 + } 233 + 234 + // Build import graph for multi-file awareness 235 + const graph = buildImportGraph(files); 236 + // Build reverse map: file -> set of files that import it 237 + const importedByMap = new Map(); 238 + for (const [importer, imports] of graph) { 239 + for (const imported of imports) { 240 + if (!importedByMap.has(imported)) importedByMap.set(imported, new Set()); 241 + importedByMap.get(imported).add(importer); 242 + } 243 + } 244 + 245 + for (const file of files) { 246 + const ext = path.extname(file).toLowerCase(); 247 + let fileFindings; 248 + if (HTML_EXTENSIONS.has(ext)) { 249 + fileFindings = await detectHtml(file, scanOptions); 250 + } else { 251 + fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file, scanOptions); 252 + } 253 + // Annotate findings with import context 254 + const importers = importedByMap.get(file); 255 + if (importers && importers.size > 0) { 256 + const importerNames = [...importers].map(f => path.basename(f)); 257 + for (const f of fileFindings) { 258 + f.importedBy = importerNames; 259 + } 260 + } 261 + allFindings.push(...fileFindings); 262 + } 263 + } else if (stat.isFile()) { 264 + if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue; 265 + const ext = path.extname(resolved).toLowerCase(); 266 + if (HTML_EXTENSIONS.has(ext)) { 267 + allFindings.push(...await detectHtml(resolved, scanOptions)); 268 + } else { 269 + allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved, scanOptions)); 270 + } 271 + } 272 + } 273 + } finally { 274 + if (browserDetector) await browserDetector.close(); 275 + } 276 + } 277 + 278 + allFindings = filterDetectionFindings(allFindings, detectionConfig); 279 + 280 + if (allFindings.length > 0) { 281 + if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n'); 282 + else if (quietMode) process.stderr.write(formatFindingSummary(allFindings.length) + '\n'); 283 + else process.stderr.write(formatFindings(allFindings, false) + '\n'); 284 + process.exit(2); 285 + } 286 + if (jsonMode) process.stdout.write('[]\n'); 287 + process.exit(0); 288 + } 289 + 290 + export { formatFindings, handleStdin, confirm, printUsage, detectCli };
+750
tools/design-detector/detector/design-system.mjs
··· 1 + import fs from 'node:fs'; 2 + import path from 'node:path'; 3 + 4 + import { finding } from './findings.mjs'; 5 + import { GENERIC_FONTS } from './shared/constants.mjs'; 6 + import { parseAnyColor, resolveLengthPx } from './rules/checks.mjs'; 7 + 8 + const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md']; 9 + const FALLBACK_DIRS = ['.agents/context', 'docs']; 10 + const COLOR_CHANNEL_TOLERANCE = 6; 11 + const RADIUS_TOLERANCE_PX = 0.5; 12 + 13 + const CSS_COLOR_RE = /#[0-9a-f]{3,8}\b|rgba?\([^)]+\)|oklch\([^)]+\)|hsla?\([^)]+\)/gi; 14 + const FONT_DECL_RE = /font-family\s*:\s*([^;}\n]+)/gi; 15 + const FONT_JS_RE = /fontFamily\s*[:=]\s*["'`]([^"'`]+)["'`]/g; 16 + const GOOGLE_FONT_RE = /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi; 17 + const BORDER_RADIUS_RE = /border-radius\s*:\s*([^;}\n]+)/gi; 18 + const BORDER_RADIUS_JS_RE = /borderRadius\s*[:=]\s*["'`]([^"'`]+)["'`]/g; 19 + const STATIC_DESIGN_SKIP_TAGS = new Set(['head', 'title', 'meta', 'link', 'style', 'script', 'noscript', 'template', 'source']); 20 + 21 + function firstExisting(dir, names) { 22 + for (const name of names) { 23 + const abs = path.join(dir, name); 24 + if (fs.existsSync(abs)) return abs; 25 + } 26 + return null; 27 + } 28 + 29 + function resolveDesignMdPath(cwd = process.cwd()) { 30 + const root = firstExisting(cwd, DESIGN_NAMES); 31 + if (root) return { path: root, contextDir: cwd }; 32 + 33 + for (const rel of FALLBACK_DIRS) { 34 + const dir = path.resolve(cwd, rel); 35 + const found = firstExisting(dir, DESIGN_NAMES); 36 + if (found) return { path: found, contextDir: dir }; 37 + } 38 + 39 + return null; 40 + } 41 + 42 + function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) { 43 + const candidates = [ 44 + path.join(cwd, '.impeccable', 'design.json'), 45 + path.join(cwd, 'DESIGN.json'), 46 + path.join(contextDir, 'DESIGN.json'), 47 + ]; 48 + return candidates.find((candidate, index) => 49 + candidates.indexOf(candidate) === index && fs.existsSync(candidate) 50 + ) || null; 51 + } 52 + 53 + function parseFrontmatter(md) { 54 + const lines = String(md || '').split(/\r?\n/); 55 + if (lines[0]?.trim() !== '---') return null; 56 + let end = -1; 57 + for (let i = 1; i < lines.length; i++) { 58 + if (lines[i].trim() === '---') { end = i; break; } 59 + } 60 + if (end === -1) return null; 61 + try { 62 + return parseYamlSubset(lines.slice(1, end).join('\n')); 63 + } catch { 64 + return null; 65 + } 66 + } 67 + 68 + function parseYamlSubset(yaml) { 69 + const root = {}; 70 + const stack = [{ indent: -1, obj: root }]; 71 + 72 + for (const raw of String(yaml || '').split(/\r?\n/)) { 73 + if (!raw.trim() || /^\s*#/.test(raw)) continue; 74 + const indent = raw.match(/^\s*/)[0].length; 75 + const content = raw.slice(indent); 76 + const colonIdx = findTopLevelColon(content); 77 + if (colonIdx === -1) continue; 78 + 79 + while (stack.length > 1 && stack[stack.length - 1].indent >= indent) stack.pop(); 80 + 81 + const key = unquoteYamlKey(content.slice(0, colonIdx).trim()); 82 + const rest = stripInlineYamlComment(content.slice(colonIdx + 1).trim()); 83 + const parent = stack[stack.length - 1].obj; 84 + 85 + if (rest === '') { 86 + const obj = {}; 87 + parent[key] = obj; 88 + stack.push({ indent, obj }); 89 + } else { 90 + parent[key] = parseScalar(rest); 91 + } 92 + } 93 + 94 + return root; 95 + } 96 + 97 + function findTopLevelColon(s) { 98 + let inQuote = null; 99 + for (let i = 0; i < s.length; i++) { 100 + const ch = s[i]; 101 + if (inQuote) { 102 + if (ch === inQuote && s[i - 1] !== '\\') inQuote = null; 103 + } else if (ch === '"' || ch === "'") { 104 + inQuote = ch; 105 + } else if (ch === ':') { 106 + return i; 107 + } 108 + } 109 + return -1; 110 + } 111 + 112 + function unquoteYamlKey(key) { 113 + if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) { 114 + return key.slice(1, -1); 115 + } 116 + return key; 117 + } 118 + 119 + function stripInlineYamlComment(s) { 120 + let inQuote = null; 121 + for (let i = 0; i < s.length; i++) { 122 + const ch = s[i]; 123 + if (inQuote) { 124 + if (ch === inQuote && s[i - 1] !== '\\') inQuote = null; 125 + } else if (ch === '"' || ch === "'") { 126 + inQuote = ch; 127 + } else if (ch === '#' && i > 0 && /\s/.test(s[i - 1])) { 128 + return s.slice(0, i).trimEnd(); 129 + } 130 + } 131 + return s; 132 + } 133 + 134 + function parseScalar(raw) { 135 + const s = raw.trim(); 136 + if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) { 137 + return s.slice(1, -1); 138 + } 139 + if (s === 'true') return true; 140 + if (s === 'false') return false; 141 + if (s === 'null' || s === '~') return null; 142 + if (/^-?\d+$/.test(s)) return Number(s); 143 + if (/^-?\d*\.\d+$/.test(s)) return Number(s); 144 + return s; 145 + } 146 + 147 + function safeReadJson(filePath) { 148 + if (!filePath) return null; 149 + try { 150 + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); 151 + } catch { 152 + return null; 153 + } 154 + } 155 + 156 + function normalizeFontName(value) { 157 + return String(value || '') 158 + .trim() 159 + .replace(/\s*!important\s*$/i, '') 160 + .trim() 161 + .replace(/^["']|["']$/g, '') 162 + .replace(/\+/g, ' ') 163 + .replace(/\s+/g, ' ') 164 + .toLowerCase(); 165 + } 166 + 167 + function splitFontStack(stack) { 168 + return String(stack || '') 169 + .replace(/\s*!important\s*$/i, '') 170 + .split(',') 171 + .map(normalizeFontName) 172 + .filter(Boolean); 173 + } 174 + 175 + function primaryFont(stack) { 176 + if (!stack || /var\(/i.test(stack) || !isLiteralFontStack(stack)) return ''; 177 + return splitFontStack(stack).find(font => !GENERIC_FONTS.has(font)) || ''; 178 + } 179 + 180 + function isLiteralFontStack(stack) { 181 + const text = String(stack || ''); 182 + return !/[$`{}]|\s\+\s|\|\|/.test(text); 183 + } 184 + 185 + function cssColorLabel(raw) { 186 + return String(raw || '').trim().replace(/\s+/g, ' '); 187 + } 188 + 189 + function colorKey(color) { 190 + if (!color) return ''; 191 + return `${color.r},${color.g},${color.b}`; 192 + } 193 + 194 + function colorsClose(a, b) { 195 + if (!a || !b) return false; 196 + return Math.max( 197 + Math.abs(a.r - b.r), 198 + Math.abs(a.g - b.g), 199 + Math.abs(a.b - b.b), 200 + ) <= COLOR_CHANNEL_TOLERANCE; 201 + } 202 + 203 + function hslToRgb(H, S, L, alpha = 1) { 204 + const h = (((H % 360) + 360) % 360) / 360; 205 + const s = Math.max(0, Math.min(1, S)); 206 + const l = Math.max(0, Math.min(1, L)); 207 + const hue2rgb = (p, q, t) => { 208 + if (t < 0) t += 1; 209 + if (t > 1) t -= 1; 210 + if (t < 1 / 6) return p + (q - p) * 6 * t; 211 + if (t < 1 / 2) return q; 212 + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; 213 + return p; 214 + }; 215 + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; 216 + const p = 2 * l - q; 217 + return { 218 + r: Math.round(hue2rgb(p, q, h + 1 / 3) * 255), 219 + g: Math.round(hue2rgb(p, q, h) * 255), 220 + b: Math.round(hue2rgb(p, q, h - 1 / 3) * 255), 221 + a: alpha, 222 + }; 223 + } 224 + 225 + function parseDesignColor(value) { 226 + const text = String(value || '').trim(); 227 + const parsed = parseAnyColor(text); 228 + if (parsed) return parsed; 229 + const hsl = text.match(/hsla?\(\s*([-\d.]+)(?:deg)?\s*,?\s*([\d.]+)%\s*,?\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+))?\s*\)/i); 230 + if (hsl) { 231 + return hslToRgb( 232 + parseFloat(hsl[1]), 233 + parseFloat(hsl[2]) / 100, 234 + parseFloat(hsl[3]) / 100, 235 + hsl[4] !== undefined ? parseFloat(hsl[4]) : 1, 236 + ); 237 + } 238 + return null; 239 + } 240 + 241 + function addDesignColor(out, value, label) { 242 + const parsed = parseDesignColor(value); 243 + if (!parsed) return; 244 + const key = colorKey(parsed); 245 + if (!out.allowedColorKeys.has(key)) { 246 + out.allowedColorKeys.set(key, { color: parsed, labels: [] }); 247 + } 248 + out.allowedColorKeys.get(key).labels.push(label || cssColorLabel(value)); 249 + } 250 + 251 + function addColorObject(out, colors, prefix = 'colors') { 252 + if (!colors || typeof colors !== 'object') return; 253 + for (const [name, value] of Object.entries(colors)) { 254 + if (typeof value === 'string') { 255 + addDesignColor(out, value, `${prefix}.${name}`); 256 + } 257 + } 258 + } 259 + 260 + function addSidecarColors(out, sidecar) { 261 + const colorMeta = sidecar?.extensions?.colorMeta; 262 + if (!colorMeta || typeof colorMeta !== 'object') return; 263 + 264 + for (const [name, meta] of Object.entries(colorMeta)) { 265 + if (!meta || typeof meta !== 'object') continue; 266 + if (typeof meta.canonical === 'string') addDesignColor(out, meta.canonical, `sidecar.${name}`); 267 + if (Array.isArray(meta.tonalRamp)) { 268 + for (const [index, value] of meta.tonalRamp.entries()) { 269 + if (typeof value === 'string') addDesignColor(out, value, `sidecar.${name}.tonalRamp[${index}]`); 270 + } 271 + } 272 + } 273 + } 274 + 275 + function addTypographyFonts(out, typography) { 276 + if (!typography || typeof typography !== 'object') return; 277 + for (const role of Object.values(typography)) { 278 + if (!role || typeof role !== 'object') continue; 279 + if (typeof role.fontFamily !== 'string') continue; 280 + for (const font of splitFontStack(role.fontFamily)) { 281 + if (!GENERIC_FONTS.has(font)) out.allowedFonts.add(font); 282 + } 283 + } 284 + } 285 + 286 + function addRoundedScale(out, rounded) { 287 + if (!rounded || typeof rounded !== 'object') return; 288 + for (const [rawName, value] of Object.entries(rounded)) { 289 + const name = unquoteYamlKey(rawName).toLowerCase(); 290 + addRoundedToken(out, name, value); 291 + } 292 + } 293 + 294 + function addRoundedToken(out, name, value) { 295 + if (typeof value !== 'string' && typeof value !== 'number') return; 296 + const raw = String(value).trim(); 297 + if (!raw || /var\(/i.test(raw) || raw.includes('%')) return; 298 + const px = resolveLengthPx(raw, 16); 299 + if (px == null || !Number.isFinite(px)) return; 300 + out.allowedRadii.push({ name, value: raw, px }); 301 + if (/(^|\.)(full|pill|round|rounded-full)$/.test(name)) out.hasPillRadius = true; 302 + } 303 + 304 + function addSidecarRadii(out, sidecar) { 305 + const roundedMeta = sidecar?.extensions?.roundedMeta; 306 + if (!roundedMeta || typeof roundedMeta !== 'object') return; 307 + 308 + for (const [rawName, meta] of Object.entries(roundedMeta)) { 309 + const name = unquoteYamlKey(rawName).toLowerCase(); 310 + if (typeof meta === 'string' || typeof meta === 'number') { 311 + addRoundedToken(out, `sidecar.${name}`, meta); 312 + continue; 313 + } 314 + if (!meta || typeof meta !== 'object') continue; 315 + for (const key of ['canonical', 'value']) { 316 + if (typeof meta[key] === 'string' || typeof meta[key] === 'number') { 317 + addRoundedToken(out, `sidecar.${name}.${key}`, meta[key]); 318 + } 319 + } 320 + for (const key of ['values', 'aliases']) { 321 + if (!Array.isArray(meta[key])) continue; 322 + for (const [index, value] of meta[key].entries()) { 323 + addRoundedToken(out, `sidecar.${name}.${key}[${index}]`, value); 324 + } 325 + } 326 + if (/^(full|pill|round|rounded-full)$/.test(name) || /^(full|pill|round)$/i.test(String(meta.role || ''))) { 327 + out.hasPillRadius = true; 328 + } 329 + } 330 + } 331 + 332 + function normalizeDesignSystem(input = {}) { 333 + const frontmatter = input.frontmatter || {}; 334 + const sidecar = input.sidecar || null; 335 + const out = { 336 + present: true, 337 + sourcePath: input.sourcePath || null, 338 + sidecarPath: input.sidecarPath || null, 339 + mdNewerThanJson: input.mdNewerThanJson === true, 340 + allowedFonts: new Set(), 341 + allowedColorKeys: new Map(), 342 + allowedRadii: [], 343 + hasPillRadius: false, 344 + }; 345 + 346 + addTypographyFonts(out, frontmatter.typography); 347 + addColorObject(out, frontmatter.colors); 348 + addSidecarColors(out, sidecar); 349 + addRoundedScale(out, frontmatter.rounded); 350 + addSidecarRadii(out, sidecar); 351 + 352 + out.hasFonts = out.allowedFonts.size > 0; 353 + out.hasColors = out.allowedColorKeys.size > 0; 354 + out.hasRadii = out.allowedRadii.length > 0; 355 + return out; 356 + } 357 + 358 + function loadDesignSystemForCwd(cwd = process.cwd()) { 359 + const md = resolveDesignMdPath(cwd); 360 + if (!md) return null; 361 + 362 + let frontmatter = null; 363 + let mdStat = null; 364 + try { 365 + mdStat = fs.statSync(md.path); 366 + frontmatter = parseFrontmatter(fs.readFileSync(md.path, 'utf-8')); 367 + } catch { 368 + return null; 369 + } 370 + if (!frontmatter || typeof frontmatter !== 'object') return null; 371 + 372 + const sidecarPath = resolveDesignSidecarPath(cwd, md.contextDir); 373 + const sidecar = safeReadJson(sidecarPath); 374 + let sidecarStat = null; 375 + try { 376 + if (sidecarPath) sidecarStat = fs.statSync(sidecarPath); 377 + } catch { 378 + sidecarStat = null; 379 + } 380 + 381 + return normalizeDesignSystem({ 382 + frontmatter, 383 + sidecar, 384 + sourcePath: md.path, 385 + sidecarPath, 386 + mdNewerThanJson: !!(mdStat && sidecarStat && mdStat.mtimeMs > sidecarStat.mtimeMs + 1000), 387 + }); 388 + } 389 + 390 + function isAllowedFont(font, designSystem) { 391 + if (!font || GENERIC_FONTS.has(font)) return true; 392 + if (!designSystem?.hasFonts) return true; 393 + return designSystem.allowedFonts.has(font); 394 + } 395 + 396 + function isAllowedColorRaw(raw, designSystem) { 397 + if (!designSystem?.hasColors) return true; 398 + const text = String(raw || '').trim().toLowerCase(); 399 + if (!text || text === 'transparent' || text === 'currentcolor' || text === 'inherit' || text === 'initial') return true; 400 + if (text.includes('var(')) return true; 401 + const parsed = parseDesignColor(text); 402 + if (!parsed) return true; 403 + if ((parsed.a ?? 1) <= 0.05) return true; 404 + for (const entry of designSystem.allowedColorKeys.values()) { 405 + if (colorsClose(parsed, entry.color)) return true; 406 + } 407 + return false; 408 + } 409 + 410 + function isAllowedRadiusRaw(raw, designSystem) { 411 + if (!designSystem?.hasRadii) return true; 412 + const text = String(raw || '').trim().toLowerCase(); 413 + if (!text || text === '0' || text === 'none' || text === 'initial' || text === 'inherit') return true; 414 + if (text.includes('var(') || text.includes('%')) return true; 415 + const px = resolveLengthPx(text, 16); 416 + if (px == null || !Number.isFinite(px) || px <= RADIUS_TOLERANCE_PX) return true; 417 + if (designSystem.hasPillRadius && px >= 99) return true; 418 + return designSystem.allowedRadii.some(entry => Math.abs(entry.px - px) <= RADIUS_TOLERANCE_PX); 419 + } 420 + 421 + function lineLooksCommented(line) { 422 + const trimmed = String(line || '').trim(); 423 + return trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*') || trimmed.startsWith('<!--'); 424 + } 425 + 426 + function isProbablyColorLiteral(line, match) { 427 + const raw = match?.[0] || ''; 428 + const index = match.index ?? -1; 429 + if (index < 0) return false; 430 + if (isInsideCssAttributeSelector(line, index)) return false; 431 + 432 + const before = line.slice(0, index); 433 + const after = line.slice(index + raw.length); 434 + 435 + if (raw.startsWith('#')) { 436 + if (before.endsWith('&')) return false; // HTML numeric entity, e.g. &#8596; 437 + 438 + const prevNonSpace = before.match(/\S(?=\s*$)/)?.[0] || ''; 439 + const nextNonSpace = after.match(/^\s*(\S)/)?.[1] || ''; 440 + if (prevNonSpace === '>' && nextNonSpace === '<') return false; // plain text, e.g. PR #155 441 + } 442 + 443 + const styleContext = /(?:^|[{\s;"'`(,])(?:color|background(?:-color|-image)?|border(?:-(?:top|right|bottom|left))?(?:-color)?|outline(?:-color)?|box-shadow|text-shadow|fill|stroke)\s*:\s*[^;{}"'`]*/i.test(before); 444 + const cssFunctionContext = /(?:linear-gradient|radial-gradient|conic-gradient|color-mix)\([^)]*$/i.test(before); 445 + const jsColorKeyContext = /(?:^|[,{]\s*)(?:color|background|backgroundColor|borderColor|outlineColor|fill|stroke|boxShadow|textShadow)\s*[:=]\s*["'`]?[^"'`,}]*/i.test(before); 446 + 447 + return styleContext || cssFunctionContext || jsColorKeyContext; 448 + } 449 + 450 + function isInsideCssAttributeSelector(line, index) { 451 + if (index < 0) return false; 452 + const before = line.slice(0, index); 453 + const lastOpen = before.lastIndexOf('['); 454 + if (lastOpen === -1) return false; 455 + const lastClose = before.lastIndexOf(']'); 456 + if (lastClose > lastOpen) return false; 457 + const after = line.slice(index); 458 + const close = after.indexOf(']'); 459 + const block = after.indexOf('{'); 460 + return close !== -1 && (block === -1 || close < block); 461 + } 462 + 463 + function makeDesignFinding(id, filePath, snippet, line = 0, extras = {}) { 464 + return { ...finding(id, filePath, snippet, line), ...extras }; 465 + } 466 + 467 + function decodeGoogleFamily(value) { 468 + const family = String(value || '').split(':')[0].replace(/\+/g, ' '); 469 + try { 470 + return decodeURIComponent(family); 471 + } catch { 472 + return family; 473 + } 474 + } 475 + 476 + function checkFontStack(stack, filePath, line, designSystem, context) { 477 + const primary = primaryFont(stack); 478 + if (!primary || isAllowedFont(primary, designSystem)) return []; 479 + const display = primary.replace(/\b\w/g, ch => ch.toUpperCase()); 480 + return [makeDesignFinding( 481 + 'design-system-font', 482 + filePath, 483 + `${context}: ${display} is not declared in DESIGN.md typography`, 484 + line, 485 + { ignoreValue: display }, 486 + )]; 487 + } 488 + 489 + function extractRadiusTokens(value) { 490 + return String(value || '') 491 + .replace(/\s*\/\s*/g, ' ') 492 + .split(/\s+/) 493 + .map(token => token.trim()) 494 + .filter(Boolean); 495 + } 496 + 497 + function checkRadiusValue(value, filePath, line, designSystem, context) { 498 + const findings = []; 499 + for (const token of extractRadiusTokens(value)) { 500 + if (isAllowedRadiusRaw(token, designSystem)) continue; 501 + findings.push(makeDesignFinding( 502 + 'design-system-radius', 503 + filePath, 504 + `${context}: ${token} is outside the DESIGN.md rounded scale`, 505 + line, 506 + { ignoreValue: token }, 507 + )); 508 + } 509 + return findings; 510 + } 511 + 512 + function checkSourceDesignSystem(content, filePath, options = {}) { 513 + const designSystem = options.designSystem; 514 + if (!designSystem?.present) return []; 515 + 516 + const findings = []; 517 + const lines = String(content || '').split('\n'); 518 + for (let i = 0; i < lines.length; i++) { 519 + const line = lines[i]; 520 + const lineNum = i + 1; 521 + if (lineLooksCommented(line)) continue; 522 + 523 + if (designSystem.hasFonts) { 524 + for (const match of line.matchAll(FONT_DECL_RE)) { 525 + findings.push(...checkFontStack(match[1], filePath, lineNum, designSystem, 'font-family')); 526 + } 527 + for (const match of line.matchAll(FONT_JS_RE)) { 528 + findings.push(...checkFontStack(match[1], filePath, lineNum, designSystem, 'fontFamily')); 529 + } 530 + for (const match of line.matchAll(GOOGLE_FONT_RE)) { 531 + const url = match[0]; 532 + for (const familyMatch of url.matchAll(/[?&]family=([^&]+)/g)) { 533 + const font = normalizeFontName(decodeGoogleFamily(familyMatch[1])); 534 + if (!font || isAllowedFont(font, designSystem)) continue; 535 + const display = decodeGoogleFamily(familyMatch[1]); 536 + findings.push(makeDesignFinding( 537 + 'design-system-font', 538 + filePath, 539 + `Google Fonts: ${display} is not declared in DESIGN.md typography`, 540 + lineNum, 541 + { ignoreValue: display }, 542 + )); 543 + } 544 + } 545 + } 546 + 547 + if (designSystem.hasColors) { 548 + for (const match of line.matchAll(CSS_COLOR_RE)) { 549 + if (!isProbablyColorLiteral(line, match)) continue; 550 + const raw = cssColorLabel(match[0]); 551 + if (isAllowedColorRaw(raw, designSystem)) continue; 552 + findings.push(makeDesignFinding( 553 + 'design-system-color', 554 + filePath, 555 + `Undocumented color ${raw} is outside DESIGN.md colors`, 556 + lineNum, 557 + { ignoreValue: raw }, 558 + )); 559 + } 560 + } 561 + 562 + if (designSystem.hasRadii) { 563 + for (const match of line.matchAll(BORDER_RADIUS_RE)) { 564 + findings.push(...checkRadiusValue(match[1], filePath, lineNum, designSystem, 'border-radius')); 565 + } 566 + for (const match of line.matchAll(BORDER_RADIUS_JS_RE)) { 567 + findings.push(...checkRadiusValue(match[1], filePath, lineNum, designSystem, 'borderRadius')); 568 + } 569 + } 570 + } 571 + 572 + return dedupeDesignFindings(findings); 573 + } 574 + 575 + function hasDirectText(el) { 576 + return Array.from(el.childNodes || []).some(node => node.nodeType === 3 && node.textContent.trim().length > 0); 577 + } 578 + 579 + function sampleText(el) { 580 + const text = String(el.textContent || '').replace(/\s+/g, ' ').trim(); 581 + return text ? ` "${text.slice(0, 40)}"` : ''; 582 + } 583 + 584 + function collectStaticDesignSystemFindings(document, window, filePath, designSystem) { 585 + if (!designSystem?.present) return []; 586 + const findings = []; 587 + const seenFonts = new Set(); 588 + const seenColors = new Set(); 589 + const seenRadii = new Set(); 590 + 591 + for (const el of document.querySelectorAll('*')) { 592 + if (shouldSkipStaticDesignElement(el, window)) continue; 593 + const tag = el.tagName?.toLowerCase?.() || 'unknown'; 594 + const style = window.getComputedStyle(el); 595 + 596 + if (designSystem.hasFonts && hasDirectText(el)) { 597 + const font = primaryFont(style.fontFamily || ''); 598 + if (font && !seenFonts.has(font) && !isAllowedFont(font, designSystem)) { 599 + seenFonts.add(font); 600 + findings.push(makeDesignFinding( 601 + 'design-system-font', 602 + filePath, 603 + `${tag}${sampleText(el)} uses ${font}; not declared in DESIGN.md typography`, 604 + 0, 605 + { ignoreValue: font }, 606 + )); 607 + } 608 + } 609 + 610 + if (designSystem.hasColors) { 611 + const colorChecks = []; 612 + if (hasDirectText(el)) colorChecks.push(['text color', style.color]); 613 + if (!isTransparentCss(style.backgroundColor)) colorChecks.push(['background', style.backgroundColor]); 614 + for (const side of ['Top', 'Right', 'Bottom', 'Left']) { 615 + if ((parseFloat(style[`border${side}Width`]) || 0) > 0) { 616 + colorChecks.push([`border-${side.toLowerCase()}`, style[`border${side}Color`]]); 617 + } 618 + } 619 + if ((parseFloat(style.outlineWidth) || 0) > 0) colorChecks.push(['outline', style.outlineColor]); 620 + 621 + for (const [kind, raw] of colorChecks) { 622 + const label = cssColorLabel(raw); 623 + if (isAllowedColorRaw(label, designSystem)) continue; 624 + const key = `${kind}:${label}`; 625 + if (seenColors.has(key)) continue; 626 + seenColors.add(key); 627 + findings.push(makeDesignFinding( 628 + 'design-system-color', 629 + filePath, 630 + `${kind} ${label} on ${tag}${sampleText(el)} is outside DESIGN.md colors`, 631 + 0, 632 + { ignoreValue: label }, 633 + )); 634 + } 635 + } 636 + 637 + if (designSystem.hasRadii) { 638 + const rawRadius = String(style.borderRadius || '').trim(); 639 + if (!rawRadius) continue; 640 + for (const token of extractRadiusTokens(rawRadius)) { 641 + if (isAllowedRadiusRaw(token, designSystem)) continue; 642 + if (seenRadii.has(token)) continue; 643 + seenRadii.add(token); 644 + findings.push(makeDesignFinding( 645 + 'design-system-radius', 646 + filePath, 647 + `border-radius ${token} on ${tag}${sampleText(el)} is outside the DESIGN.md rounded scale`, 648 + 0, 649 + { ignoreValue: token }, 650 + )); 651 + } 652 + } 653 + } 654 + 655 + return findings; 656 + } 657 + 658 + function shouldSkipStaticDesignElement(el, window) { 659 + const tag = el.tagName?.toLowerCase?.() || ''; 660 + if (STATIC_DESIGN_SKIP_TAGS.has(tag)) return true; 661 + 662 + let current = el; 663 + while (current) { 664 + if (current.getAttribute?.('hidden') !== null || current.getAttribute?.('aria-hidden') === 'true') return true; 665 + const style = window.getComputedStyle(current); 666 + const display = String(style.display || '').toLowerCase(); 667 + const visibility = String(style.visibility || '').toLowerCase(); 668 + if (display === 'none' || visibility === 'hidden' || visibility === 'collapse') return true; 669 + current = current.parentElement; 670 + } 671 + return false; 672 + } 673 + 674 + function isTransparentCss(value) { 675 + const text = String(value || '').trim().toLowerCase(); 676 + if (!text || text === 'transparent') return true; 677 + const parsed = parseDesignColor(text); 678 + return parsed ? (parsed.a ?? 1) <= 0.05 : false; 679 + } 680 + 681 + function canonicalDesignFindingKey(item) { 682 + if (!item?.antipattern?.startsWith?.('design-system-')) return null; 683 + const value = item.ignoreValue || item.value || ''; 684 + if (item.antipattern === 'design-system-font') { 685 + const context = /google fonts/i.test(item.snippet || '') ? 'google-font' : 'font'; 686 + const font = normalizeFontName(value); 687 + return font ? `${item.antipattern}:${context}:${font}` : null; 688 + } 689 + if (item.antipattern === 'design-system-color') { 690 + const parsed = parseDesignColor(value); 691 + if (parsed) return `${item.antipattern}:color:${colorKey(parsed)}`; 692 + const label = cssColorLabel(value).toLowerCase(); 693 + return label ? `${item.antipattern}:color:${label}` : null; 694 + } 695 + if (item.antipattern === 'design-system-radius') { 696 + const px = resolveLengthPx(String(value || '').trim(), 16); 697 + if (px != null && Number.isFinite(px)) return `${item.antipattern}:radius:${Math.round(px * 100) / 100}`; 698 + const label = String(value || '').trim().toLowerCase(); 699 + return label ? `${item.antipattern}:radius:${label}` : null; 700 + } 701 + return null; 702 + } 703 + 704 + function mergeDesignSystemFindings(...groups) { 705 + const out = []; 706 + const seen = new Map(); 707 + for (const group of groups) { 708 + for (const item of group || []) { 709 + const key = canonicalDesignFindingKey(item); 710 + if (key) { 711 + if (seen.has(key)) { 712 + const existing = out[seen.get(key)]; 713 + if ((existing.line || 0) <= 0 && (item.line || 0) > 0) existing.line = item.line; 714 + continue; 715 + } 716 + seen.set(key, out.length); 717 + } 718 + out.push(item); 719 + } 720 + } 721 + return out; 722 + } 723 + 724 + function dedupeDesignFindings(findings) { 725 + const out = []; 726 + const seen = new Set(); 727 + for (const item of findings) { 728 + const key = [ 729 + item.antipattern, 730 + item.line || 0, 731 + normalizeFontName(item.ignoreValue || item.snippet || ''), 732 + ].join('\0'); 733 + if (seen.has(key)) continue; 734 + seen.add(key); 735 + out.push(item); 736 + } 737 + return out; 738 + } 739 + 740 + export { 741 + parseFrontmatter, 742 + normalizeDesignSystem, 743 + loadDesignSystemForCwd, 744 + isAllowedFont, 745 + isAllowedColorRaw, 746 + isAllowedRadiusRaw, 747 + checkSourceDesignSystem, 748 + collectStaticDesignSystemFindings, 749 + mergeDesignSystemFindings, 750 + };
+5138
tools/design-detector/detector/detect-antipatterns-browser.js
··· 1 + /** 2 + * Anti-Pattern Browser Detector for Impeccable 3 + * Copyright (c) 2026 Paul Bakaus 4 + * SPDX-License-Identifier: Apache-2.0 5 + * 6 + * GENERATED -- do not edit. Source: cli/engine/browser/injected/index.mjs 7 + * Rebuild: node scripts/build-browser-detector.js 8 + * 9 + * Usage: <script src="detect-antipatterns-browser.js"></script> 10 + * Re-scan: window.impeccableScan() 11 + */ 12 + (function () { 13 + if (typeof window === 'undefined') return; 14 + // --- cli/engine/shared/constants.mjs --- 15 + // ─── Section 1: Constants ─────────────────────────────────────────────────── 16 + 17 + const SAFE_TAGS = new Set([ 18 + 'blockquote', 'nav', 'a', 'input', 'textarea', 'select', 19 + 'pre', 'code', 'span', 'th', 'td', 'tr', 'li', 'label', 20 + 'button', 'hr', 'html', 'head', 'body', 'script', 'style', 21 + 'link', 'meta', 'title', 'br', 'img', 'svg', 'path', 'circle', 22 + 'rect', 'line', 'polyline', 'polygon', 'g', 'defs', 'use', 23 + ]); 24 + 25 + // Per-check safe-tags override for the border (side-tab / border-accent) 26 + // rule. We intentionally re-allow <label> here because card-shaped clickable 27 + // labels (e.g. .checklist-item wrapping a checkbox + content) are one of the 28 + // canonical side-tab anti-pattern shapes and must be detected. The rule's 29 + // other preconditions (non-neutral color, width >= 2px on a single side, 30 + // radius > 0 or width >= 3, element size >= 20x20 in the browser path) 31 + // already filter out plain inline form labels so this does not introduce 32 + // false positives. See modern-color-borders.html for the test matrix. 33 + const BORDER_SAFE_TAGS = new Set( 34 + [...SAFE_TAGS].filter(t => t !== 'label') 35 + ); 36 + 37 + const OVERUSED_FONTS = new Set([ 38 + // Older monoculture (still ubiquitous): 39 + 'inter', 'roboto', 'open sans', 'lato', 'montserrat', 'arial', 'helvetica', 40 + // Newer monoculture (the Anthropic-skill / Vercel / GitHub default wave): 41 + 'fraunces', 'instrument sans', 'instrument serif', 42 + 'geist', 'geist sans', 'geist mono', 43 + 'mona sans', 44 + 'plus jakarta sans', 'space grotesk', 'recoleta', 45 + ]); 46 + 47 + // Brand-associated fonts: don't flag these as "overused" on the brand's own domains. 48 + // Keys are font names, values are arrays of hostname suffixes where the font is allowed. 49 + const GOOGLE_DOMAINS = [ 50 + 'google.com', 'youtube.com', 'android.com', 'chromium.org', 51 + 'chrome.com', 'web.dev', 'gstatic.com', 'firebase.google.com', 52 + ]; 53 + const VERCEL_DOMAINS = ['vercel.com', 'nextjs.org', 'v0.app']; 54 + const GITHUB_DOMAINS = ['github.com', 'githubnext.com']; 55 + const BRAND_FONT_DOMAINS = { 56 + 'roboto': GOOGLE_DOMAINS, 57 + 'google sans': GOOGLE_DOMAINS, 58 + 'product sans': GOOGLE_DOMAINS, 59 + 'geist': VERCEL_DOMAINS, 60 + 'geist sans': VERCEL_DOMAINS, 61 + 'geist mono': VERCEL_DOMAINS, 62 + 'mona sans': GITHUB_DOMAINS, 63 + }; 64 + 65 + function isBrandFontOnOwnDomain(font) { 66 + if (typeof location === 'undefined') return false; 67 + const allowed = BRAND_FONT_DOMAINS[font]; 68 + if (!allowed) return false; 69 + const host = location.hostname.toLowerCase(); 70 + return allowed.some(suffix => host === suffix || host.endsWith('.' + suffix)); 71 + } 72 + 73 + const GENERIC_FONTS = new Set([ 74 + 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 75 + 'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded', 76 + '-apple-system', 'blinkmacsystemfont', 'segoe ui', 77 + 'inherit', 'initial', 'unset', 'revert', 78 + ]); 79 + 80 + // WCAG large text thresholds are defined in points: 18pt normal text and 81 + // 14pt bold text. Browsers expose font-size in CSS pixels at 96px per inch. 82 + const WCAG_LARGE_TEXT_PX = 18 * (96 / 72); 83 + const WCAG_LARGE_BOLD_TEXT_PX = 14 * (96 / 72); 84 + 85 + // Serif faces that show up in italic-display heroes. The rule also fires when 86 + // the primary face is unknown but the stack ends in the generic `serif` token, 87 + // which catches custom/private faces with a serif fallback. 88 + const KNOWN_SERIF_FONTS = new Set([ 89 + 'fraunces', 'recoleta', 'newsreader', 'playfair display', 'playfair', 90 + 'cormorant', 'cormorant garamond', 'garamond', 'eb garamond', 91 + 'tiempos', 'tiempos headline', 'tiempos text', 92 + 'lora', 'vollkorn', 'spectral', 93 + 'source serif pro', 'source serif 4', 'source serif', 94 + 'ibm plex serif', 'merriweather', 95 + 'libre caslon', 'libre baskerville', 'baskerville', 96 + 'georgia', 'times new roman', 'times', 97 + 'dm serif display', 'dm serif text', 98 + 'instrument serif', 'gt sectra', 'ogg', 'canela', 99 + 'freight display', 'freight text', 100 + ]); 101 + 102 + // --- cli/engine/registry/antipatterns.mjs --- 103 + const ANTIPATTERNS = [ 104 + // ── AI slop: tells that something was AI-generated ── 105 + { 106 + id: 'side-tab', 107 + category: 'slop', 108 + name: 'Side-tab accent border', 109 + description: 110 + 'Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.', 111 + skillSection: 'Visual Details', 112 + skillGuideline: 'colored accent stripe', 113 + }, 114 + { 115 + id: 'border-accent-on-rounded', 116 + category: 'slop', 117 + name: 'Border accent on rounded element', 118 + description: 119 + 'Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.', 120 + skillSection: 'Visual Details', 121 + skillGuideline: 'colored accent stripe', 122 + }, 123 + { 124 + id: 'overused-font', 125 + category: 'slop', 126 + name: 'Overused font', 127 + description: 128 + 'Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.', 129 + skillSection: 'Typography', 130 + skillGuideline: 'overused fonts like Inter', 131 + }, 132 + { 133 + id: 'single-font', 134 + category: 'slop', 135 + name: 'Single font for everything', 136 + description: 137 + 'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.', 138 + skillSection: 'Typography', 139 + skillGuideline: 'only one font family for the entire page', 140 + }, 141 + { 142 + id: 'flat-type-hierarchy', 143 + category: 'slop', 144 + name: 'Flat type hierarchy', 145 + description: 146 + 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', 147 + skillSection: 'Typography', 148 + skillGuideline: 'flat type hierarchy', 149 + }, 150 + { 151 + id: 'gradient-text', 152 + category: 'slop', 153 + name: 'Gradient text', 154 + description: 155 + 'Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.', 156 + skillSection: 'Color & Contrast', 157 + skillGuideline: 'gradient text for', 158 + }, 159 + { 160 + id: 'ai-color-palette', 161 + category: 'slop', 162 + name: 'AI color palette', 163 + description: 164 + 'Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.', 165 + skillSection: 'Color & Contrast', 166 + skillGuideline: 'AI color palette', 167 + }, 168 + { 169 + id: 'cream-palette', 170 + category: 'slop', 171 + name: 'Cream / beige palette', 172 + description: 173 + 'A warm cream or beige page background has become the default "tasteful" AI surface, reached for by reflex. Choose a background that comes from a deliberate palette, not the safe warm off-white.', 174 + skillSection: 'Color & Contrast', 175 + skillGuideline: 'cream and beige as the default surface', 176 + }, 177 + { 178 + id: 'nested-cards', 179 + category: 'slop', 180 + name: 'Nested cards', 181 + description: 182 + 'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.', 183 + skillSection: 'Layout & Space', 184 + skillGuideline: 'Nest cards inside cards', 185 + }, 186 + { 187 + id: 'monotonous-spacing', 188 + category: 'slop', 189 + name: 'Monotonous spacing', 190 + description: 191 + 'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.', 192 + skillSection: 'Layout & Space', 193 + skillGuideline: 'same spacing everywhere', 194 + }, 195 + { 196 + id: 'bounce-easing', 197 + category: 'slop', 198 + name: 'Bounce or elastic easing', 199 + description: 200 + 'Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.', 201 + skillSection: 'Motion', 202 + skillGuideline: 'bounce or elastic easing', 203 + }, 204 + { 205 + id: 'dark-glow', 206 + category: 'slop', 207 + name: 'Dark mode with glowing accents', 208 + description: 209 + 'Dark backgrounds with colored box-shadow glows are the default "cool" look of AI-generated UIs. Use subtle, purposeful lighting instead — or skip the dark theme entirely.', 210 + skillSection: 'Color & Contrast', 211 + skillGuideline: 'dark mode with glowing accents', 212 + }, 213 + { 214 + id: 'icon-tile-stack', 215 + category: 'slop', 216 + name: 'Icon tile stacked above heading', 217 + description: 218 + 'A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.', 219 + skillSection: 'Typography', 220 + skillGuideline: 'large icons with rounded corners above every heading', 221 + }, 222 + { 223 + id: 'italic-serif-display', 224 + category: 'slop', 225 + name: 'Italic serif display headline', 226 + description: 227 + 'Oversized italic serif (Fraunces, Recoleta, Playfair, Newsreader-italic) as the primary hero headline reads as taste in isolation but has become the universal AI-startup landing page hero. Set roman, or move to a non-serif display face. Editorial / magazine register may legitimately want this — judge by context.', 228 + skillSection: 'Typography', 229 + skillGuideline: 'oversized italic serif as the hero headline', 230 + }, 231 + { 232 + id: 'hero-eyebrow-chip', 233 + category: 'slop', 234 + name: 'Hero eyebrow / pill chip', 235 + description: 236 + 'A tiny uppercase letter-spaced label sitting immediately above an oversized hero headline — or the same shape rendered as a pill chip — is now the default AI SaaS hero. Drop the eyebrow, integrate the kicker into the headline, or run it as a navigation breadcrumb instead.', 237 + skillSection: 'Typography', 238 + skillGuideline: 'tiny uppercase tracked label above the hero headline', 239 + }, 240 + { 241 + id: 'repeated-section-kickers', 242 + category: 'slop', 243 + severity: 'advisory', 244 + name: 'Repeated section kicker labels', 245 + description: 246 + 'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.', 247 + skillSection: 'Typography', 248 + skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding', 249 + }, 250 + { 251 + id: 'numbered-section-markers', 252 + category: 'slop', 253 + severity: 'advisory', 254 + name: 'Numbered section markers (01 / 02 / 03)', 255 + description: 256 + 'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.', 257 + skillSection: 'Layout & Space', 258 + skillGuideline: 'numbered section markers', 259 + }, 260 + { 261 + id: 'em-dash-overuse', 262 + category: 'slop', 263 + name: 'Em-dash overuse', 264 + description: 265 + 'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.', 266 + skillSection: 'Copy', 267 + skillGuideline: 'no em dashes', 268 + }, 269 + { 270 + id: 'marketing-buzzword', 271 + category: 'slop', 272 + name: 'Marketing buzzword', 273 + description: 274 + 'Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.', 275 + skillSection: 'Copy', 276 + skillGuideline: 'marketing buzzwords', 277 + }, 278 + { 279 + id: 'aphoristic-cadence', 280 + category: 'slop', 281 + name: 'Aphoristic-cadence copy', 282 + description: 283 + 'Three or more sections landing on a short rebuttal sentence ("X. No Y." / "X. Just Y.") or a manufactured-contrast aphorism ("Not a feature. A platform.") reads as AI cadence, not voice. Once is fine; the pattern is the tell.', 284 + skillSection: 'Copy', 285 + skillGuideline: 'aphoristic cadence', 286 + }, 287 + { 288 + id: 'oversized-h1', 289 + category: 'slop', 290 + name: 'Oversized hero headline', 291 + description: 292 + 'A full-sentence headline set at display size ends up dominating the viewport, leaving no room for anything else above the fold. A punchy one- or two-word headline at that size is fine — the problem is a long headline blown up too large. Set long headlines smaller, or tighten the copy.', 293 + skillSection: 'Typography', 294 + skillGuideline: 'long headline set at display size', 295 + }, 296 + { 297 + id: 'extreme-negative-tracking', 298 + category: 'slop', 299 + name: 'Crushed letter spacing', 300 + description: 301 + 'Letter-spacing pulled tighter than the point where characters keep their own shapes costs legibility. Tighten display type optically, not destructively.', 302 + skillSection: 'Typography', 303 + skillGuideline: 'letter spacing crushed past legibility', 304 + }, 305 + { 306 + id: 'broken-image', 307 + category: 'quality', 308 + name: 'Broken or placeholder image', 309 + description: 310 + '<img> tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.', 311 + skillSection: 'Imagery', 312 + skillGuideline: 'broken image references', 313 + }, 314 + 315 + // ── Quality: general design and accessibility issues ── 316 + { 317 + id: 'gray-on-color', 318 + category: 'quality', 319 + name: 'Gray text on colored background', 320 + description: 321 + 'Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.', 322 + skillSection: 'Color & Contrast', 323 + skillGuideline: 'gray text on colored backgrounds', 324 + }, 325 + { 326 + id: 'low-contrast', 327 + category: 'quality', 328 + name: 'Low contrast text', 329 + description: 330 + 'Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.', 331 + }, 332 + { 333 + id: 'layout-transition', 334 + category: 'quality', 335 + name: 'Layout property animation', 336 + description: 337 + 'Animating width, height, padding, or margin causes layout thrash and janky performance. Use transform and opacity instead, or grid-template-rows for height animations.', 338 + skillSection: 'Motion', 339 + skillGuideline: 'Animate layout properties', 340 + }, 341 + { 342 + id: 'line-length', 343 + category: 'quality', 344 + name: 'Line length too long', 345 + description: 346 + 'Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line. Add a max-width (65ch to 75ch) to text containers.', 347 + skillSection: 'Layout & Space', 348 + skillGuideline: 'wrap beyond ~80 characters', 349 + }, 350 + { 351 + id: 'cramped-padding', 352 + category: 'quality', 353 + name: 'Cramped padding', 354 + description: 355 + 'Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 12–16px) of padding inside bordered, outlined, or colored containers.', 356 + skillSection: 'Layout & Space', 357 + skillGuideline: 'inside bordered or colored containers', 358 + }, 359 + { 360 + id: 'body-text-viewport-edge', 361 + category: 'quality', 362 + name: 'Body text touching viewport edge', 363 + description: 364 + 'Body paragraphs render flush against the left or right viewport edge with no container providing horizontal padding. Wrap content in a container with at least 16px (ideally 24-32px) of horizontal padding, or apply max-width with mx-auto.', 365 + }, 366 + { 367 + id: 'tight-leading', 368 + category: 'quality', 369 + name: 'Tight line height', 370 + description: 371 + 'Line height below 1.3x the font size makes multi-line text hard to read. Use 1.5 to 1.7 for body text so lines have room to breathe.', 372 + }, 373 + { 374 + id: 'skipped-heading', 375 + category: 'quality', 376 + name: 'Skipped heading level', 377 + description: 378 + 'Heading levels should not skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation. Skipping levels breaks the document outline.', 379 + }, 380 + { 381 + id: 'justified-text', 382 + category: 'quality', 383 + name: 'Justified text', 384 + description: 385 + 'Justified text without hyphenation creates uneven word spacing ("rivers of white"). Use text-align: left for body text, or enable hyphens: auto if you must justify.', 386 + }, 387 + { 388 + id: 'tiny-text', 389 + category: 'quality', 390 + name: 'Tiny body text', 391 + description: 392 + 'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.', 393 + }, 394 + { 395 + id: 'all-caps-body', 396 + category: 'quality', 397 + name: 'All-caps body text', 398 + description: 399 + 'Long passages in uppercase are hard to read. We recognize words by shape (ascenders and descenders), which all-caps removes. Reserve uppercase for short labels and headings.', 400 + skillSection: 'Typography', 401 + skillGuideline: 'long body passages in uppercase', 402 + }, 403 + { 404 + id: 'wide-tracking', 405 + category: 'quality', 406 + name: 'Wide letter spacing on body text', 407 + description: 408 + 'Letter spacing above 0.05em on body text disrupts natural character groupings and slows reading. Reserve wide tracking for short uppercase labels only.', 409 + }, 410 + { 411 + id: 'text-overflow', 412 + category: 'quality', 413 + name: 'Content overflowing its container', 414 + description: 415 + 'Content renders wider than its container, spilling out or forcing a horizontal scrollbar. Let text wrap, constrain widths, or give the region a deliberate scroll affordance.', 416 + skillSection: 'Layout & Space', 417 + skillGuideline: 'content wider than its container', 418 + }, 419 + { 420 + id: 'clipped-overflow-container', 421 + category: 'quality', 422 + name: 'Positioned child clipped by overflow container', 423 + description: 424 + 'A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.', 425 + skillSection: 'Layout & Space', 426 + skillGuideline: 'overflow container clipping positioned children', 427 + }, 428 + { 429 + id: 'design-system-font', 430 + category: 'quality', 431 + name: 'Font outside DESIGN.md', 432 + description: 433 + 'A font is used that is not declared in DESIGN.md typography. Use the documented type system or update DESIGN.md if this is an intentional brand addition.', 434 + skillSection: 'Typography', 435 + skillGuideline: 'font family outside the project design system', 436 + }, 437 + { 438 + id: 'design-system-color', 439 + category: 'quality', 440 + severity: 'advisory', 441 + name: 'Color outside DESIGN.md', 442 + description: 443 + 'A literal color is outside the DESIGN.md palette and sidecar tonal ramps. This may be legitimate, but it should be an intentional design-system addition rather than drift.', 444 + skillSection: 'Color & Contrast', 445 + skillGuideline: 'literal color outside the project design system', 446 + }, 447 + { 448 + id: 'design-system-radius', 449 + category: 'quality', 450 + severity: 'advisory', 451 + name: 'Radius outside DESIGN.md', 452 + description: 453 + 'A border-radius value is outside the DESIGN.md rounded scale. Use a documented radius token or update the design system if the new shape is intentional.', 454 + skillSection: 'Visual Details', 455 + skillGuideline: 'border radius outside the project design system', 456 + }, 457 + 458 + // ── Provider tells: opt-in via --gpt / --gemini (gated off by default) ── 459 + { 460 + id: 'gpt-thin-border-wide-shadow', 461 + category: 'slop', 462 + severity: 'advisory', 463 + gated: 'gpt', 464 + name: 'Hairline border with wide shadow', 465 + description: 466 + 'A hairline border paired with a wide, diffuse shadow is a recurring generated-UI signature. Commit to one — a defined edge or a soft elevation — rather than both at once.', 467 + skillSection: 'Visual Details', 468 + skillGuideline: 'hairline border plus wide diffuse shadow', 469 + }, 470 + { 471 + id: 'repeating-stripes-gradient', 472 + category: 'slop', 473 + severity: 'advisory', 474 + gated: 'gpt', 475 + name: 'Repeating-gradient stripes', 476 + description: 477 + 'Repeating-gradient stripes used as surface decoration are a recurring generated-UI signature. Reach for a deliberate texture or leave the surface plain.', 478 + skillSection: 'Visual Details', 479 + skillGuideline: 'repeating-gradient decorative stripes', 480 + }, 481 + { 482 + id: 'theater-slop-phrase', 483 + category: 'slop', 484 + severity: 'advisory', 485 + gated: 'gpt', 486 + name: 'Theater framing copy', 487 + description: 488 + 'Dismissing something as "theater" is a recurring generated-copy tic. Say plainly what the thing does or does not do.', 489 + skillSection: 'Copy', 490 + skillGuideline: 'theater framing copy', 491 + }, 492 + { 493 + id: 'image-hover-transform', 494 + category: 'slop', 495 + severity: 'advisory', 496 + gated: 'gemini', 497 + name: 'Image hover transform', 498 + description: 499 + 'Scaling or rotating an image on hover is a recurring generated-UI signature. Let imagery sit still, or use a subtler, purposeful interaction.', 500 + skillSection: 'Motion', 501 + skillGuideline: 'image scale or rotate on hover', 502 + }, 503 + ]; 504 + 505 + // --- cli/engine/shared/color.mjs --- 506 + // ─── Section 2: Color Utilities ───────────────────────────────────────────── 507 + 508 + function isNeutralColor(color) { 509 + if (!color || color === 'transparent') return true; 510 + 511 + // rgb/rgba — use channel spread. Threshold 30 ≈ 11.7% of the 0–255 range. 512 + const rgb = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); 513 + if (rgb) { 514 + return (Math.max(+rgb[1], +rgb[2], +rgb[3]) - Math.min(+rgb[1], +rgb[2], +rgb[3])) < 30; 515 + } 516 + 517 + // oklch()/lch() — chroma is the second numeric component. 518 + // oklch chroma is ~0–0.4 in sRGB gamut; >= 0.02 reads as tinted, not gray. 519 + // lch chroma is ~0–150; >= 3 reads as tinted. jsdom emits both formats 520 + // literally (it does NOT convert them to rgb). 521 + const oklch = color.match(/oklch\(\s*[\d.]+%?\s*([\d.-]+)/i); 522 + if (oklch) return parseFloat(oklch[1]) < 0.02; 523 + const lch = color.match(/lch\(\s*[\d.]+%?\s*([\d.-]+)/i); 524 + if (lch) return parseFloat(lch[1]) < 3; 525 + 526 + // oklab()/lab() — a and b are signed axes; chroma = sqrt(a² + b²). 527 + // oklab a/b are ~-0.4..0.4, threshold 0.02. lab a/b are ~-128..127, threshold 3. 528 + const oklab = color.match(/oklab\(\s*[\d.]+%?\s*([\d.-]+)\s+([\d.-]+)/i); 529 + if (oklab) { 530 + const a = parseFloat(oklab[1]), b = parseFloat(oklab[2]); 531 + return Math.hypot(a, b) < 0.02; 532 + } 533 + const lab = color.match(/lab\(\s*[\d.]+%?\s*([\d.-]+)\s+([\d.-]+)/i); 534 + if (lab) { 535 + const a = parseFloat(lab[1]), b = parseFloat(lab[2]); 536 + return Math.hypot(a, b) < 3; 537 + } 538 + 539 + // hsl/hsla — saturation is the second numeric component (percent). 540 + // Modern jsdom usually converts hsl() to rgb, but handle it directly for 541 + // safety across versions and for any engine that preserves the format. 542 + const hsl = color.match(/hsla?\(\s*[\d.-]+\s*,?\s*([\d.]+)%/i); 543 + if (hsl) return parseFloat(hsl[1]) < 10; 544 + 545 + // hwb(hue whiteness% blackness%) — a pixel is fully gray when 546 + // whiteness + blackness >= 100; chroma-like saturation = 1 - (w+b)/100. 547 + const hwb = color.match(/hwb\(\s*[\d.-]+\s+([\d.]+)%\s+([\d.]+)%/i); 548 + if (hwb) { 549 + const w = parseFloat(hwb[1]), b = parseFloat(hwb[2]); 550 + return (1 - Math.min(100, w + b) / 100) < 0.1; 551 + } 552 + 553 + // Unknown / unrecognized format — err on the side of DETECTING rather 554 + // than silently skipping. This is the opposite of the previous default, 555 + // which was the root cause of the oklch bug. 556 + return false; 557 + } 558 + 559 + function parseRgb(color) { 560 + if (!color || color === 'transparent') return null; 561 + const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/); 562 + if (!m) return null; 563 + return { r: +m[1], g: +m[2], b: +m[3], a: m[4] !== undefined ? +m[4] : 1 }; 564 + } 565 + 566 + function relativeLuminance({ r, g, b }) { 567 + const [rs, gs, bs] = [r / 255, g / 255, b / 255].map(c => 568 + c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 569 + ); 570 + return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs; 571 + } 572 + 573 + function contrastRatio(c1, c2) { 574 + const l1 = relativeLuminance(c1); 575 + const l2 = relativeLuminance(c2); 576 + return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); 577 + } 578 + 579 + function parseGradientColors(bgImage) { 580 + if (!bgImage || !bgImage.includes('gradient')) return []; 581 + const colors = []; 582 + for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) { 583 + const c = parseRgb(m[0]); 584 + if (c) colors.push(c); 585 + } 586 + for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) { 587 + const h = m[1]; 588 + if (h.length === 6) { 589 + colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 }); 590 + } else { 591 + colors.push({ r: parseInt(h[0]+h[0],16), g: parseInt(h[1]+h[1],16), b: parseInt(h[2]+h[2],16), a: 1 }); 592 + } 593 + } 594 + return colors; 595 + } 596 + 597 + function hasChroma(c, threshold = 30) { 598 + if (!c) return false; 599 + return (Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b)) >= threshold; 600 + } 601 + 602 + function getHue(c) { 603 + if (!c) return 0; 604 + const r = c.r / 255, g = c.g / 255, b = c.b / 255; 605 + const max = Math.max(r, g, b), min = Math.min(r, g, b); 606 + if (max === min) return 0; 607 + const d = max - min; 608 + let h; 609 + if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6; 610 + else if (max === g) h = ((b - r) / d + 2) / 6; 611 + else h = ((r - g) / d + 4) / 6; 612 + return Math.round(h * 360); 613 + } 614 + 615 + function colorToHex(c) { 616 + if (!c) return '?'; 617 + return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join(''); 618 + } 619 + 620 + // --- cli/engine/rules/checks.mjs --- 621 + const DETECTOR_IS_BROWSER = typeof window !== 'undefined'; 622 + 623 + // ─── Section 3: Pure Detection ────────────────────────────────────────────── 624 + 625 + function checkBorders(tag, widths, colors, radius) { 626 + if (BORDER_SAFE_TAGS.has(tag)) return []; 627 + const findings = []; 628 + const sides = ['Top', 'Right', 'Bottom', 'Left']; 629 + 630 + for (const side of sides) { 631 + const w = widths[side]; 632 + if (w < 1 || isNeutralColor(colors[side])) continue; 633 + 634 + const otherSides = sides.filter(s => s !== side); 635 + const maxOther = Math.max(...otherSides.map(s => widths[s])); 636 + if (!(w >= 2 && (maxOther <= 1 || w >= maxOther * 2))) continue; 637 + 638 + const sn = side.toLowerCase(); 639 + const isSide = side === 'Left' || side === 'Right'; 640 + 641 + if (isSide) { 642 + if (radius > 0) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` }); 643 + else if (w >= 3) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` }); 644 + } else { 645 + if (radius > 0 && w >= 2) findings.push({ id: 'border-accent-on-rounded', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` }); 646 + } 647 + } 648 + 649 + return findings; 650 + } 651 + 652 + // Returns true if the given text is composed entirely of emoji characters 653 + // (plus whitespace / variation selectors). Emojis render as multicolor glyphs 654 + // regardless of CSS `color`, so contrast checks against the element's text 655 + // color are meaningless for these nodes. 656 + const EMOJI_CHAR_RE = /[\u{1F1E6}-\u{1F1FF}\u{1F300}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}\u{FE0F}\u{200D}\u{1F3FB}-\u{1F3FF}]/u; 657 + const EMOJI_CHARS_GLOBAL = /[\u{1F1E6}-\u{1F1FF}\u{1F300}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}\u{FE0F}\u{200D}\u{1F3FB}-\u{1F3FF}]/gu; 658 + function isEmojiOnlyText(text) { 659 + if (!text) return false; 660 + if (!EMOJI_CHAR_RE.test(text)) return false; 661 + return text.replace(EMOJI_CHARS_GLOBAL, '').trim() === ''; 662 + } 663 + 664 + function checkColors(opts) { 665 + const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, isEmojiOnly, bgClip, bgImage, classList } = opts; 666 + if (SAFE_TAGS.has(tag)) { 667 + // Exception for <a> and <button> elements styled as buttons. SAFE_TAGS 668 + // exists to suppress contrast noise on inline links and unstyled controls, 669 + // where the element has no own background and the contrast against the 670 + // ancestor surface is already the intended visual. When the element has 671 + // its own opaque background and direct text, it is a styled button — and 672 + // contrast on its own surface is a real, frequent bug worth flagging. 673 + const isStyledButton = (tag === 'a' || tag === 'button') 674 + && hasDirectText 675 + && bgColor && bgColor.a > 0.5; 676 + if (!isStyledButton) return []; 677 + } 678 + const findings = []; 679 + 680 + if (hasDirectText && textColor && !isEmojiOnly) { 681 + // Run background-dependent checks against either a solid bg or, if the 682 + // ancestor is a gradient, against every gradient stop (use the worst case). 683 + const bgs = effectiveBg ? [effectiveBg] : (effectiveBgStops && effectiveBgStops.length ? effectiveBgStops : null); 684 + if (bgs) { 685 + // Gray on colored background — flag if every stop is chromatic 686 + const textLum = relativeLuminance(textColor); 687 + const isGray = !hasChroma(textColor, 20) && textLum > 0.05 && textLum < 0.85; 688 + if (isGray && bgs.every(b => hasChroma(b, 40))) { 689 + const bgLabel = effectiveBg ? colorToHex(effectiveBg) : `gradient(${bgs.map(colorToHex).join(', ')})`; 690 + findings.push({ id: 'gray-on-color', snippet: `text ${colorToHex(textColor)} on bg ${bgLabel}` }); 691 + } 692 + 693 + // Low contrast (WCAG AA) — worst case across all bg stops 694 + const ratios = bgs.map(b => contrastRatio(textColor, b)); 695 + let worstIdx = 0; 696 + for (let i = 1; i < ratios.length; i++) if (ratios[i] < ratios[worstIdx]) worstIdx = i; 697 + const ratio = ratios[worstIdx]; 698 + const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700); 699 + const threshold = isLargeText ? 3.0 : 4.5; 700 + if (ratio < threshold) { 701 + // Skip the false-positive class where text has alpha < 1 AND we 702 + // couldn't find an opaque ancestor (effectiveBg is null, we're 703 + // comparing against gradient-stop fallback). In jsdom mode the 704 + // detector can't resolve `var(--X)` color tokens, so a dark 705 + // section sitting between the text and the body's decorative 706 + // gradient is invisible to us — we end up measuring contrast 707 + // against the body's paper-grain noise instead of the real 708 + // local bg. Real low-contrast bugs use alpha=1 and have a 709 + // resolvable opaque ancestor; semi-transparent Tailwind tokens 710 + // like `text-paper/60` on `bg-ink` sections are the FP pattern. 711 + const isAlphaFallbackFP = !DETECTOR_IS_BROWSER && !effectiveBg && (textColor.a != null && textColor.a < 1); 712 + if (!isAlphaFallbackFP) { 713 + findings.push({ id: 'low-contrast', snippet: `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}` }); 714 + } 715 + } 716 + } 717 + 718 + // AI palette: purple/violet on headings 719 + if (hasChroma(textColor, 50)) { 720 + const hue = getHue(textColor); 721 + if (hue >= 260 && hue <= 310 && (['h1', 'h2', 'h3'].includes(tag) || fontSize >= 20)) { 722 + findings.push({ id: 'ai-color-palette', snippet: `Purple/violet text (${colorToHex(textColor)}) on heading` }); 723 + } 724 + } 725 + } 726 + 727 + // Gradient text 728 + if (bgClip === 'text' && bgImage && bgImage.includes('gradient')) { 729 + findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' }); 730 + } 731 + 732 + // Tailwind class checks 733 + if (classList) { 734 + const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' '); 735 + 736 + const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/); 737 + const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); 738 + if (grayMatch && colorBgMatch) { 739 + findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` }); 740 + } 741 + 742 + if (/\bbg-clip-text\b/.test(classStr) && /\bbg-gradient-to-/.test(classStr)) { 743 + findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' }); 744 + } 745 + 746 + const purpleText = classStr.match(/\btext-(?:purple|violet|indigo)-\d+\b/); 747 + if (purpleText && (['h1', 'h2', 'h3'].includes(tag) || /\btext-(?:[2-9]xl)\b/.test(classStr))) { 748 + findings.push({ id: 'ai-color-palette', snippet: `${purpleText[0]} on heading` }); 749 + } 750 + 751 + if (/\bfrom-(?:purple|violet|indigo)-\d+\b/.test(classStr) && /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(classStr)) { 752 + findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient (Tailwind)' }); 753 + } 754 + } 755 + 756 + return findings; 757 + } 758 + 759 + function isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg) { 760 + if (!hasShadow && !hasBorder) return false; 761 + return hasRadius || hasBg; 762 + } 763 + 764 + const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']); 765 + 766 + // Pure check: given a heading and metrics about its previousElementSibling, 767 + // decide if the sibling is the canonical "icon-tile-stacked-above-heading" shape. 768 + // 769 + // Triggers when ALL of the following hold for the sibling: 770 + // • size 32–128px on both axes (not too small, not a hero image) 771 + // • aspect ratio 0.7–1.4 (squarish — excludes wide thumbnails / pill badges) 772 + // • has a non-transparent background-color, background-image, OR a visible border 773 + // (covers solid colors, white-with-border, gradients — anything that visually 774 + // defines a tile) 775 + // • border-radius < width/2 (excludes round avatars; rounded squares pass) 776 + // • contains an <svg> or icon-class <i> element that's smaller than the tile 777 + // • the tile sits above the heading (its bottom is above the heading's top) 778 + function checkIconTile(opts) { 779 + const { headingTag, headingText, headingTop, 780 + siblingTag, siblingWidth, siblingHeight, siblingBottom, 781 + siblingBgColor, siblingBgImage, siblingBorderWidth, siblingBorderRadius, 782 + hasIconChild, iconChildWidth } = opts; 783 + if (!HEADING_TAGS.has(headingTag)) return []; 784 + if (!siblingTag) return []; 785 + // Don't recurse into nested headings (e.g. h2 above h3 in a section header) 786 + if (HEADING_TAGS.has(siblingTag)) return []; 787 + 788 + // Size window: 32–128px on each axis 789 + if (!(siblingWidth >= 32 && siblingWidth <= 128)) return []; 790 + if (!(siblingHeight >= 32 && siblingHeight <= 128)) return []; 791 + 792 + // Squarish aspect ratio 793 + const ratio = siblingWidth / siblingHeight; 794 + if (ratio < 0.7 || ratio > 1.4) return []; 795 + 796 + // Must have something that visually defines the tile 797 + const bgVisible = (siblingBgColor && siblingBgColor.a > 0.1) 798 + || (siblingBgImage && siblingBgImage !== 'none' && siblingBgImage !== ''); 799 + const borderVisible = siblingBorderWidth > 0; 800 + if (!bgVisible && !borderVisible) return []; 801 + 802 + // Exclude circles (avatars). Rounded squares pass. 803 + if (siblingBorderRadius >= siblingWidth / 2) return []; 804 + 805 + // Must contain an icon element smaller than the tile 806 + if (!hasIconChild) return []; 807 + if (iconChildWidth && iconChildWidth >= siblingWidth * 0.95) return []; 808 + 809 + // Vertical stacking: tile must end above where the heading starts. 810 + // (Allow the check to skip when both top/bottom are 0 — jsdom layout case.) 811 + if (headingTop && siblingBottom && siblingBottom > headingTop + 4) return []; 812 + 813 + const text = (headingText || '').trim().slice(0, 60); 814 + return [{ 815 + id: 'icon-tile-stack', 816 + snippet: `${Math.round(siblingWidth)}x${Math.round(siblingHeight)}px icon tile above ${headingTag} "${text}"`, 817 + }]; 818 + } 819 + 820 + // Resolve the primary (non-generic) face from a font-family string and return 821 + // whether the resolved primary is serif. Two paths: 822 + // 1. Primary face is in KNOWN_SERIF_FONTS → serif. 823 + // 2. Primary face is unknown but the stack ends in the generic `serif` 824 + // token → treat as serif. Authors who declare `font-family: 'X', serif` 825 + // almost always have a serif primary; a sans declared with a serif 826 + // fallback is a code smell, not the common case. 827 + // Returns { primary, isSerif } so the snippet can name the face. 828 + function resolveSerif(fontFamily) { 829 + if (!fontFamily) return { primary: null, isSerif: false }; 830 + const tokens = fontFamily.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase()); 831 + const primary = tokens.find(f => f && !GENERIC_FONTS.has(f)) || null; 832 + if (!primary) return { primary: null, isSerif: false }; 833 + if (KNOWN_SERIF_FONTS.has(primary)) return { primary, isSerif: true }; 834 + if (tokens.includes('serif')) return { primary, isSerif: true }; 835 + return { primary, isSerif: false }; 836 + } 837 + 838 + function checkItalicSerif(opts) { 839 + const { tag, fontStyle, fontFamily, fontSize, headingText } = opts; 840 + if (fontStyle !== 'italic') return []; 841 + // Anchor the rule on hero-scale text. h1 is the canonical hero element; 842 + // h2 ≥ 48px catches the cases where the design demotes the visual hero 843 + // to an h2 but keeps the size. 844 + if (tag !== 'h1' && !(tag === 'h2' && fontSize >= 48)) return []; 845 + if (fontSize < 48) return []; 846 + const { primary, isSerif } = resolveSerif(fontFamily); 847 + if (!isSerif) return []; 848 + 849 + const text = (headingText || '').trim().slice(0, 60); 850 + return [{ 851 + id: 'italic-serif-display', 852 + snippet: `italic serif ${tag} (${primary || 'serif'}) at ${Math.round(fontSize)}px "${text}"`, 853 + }]; 854 + } 855 + 856 + // Color saturation check. Returns true when the color has visible 857 + // chroma — i.e., it's an "accent color" rather than near-neutral. 858 + // Handles rgb()/rgba(), #hex, oklch(), and hsl(). var() refs are 859 + // expected to be pre-resolved by the caller. 860 + function isAccentColor(cssColor) { 861 + if (!cssColor) return false; 862 + const s = String(cssColor).trim(); 863 + // rgb / rgba — direct channel-distance check. 864 + const rgbM = /rgba?\(\s*(\d+)\s*,?\s+|\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s.replace(/rgba?\(\s*/, 'rgb(').replace(/,/g, ', ')); 865 + const rgbStrict = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s); 866 + if (rgbStrict) { 867 + const r = +rgbStrict[1], g = +rgbStrict[2], b = +rgbStrict[3]; 868 + return (Math.max(r, g, b) - Math.min(r, g, b)) >= 40; 869 + } 870 + // #hex — 3, 4, 6, or 8 digit. 871 + const hexM = /^#([0-9a-f]{3,8})\b/i.exec(s); 872 + if (hexM) { 873 + let h = hexM[1]; 874 + if (h.length === 3 || h.length === 4) h = h.split('').map((c) => c + c).join('').slice(0, 6); 875 + else h = h.slice(0, 6); 876 + if (h.length === 6) { 877 + const r = parseInt(h.slice(0, 2), 16); 878 + const g = parseInt(h.slice(2, 4), 16); 879 + const b = parseInt(h.slice(4, 6), 16); 880 + return (Math.max(r, g, b) - Math.min(r, g, b)) >= 40; 881 + } 882 + } 883 + // oklch(L C H) — chroma C is what matters. Typical neutral grays 884 + // have C < 0.02; visible accents are 0.05+. CSS minification can 885 + // collapse spaces between L% and C ("oklch(43%.15 34)"), so we 886 + // extract all numbers and take the second rather than matching a 887 + // strict L-then-whitespace-then-C pattern. 888 + if (/^oklch\(/i.test(s)) { 889 + const nums = s.match(/\d*\.\d+|\d+/g); 890 + if (nums && nums.length >= 2) { 891 + const c = parseFloat(nums[1]); 892 + return !Number.isNaN(c) && c >= 0.05; 893 + } 894 + } 895 + // hsl(H, S%, L%) — saturation > 20% reads as accent. 896 + const hslM = /hsla?\(\s*[\d.]+\s*,\s*([\d.]+)%/i.exec(s); 897 + if (hslM) { 898 + const sat = parseFloat(hslM[1]); 899 + return !Number.isNaN(sat) && sat >= 20; 900 + } 901 + return false; 902 + } 903 + 904 + // Sibling-relationship rule. Anchor on a hero-scale h1, look at the 905 + // previousElementSibling, and gate on EITHER the classic tracked- 906 + // uppercase eyebrow OR the modern accent-colored bold eyebrow. 907 + function checkHeroEyebrow(opts) { 908 + const { 909 + headingTag, headingText, headingFontSize, 910 + siblingTag, siblingText, siblingTextTransform, 911 + siblingFontSize, siblingLetterSpacing, 912 + siblingFontWeight, siblingColor, 913 + } = opts; 914 + if (headingTag !== 'h1') return []; 915 + // We previously gated on headingFontSize >= 48 to anchor "hero scale". 916 + // But modern hero h1s use clamp() / vw / var(--text-*), none of which 917 + // jsdom can resolve — the computed value comes back as "2em" or 918 + // "var(--text-9xl)" and parseFloat returns 2 or NaN. The gate fails 919 + // on virtually every Tailwind v4 / framework build. The other gates 920 + // (sibling text 2-60 chars, font-size ≤ 14px, accent-bold OR 921 + // tracked-caps) are tight enough to avoid false positives on non- 922 + // hero h1s — a tiny tan label directly above any h1 is the 923 + // antipattern regardless of how big the h1 ends up. 924 + if (!siblingTag) return []; 925 + // An h2 above an h1 is a different anti-pattern (heading hierarchy / dual 926 + // headings) — never an eyebrow. 927 + if (HEADING_TAGS.has(siblingTag)) return []; 928 + 929 + const text = (siblingText || '').trim(); 930 + if (text.length < 2 || text.length > 60) return []; 931 + if (!(siblingFontSize > 0 && siblingFontSize <= 14)) return []; 932 + 933 + // Branch A: classic tracked-uppercase eyebrow. 934 + const isUppercased = siblingTextTransform === 'uppercase' 935 + || (/[A-Z]/.test(text) && !/[a-z]/.test(text)); 936 + const isClassicTracked = isUppercased && siblingLetterSpacing >= 1.6; 937 + 938 + // Branch B: modern accent-bold eyebrow — sentence case, low 939 + // tracking, but bold + accent-colored. The style choices changed; 940 + // the pattern is the same kicker-above-headline anti-pattern. 941 + const weight = Number(siblingFontWeight) || 400; 942 + const isAccentBold = weight >= 700 && isAccentColor(siblingColor || ''); 943 + 944 + if (!isClassicTracked && !isAccentBold) return []; 945 + 946 + const headingTextSnippet = (headingText || '').trim().slice(0, 60); 947 + const eyebrowSnippet = text.slice(0, 40); 948 + const style = isClassicTracked ? 'tracked-caps' : 'accent-bold'; 949 + return [{ 950 + id: 'hero-eyebrow-chip', 951 + snippet: `eyebrow chip (${style}) "${eyebrowSnippet}" above ${headingTag} "${headingTextSnippet}"`, 952 + }]; 953 + } 954 + 955 + function checkRepeatedSectionKickers(opts) { 956 + const { candidates, minCount = 3 } = opts; 957 + if (!Array.isArray(candidates) || candidates.length < minCount) return []; 958 + return candidates.map(candidate => ({ 959 + id: 'repeated-section-kickers', 960 + snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`, 961 + })); 962 + } 963 + 964 + const LAYOUT_TRANSITION_PROPS = new Set([ 965 + 'width', 'height', 'padding', 'margin', 966 + 'max-height', 'max-width', 'min-height', 'min-width', 967 + 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 968 + 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', 969 + ]); 970 + 971 + function checkMotion(opts) { 972 + const { tag, transitionProperty, animationName, timingFunctions, classList } = opts; 973 + if (SAFE_TAGS.has(tag)) return []; 974 + const findings = []; 975 + 976 + // --- Bounce/elastic easing --- 977 + if (animationName && animationName !== 'none' && /bounce|elastic|wobble|jiggle|spring/i.test(animationName)) { 978 + findings.push({ id: 'bounce-easing', snippet: `animation: ${animationName}` }); 979 + } 980 + if (classList && /\banimate-bounce\b/.test(classList)) { 981 + findings.push({ id: 'bounce-easing', snippet: 'animate-bounce (Tailwind)' }); 982 + } 983 + 984 + // Check timing functions for overshoot cubic-bezier (y values outside [0, 1]) 985 + if (timingFunctions) { 986 + const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g; 987 + let m; 988 + while ((m = bezierRe.exec(timingFunctions)) !== null) { 989 + const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]); 990 + if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) { 991 + findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` }); 992 + break; 993 + } 994 + } 995 + } 996 + 997 + // --- Layout property transition --- 998 + if (transitionProperty && transitionProperty !== 'all' && transitionProperty !== 'none') { 999 + const props = transitionProperty.split(',').map(p => p.trim().toLowerCase()); 1000 + const layoutFound = props.filter(p => LAYOUT_TRANSITION_PROPS.has(p)); 1001 + if (layoutFound.length > 0) { 1002 + findings.push({ id: 'layout-transition', snippet: `transition: ${layoutFound.join(', ')}` }); 1003 + } 1004 + } 1005 + 1006 + return findings; 1007 + } 1008 + 1009 + function checkGlow(opts) { 1010 + const { boxShadow, effectiveBg } = opts; 1011 + if (!boxShadow || boxShadow === 'none') return []; 1012 + if (!effectiveBg) return []; 1013 + 1014 + // Only flag on dark backgrounds (luminance < 0.1) 1015 + const bgLum = relativeLuminance(effectiveBg); 1016 + if (bgLum >= 0.1) return []; 1017 + 1018 + // Split multiple shadows (commas not inside parentheses) 1019 + const parts = boxShadow.split(/,(?![^(]*\))/); 1020 + for (const shadow of parts) { 1021 + const colorMatch = shadow.match(/rgba?\([^)]+\)/); 1022 + if (!colorMatch) continue; 1023 + const color = parseRgb(colorMatch[0]); 1024 + if (!color || !hasChroma(color, 30)) continue; 1025 + 1026 + // Extract px values — in computed style: "color Xpx Ypx BLURpx [SPREADpx]" 1027 + const afterColor = shadow.substring(shadow.indexOf(colorMatch[0]) + colorMatch[0].length); 1028 + const beforeColor = shadow.substring(0, shadow.indexOf(colorMatch[0])); 1029 + const pxVals = [...beforeColor.matchAll(/([\d.]+)px/g), ...afterColor.matchAll(/([\d.]+)px/g)] 1030 + .map(m => parseFloat(m[1])); 1031 + 1032 + // Third value is blur (offset-x, offset-y, blur, [spread]) 1033 + if (pxVals.length >= 3 && pxVals[2] > 4) { 1034 + return [{ id: 'dark-glow', snippet: `Colored glow (${colorToHex(color)}) on dark background` }]; 1035 + } 1036 + } 1037 + 1038 + return []; 1039 + } 1040 + 1041 + /** 1042 + * Regex-on-HTML checks shared between browser and Node page-level detection. 1043 + * These don't need DOM access, just the raw HTML string. 1044 + */ 1045 + function checkHtmlPatterns(html) { 1046 + const findings = []; 1047 + 1048 + // --- Color --- 1049 + 1050 + // AI color palette: purple/violet 1051 + const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi; 1052 + if (purpleHexRe.test(html)) { 1053 + const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi; 1054 + if (purpleTextRe.test(html)) { 1055 + findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' }); 1056 + } 1057 + } 1058 + 1059 + // Gradient text (background-clip: text + gradient) 1060 + const gradientRe = /(?:-webkit-)?background-clip\s*:\s*text/gi; 1061 + let gm; 1062 + while ((gm = gradientRe.exec(html)) !== null) { 1063 + const start = Math.max(0, gm.index - 200); 1064 + const context = html.substring(start, gm.index + gm[0].length + 200); 1065 + if (/gradient/i.test(context)) { 1066 + findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' }); 1067 + break; 1068 + } 1069 + } 1070 + if (/\bbg-clip-text\b/.test(html) && /\bbg-gradient-to-/.test(html)) { 1071 + findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' }); 1072 + } 1073 + 1074 + // --- Layout --- 1075 + 1076 + // Monotonous spacing 1077 + const spacingValues = []; 1078 + const spacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi; 1079 + let sm; 1080 + while ((sm = spacingRe.exec(html)) !== null) { 1081 + const v = parseInt(sm[1], 10); 1082 + if (v > 0 && v < 200) spacingValues.push(v); 1083 + } 1084 + const gapRe = /gap\s*:\s*(\d+)px/gi; 1085 + while ((sm = gapRe.exec(html)) !== null) { 1086 + spacingValues.push(parseInt(sm[1], 10)); 1087 + } 1088 + const twSpaceRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g; 1089 + while ((sm = twSpaceRe.exec(html)) !== null) { 1090 + spacingValues.push(parseInt(sm[1], 10) * 4); 1091 + } 1092 + const remSpacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi; 1093 + while ((sm = remSpacingRe.exec(html)) !== null) { 1094 + const v = Math.round(parseFloat(sm[1]) * 16); 1095 + if (v > 0 && v < 200) spacingValues.push(v); 1096 + } 1097 + const roundedSpacing = spacingValues.map(v => Math.round(v / 4) * 4); 1098 + if (roundedSpacing.length >= 10) { 1099 + const counts = {}; 1100 + for (const v of roundedSpacing) counts[v] = (counts[v] || 0) + 1; 1101 + const maxCount = Math.max(...Object.values(counts)); 1102 + const dominantPct = maxCount / roundedSpacing.length; 1103 + const unique = [...new Set(roundedSpacing)].filter(v => v > 0); 1104 + if (dominantPct > 0.6 && unique.length <= 3) { 1105 + const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0]; 1106 + findings.push({ 1107 + id: 'monotonous-spacing', 1108 + snippet: `~${dominant}px used ${maxCount}/${roundedSpacing.length} times (${Math.round(dominantPct * 100)}%)`, 1109 + }); 1110 + } 1111 + } 1112 + 1113 + // --- Motion --- 1114 + 1115 + // Bounce/elastic animation names 1116 + const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi; 1117 + const bounceMatch = bounceRe.exec(html); 1118 + if (bounceMatch) { 1119 + const animationToken = bounceMatch[1] 1120 + .split(/[,\s]+/) 1121 + .find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part)); 1122 + findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` }); 1123 + } 1124 + 1125 + // Overshoot cubic-bezier 1126 + const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g; 1127 + let bm; 1128 + while ((bm = bezierRe.exec(html)) !== null) { 1129 + const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]); 1130 + if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) { 1131 + findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})` }); 1132 + break; 1133 + } 1134 + } 1135 + 1136 + // Layout property transitions 1137 + const transRe = /transition(?:-property)?\s*:\s*([^;{}]+)/gi; 1138 + let tm; 1139 + while ((tm = transRe.exec(html)) !== null) { 1140 + const val = tm[1].toLowerCase(); 1141 + if (/\ball\b/.test(val)) continue; 1142 + const found = val.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi); 1143 + if (found) { 1144 + findings.push({ id: 'layout-transition', snippet: `transition: ${found.join(', ')}` }); 1145 + break; 1146 + } 1147 + } 1148 + 1149 + // --- Dark glow --- 1150 + 1151 + const darkBgRe = /background(?:-color)?\s*:\s*(?:#(?:0[0-9a-f]|1[0-9a-f]|2[0-3])[0-9a-f]{4}\b|#(?:0|1)[0-9a-f]{2}\b|rgb\(\s*(\d{1,2})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\))/gi; 1152 + const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/; 1153 + if (darkBgRe.test(html) || twDarkBg.test(html)) { 1154 + const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi; 1155 + let shm; 1156 + while ((shm = shadowRe.exec(html)) !== null) { 1157 + const val = shm[1]; 1158 + const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); 1159 + if (!colorMatch) continue; 1160 + const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]]; 1161 + if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue; 1162 + const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2])); 1163 + if (pxVals.length >= 3 && pxVals[2] > 4) { 1164 + findings.push({ id: 'dark-glow', snippet: `Colored glow (rgb(${r},${g},${b})) on dark page` }); 1165 + break; 1166 + } 1167 + } 1168 + } 1169 + 1170 + // --- Provider tells (gated): repeating-gradient stripes (GPT) --- 1171 + if (/repeating-(?:linear|radial|conic)-gradient\s*\(/i.test(html)) { 1172 + findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes' }); 1173 + } 1174 + 1175 + // --- Provider tells (gated): "X theater" framing copy (GPT) --- 1176 + // Lives here (regex-on-HTML) rather than in the text-content analyzers so it 1177 + // runs in the bundled browser path too, not just the CLI/static path. 1178 + { 1179 + const bodyText = html 1180 + .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ') 1181 + .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ') 1182 + .replace(/<[^>]+>/g, ' '); 1183 + const tm = /\b(\w+)\s+theater\b/i.exec(bodyText); 1184 + if (tm) findings.push({ id: 'theater-slop-phrase', snippet: `"${tm[0].trim()}"` }); 1185 + } 1186 + 1187 + // --- Provider tells (gated): image hover transform (Gemini) --- 1188 + // A CSS `img...:hover { transform: ... }` rule, or a Tailwind hover:scale / 1189 + // hover:rotate / hover:translate utility on an <img>. Each distinct 1190 + // mechanism is its own finding. 1191 + const imgHoverCss = /\bimg\b[^,{}]*:hover\b[^{}]*\{[^}]*\btransform\s*:\s*(?:scale|rotate|translate|matrix|skew)/i; 1192 + if (imgHoverCss.test(html)) { 1193 + findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule' }); 1194 + } 1195 + const imgTagRe = /<img\b[^>]*\bclass\s*=\s*"([^"]*)"/gi; 1196 + let im; 1197 + while ((im = imgTagRe.exec(html)) !== null) { 1198 + if (/\bhover:(?:scale|rotate|translate|skew)-/.test(im[1])) { 1199 + findings.push({ id: 'image-hover-transform', snippet: 'Tailwind hover transform on <img>' }); 1200 + } 1201 + } 1202 + 1203 + return findings; 1204 + } 1205 + 1206 + // ─── Section 4: resolveBackground (unified) ───────────────────────────────── 1207 + 1208 + // Read the element's own background color, computed-style first, with a 1209 + // jsdom-friendly fallback that parses the inline `background:` shorthand 1210 + // from the raw style attribute. jsdom (~v29) does not decompose the 1211 + // shorthand into `backgroundColor`, so without this fallback the CLI silently 1212 + // returns null for any element styled via `background: rgb(...)` or 1213 + // `background: #abc`. Real browsers always decompose, so the fallback is 1214 + // a no-op there. 1215 + function readOwnBackgroundColor(el, computedStyle) { 1216 + const bg = parseRgb(computedStyle.backgroundColor); 1217 + if (DETECTOR_IS_BROWSER || (bg && bg.a >= 0.1)) return bg; 1218 + const rawStyle = el.getAttribute?.('style') || ''; 1219 + const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i); 1220 + const inlineBg = bgMatch ? bgMatch[1].trim() : ''; 1221 + if (!inlineBg) return bg; 1222 + if (/gradient/i.test(inlineBg) || /url\s*\(/i.test(inlineBg)) return bg; 1223 + const fromRgb = parseRgb(inlineBg); 1224 + if (fromRgb) return fromRgb; 1225 + const hexMatch = inlineBg.match(/#([0-9a-f]{6}|[0-9a-f]{3})\b/i); 1226 + if (hexMatch) { 1227 + const h = hexMatch[1]; 1228 + if (h.length === 6) { 1229 + return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16), a: 1 }; 1230 + } 1231 + return { r: parseInt(h[0] + h[0], 16), g: parseInt(h[1] + h[1], 16), b: parseInt(h[2] + h[2], 16), a: 1 }; 1232 + } 1233 + return bg; 1234 + } 1235 + 1236 + function resolveBackground(el, win, customPropMap) { 1237 + let current = el; 1238 + while (current && current.nodeType === 1) { 1239 + const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current); 1240 + const bgImage = style.backgroundImage || ''; 1241 + const hasGradientOrUrl = bgImage && bgImage !== 'none' && (/gradient/i.test(bgImage) || /url\s*\(/i.test(bgImage)); 1242 + 1243 + // Try the solid bg-color FIRST. If the element has both a solid color 1244 + // and a gradient/url overlay (a common pattern: `background: var(--paper) 1245 + // radial-gradient(...)` for paper-grain texture), the solid color is the 1246 + // dominant visible surface for contrast purposes; the overlay is 1247 + // decorative. The old behavior bailed on any gradient ancestor, which 1248 + // caused massive false-positive contrast findings on grain-textured 1249 + // body backgrounds. 1250 + let bg = parseRgb(style.backgroundColor); 1251 + if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) { 1252 + // jsdom returns literal "var(--X)" / "oklch(...)" strings. Resolve 1253 + // through customPropMap so Tailwind v4 color tokens become RGB. 1254 + if (customPropMap) { 1255 + bg = parseColorResolved(style.backgroundColor, customPropMap); 1256 + } 1257 + if (!bg || bg.a < 0.1) { 1258 + // Inline-style fallback. jsdom doesn't decompose background 1259 + // shorthand, so colors set via inline style are otherwise invisible. 1260 + const rawStyle = current.getAttribute?.('style') || ''; 1261 + const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i); 1262 + const inlineBg = bgMatch ? bgMatch[1].trim() : ''; 1263 + if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) { 1264 + bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg); 1265 + } 1266 + } 1267 + } 1268 + 1269 + if (bg && bg.a > 0.1) { 1270 + if (DETECTOR_IS_BROWSER || bg.a >= 0.5) return bg; 1271 + } 1272 + // No solid bg-color at this level. If THIS level has a gradient/url 1273 + // with no underlying solid color we can read: 1274 + // • on body/html: assume white. Body-level gradients are almost 1275 + // always decorative texture (paper grain, noise) on top of a 1276 + // solid bg-color the page set via `background: var(--paper)` 1277 + // shorthand — which jsdom can't decompose into bg-color. The 1278 + // downstream gradient-stops fallback path produces catastrophic 1279 + // false positives in this case (gradient noise stops have 1280 + // accidental browns/blacks that look like card backgrounds). 1281 + // • on other elements: bail to null and let the caller fall back 1282 + // to gradient stops (gradient buttons / hero sections are real 1283 + // bgs worth checking against). 1284 + if (hasGradientOrUrl) { 1285 + if (current.tagName === 'BODY' || current.tagName === 'HTML') { 1286 + return { r: 255, g: 255, b: 255, a: 1 }; 1287 + } 1288 + return null; 1289 + } 1290 + current = current.parentElement; 1291 + } 1292 + return { r: 255, g: 255, b: 255 }; 1293 + } 1294 + 1295 + // Walk parents looking for a gradient background and return its color stops. 1296 + // Used as a fallback when resolveBackground() returns null because the 1297 + // effective background is a gradient (no single solid color to compare against). 1298 + function resolveGradientStops(el, win) { 1299 + let current = el; 1300 + while (current && current.nodeType === 1) { 1301 + const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current); 1302 + const bgImage = style.backgroundImage || ''; 1303 + if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) { 1304 + const stops = parseGradientColors(bgImage); 1305 + if (stops.length > 0) return stops; 1306 + } 1307 + if (!DETECTOR_IS_BROWSER) { 1308 + // jsdom doesn't decompose `background:` shorthand — peek at the raw inline style 1309 + const rawStyle = current.getAttribute?.('style') || ''; 1310 + const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i); 1311 + if (bgMatch && /gradient/i.test(bgMatch[1])) { 1312 + const stops = parseGradientColors(bgMatch[1]); 1313 + if (stops.length > 0) return stops; 1314 + } 1315 + } 1316 + current = current.parentElement; 1317 + } 1318 + return null; 1319 + } 1320 + 1321 + // Parse a single CSS length token to pixels. Accepts "12px", "50%", a 1322 + // shorthand like "12px 4px" (uses the first value), or empty / null. 1323 + // Returns the pixel value, or null when the input is unparseable. 1324 + // Percentages convert against `widthPx` when one is supplied. Without a 1325 + // usable width (jsdom returns "auto" for many real-world elements, 1326 + // which parseFloat collapses to 0), fall back to the raw percentage 1327 + // number so callers gating on `> 0` (border-accent-on-rounded, 1328 + // isCardLike's hasRadius) still see a positive value, matching the 1329 + // original parseFloat("50%") === 50 behavior. 1330 + function parseRadiusToPx(value, widthPx) { 1331 + if (!value || typeof value !== 'string') return null; 1332 + const trimmed = value.trim(); 1333 + if (!trimmed) return null; 1334 + const first = trimmed.split(/\s+/)[0]; 1335 + const num = parseFloat(first); 1336 + if (Number.isNaN(num)) return null; 1337 + if (/%$/.test(first)) { 1338 + if (widthPx && widthPx > 0) return (num / 100) * widthPx; 1339 + return num; 1340 + } 1341 + return num; 1342 + } 1343 + 1344 + function resolveBorderRadiusPx(el, style, widthPx, win) { 1345 + const fromComputed = parseRadiusToPx(style.borderRadius, widthPx); 1346 + if (fromComputed !== null) return fromComputed; 1347 + return 0; 1348 + } 1349 + 1350 + // ─── Section 5: Element Adapters ──────────────────────────────────────────── 1351 + 1352 + // Browser adapters — call getComputedStyle/getBoundingClientRect on live DOM 1353 + 1354 + function checkElementBordersDOM(el) { 1355 + const tag = el.tagName.toLowerCase(); 1356 + if (BORDER_SAFE_TAGS.has(tag)) return []; 1357 + const rect = el.getBoundingClientRect(); 1358 + if (rect.width < 20 || rect.height < 20) return []; 1359 + const style = getComputedStyle(el); 1360 + const sides = ['Top', 'Right', 'Bottom', 'Left']; 1361 + const widths = {}, colors = {}; 1362 + for (const s of sides) { 1363 + widths[s] = parseFloat(style[`border${s}Width`]) || 0; 1364 + colors[s] = style[`border${s}Color`] || ''; 1365 + } 1366 + return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0); 1367 + } 1368 + 1369 + function checkElementColorsDOM(el) { 1370 + const tag = el.tagName.toLowerCase(); 1371 + // No early SAFE_TAGS bail here — checkColors() does its own gating that 1372 + // includes the styled-button exception for <a> / <button> with their own 1373 + // opaque background. Bailing here would prevent that exception from firing. 1374 + const rect = el.getBoundingClientRect(); 1375 + if (rect.width < 10 || rect.height < 10) return []; 1376 + const style = getComputedStyle(el); 1377 + const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join(''); 1378 + const hasDirectText = directText.trim().length > 0; 1379 + const effectiveBg = resolveBackground(el); 1380 + return checkColors({ 1381 + tag, 1382 + textColor: parseRgb(style.color), 1383 + bgColor: readOwnBackgroundColor(el, style), 1384 + effectiveBg, 1385 + effectiveBgStops: effectiveBg ? null : resolveGradientStops(el), 1386 + fontSize: parseFloat(style.fontSize) || 16, 1387 + fontWeight: parseInt(style.fontWeight) || 400, 1388 + hasDirectText, 1389 + isEmojiOnly: isEmojiOnlyText(directText), 1390 + bgClip: style.webkitBackgroundClip || style.backgroundClip || '', 1391 + bgImage: style.backgroundImage || '', 1392 + classList: el.getAttribute('class') || '', 1393 + }); 1394 + } 1395 + 1396 + function checkElementIconTileDOM(el) { 1397 + const tag = el.tagName.toLowerCase(); 1398 + if (!HEADING_TAGS.has(tag)) return []; 1399 + const sibling = el.previousElementSibling; 1400 + if (!sibling) return []; 1401 + 1402 + const sibRect = sibling.getBoundingClientRect(); 1403 + const headRect = el.getBoundingClientRect(); 1404 + const sibStyle = getComputedStyle(sibling); 1405 + 1406 + // The tile may either contain an <svg>/<i> icon child, OR the tile itself 1407 + // may contain an emoji/symbol character directly as its only text content 1408 + // (the "card-icon" pattern from many AI-generated demos). 1409 + const iconChild = sibling.querySelector('svg, i[data-lucide], i[class*="fa-"], i[class*="icon"]'); 1410 + const iconRect = iconChild?.getBoundingClientRect(); 1411 + const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join(''); 1412 + const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText); 1413 + 1414 + return checkIconTile({ 1415 + headingTag: tag, 1416 + headingText: el.textContent || '', 1417 + headingTop: headRect.top, 1418 + siblingTag: sibling.tagName.toLowerCase(), 1419 + siblingWidth: sibRect.width, 1420 + siblingHeight: sibRect.height, 1421 + siblingBottom: sibRect.bottom, 1422 + siblingBgColor: parseRgb(sibStyle.backgroundColor), 1423 + siblingBgImage: sibStyle.backgroundImage || '', 1424 + siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0, 1425 + siblingBorderRadius: parseFloat(sibStyle.borderRadius) || 0, 1426 + hasIconChild: !!iconChild || hasInlineEmojiIcon, 1427 + iconChildWidth: iconRect?.width || 0, 1428 + }); 1429 + } 1430 + 1431 + function checkElementItalicSerifDOM(el) { 1432 + const tag = el.tagName.toLowerCase(); 1433 + if (tag !== 'h1' && tag !== 'h2') return []; 1434 + const style = getComputedStyle(el); 1435 + return checkItalicSerif({ 1436 + tag, 1437 + fontStyle: style.fontStyle || '', 1438 + fontFamily: style.fontFamily || '', 1439 + fontSize: parseFloat(style.fontSize) || 0, 1440 + headingText: el.textContent || '', 1441 + }); 1442 + } 1443 + 1444 + function checkElementHeroEyebrowDOM(el) { 1445 + const tag = el.tagName.toLowerCase(); 1446 + if (tag !== 'h1') return []; 1447 + const sibling = el.previousElementSibling; 1448 + if (!sibling) return []; 1449 + const headStyle = getComputedStyle(el); 1450 + const sibStyle = getComputedStyle(sibling); 1451 + return checkHeroEyebrow({ 1452 + headingTag: tag, 1453 + headingText: el.textContent || '', 1454 + headingFontSize: parseFloat(headStyle.fontSize) || 0, 1455 + siblingTag: sibling.tagName.toLowerCase(), 1456 + siblingText: sibling.textContent || '', 1457 + siblingTextTransform: sibStyle.textTransform || '', 1458 + siblingFontSize: parseFloat(sibStyle.fontSize) || 0, 1459 + siblingLetterSpacing: parseFloat(sibStyle.letterSpacing) || 0, 1460 + siblingFontWeight: sibStyle.fontWeight || '', 1461 + siblingColor: sibStyle.color || '', 1462 + }); 1463 + } 1464 + 1465 + // Build a map of CSS custom properties declared on :root / :host / html. 1466 + // Used to resolve var(--X) refs that jsdom returns verbatim in 1467 + // getComputedStyle. Tailwind v4 routes every utility class through 1468 + // CSS vars (font-weight: var(--font-weight-bold), font-size: 1469 + // var(--text-xs), letter-spacing: var(--tracking-widest)), so without 1470 + // resolution every style-based check silently fails on Tailwind v4 1471 + // builds — the values come back as literal "var(--font-weight-bold)" 1472 + // strings and parseFloat returns NaN. 1473 + function buildCustomPropMap(document) { 1474 + const map = new Map(); 1475 + let sheets; 1476 + try { sheets = Array.from(document.styleSheets || []); } 1477 + catch { return map; } 1478 + for (const sheet of sheets) { 1479 + let rules; 1480 + try { rules = Array.from(sheet.cssRules || []); } 1481 + catch { continue; } 1482 + for (const rule of rules) { 1483 + // Style rules only (type 1). Walk @media / @supports if present. 1484 + if (rule.type === 4 /* MEDIA_RULE */ || rule.type === 12 /* SUPPORTS_RULE */) { 1485 + try { rules.push(...Array.from(rule.cssRules || [])); } catch { /* ignore */ } 1486 + continue; 1487 + } 1488 + if (rule.type !== 1 /* STYLE_RULE */) continue; 1489 + const sel = rule.selectorText || ''; 1490 + if (!/(^|,\s*)(:root|html|:host)\b/i.test(sel)) continue; 1491 + const style = rule.style; 1492 + if (!style) continue; 1493 + for (let i = 0; i < style.length; i++) { 1494 + const prop = style[i]; 1495 + if (!prop || !prop.startsWith('--')) continue; 1496 + const val = style.getPropertyValue(prop).trim(); 1497 + if (val) map.set(prop, val); 1498 + } 1499 + } 1500 + } 1501 + return map; 1502 + } 1503 + 1504 + // Resolve var(--X[, fallback]) refs in a computed-style value string. 1505 + // Recurses up to 8 levels for chained refs (--a: var(--b)). Returns 1506 + // the original string when no refs are present or the chain doesn't 1507 + // resolve. Safe to call on already-resolved values. 1508 + function resolveVarRefs(raw, customPropMap, depth = 0) { 1509 + if (typeof raw !== 'string' || !raw.includes('var(')) return raw; 1510 + if (depth > 8) return raw; 1511 + return raw.replace(/var\(\s*(--[a-zA-Z0-9_-]+)\s*(?:,\s*([^)]+))?\)/g, (_m, name, fallback) => { 1512 + const v = customPropMap.get(name); 1513 + if (v != null) return resolveVarRefs(v, customPropMap, depth + 1); 1514 + return fallback ? resolveVarRefs(fallback.trim(), customPropMap, depth + 1) : _m; 1515 + }); 1516 + } 1517 + 1518 + // OKLCH → sRGB conversion (Björn Ottosson's matrices). L in 0..1 (or %), 1519 + // C in 0..~0.4 typical, H in degrees. Returns clamped {r,g,b,a:1} in 0..255. 1520 + // Needed because jsdom doesn't compute oklch() values — getComputedStyle 1521 + // returns the literal "oklch(...)" string. Without this, the entire 1522 + // Tailwind v4 color palette (which is OKLCH-based) is invisible to the 1523 + // detector's contrast / color checks. 1524 + function oklchToRgb(L, C, H) { 1525 + const hRad = (H * Math.PI) / 180; 1526 + const a = C * Math.cos(hRad); 1527 + const b = C * Math.sin(hRad); 1528 + const l_ = L + 0.3963377774 * a + 0.2158037573 * b; 1529 + const m_ = L - 0.1055613458 * a - 0.0638541728 * b; 1530 + const s_ = L - 0.0894841775 * a - 1.2914855480 * b; 1531 + const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_; 1532 + const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc; 1533 + const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc; 1534 + const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc; 1535 + const enc = (x) => { 1536 + const c = Math.max(0, Math.min(1, x)); 1537 + return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055; 1538 + }; 1539 + return { 1540 + r: Math.round(enc(rLin) * 255), 1541 + g: Math.round(enc(gLin) * 255), 1542 + b: Math.round(enc(bLin) * 255), 1543 + a: 1, 1544 + }; 1545 + } 1546 + 1547 + // Extended color parser: rgb/rgba/hex/oklch. Returns null on no match. 1548 + // Use this when the input might be any CSS color form; use plain parseRgb 1549 + // when you only expect computed rgb() values from real browsers. 1550 + function parseAnyColor(s) { 1551 + if (!s || typeof s !== 'string') return null; 1552 + const str = s.trim(); 1553 + if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null; 1554 + let m; 1555 + m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+))?\s*\)/); 1556 + if (m) return { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: m[4] !== undefined ? +m[4] : 1 }; 1557 + m = str.match(/^#([0-9a-f]{3,8})$/i); 1558 + if (m) { 1559 + const h = m[1]; 1560 + if (h.length === 3 || h.length === 4) { 1561 + return { 1562 + r: parseInt(h[0] + h[0], 16), 1563 + g: parseInt(h[1] + h[1], 16), 1564 + b: parseInt(h[2] + h[2], 16), 1565 + a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1, 1566 + }; 1567 + } 1568 + if (h.length === 6 || h.length === 8) { 1569 + return { 1570 + r: parseInt(h.slice(0, 2), 16), 1571 + g: parseInt(h.slice(2, 4), 16), 1572 + b: parseInt(h.slice(4, 6), 16), 1573 + a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1, 1574 + }; 1575 + } 1576 + } 1577 + // OKLCH parser. Tailwind v4's CSS minifier squishes the space after 1578 + // `%` ("21.5%.02 50"), so the separator between L and C may be absent. 1579 + // Match L (with optional %), then C and H separated permissively. 1580 + m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i); 1581 + if (m) { 1582 + const Lnum = parseFloat(m[1]); 1583 + const L = m[2] === '%' ? Lnum / 100 : Lnum; 1584 + const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); 1585 + if (m[5] !== undefined) { 1586 + const alpha = parseFloat(m[5]); 1587 + rgb.a = m[6] === '%' ? alpha / 100 : alpha; 1588 + } 1589 + return rgb; 1590 + } 1591 + return null; 1592 + } 1593 + 1594 + // Resolve var() refs in a color string (via customPropMap), then parse. 1595 + // Returns null on any failure. Used in jsdom-mode paths where 1596 + // getComputedStyle returns literal "var(--X)" or "oklch(...)" strings. 1597 + function parseColorResolved(str, customPropMap) { 1598 + if (!str) return null; 1599 + const resolved = customPropMap ? resolveVarRefs(str, customPropMap) : str; 1600 + return parseAnyColor(resolved); 1601 + } 1602 + 1603 + const REPEATED_KICKER_SKIP_SELECTOR = [ 1604 + 'nav', 1605 + 'form', 1606 + 'table', 1607 + 'thead', 1608 + 'tbody', 1609 + 'tfoot', 1610 + 'figure', 1611 + 'figcaption', 1612 + 'ol', 1613 + 'ul', 1614 + 'li', 1615 + '[role="navigation"]', 1616 + '[aria-label*="breadcrumb" i]', 1617 + '[class*="breadcrumb" i]', 1618 + '[aria-hidden="true"]', 1619 + '[data-impeccable-allow-kickers]', 1620 + ].join(','); 1621 + 1622 + const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [ 1623 + 'article', 1624 + 'button', 1625 + 'a', 1626 + 'li', 1627 + '[role="listitem"]', 1628 + '[role="option"]', 1629 + ].join(','); 1630 + 1631 + function cleanInlineText(el) { 1632 + return [...el.childNodes] 1633 + .filter(n => n.nodeType === 3) 1634 + .map(n => n.textContent) 1635 + .join(' ') 1636 + .replace(/\s+/g, ' ') 1637 + .trim(); 1638 + } 1639 + 1640 + function isRepeatedKickerCardContext(heading, kicker) { 1641 + const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR); 1642 + return Boolean(item && (!item.contains || item.contains(kicker))); 1643 + } 1644 + 1645 + function isRepeatedKickerCandidate(opts) { 1646 + const { 1647 + headingTag, 1648 + headingText, 1649 + headingFontSize, 1650 + kickerTag, 1651 + kickerText, 1652 + kickerTextTransform, 1653 + kickerFontSize, 1654 + kickerLetterSpacing, 1655 + } = opts; 1656 + if (!['h2', 'h3', 'h4'].includes(headingTag)) return false; 1657 + if (!headingText || headingText.length < 3) return false; 1658 + if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false; 1659 + if (!(headingFontSize >= 20)) return false; 1660 + if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false; 1661 + if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false; 1662 + if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false; 1663 + if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false; 1664 + 1665 + const isUppercased = kickerTextTransform === 'uppercase' 1666 + || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText)); 1667 + if (!isUppercased) return false; 1668 + if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false; 1669 + const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08); 1670 + if (!(kickerLetterSpacing >= minTrackedSpacing)) return false; 1671 + return true; 1672 + } 1673 + 1674 + function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) { 1675 + const candidates = []; 1676 + for (const heading of doc.querySelectorAll('h2, h3, h4')) { 1677 + if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; 1678 + const kicker = heading.previousElementSibling; 1679 + if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; 1680 + if (isRepeatedKickerCardContext(heading, kicker)) continue; 1681 + 1682 + const headingStyle = getStyle(heading); 1683 + const kickerStyle = getStyle(kicker); 1684 + const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim(); 1685 + const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim(); 1686 + const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0; 1687 + const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0; 1688 + const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize); 1689 + 1690 + if (!isRepeatedKickerCandidate({ 1691 + headingTag: heading.tagName.toLowerCase(), 1692 + headingText, 1693 + headingFontSize, 1694 + kickerTag: kicker.tagName.toLowerCase(), 1695 + kickerText, 1696 + kickerTextTransform: kickerStyle.textTransform || '', 1697 + kickerFontSize, 1698 + kickerLetterSpacing, 1699 + })) { 1700 + continue; 1701 + } 1702 + 1703 + candidates.push({ 1704 + headingTag: heading.tagName.toLowerCase(), 1705 + headingText: headingText.replace(/^"|"$/g, '').slice(0, 60), 1706 + kickerText: kickerText.slice(0, 40), 1707 + }); 1708 + } 1709 + return candidates; 1710 + } 1711 + 1712 + function checkRepeatedSectionKickersDOM() { 1713 + const candidates = collectRepeatedSectionKickerCandidates( 1714 + document, 1715 + (el) => getComputedStyle(el), 1716 + (value, fontSize) => resolveLengthPx(value, fontSize) || 0, 1717 + ); 1718 + return checkRepeatedSectionKickers({ candidates }); 1719 + } 1720 + 1721 + function checkElementMotionDOM(el) { 1722 + const tag = el.tagName.toLowerCase(); 1723 + if (SAFE_TAGS.has(tag)) return []; 1724 + const style = getComputedStyle(el); 1725 + return checkMotion({ 1726 + tag, 1727 + transitionProperty: style.transitionProperty || '', 1728 + animationName: style.animationName || '', 1729 + timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '), 1730 + classList: el.getAttribute('class') || '', 1731 + }); 1732 + } 1733 + 1734 + function checkElementGlowDOM(el) { 1735 + const tag = el.tagName.toLowerCase(); 1736 + const style = getComputedStyle(el); 1737 + if (!style.boxShadow || style.boxShadow === 'none') return []; 1738 + // Use parent's background — glow radiates outward, so the surrounding context matters 1739 + // If resolveBackground returns null (gradient), try to infer from the gradient colors 1740 + let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el); 1741 + if (!parentBg) { 1742 + // Gradient background — sample its colors to determine if it's dark 1743 + let cur = el.parentElement; 1744 + while (cur && cur.nodeType === 1) { 1745 + const bgImage = getComputedStyle(cur).backgroundImage || ''; 1746 + const gradColors = parseGradientColors(bgImage); 1747 + if (gradColors.length > 0) { 1748 + // Average the gradient colors 1749 + const avg = { r: 0, g: 0, b: 0 }; 1750 + for (const c of gradColors) { avg.r += c.r; avg.g += c.g; avg.b += c.b; } 1751 + avg.r = Math.round(avg.r / gradColors.length); 1752 + avg.g = Math.round(avg.g / gradColors.length); 1753 + avg.b = Math.round(avg.b / gradColors.length); 1754 + parentBg = avg; 1755 + break; 1756 + } 1757 + cur = cur.parentElement; 1758 + } 1759 + } 1760 + return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg: parentBg }); 1761 + } 1762 + 1763 + function checkElementAIPaletteDOM(el) { 1764 + const style = getComputedStyle(el); 1765 + const findings = []; 1766 + 1767 + // Check gradient backgrounds for purple/violet or cyan 1768 + const bgImage = style.backgroundImage || ''; 1769 + const gradColors = parseGradientColors(bgImage); 1770 + for (const c of gradColors) { 1771 + if (hasChroma(c, 50)) { 1772 + const hue = getHue(c); 1773 + if (hue >= 260 && hue <= 310) { 1774 + findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient background' }); 1775 + break; 1776 + } 1777 + if (hue >= 160 && hue <= 200) { 1778 + findings.push({ id: 'ai-color-palette', snippet: 'Cyan gradient background' }); 1779 + break; 1780 + } 1781 + } 1782 + } 1783 + 1784 + // Check for neon text (vivid cyan/purple color on dark background) 1785 + const textColor = parseRgb(style.color); 1786 + if (textColor && hasChroma(textColor, 80)) { 1787 + const hue = getHue(textColor); 1788 + const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310); 1789 + if (isAIPalette) { 1790 + const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null; 1791 + // Also check gradient parents 1792 + let effectiveBg = parentBg; 1793 + if (!effectiveBg) { 1794 + let cur = el.parentElement; 1795 + while (cur && cur.nodeType === 1) { 1796 + const gi = getComputedStyle(cur).backgroundImage || ''; 1797 + const gc = parseGradientColors(gi); 1798 + if (gc.length > 0) { 1799 + const avg = { r: 0, g: 0, b: 0 }; 1800 + for (const c of gc) { avg.r += c.r; avg.g += c.g; avg.b += c.b; } 1801 + avg.r = Math.round(avg.r / gc.length); 1802 + avg.g = Math.round(avg.g / gc.length); 1803 + avg.b = Math.round(avg.b / gc.length); 1804 + effectiveBg = avg; 1805 + break; 1806 + } 1807 + cur = cur.parentElement; 1808 + } 1809 + } 1810 + if (effectiveBg && relativeLuminance(effectiveBg) < 0.1) { 1811 + const label = hue >= 260 ? 'Purple/violet' : 'Cyan'; 1812 + findings.push({ id: 'ai-color-palette', snippet: `${label} neon text on dark background` }); 1813 + } 1814 + } 1815 + } 1816 + 1817 + return findings; 1818 + } 1819 + 1820 + const QUALITY_TEXT_TAGS = new Set(['p', 'li', 'td', 'th', 'dd', 'blockquote', 'figcaption']); 1821 + 1822 + // Resolve a CSS font-size value to pixels by walking up the parent chain. 1823 + // Browsers resolve em/rem/% to px in getComputedStyle, but jsdom returns the 1824 + // specified value verbatim — so for the Node path we walk parents ourselves. 1825 + function resolveFontSizePx(el, win) { 1826 + const chain = []; // raw font-size strings, leaf → root 1827 + let cur = el; 1828 + while (cur && cur.nodeType === 1) { 1829 + const fs = (win ? win.getComputedStyle(cur) : getComputedStyle(cur)).fontSize; 1830 + chain.push(fs || ''); 1831 + cur = cur.parentElement; 1832 + } 1833 + // Walk root → leaf, resolving each value relative to its parent context. 1834 + let px = 16; // root default 1835 + for (let i = chain.length - 1; i >= 0; i--) { 1836 + const v = chain[i]; 1837 + if (!v || v === 'inherit') continue; 1838 + const num = parseFloat(v); 1839 + if (isNaN(num)) continue; 1840 + if (v.endsWith('px')) px = num; 1841 + else if (v.endsWith('rem')) px = num * 16; 1842 + else if (v.endsWith('em')) px = num * px; 1843 + else if (v.endsWith('%')) px = (num / 100) * px; 1844 + else px = num; // unitless — already resolved 1845 + } 1846 + return px; 1847 + } 1848 + 1849 + // Resolve a CSS length value (line-height, letter-spacing, etc.) given a 1850 + // known font-size context. Returns null for "normal" / unparseable values. 1851 + function resolveLengthPx(value, fontSizePx) { 1852 + if (!value || value === 'normal' || value === 'auto' || value === 'inherit') return null; 1853 + const num = parseFloat(value); 1854 + if (isNaN(num)) return null; 1855 + if (value.endsWith('px')) return num; 1856 + if (value.endsWith('rem')) return num * 16; 1857 + if (value.endsWith('em')) return num * fontSizePx; 1858 + if (value.endsWith('%')) return (num / 100) * fontSizePx; 1859 + // Unitless line-height = multiplier, return px equivalent 1860 + return num * fontSizePx; 1861 + } 1862 + 1863 + function cssColorIsTransparent(value) { 1864 + if (!value) return true; 1865 + const str = String(value).trim().toLowerCase(); 1866 + if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true; 1867 + const parsed = parseAnyColor(str); 1868 + if (parsed) return (parsed.a ?? 1) <= 0.05; 1869 + return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str); 1870 + } 1871 + 1872 + function colorsNearlyMatch(a, b) { 1873 + const ca = parseAnyColor(a); 1874 + const cb = parseAnyColor(b); 1875 + if (!ca || !cb) return false; 1876 + const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1)); 1877 + const channelDelta = Math.max( 1878 + Math.abs(ca.r - cb.r), 1879 + Math.abs(ca.g - cb.g), 1880 + Math.abs(ca.b - cb.b), 1881 + ); 1882 + return alphaDelta <= 0.03 && channelDelta <= 3; 1883 + } 1884 + 1885 + function getComputedStyleFor(win, el) { 1886 + if (win && typeof win.getComputedStyle === 'function') { 1887 + try { return win.getComputedStyle(el); } catch {} 1888 + } 1889 + if (typeof getComputedStyle === 'function') { 1890 + try { return getComputedStyle(el); } catch {} 1891 + } 1892 + return null; 1893 + } 1894 + 1895 + function hasVisibleBackgroundBoundary(style, el, win) { 1896 + const bg = style?.backgroundColor || ''; 1897 + if (cssColorIsTransparent(bg)) return false; 1898 + 1899 + let parent = el?.parentElement || null; 1900 + while (parent) { 1901 + const parentStyle = getComputedStyleFor(win, parent); 1902 + const parentBg = parentStyle?.backgroundColor || ''; 1903 + if (!cssColorIsTransparent(parentBg)) { 1904 + return !colorsNearlyMatch(bg, parentBg); 1905 + } 1906 + parent = parent.parentElement; 1907 + } 1908 + 1909 + return true; 1910 + } 1911 + 1912 + const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']); 1913 + 1914 + function hasMeaningfulDirectText(node) { 1915 + if (!node?.childNodes) return false; 1916 + for (const child of node.childNodes) { 1917 + if (child.nodeType === 3 && child.textContent.trim().length > 4) return true; 1918 + } 1919 + return false; 1920 + } 1921 + 1922 + function textDescendantsFlushSides(el, rect) { 1923 + const flush = { top: false, right: false, bottom: false, left: false }; 1924 + if (!rect || !el?.querySelectorAll) return flush; 1925 + const TEXT_EDGE_THRESHOLD = 4; 1926 + const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th'); 1927 + for (const node of candidates) { 1928 + if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue; 1929 + let nodeRect = null; 1930 + try { nodeRect = node.getBoundingClientRect(); } catch {} 1931 + if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue; 1932 + if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue; 1933 + if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true; 1934 + if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true; 1935 + if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true; 1936 + if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true; 1937 + } 1938 + return flush; 1939 + } 1940 + 1941 + // Pure quality checks. Most run on computed CSS and DOM-only inputs (work in 1942 + // jsdom and the browser). Two checks (line-length, cramped-padding) gate on 1943 + // element rect dimensions, which jsdom can't compute — pass `rect: null` from 1944 + // the Node adapter to skip those. 1945 + // 1946 + // Both adapters resolve font-size, line-height and letter-spacing to pixels 1947 + // before calling this so the pure function only deals with numbers. 1948 + function checkQuality(opts) { 1949 + const { el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax = 80, viewportWidth = 0, win = null } = opts; 1950 + const findings = []; 1951 + // Skip browser extension injected elements 1952 + const elId = el.id || ''; 1953 + if (elId.startsWith('claude-') || elId.startsWith('cic-')) return findings; 1954 + 1955 + // --- Line length too long --- (browser-only: needs rect.width) 1956 + if (rect && hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > lineMax) { 1957 + const charsPerLine = rect.width / (fontSize * 0.5); 1958 + if (charsPerLine > lineMax + 5) { 1959 + findings.push({ id: 'line-length', snippet: `~${Math.round(charsPerLine)} chars/line (aim for <${lineMax})` }); 1960 + } 1961 + } 1962 + 1963 + // --- Cramped padding --- (browser-only: needs rect to skip small badges/labels) 1964 + // Vertical and horizontal thresholds are independent because line-height 1965 + // already provides built-in vertical breathing room (the line box is taller 1966 + // than the cap height), but horizontal has no equivalent. Both scale with 1967 + // font-size — bigger text demands proportionally more padding. 1968 + // vertical: max(4px, fontSize × 0.3) 1969 + // horizontal: max(8px, fontSize × 0.5) 1970 + const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre')); 1971 + if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { 1972 + const borders = { 1973 + top: parseFloat(style.borderTopWidth) || 0, 1974 + right: parseFloat(style.borderRightWidth) || 0, 1975 + bottom: parseFloat(style.borderBottomWidth) || 0, 1976 + left: parseFloat(style.borderLeftWidth) || 0, 1977 + }; 1978 + const borderCount = Object.values(borders).filter(w => w > 0).length; 1979 + const hasBg = hasVisibleBackgroundBoundary(style, el, win); 1980 + if (borderCount >= 2 || hasBg) { 1981 + const vPads = [], hPads = []; 1982 + if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0); 1983 + if (hasBg || borders.bottom > 0) vPads.push(parseFloat(style.paddingBottom) || 0); 1984 + if (hasBg || borders.left > 0) hPads.push(parseFloat(style.paddingLeft) || 0); 1985 + if (hasBg || borders.right > 0) hPads.push(parseFloat(style.paddingRight) || 0); 1986 + 1987 + const vMin = vPads.length ? Math.min(...vPads) : Infinity; 1988 + const hMin = hPads.length ? Math.min(...hPads) : Infinity; 1989 + const vThresh = Math.max(4, fontSize * 0.3); 1990 + const hThresh = Math.max(8, fontSize * 0.5); 1991 + 1992 + // Emit at most one finding per element — pick whichever axis is worse. 1993 + if (vMin < vThresh) { 1994 + findings.push({ id: 'cramped-padding', snippet: `${vMin}px vertical padding (need ≥${vThresh.toFixed(1)}px for ${fontSize}px text)` }); 1995 + } else if (hMin < hThresh) { 1996 + findings.push({ id: 'cramped-padding', snippet: `${hMin}px horizontal padding (need ≥${hThresh.toFixed(1)}px for ${fontSize}px text)` }); 1997 + } 1998 + } 1999 + } 2000 + 2001 + // --- Flush against a visible boundary --- 2002 + // Fires when a container has a visible boundary (border, outline, OR a 2003 + // non-transparent background) AND near-zero padding on the bounded 2004 + // side(s) AND text-bearing children land flush against the boundary. 2005 + // 2006 + // Distinct from cramped-padding: that rule needs the element itself to 2007 + // have direct text (hasDirectText). This rule targets the OPPOSITE 2008 + // shape — a container with NO direct text, only children — which is 2009 + // exactly what cramped-padding misses (a section wrapping a label + 2010 + // list lands a free pass). 2011 + // 2012 + // The classic shape: agent writes `padding: 28px 0 0` shorthand on a 2013 + // section that also has a border, zeroing horizontal padding so the 2014 + // text-bearing children touch the side borders. Background and 2015 + // outline count too: a colored card with zero padding has the same 2016 + // visual failure mode. 2017 + { 2018 + const FLUSH_SKIP_TAGS = new Set(['HTML', 'BODY', 'MAIN', 'HEADER', 'FOOTER', 'NAV', 'ARTICLE', 'ASIDE', 'BUTTON', 'A', 'LABEL', 'SUMMARY', 'CODE', 'PRE', 'INPUT', 'TEXTAREA', 'SELECT', 'FORM', 'FIGURE', 'TABLE', 'TBODY', 'THEAD', 'TR', 'TD', 'TH']); 2019 + const upperTag = tag ? tag.toUpperCase() : ''; 2020 + const elPosition = style.position || ''; 2021 + if ( 2022 + !FLUSH_SKIP_TAGS.has(upperTag) && 2023 + !hasDirectText && 2024 + !['fixed', 'absolute'].includes(elPosition) && 2025 + el.children && el.children.length > 0 2026 + ) { 2027 + const borderW = { 2028 + top: parseFloat(style.borderTopWidth) || 0, 2029 + right: parseFloat(style.borderRightWidth) || 0, 2030 + bottom: parseFloat(style.borderBottomWidth) || 0, 2031 + left: parseFloat(style.borderLeftWidth) || 0, 2032 + }; 2033 + const borderVisible = { 2034 + top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor), 2035 + right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor), 2036 + bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor), 2037 + left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor), 2038 + }; 2039 + // Outline detection. jsdom decomposes `border` shorthand into 2040 + // border{Top,…}Width/Color but does NOT decompose `outline` — 2041 + // the longhands come back empty when the value was set via the 2042 + // shorthand. Fall back to parsing `style.outline` ourselves. 2043 + let outlineW = parseFloat(style.outlineWidth) || 0; 2044 + let outlineStyleVal = style.outlineStyle || ''; 2045 + let outlineColorVal = style.outlineColor || ''; 2046 + if (!outlineW && style.outline) { 2047 + const wMatch = style.outline.match(/(\d+(?:\.\d+)?)\s*px/); 2048 + if (wMatch) outlineW = parseFloat(wMatch[1]) || 0; 2049 + if (!outlineStyleVal) { 2050 + outlineStyleVal = /\b(solid|dashed|dotted|double|groove|ridge|inset|outset)\b/.test(style.outline) ? 'solid' : ''; 2051 + } 2052 + if (!outlineColorVal) { 2053 + const cMatch = style.outline.match(/(rgba?\([^)]+\)|#[0-9a-fA-F]{3,8}|[a-zA-Z]+)\s*$/); 2054 + if (cMatch) outlineColorVal = cMatch[1]; 2055 + } 2056 + } 2057 + const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; 2058 + const bgVisible = hasVisibleBackgroundBoundary(style, el, win); 2059 + 2060 + const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible; 2061 + if (anyVisible) { 2062 + // Resolve padding to px (jsdom returns raw "1.5rem" etc., not the 2063 + // computed px value; parseFloat would strip the unit and treat 2064 + // 1.5rem as 1.5px, false-flagging legitimate insets). 2065 + const pad = { 2066 + top: resolveLengthPx(style.paddingTop, fontSize) ?? 0, 2067 + right: resolveLengthPx(style.paddingRight, fontSize) ?? 0, 2068 + bottom: resolveLengthPx(style.paddingBottom, fontSize) ?? 0, 2069 + left: resolveLengthPx(style.paddingLeft, fontSize) ?? 0, 2070 + }; 2071 + const PAD_THRESHOLD = 2; 2072 + // Children-insulate-this-side: a side is insulated if ANY direct 2073 + // child has its own padding ≥ 4px on that side. Rationale: in 2074 + // typical flow, only the first/last (or leftmost/rightmost) 2075 + // children actually sit at the parent's edges. If even one of 2076 + // them has its own padding, the visual flush is broken on that 2077 + // side. Classic example: a column-flow card frame where the 2078 + // top child (header) has padding-top:12 and the bottom child 2079 + // (footer) has padding-bottom:8 — the parent's padding:0 doesn't 2080 + // matter; nothing is actually flush. The `any-child-insulates` 2081 + // heuristic accepts some false negatives (a card with one heavily 2082 + // padded middle child won't flag) for far fewer false positives. 2083 + const CHILD_INSULATE_THRESHOLD = 4; 2084 + const childrenInsulate = { top: false, right: false, bottom: false, left: false }; 2085 + for (const child of el.children) { 2086 + let childStyle = getComputedStyleFor(win, child); 2087 + if (!childStyle) continue; 2088 + const childPad = { 2089 + top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0, 2090 + right: resolveLengthPx(childStyle.paddingRight, fontSize) ?? 0, 2091 + bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0, 2092 + left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0, 2093 + }; 2094 + const childMargin = { 2095 + top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0, 2096 + right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0, 2097 + bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0, 2098 + left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0, 2099 + }; 2100 + if (rect && typeof child.getBoundingClientRect === 'function') { 2101 + try { 2102 + const childRect = child.getBoundingClientRect(); 2103 + if (childRect && childRect.width > 0 && childRect.height > 0) { 2104 + if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true; 2105 + if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true; 2106 + if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true; 2107 + if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true; 2108 + } 2109 + } catch {} 2110 + } 2111 + for (const s of ['top', 'right', 'bottom', 'left']) { 2112 + if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) { 2113 + childrenInsulate[s] = true; 2114 + } 2115 + } 2116 + } 2117 + 2118 + const textFlush = rect ? textDescendantsFlushSides(el, rect) : null; 2119 + const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible; 2120 + const flushSides = []; 2121 + for (const side of ['top', 'right', 'bottom', 'left']) { 2122 + const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right')); 2123 + const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide; 2124 + if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) { 2125 + flushSides.push(side); 2126 + } 2127 + } 2128 + 2129 + if (flushSides.length > 0) { 2130 + // Confirm at least one direct child has substantial text content 2131 + // (> 4 chars). Without this, the flush is harmless: e.g. an 2132 + // image-only card. 2133 + let hasTextChild = false; 2134 + for (const child of el.children) { 2135 + const childText = (child.textContent || '').trim(); 2136 + if (childText.length > 4) { hasTextChild = true; break; } 2137 + } 2138 + if (hasTextChild) { 2139 + const cls = (typeof el.className === 'string' && el.className.trim()) 2140 + ? el.className.trim().split(/\s+/)[0] 2141 + : ''; 2142 + const boundaryParts = []; 2143 + const borderSidesVisible = ['top', 'right', 'bottom', 'left'].filter(s => borderVisible[s]); 2144 + if (borderSidesVisible.length === 4) boundaryParts.push('border'); 2145 + else if (borderSidesVisible.length > 0) boundaryParts.push(`border-${borderSidesVisible.join('/')}`); 2146 + if (outlineVisible) boundaryParts.push('outline'); 2147 + if (bgVisible) boundaryParts.push('bg'); 2148 + const sidesLabel = flushSides.length === 4 ? 'all sides' : flushSides.join('/'); 2149 + const ident = cls 2150 + ? `<${tag.toLowerCase()}> "${cls}"` 2151 + : `<${tag.toLowerCase()}>`; 2152 + findings.push({ 2153 + id: 'cramped-padding', 2154 + snippet: `${ident}: children flush against ${boundaryParts.join('+')} on ${sidesLabel} (no inset)`, 2155 + }); 2156 + } 2157 + } 2158 + } 2159 + } 2160 + } 2161 + 2162 + // --- Body text touching viewport edge --- (browser-only: needs rect) 2163 + // Catches the failure mode where the agent ships body paragraphs 2164 + // with NO container providing horizontal padding — text bleeds 2165 + // directly to the viewport edge. Different from cramped-padding, 2166 + // which requires a colored/bordered container. Here the failure 2167 + // is the absence of the container entirely. 2168 + // 2169 + // Gate aggressively to avoid false positives: 2170 + // - <p> or <li> only (body content; not headings, not nav, not 2171 + // wrappers) 2172 + // - text > 40 chars (paragraph-like, not a label) 2173 + // - rect.width > 50% of viewport (real body, not a pull-quote) 2174 + // - rect.left < 16 OR rect.right > viewport - 16 (actually 2175 + // touching the edge) 2176 + // - not inside <nav> or <header> (those legitimately bleed) 2177 + // - element itself has no background-color (intentional full-bleed 2178 + // sections set a bg-color and provide their own internal padding) 2179 + if (rect && hasDirectText && textLen > 40 && ['P', 'LI'].includes(tag.toUpperCase()) && viewportWidth > 0) { 2180 + const inNavHeader = el.closest && (el.closest('nav') || el.closest('header')); 2181 + const hasOwnBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)' && style.backgroundColor !== 'transparent'; 2182 + const isPositioned = ['fixed', 'absolute'].includes(style.position || ''); 2183 + const widthRatio = rect.width / viewportWidth; 2184 + const leftClose = rect.left < 16; 2185 + const rightClose = rect.right > viewportWidth - 16; 2186 + if (!inNavHeader && !hasOwnBg && !isPositioned && widthRatio > 0.5 && (leftClose || rightClose)) { 2187 + const which = leftClose && rightClose 2188 + ? `left ${Math.round(rect.left)}px / right ${Math.round(viewportWidth - rect.right)}px` 2189 + : leftClose 2190 + ? `left ${Math.round(rect.left)}px` 2191 + : `right ${Math.round(viewportWidth - rect.right)}px`; 2192 + findings.push({ id: 'body-text-viewport-edge', snippet: `<${tag.toLowerCase()}> with ${textLen}-char body bleeds to viewport edge (${which})` }); 2193 + } 2194 + } 2195 + 2196 + // --- Tight line height --- 2197 + if (hasDirectText && textLen > 50 && !['h1','h2','h3','h4','h5','h6'].includes(tag)) { 2198 + if (lineHeightPx != null && fontSize > 0) { 2199 + const ratio = lineHeightPx / fontSize; 2200 + if (ratio > 0 && ratio < 1.3) { 2201 + findings.push({ id: 'tight-leading', snippet: `line-height ${ratio.toFixed(2)}x (need >=1.3)` }); 2202 + } 2203 + } 2204 + } 2205 + 2206 + // --- Justified text (without hyphens) --- 2207 + if (hasDirectText && style.textAlign === 'justify') { 2208 + const hyphens = style.hyphens || style.webkitHyphens || ''; 2209 + if (hyphens !== 'auto') { 2210 + findings.push({ id: 'justified-text', snippet: 'text-align: justify without hyphens: auto' }); 2211 + } 2212 + } 2213 + 2214 + // --- Tiny body text --- 2215 + // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.) 2216 + if (hasDirectText && textLen > 20 && fontSize < 12) { 2217 + const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption']; 2218 + const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]'); 2219 + const isUppercase = style.textTransform === 'uppercase'; 2220 + if (!skipTags.includes(tag) && !inUIContext && !isUppercase) { 2221 + findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` }); 2222 + } 2223 + } 2224 + 2225 + // --- All-caps body text --- 2226 + if (hasDirectText && textLen > 30 && style.textTransform === 'uppercase') { 2227 + if (!['h1','h2','h3','h4','h5','h6'].includes(tag)) { 2228 + findings.push({ id: 'all-caps-body', snippet: `text-transform: uppercase on ${textLen} chars of body text` }); 2229 + } 2230 + } 2231 + 2232 + // --- Wide letter spacing on body text --- 2233 + if (hasDirectText && textLen > 20 && style.textTransform !== 'uppercase') { 2234 + if (letterSpacingPx != null && letterSpacingPx > 0 && fontSize > 0) { 2235 + const trackingEm = letterSpacingPx / fontSize; 2236 + if (trackingEm > 0.05) { 2237 + findings.push({ id: 'wide-tracking', snippet: `letter-spacing: ${trackingEm.toFixed(2)}em on body text` }); 2238 + } 2239 + } 2240 + } 2241 + 2242 + // --- Crushed letter spacing (mirror of wide-tracking) --- 2243 + // Tracking pulled tighter than ~-0.05em crushes characters into each other. 2244 + // Optical tightening that display type legitimately wants (around -0.02em) 2245 + // stays well above this floor. 2246 + if (hasDirectText && textLen > 20 && fontSize > 0) { 2247 + if (letterSpacingPx != null && letterSpacingPx < 0) { 2248 + const trackingEm = letterSpacingPx / fontSize; 2249 + if (trackingEm <= -0.05) { 2250 + const excerpt = (el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 40); 2251 + findings.push({ id: 'extreme-negative-tracking', snippet: `letter-spacing: ${trackingEm.toFixed(2)}em — "${excerpt}"` }); 2252 + } 2253 + } 2254 + } 2255 + 2256 + return findings; 2257 + } 2258 + 2259 + function checkElementQualityDOM(el) { 2260 + const tag = el.tagName.toLowerCase(); 2261 + const style = getComputedStyle(el); 2262 + const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 10); 2263 + const textLen = el.textContent?.trim().length || 0; 2264 + // Browser getComputedStyle resolves everything to px — direct parseFloat 2265 + // works. 2266 + const fontSize = parseFloat(style.fontSize) || 16; 2267 + const lineHeightPx = resolveLengthPx(style.lineHeight, fontSize); 2268 + const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize); 2269 + const rect = el.getBoundingClientRect(); 2270 + const lineMax = (typeof window !== 'undefined' && window.__IMPECCABLE_CONFIG__?.lineLengthMax) || 80; 2271 + const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0; 2272 + return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax, viewportWidth, win: typeof window !== 'undefined' ? window : null }); 2273 + } 2274 + 2275 + // Pure page-level skipped-heading walk. Takes a Document so it works in both 2276 + // the browser and jsdom. 2277 + function checkPageQualityFromDoc(doc) { 2278 + const findings = []; 2279 + const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6'); 2280 + let prevLevel = 0; 2281 + let prevText = ''; 2282 + for (const h of headings) { 2283 + const level = parseInt(h.tagName[1]); 2284 + const text = (h.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60); 2285 + if (prevLevel > 0 && level > prevLevel + 1) { 2286 + findings.push({ 2287 + id: 'skipped-heading', 2288 + snippet: `<h${prevLevel}> "${prevText}" followed by <h${level}> "${text}" (missing h${prevLevel + 1})`, 2289 + }); 2290 + } 2291 + prevLevel = level; 2292 + prevText = text; 2293 + } 2294 + return findings; 2295 + } 2296 + 2297 + // Browser adapter (returns the legacy { type, detail } shape used by the overlay loop) 2298 + function checkPageQualityDOM() { 2299 + return checkPageQualityFromDoc(document).map(f => ({ type: f.id, detail: f.snippet })); 2300 + } 2301 + 2302 + // Node adapters — take pre-extracted jsdom computed style 2303 + 2304 + // jsdom doesn't lay out OR resolve em/rem/% to px — so we pre-resolve every 2305 + // CSS length the rule needs ourselves (walking the parent chain for 2306 + // font-size inheritance), and pass `rect: null` to skip the two rules that 2307 + // genuinely need element rects (line-length, cramped-padding). 2308 + function checkElementQuality(el, style, tag, window) { 2309 + const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 10); 2310 + const textLen = el.textContent?.trim().length || 0; 2311 + const fontSize = resolveFontSizePx(el, window); 2312 + const lineHeightPx = resolveLengthPx(style.lineHeight, fontSize); 2313 + const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize); 2314 + return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect: null, win: window }); 2315 + } 2316 + 2317 + function checkElementBorders(tag, style, overrides, resolvedRadius) { 2318 + const sides = ['Top', 'Right', 'Bottom', 'Left']; 2319 + const widths = {}, colors = {}; 2320 + for (const s of sides) { 2321 + widths[s] = parseFloat(style[`border${s}Width`]) || 0; 2322 + colors[s] = style[`border${s}Color`] || ''; 2323 + // jsdom silently drops any border shorthand containing var(), leaving 2324 + // both width and color empty on the computed style. When the detectHtml 2325 + // pre-pass pulled a resolved value off the rule, use it to fill in the 2326 + // missing side so the side-tab check can run. Real browsers resolve 2327 + // var() natively, so this fallback is a no-op in the browser path. 2328 + if (widths[s] === 0 && overrides && overrides[s]) { 2329 + widths[s] = overrides[s].width; 2330 + colors[s] = overrides[s].color; 2331 + } else if (colors[s] && colors[s].startsWith('var(') && overrides && overrides[s]) { 2332 + // Longhand case: jsdom kept the width but left the color as the 2333 + // literal `var(...)` string. Substitute the resolved color. 2334 + colors[s] = overrides[s].color; 2335 + } 2336 + } 2337 + // resolvedRadius lets the caller pre-resolve the radius via 2338 + // resolveBorderRadiusPx so the value survives jsdom 29.1.0's broken 2339 + // shorthand serialization. Falls back to the computed value for tests 2340 + // and browser callers that don't pre-resolve. 2341 + const radius = resolvedRadius != null 2342 + ? resolvedRadius 2343 + : (parseFloat(style.borderRadius) || 0); 2344 + return checkBorders(tag, widths, colors, radius); 2345 + } 2346 + 2347 + function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule) { 2348 + const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join(''); 2349 + const hasDirectText = directText.trim().length > 0; 2350 + 2351 + const effectiveBg = resolveBackground(el, window, customPropMap); 2352 + // jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain 2353 + // parseRgb misses Tailwind-tokenized text colors. Resolve through the 2354 + // customPropMap first; fall back to parseRgb for vanilla rgb() pages. 2355 + let textColor = customPropMap ? parseColorResolved(style.color, customPropMap) : null; 2356 + if (!textColor) textColor = parseRgb(style.color); 2357 + 2358 + // Anchor-inherit FP workaround: jsdom's UA stylesheet has `:link { color: 2359 + // blue }` at high specificity. The page's `a { color: inherit }` rule 2360 + // (Tailwind v4 preflight) loses to jsdom even though it WINS in real 2361 + // browsers (Chrome's UA wraps :link in :where() — zero specificity). 2362 + // When the page declares the inherit rule AND we see jsdom's default 2363 + // link blue on an anchor, walk to the nearest non-anchor ancestor and 2364 + // use its color instead. 2365 + if ( 2366 + hasAnchorInheritRule && 2367 + textColor && 2368 + textColor.r === 0 && textColor.g === 0 && textColor.b === 238 && 2369 + (tag === 'a' || el.closest?.('a')) 2370 + ) { 2371 + let cur = el.parentElement; 2372 + while (cur && cur.tagName !== 'HTML') { 2373 + if (cur.tagName !== 'A') { 2374 + const ps = window.getComputedStyle(cur); 2375 + const inh = (customPropMap ? parseColorResolved(ps.color, customPropMap) : null) || parseRgb(ps.color); 2376 + if (inh && !(inh.r === 0 && inh.g === 0 && inh.b === 238)) { 2377 + textColor = inh; 2378 + break; 2379 + } 2380 + } 2381 + cur = cur.parentElement; 2382 + } 2383 + } 2384 + 2385 + return checkColors({ 2386 + tag, 2387 + textColor, 2388 + bgColor: readOwnBackgroundColor(el, style), 2389 + effectiveBg, 2390 + effectiveBgStops: effectiveBg ? null : resolveGradientStops(el, window), 2391 + fontSize: parseFloat(style.fontSize) || 16, 2392 + fontWeight: parseInt(style.fontWeight) || 400, 2393 + hasDirectText, 2394 + isEmojiOnly: isEmojiOnlyText(directText), 2395 + bgClip: style.webkitBackgroundClip || style.backgroundClip || '', 2396 + bgImage: style.backgroundImage || '', 2397 + classList: el.getAttribute?.('class') || el.className || '', 2398 + }); 2399 + } 2400 + 2401 + function checkElementIconTile(el, tag, window) { 2402 + if (!HEADING_TAGS.has(tag)) return []; 2403 + const sibling = el.previousElementSibling; 2404 + if (!sibling) return []; 2405 + 2406 + const sibStyle = window.getComputedStyle(sibling); 2407 + // jsdom doesn't lay out — read explicit pixel dimensions from CSS instead. 2408 + const sibWidth = parseFloat(sibStyle.width) || 0; 2409 + const sibHeight = parseFloat(sibStyle.height) || 0; 2410 + 2411 + const iconChild = sibling.querySelector('svg, i[data-lucide], i[class*="fa-"], i[class*="icon"]'); 2412 + let iconWidth = 0; 2413 + if (iconChild) { 2414 + const iconStyle = window.getComputedStyle(iconChild); 2415 + iconWidth = parseFloat(iconStyle.width) || parseFloat(iconChild.getAttribute('width')) || 0; 2416 + } 2417 + // Or: tile contains an emoji/symbol character directly as its only content 2418 + const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join(''); 2419 + const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText); 2420 + 2421 + return checkIconTile({ 2422 + headingTag: tag, 2423 + headingText: el.textContent || '', 2424 + headingTop: 0, // jsdom: no layout, skip vertical-stacking gate 2425 + siblingTag: sibling.tagName.toLowerCase(), 2426 + siblingWidth: sibWidth, 2427 + siblingHeight: sibHeight, 2428 + siblingBottom: 0, 2429 + siblingBgColor: parseRgb(sibStyle.backgroundColor), 2430 + siblingBgImage: sibStyle.backgroundImage || '', 2431 + siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0, 2432 + siblingBorderRadius: resolveBorderRadiusPx(sibling, sibStyle, sibWidth, window), 2433 + hasIconChild: !!iconChild || hasInlineEmojiIcon, 2434 + iconChildWidth: iconWidth, 2435 + }); 2436 + } 2437 + 2438 + function checkElementItalicSerif(el, style, tag) { 2439 + if (tag !== 'h1' && tag !== 'h2') return []; 2440 + return checkItalicSerif({ 2441 + tag, 2442 + fontStyle: style.fontStyle || '', 2443 + fontFamily: style.fontFamily || '', 2444 + fontSize: parseFloat(style.fontSize) || 0, 2445 + headingText: el.textContent || '', 2446 + }); 2447 + } 2448 + 2449 + function checkElementHeroEyebrow(el, style, tag, window, customPropMap) { 2450 + if (tag !== 'h1') return []; 2451 + const sibling = el.previousElementSibling; 2452 + if (!sibling) return []; 2453 + const sibStyle = window.getComputedStyle(sibling); 2454 + // Resolve Tailwind v4 CSS-variable wrappers (font-weight:var(--font-weight-bold) 2455 + // etc.) before parsing. jsdom returns these verbatim from getComputedStyle; 2456 + // without resolution every style-based gate fails silently on Tailwind v4 builds. 2457 + const fontSizeRaw = customPropMap ? resolveVarRefs(sibStyle.fontSize, customPropMap) : sibStyle.fontSize; 2458 + const fontWeightRaw = customPropMap ? resolveVarRefs(sibStyle.fontWeight, customPropMap) : sibStyle.fontWeight; 2459 + const letterSpacingRaw = customPropMap ? resolveVarRefs(sibStyle.letterSpacing, customPropMap) : sibStyle.letterSpacing; 2460 + const colorRaw = customPropMap ? resolveVarRefs(sibStyle.color, customPropMap) : sibStyle.color; 2461 + const headingFontSizeRaw = customPropMap ? resolveVarRefs(style.fontSize, customPropMap) : style.fontSize; 2462 + const siblingFontSize = parseFloat(fontSizeRaw) || 0; 2463 + // resolveLengthPx returns null for 'normal' / 'auto'; coerce to 0 so the 2464 + // gate falls through cleanly. jsdom returns letter-spacing verbatim 2465 + // (e.g. '0.15em'), unlike real browsers, so this conversion is required. 2466 + return checkHeroEyebrow({ 2467 + headingTag: tag, 2468 + headingText: el.textContent || '', 2469 + headingFontSize: parseFloat(headingFontSizeRaw) || 0, 2470 + siblingTag: sibling.tagName.toLowerCase(), 2471 + siblingText: sibling.textContent || '', 2472 + siblingTextTransform: sibStyle.textTransform || '', 2473 + siblingFontSize, 2474 + siblingLetterSpacing: resolveLengthPx(letterSpacingRaw, siblingFontSize) || 0, 2475 + siblingFontWeight: fontWeightRaw || '', 2476 + siblingColor: colorRaw || '', 2477 + }); 2478 + } 2479 + 2480 + function checkRepeatedSectionKickersFromDoc(doc, win) { 2481 + const candidates = collectRepeatedSectionKickerCandidates( 2482 + doc, 2483 + (el) => win.getComputedStyle(el), 2484 + (value, fontSize) => resolveLengthPx(value, fontSize) || 0, 2485 + ); 2486 + return checkRepeatedSectionKickers({ candidates }); 2487 + } 2488 + 2489 + function checkElementMotion(tag, style) { 2490 + return checkMotion({ 2491 + tag, 2492 + transitionProperty: style.transitionProperty || '', 2493 + animationName: style.animationName || '', 2494 + timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '), 2495 + classList: '', 2496 + }); 2497 + } 2498 + 2499 + function checkElementGlow(tag, style, effectiveBg) { 2500 + if (!style.boxShadow || style.boxShadow === 'none') return []; 2501 + return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg }); 2502 + } 2503 + 2504 + // ─── Section 6: Page-Level Checks ─────────────────────────────────────────── 2505 + 2506 + // Browser page-level checks — use document/getComputedStyle globals 2507 + 2508 + function checkTypography() { 2509 + const findings = []; 2510 + 2511 + // Walk actual text-bearing elements and tally font usage by *computed style*. 2512 + // This is much more accurate than scanning CSS rules — it ignores rules that 2513 + // exist in the stylesheet but apply to nothing (e.g. demo classes showing 2514 + // anti-patterns), and counts what the user actually sees. 2515 + const fontUsage = new Map(); // primary font name → count of elements 2516 + let totalTextElements = 0; 2517 + for (const el of document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, td, th, dd, blockquote, figcaption, a, button, label, span')) { 2518 + // Skip impeccable's own elements 2519 + if (el.closest && el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; 2520 + // Only count elements that actually have visible direct text 2521 + const hasText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); 2522 + if (!hasText) continue; 2523 + const style = getComputedStyle(el); 2524 + const ff = style.fontFamily; 2525 + if (!ff) continue; 2526 + const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase()); 2527 + const primary = stack.find(f => f && !GENERIC_FONTS.has(f)); 2528 + if (!primary) continue; 2529 + fontUsage.set(primary, (fontUsage.get(primary) || 0) + 1); 2530 + totalTextElements++; 2531 + } 2532 + 2533 + if (totalTextElements >= 20) { 2534 + // A font is "primary" if it's used by at least 15% of text elements 2535 + const PRIMARY_THRESHOLD = 0.15; 2536 + for (const [font, count] of fontUsage) { 2537 + const share = count / totalTextElements; 2538 + if (share < PRIMARY_THRESHOLD) continue; 2539 + if (!OVERUSED_FONTS.has(font)) continue; 2540 + if (isBrandFontOnOwnDomain(font)) continue; 2541 + findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` }); 2542 + } 2543 + 2544 + // Single-font check: only one distinct primary font across all text 2545 + if (fontUsage.size === 1) { 2546 + const only = [...fontUsage.keys()][0]; 2547 + findings.push({ type: 'single-font', detail: `only font used is ${only}` }); 2548 + } 2549 + } 2550 + 2551 + const sizes = new Set(); 2552 + for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { 2553 + const fs = parseFloat(getComputedStyle(el).fontSize); 2554 + if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); 2555 + } 2556 + if (sizes.size >= 3) { 2557 + const sorted = [...sizes].sort((a, b) => a - b); 2558 + const ratio = sorted[sorted.length - 1] / sorted[0]; 2559 + if (ratio < 2.0) { 2560 + findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); 2561 + } 2562 + } 2563 + 2564 + return findings; 2565 + } 2566 + 2567 + function isCardLikeDOM(el) { 2568 + const tag = el.tagName.toLowerCase(); 2569 + if (SAFE_TAGS.has(tag) || ['input','select','textarea','img','video','canvas','picture'].includes(tag)) return false; 2570 + const style = getComputedStyle(el); 2571 + const cls = el.getAttribute('class') || ''; 2572 + const hasShadow = (style.boxShadow && style.boxShadow !== 'none') || /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls); 2573 + const hasBorder = /\bborder\b/.test(cls); 2574 + const hasRadius = parseFloat(style.borderRadius) > 0 || /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls); 2575 + const hasBg = (style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)') || /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls); 2576 + return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg); 2577 + } 2578 + 2579 + function checkLayout() { 2580 + const findings = []; 2581 + const flaggedEls = new Set(); 2582 + 2583 + for (const el of document.querySelectorAll('*')) { 2584 + if (!isCardLikeDOM(el) || flaggedEls.has(el)) continue; 2585 + const cls = el.getAttribute('class') || ''; 2586 + const style = getComputedStyle(el); 2587 + if (style.position === 'absolute' || style.position === 'fixed') continue; 2588 + if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue; 2589 + if ((el.textContent?.trim().length || 0) < 10) continue; 2590 + const rect = el.getBoundingClientRect(); 2591 + if (rect.width < 50 || rect.height < 30) continue; 2592 + 2593 + let parent = el.parentElement; 2594 + while (parent) { 2595 + if (isCardLikeDOM(parent)) { flaggedEls.add(el); break; } 2596 + parent = parent.parentElement; 2597 + } 2598 + } 2599 + 2600 + for (const el of flaggedEls) { 2601 + let isAncestor = false; 2602 + for (const other of flaggedEls) { 2603 + if (other !== el && el.contains(other)) { isAncestor = true; break; } 2604 + } 2605 + if (!isAncestor) findings.push({ type: 'nested-cards', detail: 'Card inside card', el }); 2606 + } 2607 + 2608 + return findings; 2609 + } 2610 + 2611 + // Node page-level checks — take document/window as parameters 2612 + 2613 + function checkPageTypography(doc, win) { 2614 + const findings = []; 2615 + 2616 + const fonts = new Set(); 2617 + const overusedFound = new Set(); 2618 + 2619 + for (const sheet of doc.styleSheets) { 2620 + let rules; 2621 + try { rules = sheet.cssRules || sheet.rules; } catch { continue; } 2622 + if (!rules) continue; 2623 + for (const rule of rules) { 2624 + if (rule.type !== 1) continue; 2625 + const ff = rule.style?.fontFamily; 2626 + if (!ff) continue; 2627 + const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase()); 2628 + const primary = stack.find(f => f && !GENERIC_FONTS.has(f)); 2629 + if (primary) { 2630 + fonts.add(primary); 2631 + if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary); 2632 + } 2633 + } 2634 + } 2635 + 2636 + // Check Google Fonts links in HTML 2637 + const html = doc.documentElement?.outerHTML || ''; 2638 + const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi; 2639 + let m; 2640 + while ((m = gfRe.exec(html)) !== null) { 2641 + const families = m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase()); 2642 + for (const f of families) { 2643 + fonts.add(f); 2644 + if (OVERUSED_FONTS.has(f)) overusedFound.add(f); 2645 + } 2646 + } 2647 + 2648 + // Also parse raw HTML/style content for font-family (jsdom may not expose all via CSSOM) 2649 + const ffRe = /font-family\s*:\s*([^;}]+)/gi; 2650 + let fm; 2651 + while ((fm = ffRe.exec(html)) !== null) { 2652 + for (const f of fm[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) { 2653 + if (f && !GENERIC_FONTS.has(f)) { 2654 + fonts.add(f); 2655 + if (OVERUSED_FONTS.has(f)) overusedFound.add(f); 2656 + } 2657 + } 2658 + } 2659 + 2660 + for (const font of overusedFound) { 2661 + findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); 2662 + } 2663 + 2664 + // Single font 2665 + if (fonts.size === 1) { 2666 + const els = doc.querySelectorAll('*'); 2667 + if (els.length >= 20) { 2668 + findings.push({ id: 'single-font', snippet: `only font used is ${[...fonts][0]}` }); 2669 + } 2670 + } 2671 + 2672 + // Flat type hierarchy 2673 + const sizes = new Set(); 2674 + const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div'); 2675 + for (const el of textEls) { 2676 + const fontSize = parseFloat(win.getComputedStyle(el).fontSize); 2677 + // Filter out sub-8px values (jsdom doesn't resolve relative units properly) 2678 + if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); 2679 + } 2680 + if (sizes.size >= 3) { 2681 + const sorted = [...sizes].sort((a, b) => a - b); 2682 + const ratio = sorted[sorted.length - 1] / sorted[0]; 2683 + if (ratio < 2.0) { 2684 + findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); 2685 + } 2686 + } 2687 + 2688 + return findings; 2689 + } 2690 + 2691 + function isCardLike(el, win) { 2692 + const tag = el.tagName.toLowerCase(); 2693 + if (SAFE_TAGS.has(tag) || ['input', 'select', 'textarea', 'img', 'video', 'canvas', 'picture'].includes(tag)) return false; 2694 + 2695 + const style = win.getComputedStyle(el); 2696 + const rawStyle = el.getAttribute?.('style') || ''; 2697 + const cls = el.getAttribute?.('class') || ''; 2698 + 2699 + const hasShadow = (style.boxShadow && style.boxShadow !== 'none') || 2700 + /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls) || /box-shadow/i.test(rawStyle); 2701 + const hasBorder = /\bborder\b/.test(cls); 2702 + const widthPx = parseFloat(style.width) || 0; 2703 + const hasRadius = resolveBorderRadiusPx(el, style, widthPx, win) > 0 || 2704 + /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls) || /border-radius/i.test(rawStyle); 2705 + const hasBg = /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls) || 2706 + /background(?:-color)?\s*:\s*(?!transparent)/i.test(rawStyle); 2707 + 2708 + return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg); 2709 + } 2710 + 2711 + function checkPageLayout(doc, win) { 2712 + const findings = []; 2713 + 2714 + // Nested cards 2715 + const allEls = doc.querySelectorAll('*'); 2716 + const flaggedEls = new Set(); 2717 + for (const el of allEls) { 2718 + if (!isCardLike(el, win)) continue; 2719 + if (flaggedEls.has(el)) continue; 2720 + 2721 + const tag = el.tagName.toLowerCase(); 2722 + const cls = el.getAttribute?.('class') || ''; 2723 + const rawStyle = el.getAttribute?.('style') || ''; 2724 + 2725 + if (['pre', 'code'].includes(tag)) continue; 2726 + if (/\b(?:absolute|fixed)\b/.test(cls) || /position\s*:\s*(?:absolute|fixed)/i.test(rawStyle)) continue; 2727 + if ((el.textContent?.trim().length || 0) < 10) continue; 2728 + if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue; 2729 + 2730 + // Walk up to find card-like ancestor 2731 + let parent = el.parentElement; 2732 + while (parent) { 2733 + if (isCardLike(parent, win)) { 2734 + flaggedEls.add(el); 2735 + break; 2736 + } 2737 + parent = parent.parentElement; 2738 + } 2739 + } 2740 + 2741 + // Only report innermost nested cards 2742 + for (const el of flaggedEls) { 2743 + let isAncestorOfFlagged = false; 2744 + for (const other of flaggedEls) { 2745 + if (other !== el && el.contains(other)) { 2746 + isAncestorOfFlagged = true; 2747 + break; 2748 + } 2749 + } 2750 + if (!isAncestorOfFlagged) { 2751 + findings.push({ id: 'nested-cards', snippet: `Card inside card (${el.tagName.toLowerCase()})` }); 2752 + } 2753 + } 2754 + 2755 + return findings; 2756 + } 2757 + 2758 + // ─── Cream / beige palette (the default "tasteful" AI surface) ──────────────── 2759 + // A warm, lightly-tinted off-white page background — light, with R≥G≥B and a 2760 + // small warm tint (not white, not a strong color). The current reflex surface. 2761 + function isCreamColor(rgb) { 2762 + if (!rgb) return false; 2763 + const { r, g, b } = rgb; 2764 + if (Math.min(r, g, b) < 209) return false; // must be light 2765 + if (!(r >= g && g >= b)) return false; // warm ordering 2766 + const warmth = r - b; 2767 + return warmth >= 6 && warmth <= 48; // tinted, not white, not strong 2768 + } 2769 + 2770 + // Tailwind background utilities that render as a warm off-white surface. The 2771 + // static engine doesn't fetch Tailwind's CSS, so a `bg-amber-50` on <body> 2772 + // resolves to nothing in computed style — catch it from the class list 2773 + // instead. Candidate tokens map to their actual Tailwind hex and are still 2774 + // filtered through isCreamColor, so neutral grays (stone) and over-saturated 2775 + // shades drop out on their own. 2776 + const TAILWIND_BG_HEX = { 2777 + 'bg-amber-50': '#fffbeb', 'bg-amber-100': '#fef3c7', 2778 + 'bg-orange-50': '#fff7ed', 'bg-orange-100': '#ffedd5', 2779 + 'bg-yellow-50': '#fefce8', 2780 + 'bg-stone-50': '#fafaf9', 'bg-stone-100': '#f5f5f4', 'bg-stone-200': '#e7e5e4', 2781 + }; 2782 + 2783 + function creamFromClassList(cls) { 2784 + if (!cls) return null; 2785 + // Arbitrary value: bg-[#f5f0e6] / bg-[rgb(245_240_230)] (underscores = spaces). 2786 + const arb = cls.match(/\bbg-\[([^\]]+)\]/); 2787 + if (arb && isCreamColor(parseAnyColor(arb[1].replace(/_/g, ' ')))) return `bg-[${arb[1]}]`; 2788 + // Named warm-light utilities. 2789 + for (const [tok, hex] of Object.entries(TAILWIND_BG_HEX)) { 2790 + if (new RegExp(`(^|\\s)${tok}($|\\s)`).test(cls) && isCreamColor(parseAnyColor(hex))) return tok; 2791 + } 2792 + return null; 2793 + } 2794 + 2795 + function checkCreamPalette(doc, win) { 2796 + const findings = []; 2797 + const body = doc.body || (doc.querySelector ? doc.querySelector('body') : null); 2798 + if (!body) return findings; 2799 + const html = doc.documentElement; 2800 + const getCS = (el) => (win ? win.getComputedStyle(el) : getComputedStyle(el)); 2801 + 2802 + // 1. Computed background — covers inline / <style> / linked CSS, and Tailwind 2803 + // once it's actually rendered (browser path). 2804 + let bg = readOwnBackgroundColor(body, getCS(body)); 2805 + if (!bg || bg.a === 0) { 2806 + if (html) bg = readOwnBackgroundColor(html, getCS(html)); 2807 + } 2808 + if (isCreamColor(bg)) { 2809 + findings.push({ id: 'cream-palette', snippet: `cream/beige page background rgb(${bg.r}, ${bg.g}, ${bg.b})` }); 2810 + return findings; 2811 + } 2812 + 2813 + // 2. Tailwind class fallback — for the static path, where utility classes 2814 + // never resolve to computed CSS. 2815 + for (const el of [body, html]) { 2816 + const tok = creamFromClassList(el && el.getAttribute ? el.getAttribute('class') : ''); 2817 + if (tok) { 2818 + findings.push({ id: 'cream-palette', snippet: `cream/beige page background (Tailwind ${tok})` }); 2819 + break; 2820 + } 2821 + } 2822 + return findings; 2823 + } 2824 + 2825 + // ─── Oversized hero headline ──────────────────────────────────────────────── 2826 + // Fires when a *long* headline is set at display size and actually dominates 2827 + // the viewport. A punchy one- or two-word headline at the same size is a 2828 + // legitimate stylistic choice, and a large-but-contained two-line hero should 2829 + // pass too — length and viewport share together are the tell. 2830 + const OVERSIZED_H1_FONT_PX = 72; 2831 + const OVERSIZED_H1_MIN_CHARS = 40; 2832 + const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28; 2833 + const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25; 2834 + function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) { 2835 + if (tag !== 'h1') return []; 2836 + const textLen = headingText.length; 2837 + if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) { 2838 + let viewportDetail = ''; 2839 + if (rect && viewportWidth > 0 && viewportHeight > 0) { 2840 + const heightRatio = rect.height / viewportHeight; 2841 + const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight); 2842 + const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO 2843 + || areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO; 2844 + if (!dominatesViewport) return []; 2845 + viewportDetail = `, ${Math.round(heightRatio * 100)}vh`; 2846 + } 2847 + return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }]; 2848 + } 2849 + return []; 2850 + } 2851 + 2852 + function checkElementOversizedH1(el, style, tag, window) { 2853 + if (tag !== 'h1') return []; 2854 + const fontSize = resolveFontSizePx(el, window); 2855 + const headingText = (el.textContent || '').trim().replace(/\s+/g, ' '); 2856 + return checkOversizedH1({ tag, fontSize, headingText }); 2857 + } 2858 + 2859 + function checkElementOversizedH1DOM(el) { 2860 + const tag = el.tagName.toLowerCase(); 2861 + if (tag !== 'h1') return []; 2862 + const style = getComputedStyle(el); 2863 + const fontSize = parseFloat(style.fontSize) || 0; 2864 + const headingText = (el.textContent || '').trim().replace(/\s+/g, ' '); 2865 + const rect = el.getBoundingClientRect(); 2866 + const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0; 2867 + const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0; 2868 + return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight }); 2869 + } 2870 + 2871 + // ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ──────────── 2872 + const CSS_COLOR_TOKEN_RE = /(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\([^)]*\)|#[0-9a-fA-F]{3,8}\b|\b(?:black|white|transparent|currentcolor)\b/gi; 2873 + 2874 + function shadowLayerAlpha(layer) { 2875 + CSS_COLOR_TOKEN_RE.lastIndex = 0; 2876 + const match = CSS_COLOR_TOKEN_RE.exec(layer); 2877 + if (!match) return 1; 2878 + if (match[0].toLowerCase() === 'transparent') return 0; 2879 + const parsed = parseAnyColor(match[0]); 2880 + return parsed ? (parsed.a ?? 1) : 1; 2881 + } 2882 + 2883 + function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) { 2884 + if (!boxShadow || boxShadow === 'none') return 0; 2885 + let maxBlur = 0; 2886 + // Split into layers on commas not inside parentheses (rgba(...) etc.). 2887 + for (const layer of boxShadow.split(/,(?![^()]*\))/)) { 2888 + if (shadowLayerAlpha(layer) < minAlpha) continue; 2889 + // Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the 2890 + // ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps 2891 + // unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") — 2892 + // both reduce to the same numbers here. 2893 + const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' '); 2894 + const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0])); 2895 + if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]); 2896 + } 2897 + return maxBlur; 2898 + } 2899 + 2900 + function cssColorAlpha(value) { 2901 + if (cssColorIsTransparent(value)) return 0; 2902 + const parsed = parseAnyColor(value); 2903 + return parsed ? (parsed.a ?? 1) : 1; 2904 + } 2905 + 2906 + function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) { 2907 + const visibleThinBorders = borderWidths 2908 + .map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') })) 2909 + .filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28); 2910 + const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width)); 2911 + const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 }); 2912 + if (visibleThinBorders.length >= 2 && blur >= 16) { 2913 + return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }]; 2914 + } 2915 + return []; 2916 + } 2917 + 2918 + function borderWidthsFromStyle(style) { 2919 + return [ 2920 + parseFloat(style.borderTopWidth) || 0, 2921 + parseFloat(style.borderRightWidth) || 0, 2922 + parseFloat(style.borderBottomWidth) || 0, 2923 + parseFloat(style.borderLeftWidth) || 0, 2924 + ]; 2925 + } 2926 + 2927 + function borderColorsFromStyle(style) { 2928 + return [ 2929 + style.borderTopColor || '', 2930 + style.borderRightColor || '', 2931 + style.borderBottomColor || '', 2932 + style.borderLeftColor || '', 2933 + ]; 2934 + } 2935 + 2936 + function checkElementGptBorderShadow(el, style) { 2937 + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); 2938 + } 2939 + 2940 + function checkElementGptBorderShadowDOM(el) { 2941 + const style = getComputedStyle(el); 2942 + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); 2943 + } 2944 + 2945 + // ─── Clipped overflow container ─────────────────────────────────────────────── 2946 + // A clipping container (overflow hidden/clip, not a scroll region) wrapping an 2947 + // absolutely/fixed-positioned descendant clips popovers/menus that must escape. 2948 + function classSelector(el) { 2949 + const cls = (el.getAttribute ? el.getAttribute('class') : el.className) || ''; 2950 + const tokens = String(cls).trim().split(/\s+/).filter(Boolean); 2951 + const tag = el.tagName ? el.tagName.toLowerCase() : 'el'; 2952 + return tokens.length ? `${tag}.${tokens.join('.')}` : tag; 2953 + } 2954 + 2955 + function positionedChildIsDecorative(child) { 2956 + if (!child || typeof child.getAttribute !== 'function') return false; 2957 + if (child.closest?.('[aria-hidden="true"]')) return true; 2958 + const role = (child.getAttribute('role') || '').toLowerCase(); 2959 + if (role === 'none' || role === 'presentation') return true; 2960 + const tag = child.tagName ? child.tagName.toLowerCase() : ''; 2961 + if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true; 2962 + const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`; 2963 + if ( 2964 + /\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) && 2965 + !positionedChildHasSubstantiveContent(child) 2966 + ) { 2967 + return true; 2968 + } 2969 + return false; 2970 + } 2971 + 2972 + const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [ 2973 + 'a[href]', 2974 + 'button', 2975 + 'input', 2976 + 'select', 2977 + 'summary', 2978 + 'textarea', 2979 + '[tabindex]:not([tabindex="-1"])', 2980 + '[role="button"]', 2981 + '[role="dialog"]', 2982 + '[role="link"]', 2983 + '[role="listbox"]', 2984 + '[role="menu"]', 2985 + '[role="menuitem"]', 2986 + '[role="option"]', 2987 + '[role="tooltip"]', 2988 + ].join(','); 2989 + 2990 + function positionedChildHasSubstantiveContent(child) { 2991 + const text = (child.textContent || '').replace(/\s+/g, ' ').trim(); 2992 + if (text.length > 0) return true; 2993 + if (typeof child.matches === 'function') { 2994 + try { 2995 + if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; 2996 + } catch {} 2997 + } 2998 + if (typeof child.querySelector === 'function') { 2999 + try { 3000 + if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; 3001 + } catch {} 3002 + } 3003 + return false; 3004 + } 3005 + 3006 + function clippingContainerIsIntentionalViewport(el) { 3007 + if (!el || typeof el.getAttribute !== 'function') return false; 3008 + const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase(); 3009 + if (/\b(carousel|slider)\b/.test(roleDescription)) return true; 3010 + const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase(); 3011 + return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) || 3012 + /\b(demo-area|demo-stage|demo-viewport)\b/.test(ident); 3013 + } 3014 + 3015 + function elementRect(el) { 3016 + if (!el || typeof el.getBoundingClientRect !== 'function') return null; 3017 + try { 3018 + const rect = el.getBoundingClientRect(); 3019 + if (!rect) return null; 3020 + const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height]; 3021 + if (!values.every(Number.isFinite)) return null; 3022 + if (rect.width <= 0 && rect.height <= 0) return null; 3023 + return rect; 3024 + } catch { 3025 + return null; 3026 + } 3027 + } 3028 + 3029 + function positionedStyleImpliesEscape(style) { 3030 + const values = [ 3031 + style.top, 3032 + style.right, 3033 + style.bottom, 3034 + style.left, 3035 + style.inset, 3036 + style.insetBlock, 3037 + style.insetInline, 3038 + style.insetBlockStart, 3039 + style.insetBlockEnd, 3040 + style.insetInlineStart, 3041 + style.insetInlineEnd, 3042 + ].filter(Boolean).map(value => String(value).trim().toLowerCase()); 3043 + for (const value of values) { 3044 + if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true; 3045 + if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true; 3046 + } 3047 + return false; 3048 + } 3049 + 3050 + function positionedChildEscapesClip(el, child, clipX, clipY) { 3051 + const parentRect = elementRect(el); 3052 + const childRect = elementRect(child); 3053 + if (!parentRect || !childRect) return null; 3054 + const threshold = 2; 3055 + return Boolean( 3056 + (clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) || 3057 + (clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold)) 3058 + ); 3059 + } 3060 + 3061 + function checkClippedOverflow(el, style, getStyle) { 3062 + const clips = (v) => v === 'hidden' || v === 'clip'; 3063 + const scrolls = (v) => v === 'auto' || v === 'scroll'; 3064 + const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || ''; 3065 + const clipX = clips(ox) || clips(ov); 3066 + const clipY = clips(oy) || clips(ov); 3067 + const anyClip = clipX || clipY; 3068 + const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov); 3069 + if (!anyClip || anyScroll) return []; 3070 + if (clippingContainerIsIntentionalViewport(el)) return []; 3071 + if (!el.querySelectorAll) return []; 3072 + for (const child of el.querySelectorAll('*')) { 3073 + const childStyle = getStyle(child); 3074 + const pos = childStyle.position || ''; 3075 + if (pos === 'absolute' || pos === 'fixed') { 3076 + if (positionedChildIsDecorative(child)) continue; 3077 + const escapes = positionedChildEscapesClip(el, child, clipX, clipY); 3078 + if (escapes === false) continue; 3079 + if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue; 3080 + return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }]; 3081 + } 3082 + } 3083 + return []; 3084 + } 3085 + 3086 + function checkElementClippedOverflow(el, style, tag, window) { 3087 + return checkClippedOverflow(el, style, (n) => window.getComputedStyle(n)); 3088 + } 3089 + 3090 + function checkElementClippedOverflowDOM(el) { 3091 + const style = getComputedStyle(el); 3092 + return checkClippedOverflow(el, style, (n) => getComputedStyle(n)); 3093 + } 3094 + 3095 + // ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ────────────── 3096 + const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']); 3097 + 3098 + function metricLengthPx(value, fontSizePx = 16) { 3099 + if (typeof value === 'number' && Number.isFinite(value)) return value; 3100 + if (typeof value !== 'string') return null; 3101 + return resolveLengthPx(value, fontSizePx); 3102 + } 3103 + 3104 + function firstMetricLengthPx(fontSizePx, ...values) { 3105 + for (const value of values) { 3106 + const parsed = metricLengthPx(value, fontSizePx); 3107 + if (parsed !== null) return parsed; 3108 + } 3109 + return null; 3110 + } 3111 + 3112 + function expandBoxShorthand(parts) { 3113 + if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]]; 3114 + if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]]; 3115 + if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]]; 3116 + return [parts[0], parts[1], parts[2], parts[3]]; 3117 + } 3118 + 3119 + function clippedByInset(clipPath) { 3120 + const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/); 3121 + if (!match) return false; 3122 + const beforeRound = match[1].split(/\s+round\s+/)[0].trim(); 3123 + if (!beforeRound) return false; 3124 + const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4)); 3125 + const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/)); 3126 + if (percents.some(match => !match)) return false; 3127 + const [top, right, bottom, left] = percents.map(match => parseFloat(match[1])); 3128 + return top + bottom >= 100 || left + right >= 100; 3129 + } 3130 + 3131 + function clippedByRect(clip) { 3132 + const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/); 3133 + if (!match) return false; 3134 + const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean); 3135 + if (values.length !== 4) return false; 3136 + const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16)); 3137 + if ([top, right, bottom, left].some(value => value === null)) return false; 3138 + return bottom <= top || right <= left; 3139 + } 3140 + 3141 + function isScreenReaderOnlyTextStyle(style, metrics = {}) { 3142 + if (!style) return false; 3143 + const overflowValues = [style.overflow, style.overflowX, style.overflowY] 3144 + .map(value => String(value || '').toLowerCase()); 3145 + const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip'); 3146 + 3147 + const fontSize = metricLengthPx(style.fontSize, 16) || 16; 3148 + const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize); 3149 + const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize); 3150 + const isTiny = width !== null && height !== null && width <= 2 && height <= 2; 3151 + const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow; 3152 + 3153 + const clipPath = String(style.clipPath || style.webkitClipPath || '').trim(); 3154 + const clip = String(style.clip || '').trim(); 3155 + return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); 3156 + } 3157 + 3158 + function isRenderedForBrowserRule(el) { 3159 + for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) { 3160 + if (cur.getAttribute?.('aria-hidden') === 'true') return false; 3161 + const style = getComputedStyle(cur); 3162 + const visibility = String(style.visibility || '').toLowerCase(); 3163 + if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false; 3164 + if ((parseFloat(style.opacity) || 0) <= 0.01) return false; 3165 + if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false; 3166 + } 3167 + return true; 3168 + } 3169 + 3170 + function checkElementTextOverflowDOM(el) { 3171 + const tag = el.tagName.toLowerCase(); 3172 + if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; 3173 + if (!isRenderedForBrowserRule(el)) return []; 3174 + // Only the element that actually owns overflowing text — not its ancestors, 3175 + // which inherit a wider scrollWidth from the spilling descendant. 3176 + const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); 3177 + if (!hasDirectText) return []; 3178 + const style = getComputedStyle(el); 3179 + const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null; 3180 + if (isScreenReaderOnlyTextStyle(style, { 3181 + width: rect?.width, 3182 + height: rect?.height, 3183 + clientWidth: el.clientWidth, 3184 + clientHeight: el.clientHeight, 3185 + })) return []; 3186 + const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || ''); 3187 + if (isScrollRegion(style)) return []; 3188 + // A scrollable ancestor means this overflow is intentional and scrollable. 3189 + for (let p = el.parentElement; p; p = p.parentElement) { 3190 + if (isScrollRegion(getComputedStyle(p))) return []; 3191 + } 3192 + const delta = el.scrollWidth - el.clientWidth; 3193 + if (el.clientWidth > 0 && delta >= 16) { 3194 + return [{ id: 'text-overflow', snippet: `${classSelector(el)} overflows its box by ${Math.round(delta)}px` }]; 3195 + } 3196 + return []; 3197 + } 3198 + 3199 + // --- cli/engine/browser/injected/index.mjs --- 3200 + const IS_BROWSER = typeof window !== 'undefined'; 3201 + 3202 + // ─── Section 7: Browser UI (IS_BROWSER only) ──────────────────────────────── 3203 + 3204 + if (IS_BROWSER) { 3205 + // Detect extension mode via the script tag's data attribute or the document element fallback. 3206 + // currentScript is reliable for synchronously-executing scripts (which our IIFE is). 3207 + const _myScript = document.currentScript; 3208 + const EXTENSION_MODE = (_myScript && _myScript.dataset.impeccableExtension === 'true') 3209 + || document.documentElement.dataset.impeccableExtension === 'true'; 3210 + 3211 + // Kinpaku gold — pinned to the site's brand token (see 3212 + // site/styles/kinpaku-tokens.css --ks-kinpaku). Keep this in sync with 3213 + // the picker's C.brand in skill/scripts/live-browser.js and the kit's 3214 + // picker section in site/styles/kinpaku-kit.css. 3215 + // 3216 + // One color across both light and dark host pages. The outline is a 3217 + // 2px gesture pointing at an element + a labeled tag — it's a marker, 3218 + // not body text, so it doesn't need WCAG AA against the page. The 3219 + // label text inside the gold tag is dark (LABEL_INK) which has ~16:1 3220 + // against the leaf gold, so reading the rule name is solid in both 3221 + // modes. Hover deepens the gold (preserves chroma — never drops it, 3222 + // dropping chroma washes the gold into a sand/olive tone). 3223 + const BRAND_COLOR = 'oklch(84% 0.19 80.46)'; 3224 + const BRAND_COLOR_HOVER = 'oklch(74% 0.18 80)'; 3225 + const LABEL_INK = 'oklch(4% 0.004 95)'; 3226 + const LABEL_BG = BRAND_COLOR; 3227 + const OUTLINE_COLOR = BRAND_COLOR; 3228 + 3229 + // Inject hover styles via CSS (more reliable than JS event listeners) 3230 + const styleEl = document.createElement('style'); 3231 + styleEl.textContent = ` 3232 + @keyframes impeccable-reveal { 3233 + from { opacity: 0; } 3234 + to { opacity: 1; } 3235 + } 3236 + .impeccable-overlay:not(.impeccable-banner) { 3237 + pointer-events: none; 3238 + outline: 2px solid ${OUTLINE_COLOR}; 3239 + border-radius: 4px; 3240 + transition: outline-color 0.15s ease; 3241 + animation: impeccable-reveal 0.4s cubic-bezier(0.16, 1, 0.3, 1) both; 3242 + animation-play-state: paused; 3243 + border-top-left-radius: 0; 3244 + } 3245 + .impeccable-overlay.impeccable-visible { 3246 + animation-play-state: running; 3247 + } 3248 + .impeccable-overlay.impeccable-hover { 3249 + outline-color: ${BRAND_COLOR_HOVER}; 3250 + z-index: 100001 !important; 3251 + } 3252 + .impeccable-overlay.impeccable-hover .impeccable-label { 3253 + background: ${BRAND_COLOR_HOVER}; 3254 + } 3255 + .impeccable-overlay.impeccable-spotlight { 3256 + z-index: 100002 !important; 3257 + } 3258 + .impeccable-overlay.impeccable-spotlight-dimmed { 3259 + opacity: 0.15 !important; 3260 + animation: none !important; 3261 + filter: blur(3px); 3262 + } 3263 + .impeccable-spotlight-backdrop { 3264 + position: fixed; 3265 + top: 0; left: 0; right: 0; bottom: 0; 3266 + backdrop-filter: blur(3px) brightness(0.6); 3267 + -webkit-backdrop-filter: blur(3px) brightness(0.6); 3268 + pointer-events: none; 3269 + z-index: 99998; 3270 + opacity: 0; 3271 + outline: none !important; 3272 + animation: none !important; 3273 + } 3274 + .impeccable-spotlight-backdrop.impeccable-visible { 3275 + opacity: 1; 3276 + } 3277 + .impeccable-hidden .impeccable-overlay${EXTENSION_MODE ? '' : ':not(.impeccable-banner)'} { 3278 + display: none !important; 3279 + } 3280 + `; 3281 + (document.head || document.documentElement).appendChild(styleEl); 3282 + 3283 + // Spotlight backdrop element (created lazily on first use) 3284 + let spotlightBackdrop = null; 3285 + let spotlightTarget = null; 3286 + 3287 + function getSpotlightBackdrop() { 3288 + if (!spotlightBackdrop) { 3289 + spotlightBackdrop = document.createElement('div'); 3290 + spotlightBackdrop.className = 'impeccable-spotlight-backdrop'; 3291 + document.body.appendChild(spotlightBackdrop); 3292 + } 3293 + return spotlightBackdrop; 3294 + } 3295 + 3296 + function updateSpotlightClipPath() { 3297 + if (!spotlightBackdrop || !spotlightTarget) return; 3298 + const r = spotlightTarget.getBoundingClientRect(); 3299 + // Match the overlay's outer edge: element rect + 4px (2px overlay offset + 2px outline width) 3300 + const inset = 4; 3301 + const radius = 6; // outline border-radius (4) + outline width (2) 3302 + const x1 = r.left - inset; 3303 + const y1 = r.top - inset; 3304 + const x2 = r.right + inset; 3305 + const y2 = r.bottom + inset; 3306 + const vw = window.innerWidth; 3307 + const vh = window.innerHeight; 3308 + // Outer rect + rounded inner rect (evenodd creates a hole) 3309 + const path = `M0 0H${vw}V${vh}H0Z M${x1 + radius} ${y1}H${x2 - radius}A${radius} ${radius} 0 0 1 ${x2} ${y1 + radius}V${y2 - radius}A${radius} ${radius} 0 0 1 ${x2 - radius} ${y2}H${x1 + radius}A${radius} ${radius} 0 0 1 ${x1} ${y2 - radius}V${y1 + radius}A${radius} ${radius} 0 0 1 ${x1 + radius} ${y1}Z`; 3310 + spotlightBackdrop.style.clipPath = `path(evenodd, "${path}")`; 3311 + } 3312 + 3313 + function showSpotlight(target) { 3314 + if (!target || !target.getBoundingClientRect) return; 3315 + // Respect the spotlightBlur setting: if disabled, don't show the backdrop 3316 + if (window.__IMPECCABLE_CONFIG__?.spotlightBlur === false) { 3317 + spotlightTarget = target; 3318 + return; 3319 + } 3320 + spotlightTarget = target; 3321 + const bd = getSpotlightBackdrop(); 3322 + updateSpotlightClipPath(); 3323 + bd.classList.add('impeccable-visible'); 3324 + } 3325 + 3326 + function hideSpotlight() { 3327 + spotlightTarget = null; 3328 + if (spotlightBackdrop) spotlightBackdrop.classList.remove('impeccable-visible'); 3329 + } 3330 + 3331 + function isInViewport(el) { 3332 + const r = el.getBoundingClientRect(); 3333 + return r.top >= 0 && r.left >= 0 && r.bottom <= window.innerHeight && r.right <= window.innerWidth; 3334 + } 3335 + 3336 + // Reposition spotlight on scroll/resize 3337 + window.addEventListener('scroll', () => { 3338 + if (spotlightTarget) updateSpotlightClipPath(); 3339 + }, { passive: true }); 3340 + window.addEventListener('resize', () => { 3341 + if (spotlightTarget) updateSpotlightClipPath(); 3342 + }); 3343 + 3344 + const overlays = []; 3345 + const TYPE_LABELS = {}; 3346 + const RULE_CATEGORY = {}; 3347 + for (const ap of ANTIPATTERNS) { 3348 + TYPE_LABELS[ap.id] = ap.name.toLowerCase(); 3349 + RULE_CATEGORY[ap.id] = ap.category || 'quality'; 3350 + } 3351 + 3352 + function isInFixedContext(el) { 3353 + let p = el; 3354 + while (p && p !== document.body) { 3355 + if (getComputedStyle(p).position === 'fixed') return true; 3356 + p = p.parentElement; 3357 + } 3358 + return false; 3359 + } 3360 + 3361 + function positionOverlay(overlay) { 3362 + const el = overlay._targetEl; 3363 + if (!el) return; 3364 + const rect = el.getBoundingClientRect(); 3365 + if (overlay._isFixed) { 3366 + // Viewport-relative coords for fixed targets 3367 + overlay.style.top = `${rect.top - 2}px`; 3368 + overlay.style.left = `${rect.left - 2}px`; 3369 + } else { 3370 + // Document-relative coords for normal targets 3371 + overlay.style.top = `${rect.top + scrollY - 2}px`; 3372 + overlay.style.left = `${rect.left + scrollX - 2}px`; 3373 + } 3374 + overlay.style.width = `${rect.width + 4}px`; 3375 + overlay.style.height = `${rect.height + 4}px`; 3376 + } 3377 + 3378 + function repositionOverlays() { 3379 + for (const o of overlays) { 3380 + if (!o._targetEl || o.classList.contains('impeccable-banner')) continue; 3381 + // Skip overlays whose target is currently hidden (display: none on the overlay) 3382 + if (o.style.display === 'none') continue; 3383 + positionOverlay(o); 3384 + } 3385 + } 3386 + 3387 + let resizeRAF; 3388 + const onResize = () => { 3389 + cancelAnimationFrame(resizeRAF); 3390 + resizeRAF = requestAnimationFrame(repositionOverlays); 3391 + }; 3392 + window.addEventListener('resize', onResize); 3393 + // Reposition on scroll too -- catches sticky/parallax shifts 3394 + window.addEventListener('scroll', onResize, { passive: true }); 3395 + // Reposition when body resizes (lazy-loaded images, dynamic content, fonts loading) 3396 + if (typeof ResizeObserver !== 'undefined') { 3397 + const bodyResizeObserver = new ResizeObserver(onResize); 3398 + bodyResizeObserver.observe(document.body); 3399 + } 3400 + 3401 + // Track target element visibility via IntersectionObserver. 3402 + // Uses a huge rootMargin so all *rendered* elements count as intersecting, 3403 + // while display:none / closed <details> / hidden modals etc. do not. 3404 + // This is event-driven -- no polling needed. 3405 + let overlayIndex = 0; 3406 + const visibilityObserver = new IntersectionObserver((entries) => { 3407 + for (const entry of entries) { 3408 + const overlay = entry.target._impeccableOverlay; 3409 + if (!overlay) continue; 3410 + if (entry.isIntersecting) { 3411 + overlay.style.display = ''; 3412 + positionOverlay(overlay); 3413 + if (!overlay._revealed) { 3414 + overlay._revealed = true; 3415 + if (firstScanDone) { 3416 + // Subsequent reveals (re-scans, scroll-into-view): instant, no animation 3417 + overlay.style.animation = 'none'; 3418 + } else { 3419 + // Initial scan: staggered cascade reveal 3420 + overlay.style.animationDelay = `${Math.min((overlay._staggerIndex || 0) * 60, 600)}ms`; 3421 + } 3422 + requestAnimationFrame(() => { 3423 + overlay.classList.add('impeccable-visible'); 3424 + if (overlay._checkLabel) overlay._checkLabel(); 3425 + }); 3426 + } 3427 + } else { 3428 + overlay.style.display = 'none'; 3429 + } 3430 + } 3431 + }, { rootMargin: '99999px' }); 3432 + 3433 + function detachOverlay(overlay) { 3434 + if (!overlay) return; 3435 + if (typeof overlay._cleanup === 'function') { 3436 + try { overlay._cleanup(); } catch { /* best effort overlay teardown */ } 3437 + } 3438 + if (overlay._targetEl && overlay._targetEl._impeccableOverlay === overlay) { 3439 + visibilityObserver.unobserve(overlay._targetEl); 3440 + delete overlay._targetEl._impeccableOverlay; 3441 + } 3442 + const idx = overlays.indexOf(overlay); 3443 + if (idx >= 0) overlays.splice(idx, 1); 3444 + overlay.remove(); 3445 + } 3446 + 3447 + // Reposition overlays after CSS transitions end (e.g. reveal animations). 3448 + // Listens at document level so it catches transitions on ancestor elements 3449 + // (the transform may be on a parent, not the flagged element itself). 3450 + document.addEventListener('transitionend', (e) => { 3451 + if (e.propertyName !== 'transform') return; 3452 + for (const o of overlays) { 3453 + if (!o._targetEl || o.classList.contains('impeccable-banner') || o.style.display === 'none') continue; 3454 + if (e.target === o._targetEl || e.target.contains(o._targetEl)) { 3455 + positionOverlay(o); 3456 + } 3457 + } 3458 + }); 3459 + 3460 + const highlight = function(el, findings) { 3461 + if (el._impeccableOverlay) detachOverlay(el._impeccableOverlay); 3462 + const hasSlop = findings.some(f => RULE_CATEGORY[f.type || f.id] === 'slop'); 3463 + 3464 + const fixed = isInFixedContext(el); 3465 + const rect = el.getBoundingClientRect(); 3466 + const outline = document.createElement('div'); 3467 + outline.className = 'impeccable-overlay'; 3468 + outline._targetEl = el; 3469 + outline._isFixed = fixed; 3470 + Object.assign(outline.style, { 3471 + position: fixed ? 'fixed' : 'absolute', 3472 + top: fixed ? `${rect.top - 2}px` : `${rect.top + scrollY - 2}px`, 3473 + left: fixed ? `${rect.left - 2}px` : `${rect.left + scrollX - 2}px`, 3474 + width: `${rect.width + 4}px`, height: `${rect.height + 4}px`, 3475 + zIndex: '99999', boxSizing: 'border-box', 3476 + }); 3477 + 3478 + // Build per-finding label entries: ✦ prefix for slop 3479 + const entries = findings.map(f => { 3480 + const name = TYPE_LABELS[f.type || f.id] || f.type || f.id; 3481 + const prefix = RULE_CATEGORY[f.type || f.id] === 'slop' ? '\u2726 ' : ''; 3482 + return { name: prefix + name, detail: f.detail || f.snippet }; 3483 + }); 3484 + const allText = entries.map(e => e.name).join(', '); 3485 + 3486 + const label = document.createElement('div'); 3487 + label.className = 'impeccable-label'; 3488 + Object.assign(label.style, { 3489 + position: 'absolute', bottom: '100%', left: '-2px', 3490 + display: 'flex', alignItems: 'center', 3491 + whiteSpace: 'nowrap', 3492 + fontSize: '11px', fontWeight: '600', letterSpacing: '0.02em', 3493 + color: LABEL_INK, lineHeight: '14px', 3494 + background: LABEL_BG, 3495 + fontFamily: 'system-ui, sans-serif', 3496 + borderRadius: '4px 4px 0 0', 3497 + }); 3498 + 3499 + const textSpan = document.createElement('span'); 3500 + textSpan.style.padding = '3px 8px'; 3501 + textSpan.textContent = allText; 3502 + label.appendChild(textSpan); 3503 + 3504 + // State for cycling mode 3505 + let cycleMode = false; 3506 + let cycleIndex = 0; 3507 + let isHovered = false; 3508 + let prevBtn, nextBtn; 3509 + 3510 + function updateCycleText() { 3511 + const e = entries[cycleIndex]; 3512 + textSpan.textContent = isHovered ? e.detail : e.name; 3513 + } 3514 + 3515 + function enableCycleMode() { 3516 + if (cycleMode || entries.length < 2) return; 3517 + cycleMode = true; 3518 + 3519 + const btnStyle = { 3520 + background: 'none', border: 'none', color: 'rgba(255,255,255,0.7)', 3521 + fontSize: '11px', cursor: 'pointer', padding: '3px 4px', 3522 + fontFamily: 'system-ui, sans-serif', lineHeight: '14px', 3523 + pointerEvents: 'auto', 3524 + }; 3525 + 3526 + const navGroup = document.createElement('span'); 3527 + Object.assign(navGroup.style, { 3528 + display: 'inline-flex', alignItems: 'center', flexShrink: '0', 3529 + }); 3530 + 3531 + prevBtn = document.createElement('button'); 3532 + prevBtn.textContent = '\u2039'; 3533 + Object.assign(prevBtn.style, btnStyle); 3534 + prevBtn.style.paddingLeft = '6px'; 3535 + prevBtn.addEventListener('click', (e) => { 3536 + e.stopPropagation(); 3537 + cycleIndex = (cycleIndex - 1 + entries.length) % entries.length; 3538 + updateCycleText(); 3539 + }); 3540 + 3541 + nextBtn = document.createElement('button'); 3542 + nextBtn.textContent = '\u203A'; 3543 + Object.assign(nextBtn.style, btnStyle); 3544 + nextBtn.style.paddingRight = '2px'; 3545 + nextBtn.addEventListener('click', (e) => { 3546 + e.stopPropagation(); 3547 + cycleIndex = (cycleIndex + 1) % entries.length; 3548 + updateCycleText(); 3549 + }); 3550 + 3551 + navGroup.appendChild(prevBtn); 3552 + navGroup.appendChild(nextBtn); 3553 + label.insertBefore(navGroup, textSpan); 3554 + textSpan.style.padding = '3px 8px 3px 4px'; 3555 + updateCycleText(); 3556 + } 3557 + 3558 + outline.appendChild(label); 3559 + 3560 + // Start hidden; the IntersectionObserver will show it once the target is rendered 3561 + outline.style.display = 'none'; 3562 + outline._staggerIndex = overlayIndex++; 3563 + el._impeccableOverlay = outline; 3564 + visibilityObserver.observe(el); 3565 + 3566 + // After first paint, check label width vs outline 3567 + outline._checkLabel = () => { 3568 + if (entries.length > 1 && label.offsetWidth > outline.offsetWidth) { 3569 + enableCycleMode(); 3570 + } 3571 + }; 3572 + 3573 + // Hover: show detail text, darken 3574 + const onMouseEnter = () => { 3575 + isHovered = true; 3576 + outline.classList.add('impeccable-hover'); 3577 + outline.style.outlineColor = BRAND_COLOR_HOVER; 3578 + label.style.background = BRAND_COLOR_HOVER; 3579 + if (cycleMode) { 3580 + updateCycleText(); 3581 + } else { 3582 + textSpan.textContent = entries.map(e => e.detail).join(' | '); 3583 + } 3584 + }; 3585 + const onMouseLeave = () => { 3586 + isHovered = false; 3587 + outline.classList.remove('impeccable-hover'); 3588 + outline.style.outlineColor = ''; 3589 + label.style.background = LABEL_BG; 3590 + if (cycleMode) { 3591 + updateCycleText(); 3592 + } else { 3593 + textSpan.textContent = allText; 3594 + } 3595 + }; 3596 + el.addEventListener('mouseenter', onMouseEnter); 3597 + el.addEventListener('mouseleave', onMouseLeave); 3598 + outline._cleanup = () => { 3599 + el.removeEventListener('mouseenter', onMouseEnter); 3600 + el.removeEventListener('mouseleave', onMouseLeave); 3601 + }; 3602 + 3603 + document.body.appendChild(outline); 3604 + overlays.push(outline); 3605 + }; 3606 + 3607 + const showPageBanner = function(findings) { 3608 + if (!findings.length) return; 3609 + const banner = document.createElement('div'); 3610 + banner.className = 'impeccable-overlay impeccable-banner'; 3611 + Object.assign(banner.style, { 3612 + position: 'fixed', top: '0', left: '0', right: '0', zIndex: '100000', 3613 + background: LABEL_BG, color: LABEL_INK, 3614 + fontFamily: 'system-ui, sans-serif', fontSize: '13px', 3615 + display: 'flex', alignItems: 'center', pointerEvents: 'auto', 3616 + height: '36px', overflow: 'hidden', maxWidth: '100vw', 3617 + transform: 'translateY(-100%)', 3618 + transition: 'transform 0.4s cubic-bezier(0.16, 1, 0.3, 1)', 3619 + }); 3620 + requestAnimationFrame(() => requestAnimationFrame(() => { 3621 + banner.style.transform = 'translateY(0)'; 3622 + })); 3623 + 3624 + // Scrollable findings area 3625 + const scrollArea = document.createElement('div'); 3626 + Object.assign(scrollArea.style, { 3627 + flex: '1', minWidth: '0', overflowX: 'auto', overflowY: 'hidden', 3628 + display: 'flex', gap: '8px', alignItems: 'center', 3629 + padding: '0 12px', scrollSnapType: 'x mandatory', 3630 + scrollbarWidth: 'none', 3631 + }); 3632 + for (const f of findings) { 3633 + const prefix = RULE_CATEGORY[f.type] === 'slop' ? '\u2726 ' : ''; 3634 + const tag = document.createElement('span'); 3635 + tag.textContent = `${prefix}${TYPE_LABELS[f.type] || f.type}: ${f.detail}`; 3636 + Object.assign(tag.style, { 3637 + background: 'rgba(255,255,255,0.15)', padding: '2px 8px', 3638 + borderRadius: '3px', fontSize: '12px', fontFamily: 'ui-monospace, monospace', 3639 + whiteSpace: 'nowrap', flexShrink: '0', scrollSnapAlign: 'start', 3640 + }); 3641 + scrollArea.appendChild(tag); 3642 + } 3643 + banner.appendChild(scrollArea); 3644 + 3645 + // Controls area (only in standalone mode, not extension) 3646 + if (!EXTENSION_MODE) { 3647 + const controls = document.createElement('div'); 3648 + Object.assign(controls.style, { 3649 + display: 'flex', alignItems: 'center', gap: '2px', 3650 + padding: '0 8px', flexShrink: '0', 3651 + }); 3652 + 3653 + // Toggle visibility button 3654 + const toggle = document.createElement('button'); 3655 + toggle.textContent = '\u25C9'; // circle with dot (visible state) 3656 + toggle.title = 'Toggle overlay visibility'; 3657 + Object.assign(toggle.style, { 3658 + background: 'none', border: 'none', 3659 + color: 'white', fontSize: '16px', cursor: 'pointer', padding: '0 4px', 3660 + opacity: '0.85', transition: 'opacity 0.15s', 3661 + }); 3662 + let overlaysVisible = true; 3663 + toggle.addEventListener('click', () => { 3664 + overlaysVisible = !overlaysVisible; 3665 + document.body.classList.toggle('impeccable-hidden', !overlaysVisible); 3666 + toggle.textContent = overlaysVisible ? '\u25C9' : '\u25CB'; // filled vs empty circle 3667 + toggle.style.opacity = overlaysVisible ? '0.85' : '0.5'; 3668 + }); 3669 + controls.appendChild(toggle); 3670 + 3671 + // Close button 3672 + const close = document.createElement('button'); 3673 + close.textContent = '\u00d7'; 3674 + close.title = 'Dismiss banner'; 3675 + Object.assign(close.style, { 3676 + background: 'none', border: 'none', 3677 + color: 'white', fontSize: '18px', cursor: 'pointer', padding: '0 4px', 3678 + }); 3679 + close.addEventListener('click', () => banner.remove()); 3680 + controls.appendChild(close); 3681 + 3682 + banner.appendChild(controls); 3683 + } 3684 + document.body.appendChild(banner); 3685 + overlays.push(banner); 3686 + }; 3687 + 3688 + // Heuristic for skipping CSS-in-JS hashed class names like "css-1a2b3c" or "_2x4hG_". 3689 + // These change between builds and produce brittle, ugly selectors. 3690 + function isLikelyHashedClass(c) { 3691 + if (!c) return true; 3692 + if (/^(css|sc|emotion|jsx|module)-[\w-]{4,}$/i.test(c)) return true; 3693 + if (/^_[\w-]{5,}$/.test(c)) return true; 3694 + if (/^[a-z0-9]{6,}$/i.test(c) && /\d/.test(c)) return true; 3695 + return false; 3696 + } 3697 + 3698 + function buildSelectorSegment(el) { 3699 + const tag = el.tagName.toLowerCase(); 3700 + let sel = tag; 3701 + 3702 + if (el.classList && el.classList.length > 0) { 3703 + const classes = [...el.classList] 3704 + .filter(c => !c.startsWith('impeccable-') && !isLikelyHashedClass(c)) 3705 + .slice(0, 2); 3706 + if (classes.length > 0) { 3707 + sel += '.' + classes.map(c => CSS.escape(c)).join('.'); 3708 + } 3709 + } 3710 + 3711 + // Disambiguate among siblings only if the parent has multiple matches 3712 + const parent = el.parentElement; 3713 + if (parent) { 3714 + try { 3715 + const matching = parent.querySelectorAll(':scope > ' + sel); 3716 + if (matching.length > 1) { 3717 + const sameType = [...parent.children].filter(c => c.tagName === el.tagName); 3718 + const idx = sameType.indexOf(el) + 1; 3719 + sel += `:nth-of-type(${idx})`; 3720 + } 3721 + } catch { 3722 + const idx = [...parent.children].indexOf(el) + 1; 3723 + sel = `${tag}:nth-child(${idx})`; 3724 + } 3725 + } 3726 + return sel; 3727 + } 3728 + 3729 + function generateSelector(el) { 3730 + if (el === document.body) return 'body'; 3731 + if (el === document.documentElement) return 'html'; 3732 + if (el.id) return '#' + CSS.escape(el.id); 3733 + 3734 + const parts = []; 3735 + let current = el; 3736 + let depth = 0; 3737 + const MAX_DEPTH = 10; 3738 + 3739 + while (current && current !== document.body && current !== document.documentElement && depth < MAX_DEPTH) { 3740 + parts.unshift(buildSelectorSegment(current)); 3741 + 3742 + // Anchor on an ancestor's ID and stop walking up 3743 + if (current.id) { 3744 + parts[0] = '#' + CSS.escape(current.id); 3745 + break; 3746 + } 3747 + 3748 + // Stop as soon as the partial selector uniquely identifies the target 3749 + const trySelector = parts.join(' > '); 3750 + try { 3751 + const matches = document.querySelectorAll(trySelector); 3752 + if (matches.length === 1 && matches[0] === el) { 3753 + return trySelector; 3754 + } 3755 + } catch { /* invalid selector — keep walking */ } 3756 + 3757 + current = current.parentElement; 3758 + depth++; 3759 + } 3760 + 3761 + return parts.join(' > '); 3762 + } 3763 + 3764 + function getDirectText(el) { 3765 + return [...el.childNodes] 3766 + .filter(n => n.nodeType === 3) 3767 + .map(n => n.textContent || '') 3768 + .join(''); 3769 + } 3770 + 3771 + function getDirectTextRect(el) { 3772 + const rects = []; 3773 + for (const node of el.childNodes) { 3774 + if (node.nodeType !== 3 || !(node.textContent || '').trim()) continue; 3775 + const range = document.createRange(); 3776 + range.selectNodeContents(node); 3777 + for (const rect of range.getClientRects()) { 3778 + if (rect.width >= 1 && rect.height >= 1) rects.push(rect); 3779 + } 3780 + range.detach?.(); 3781 + } 3782 + if (rects.length === 0) return null; 3783 + const left = Math.min(...rects.map(r => r.left)); 3784 + const top = Math.min(...rects.map(r => r.top)); 3785 + const right = Math.max(...rects.map(r => r.right)); 3786 + const bottom = Math.max(...rects.map(r => r.bottom)); 3787 + return { 3788 + left, 3789 + top, 3790 + right, 3791 + bottom, 3792 + width: right - left, 3793 + height: bottom - top, 3794 + x: left, 3795 + y: top, 3796 + }; 3797 + } 3798 + 3799 + function collectVisualContrastReasons(el, style) { 3800 + const reasons = new Set(); 3801 + const bgClip = style.webkitBackgroundClip || style.backgroundClip || ''; 3802 + const ownBgImage = style.backgroundImage || ''; 3803 + if (bgClip === 'text' && ownBgImage && ownBgImage !== 'none') { 3804 + reasons.add('background-clip text'); 3805 + } 3806 + if (style.textShadow && style.textShadow !== 'none') reasons.add('text shadow'); 3807 + 3808 + let current = el; 3809 + while (current && current.nodeType === 1) { 3810 + const tag = current.tagName?.toLowerCase(); 3811 + const currentStyle = getComputedStyle(current); 3812 + const bgImage = currentStyle.backgroundImage || ''; 3813 + const isDocumentSurface = tag === 'body' || tag === 'html'; 3814 + 3815 + if (!isDocumentSurface && bgImage && bgImage !== 'none') { 3816 + if (/url\s*\(/i.test(bgImage)) reasons.add('image background'); 3817 + if (/gradient/i.test(bgImage)) reasons.add('gradient background'); 3818 + } 3819 + if (parseFloat(currentStyle.opacity) < 0.99) reasons.add('opacity stack'); 3820 + if (currentStyle.mixBlendMode && currentStyle.mixBlendMode !== 'normal') reasons.add('blend mode'); 3821 + if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter'); 3822 + if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter'); 3823 + 3824 + const solidBg = parseRgb(currentStyle.backgroundColor); 3825 + if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break; 3826 + current = current.parentElement; 3827 + } 3828 + 3829 + const sampleRect = getDirectTextRect(el) || el.getBoundingClientRect(); 3830 + if (sampleRect && document.elementsFromPoint) { 3831 + const points = [ 3832 + [sampleRect.left + sampleRect.width / 2, sampleRect.top + sampleRect.height / 2], 3833 + [sampleRect.left + Math.min(sampleRect.width - 1, Math.max(1, sampleRect.width * 0.25)), sampleRect.top + sampleRect.height / 2], 3834 + [sampleRect.left + Math.min(sampleRect.width - 1, Math.max(1, sampleRect.width * 0.75)), sampleRect.top + sampleRect.height / 2], 3835 + ]; 3836 + for (const [x, y] of points) { 3837 + if (x < 0 || y < 0 || x > window.innerWidth || y > window.innerHeight) continue; 3838 + const stack = document.elementsFromPoint(x, y); 3839 + const selfIndex = stack.findIndex(node => node === el || el.contains(node) || node.contains?.(el)); 3840 + if (selfIndex < 0) continue; 3841 + for (const node of stack.slice(selfIndex + 1)) { 3842 + const nodeTag = node.tagName?.toLowerCase(); 3843 + if (nodeTag === 'img' || nodeTag === 'picture' || nodeTag === 'video' || nodeTag === 'canvas' || nodeTag === 'svg') { 3844 + reasons.add(`${nodeTag} underlay`); 3845 + break; 3846 + } 3847 + } 3848 + } 3849 + } 3850 + 3851 + return [...reasons]; 3852 + } 3853 + 3854 + function collectVisualContrastCandidates(options = {}) { 3855 + const maxCandidates = Number.isFinite(options.maxCandidates) ? options.maxCandidates : 12; 3856 + const candidates = []; 3857 + for (const el of document.querySelectorAll('*')) { 3858 + if (candidates.length >= maxCandidates) break; 3859 + if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; 3860 + if (el.closest('[id^="impeccable-live-"]')) continue; 3861 + if (el === document.body || el === document.documentElement) continue; 3862 + if (!isRenderedForBrowserRule(el)) continue; 3863 + 3864 + const tag = el.tagName.toLowerCase(); 3865 + const style = getComputedStyle(el); 3866 + if (style.display === 'none' || style.visibility === 'hidden') continue; 3867 + const directText = getDirectText(el); 3868 + const hasDirectText = directText.trim().length > 0; 3869 + if (!hasDirectText || isEmojiOnlyText(directText)) continue; 3870 + 3871 + const bgColor = readOwnBackgroundColor(el, style); 3872 + const isStyledButton = (tag === 'a' || tag === 'button') 3873 + && bgColor && bgColor.a > 0.5; 3874 + if (SAFE_TAGS.has(tag) && !isStyledButton) continue; 3875 + 3876 + const rect = getDirectTextRect(el) || el.getBoundingClientRect(); 3877 + if (!rect || rect.width < 4 || rect.height < 4) continue; 3878 + 3879 + const reasons = collectVisualContrastReasons(el, style); 3880 + if (reasons.length === 0) continue; 3881 + 3882 + const textColor = parseRgb(style.color); 3883 + const fontSize = parseFloat(style.fontSize) || 16; 3884 + const fontWeight = parseInt(style.fontWeight) || 400; 3885 + const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700); 3886 + const threshold = isLargeText ? 3.0 : 4.5; 3887 + const clip = { 3888 + x: Math.max(0, Math.floor(rect.left + window.scrollX - 2)), 3889 + y: Math.max(0, Math.floor(rect.top + window.scrollY - 2)), 3890 + width: Math.max(1, Math.ceil(rect.width + 4)), 3891 + height: Math.max(1, Math.ceil(rect.height + 4)), 3892 + }; 3893 + 3894 + candidates.push({ 3895 + selector: generateSelector(el), 3896 + tagName: tag, 3897 + text: directText.trim().replace(/\s+/g, ' ').slice(0, 80), 3898 + threshold, 3899 + reasons, 3900 + clip, 3901 + textColor, 3902 + preferRenderedForeground: !textColor || textColor.a < 0.99 || reasons.some(reason => 3903 + reason === 'opacity stack' || 3904 + reason === 'blend mode' || 3905 + reason === 'filter' || 3906 + reason === 'backdrop filter' || 3907 + reason === 'background-clip text' 3908 + ), 3909 + backgroundClipText: reasons.includes('background-clip text'), 3910 + }); 3911 + } 3912 + return candidates; 3913 + } 3914 + 3915 + const visualContrastImageCache = new Map(); 3916 + const visualContrastRasterCache = new WeakMap(); 3917 + 3918 + function clampByte(value) { 3919 + return Math.max(0, Math.min(255, Math.round(value))); 3920 + } 3921 + 3922 + function blendRgba(fg, bg) { 3923 + if (!fg) return bg || null; 3924 + if (!bg || fg.a == null || fg.a >= 0.999) { 3925 + return { r: clampByte(fg.r), g: clampByte(fg.g), b: clampByte(fg.b), a: fg.a == null ? 1 : fg.a }; 3926 + } 3927 + const alpha = Math.max(0, Math.min(1, fg.a)); 3928 + return { 3929 + r: clampByte(fg.r * alpha + bg.r * (1 - alpha)), 3930 + g: clampByte(fg.g * alpha + bg.g * (1 - alpha)), 3931 + b: clampByte(fg.b * alpha + bg.b * (1 - alpha)), 3932 + a: 1, 3933 + }; 3934 + } 3935 + 3936 + function pickWorstContrastColor(textColor, colors) { 3937 + const usable = (colors || []).filter(Boolean); 3938 + if (!usable.length) return null; 3939 + let worst = usable[0]; 3940 + let worstRatio = contrastRatio(textColor, worst); 3941 + for (const color of usable.slice(1)) { 3942 + const ratio = contrastRatio(textColor, color); 3943 + if (ratio < worstRatio) { 3944 + worst = color; 3945 + worstRatio = ratio; 3946 + } 3947 + } 3948 + return worst; 3949 + } 3950 + 3951 + function firstCssUrl(value) { 3952 + const match = String(value || '').match(/url\((?:"([^"]+)"|'([^']+)'|([^)]*))\)/i); 3953 + if (!match) return ''; 3954 + return (match[1] || match[2] || match[3] || '').trim(); 3955 + } 3956 + 3957 + function getLayerValue(value, index = 0) { 3958 + return String(value || '').split(',')[index]?.trim() || ''; 3959 + } 3960 + 3961 + function parsePositionToken(token, container, painted) { 3962 + if (!token || token === 'center') return (container - painted) / 2; 3963 + if (token === 'left' || token === 'top') return 0; 3964 + if (token === 'right' || token === 'bottom') return container - painted; 3965 + if (/%$/.test(token)) { 3966 + const pct = parseFloat(token) / 100; 3967 + return (container - painted) * pct; 3968 + } 3969 + if (/px$/.test(token)) return parseFloat(token) || 0; 3970 + return (container - painted) / 2; 3971 + } 3972 + 3973 + function parsePositionPair(positionValue) { 3974 + const tokens = String(positionValue || '50% 50%').trim().split(/\s+/).filter(Boolean); 3975 + const first = tokens[0] || '50%'; 3976 + if (tokens.length < 2) { 3977 + if (first === 'top' || first === 'bottom') return ['50%', first]; 3978 + return [first, '50%']; 3979 + } 3980 + return [first, tokens[1] || '50%']; 3981 + } 3982 + 3983 + function resolvePaintedImageRect(containerRect, image, sizeValue, positionValue) { 3984 + const intrinsicWidth = image.naturalWidth || image.videoWidth || image.width || 1; 3985 + const intrinsicHeight = image.naturalHeight || image.videoHeight || image.height || 1; 3986 + let paintedWidth = intrinsicWidth; 3987 + let paintedHeight = intrinsicHeight; 3988 + const size = String(sizeValue || 'auto').trim(); 3989 + 3990 + if (size === 'cover' || size === 'contain') { 3991 + const scale = size === 'cover' 3992 + ? Math.max(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight) 3993 + : Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight); 3994 + paintedWidth = intrinsicWidth * scale; 3995 + paintedHeight = intrinsicHeight * scale; 3996 + } else if (size && size !== 'auto') { 3997 + const parts = size.split(/\s+/); 3998 + const widthToken = parts[0]; 3999 + const heightToken = parts[1] || 'auto'; 4000 + if (/%$/.test(widthToken)) paintedWidth = containerRect.width * (parseFloat(widthToken) / 100); 4001 + else if (/px$/.test(widthToken)) paintedWidth = parseFloat(widthToken) || paintedWidth; 4002 + if (heightToken === 'auto') paintedHeight = paintedWidth * (intrinsicHeight / intrinsicWidth); 4003 + else if (/%$/.test(heightToken)) paintedHeight = containerRect.height * (parseFloat(heightToken) / 100); 4004 + else if (/px$/.test(heightToken)) paintedHeight = parseFloat(heightToken) || paintedHeight; 4005 + } 4006 + 4007 + const [xToken, yToken] = parsePositionPair(positionValue); 4008 + const positionX = parsePositionToken(xToken, containerRect.width, paintedWidth); 4009 + const positionY = parsePositionToken(yToken, containerRect.height, paintedHeight); 4010 + return { 4011 + left: containerRect.left + positionX, 4012 + top: containerRect.top + positionY, 4013 + width: paintedWidth, 4014 + height: paintedHeight, 4015 + intrinsicWidth, 4016 + intrinsicHeight, 4017 + }; 4018 + } 4019 + 4020 + function parseObjectPosition(positionValue) { 4021 + return parsePositionPair(positionValue); 4022 + } 4023 + 4024 + function resolveObjectImageRect(containerRect, image, style) { 4025 + const intrinsicWidth = image.naturalWidth || image.videoWidth || image.width || 1; 4026 + const intrinsicHeight = image.naturalHeight || image.videoHeight || image.height || 1; 4027 + const fit = style.objectFit || 'fill'; 4028 + let paintedWidth = containerRect.width; 4029 + let paintedHeight = containerRect.height; 4030 + if (fit === 'contain' || fit === 'cover') { 4031 + const scale = fit === 'cover' 4032 + ? Math.max(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight) 4033 + : Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight); 4034 + paintedWidth = intrinsicWidth * scale; 4035 + paintedHeight = intrinsicHeight * scale; 4036 + } else if (fit === 'none') { 4037 + paintedWidth = intrinsicWidth; 4038 + paintedHeight = intrinsicHeight; 4039 + } else if (fit === 'scale-down') { 4040 + const containScale = Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight, 1); 4041 + paintedWidth = intrinsicWidth * containScale; 4042 + paintedHeight = intrinsicHeight * containScale; 4043 + } 4044 + const [xToken, yToken] = parseObjectPosition(style.objectPosition); 4045 + return { 4046 + left: containerRect.left + parsePositionToken(xToken, containerRect.width, paintedWidth), 4047 + top: containerRect.top + parsePositionToken(yToken, containerRect.height, paintedHeight), 4048 + width: paintedWidth, 4049 + height: paintedHeight, 4050 + intrinsicWidth, 4051 + intrinsicHeight, 4052 + }; 4053 + } 4054 + 4055 + function pointToImageSource(point, paintedRect) { 4056 + if ( 4057 + point.x < paintedRect.left || 4058 + point.y < paintedRect.top || 4059 + point.x > paintedRect.left + paintedRect.width || 4060 + point.y > paintedRect.top + paintedRect.height 4061 + ) { 4062 + return null; 4063 + } 4064 + return { 4065 + x: Math.max(0, Math.min(paintedRect.intrinsicWidth - 1, ((point.x - paintedRect.left) / paintedRect.width) * paintedRect.intrinsicWidth)), 4066 + y: Math.max(0, Math.min(paintedRect.intrinsicHeight - 1, ((point.y - paintedRect.top) / paintedRect.height) * paintedRect.intrinsicHeight)), 4067 + }; 4068 + } 4069 + 4070 + async function loadVisualContrastImage(src) { 4071 + if (!src) return null; 4072 + if (visualContrastImageCache.has(src)) return visualContrastImageCache.get(src); 4073 + const promise = new Promise(resolve => { 4074 + const img = new Image(); 4075 + let settled = false; 4076 + const finish = value => { 4077 + if (settled) return; 4078 + settled = true; 4079 + clearTimeout(timer); 4080 + resolve(value); 4081 + }; 4082 + const timer = setTimeout(() => finish(null), 800); 4083 + try { 4084 + const absolute = new URL(src, location.href); 4085 + if (absolute.origin !== location.origin && absolute.protocol !== 'data:' && absolute.protocol !== 'blob:') { 4086 + img.crossOrigin = 'anonymous'; 4087 + } 4088 + } catch { 4089 + // Let the browser resolve unusual URLs itself. 4090 + } 4091 + img.onload = () => finish(img); 4092 + img.onerror = () => finish(null); 4093 + img.src = src; 4094 + }); 4095 + visualContrastImageCache.set(src, promise); 4096 + return promise; 4097 + } 4098 + 4099 + function sampleDrawablePixel(drawable, sourcePoint) { 4100 + if (visualContrastRasterCache.has(drawable)) { 4101 + const cached = visualContrastRasterCache.get(drawable); 4102 + if (!cached || !cached.ctx) return { status: 'unresolved', reason: cached?.reason || 'image sample failed' }; 4103 + try { 4104 + const x = Math.max(0, Math.min(cached.width - 1, Math.floor(sourcePoint.x * cached.scaleX))); 4105 + const y = Math.max(0, Math.min(cached.height - 1, Math.floor(sourcePoint.y * cached.scaleY))); 4106 + const data = cached.ctx.getImageData(x, y, 1, 1).data; 4107 + return { 4108 + status: 'sampled', 4109 + color: { r: data[0], g: data[1], b: data[2], a: data[3] / 255 }, 4110 + }; 4111 + } catch (err) { 4112 + return { 4113 + status: 'unresolved', 4114 + reason: /taint|cross-origin|Security/i.test(err?.message || '') ? 'tainted image' : 'image sample failed', 4115 + }; 4116 + } 4117 + } 4118 + 4119 + const canvas = document.createElement('canvas'); 4120 + const intrinsicWidth = drawable.naturalWidth || drawable.videoWidth || drawable.width || 1; 4121 + const intrinsicHeight = drawable.naturalHeight || drawable.videoHeight || drawable.height || 1; 4122 + const maxRasterSide = 640; 4123 + const scale = Math.min(1, maxRasterSide / Math.max(intrinsicWidth, intrinsicHeight)); 4124 + canvas.width = Math.max(1, Math.round(intrinsicWidth * scale)); 4125 + canvas.height = Math.max(1, Math.round(intrinsicHeight * scale)); 4126 + const ctx = canvas.getContext('2d', { willReadFrequently: true }); 4127 + if (!ctx) return { status: 'unresolved', reason: 'canvas unavailable' }; 4128 + try { 4129 + ctx.drawImage(drawable, 0, 0, canvas.width, canvas.height); 4130 + const cached = { 4131 + ctx, 4132 + width: canvas.width, 4133 + height: canvas.height, 4134 + scaleX: canvas.width / intrinsicWidth, 4135 + scaleY: canvas.height / intrinsicHeight, 4136 + }; 4137 + visualContrastRasterCache.set(drawable, cached); 4138 + const x = Math.max(0, Math.min(cached.width - 1, Math.floor(sourcePoint.x * cached.scaleX))); 4139 + const y = Math.max(0, Math.min(cached.height - 1, Math.floor(sourcePoint.y * cached.scaleY))); 4140 + const data = ctx.getImageData(x, y, 1, 1).data; 4141 + return { 4142 + status: 'sampled', 4143 + color: { r: data[0], g: data[1], b: data[2], a: data[3] / 255 }, 4144 + }; 4145 + } catch (err) { 4146 + const reason = /taint|cross-origin|Security/i.test(err?.message || '') ? 'tainted image' : 'image sample failed'; 4147 + visualContrastRasterCache.set(drawable, { ctx: null, reason }); 4148 + return { 4149 + status: 'unresolved', 4150 + reason, 4151 + }; 4152 + } 4153 + } 4154 + 4155 + async function sampleCssBackground(el, style, point, textColor) { 4156 + const rect = el.getBoundingClientRect(); 4157 + const bgImage = style.backgroundImage || ''; 4158 + if (bgImage && bgImage !== 'none') { 4159 + if (/gradient/i.test(bgImage)) { 4160 + const color = pickWorstContrastColor(textColor, parseGradientColors(bgImage)); 4161 + if (color) return { status: 'sampled', color, method: 'analytic-gradient' }; 4162 + } 4163 + if (/url\s*\(/i.test(bgImage)) { 4164 + const img = await loadVisualContrastImage(firstCssUrl(bgImage)); 4165 + if (!img) return { status: 'unresolved', reason: 'image unavailable' }; 4166 + const paintedRect = resolvePaintedImageRect( 4167 + rect, 4168 + img, 4169 + getLayerValue(style.backgroundSize) || 'auto', 4170 + getLayerValue(style.backgroundPosition) || '50% 50%', 4171 + ); 4172 + const sourcePoint = pointToImageSource(point, paintedRect); 4173 + if (!sourcePoint) return { status: 'unresolved', reason: 'point outside background image' }; 4174 + const sample = sampleDrawablePixel(img, sourcePoint); 4175 + if (sample.status === 'sampled') return { ...sample, method: 'canvas-background-image' }; 4176 + return sample; 4177 + } 4178 + } 4179 + const bg = parseRgb(style.backgroundColor); 4180 + if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' }; 4181 + return { status: 'unresolved', reason: 'no readable background' }; 4182 + } 4183 + 4184 + async function sampleImageElement(img, point) { 4185 + const rect = img.getBoundingClientRect(); 4186 + const style = getComputedStyle(img); 4187 + const paintedRect = resolveObjectImageRect(rect, img, style); 4188 + const sourcePoint = pointToImageSource(point, paintedRect); 4189 + if (!sourcePoint) return { status: 'unresolved', reason: 'point outside image' }; 4190 + const sample = sampleDrawablePixel(img, sourcePoint); 4191 + if (sample.status === 'sampled') return { ...sample, method: 'canvas-img-underlay' }; 4192 + 4193 + if (img.currentSrc || img.src) { 4194 + const loaded = await loadVisualContrastImage(img.currentSrc || img.src); 4195 + if (loaded) { 4196 + const loadedRect = { ...paintedRect, intrinsicWidth: loaded.naturalWidth || loaded.width || paintedRect.intrinsicWidth, intrinsicHeight: loaded.naturalHeight || loaded.height || paintedRect.intrinsicHeight }; 4197 + const loadedPoint = pointToImageSource(point, loadedRect); 4198 + if (loadedPoint) { 4199 + const loadedSample = sampleDrawablePixel(loaded, loadedPoint); 4200 + if (loadedSample.status === 'sampled') return { ...loadedSample, method: 'canvas-img-underlay' }; 4201 + } 4202 + } 4203 + } 4204 + return sample; 4205 + } 4206 + 4207 + function textSamplePoints(rect) { 4208 + const insetX = Math.min(12, Math.max(1, rect.width * 0.12)); 4209 + const insetY = Math.min(8, Math.max(1, rect.height * 0.22)); 4210 + const xs = rect.width < 28 4211 + ? [rect.left + rect.width / 2] 4212 + : [rect.left + insetX, rect.left + rect.width / 2, rect.right - insetX]; 4213 + const ys = rect.height < 22 4214 + ? [rect.top + rect.height / 2] 4215 + : [rect.top + insetY, rect.top + rect.height / 2, rect.bottom - insetY]; 4216 + const points = []; 4217 + for (const y of ys) { 4218 + for (const x of xs) { 4219 + if (x >= 0 && y >= 0 && x <= window.innerWidth && y <= window.innerHeight) points.push({ x, y }); 4220 + } 4221 + } 4222 + return points; 4223 + } 4224 + 4225 + async function sampleVisualBackgroundAtPoint(el, point, textColor, depth = 0) { 4226 + if (depth > 8) { 4227 + return { status: 'unresolved', reason: 'background stack too deep' }; 4228 + } 4229 + const stack = typeof document.elementsFromPoint === 'function' 4230 + ? document.elementsFromPoint(point.x, point.y) 4231 + : []; 4232 + const selfIndex = stack.findIndex(node => node === el || el.contains(node)); 4233 + const nodes = selfIndex >= 0 ? stack.slice(selfIndex) : [el, ...stack]; 4234 + const unresolved = []; 4235 + 4236 + for (const node of nodes) { 4237 + if (!node || node.nodeType !== 1) continue; 4238 + if (node.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; 4239 + const tag = node.tagName?.toLowerCase(); 4240 + if (tag === 'img') { 4241 + const sample = await sampleImageElement(node, point); 4242 + if (sample.status === 'sampled') return sample; 4243 + unresolved.push(sample.reason); 4244 + continue; 4245 + } 4246 + if (tag === 'canvas' || tag === 'video') { 4247 + const rect = node.getBoundingClientRect(); 4248 + const sourcePoint = pointToImageSource(point, { 4249 + left: rect.left, 4250 + top: rect.top, 4251 + width: rect.width, 4252 + height: rect.height, 4253 + intrinsicWidth: node.width || node.videoWidth || rect.width, 4254 + intrinsicHeight: node.height || node.videoHeight || rect.height, 4255 + }); 4256 + if (sourcePoint) { 4257 + const sample = sampleDrawablePixel(node, sourcePoint); 4258 + if (sample.status === 'sampled') return { ...sample, method: `canvas-${tag}-underlay` }; 4259 + unresolved.push(sample.reason); 4260 + } 4261 + continue; 4262 + } 4263 + const style = getComputedStyle(node); 4264 + const sample = await sampleCssBackground(node, style, point, textColor); 4265 + if (sample.status === 'sampled') { 4266 + if (!sample.color || sample.color.a == null || sample.color.a >= 0.95) return sample; 4267 + const under = await sampleVisualBackgroundAtPoint(node.parentElement || document.body, point, textColor, depth + 1); 4268 + if (under.status === 'sampled') { 4269 + return { 4270 + status: 'sampled', 4271 + color: blendRgba(sample.color, under.color), 4272 + method: `${sample.method}+alpha`, 4273 + }; 4274 + } 4275 + return sample; 4276 + } 4277 + unresolved.push(sample.reason); 4278 + } 4279 + 4280 + return { 4281 + status: 'unresolved', 4282 + reason: [...new Set(unresolved.filter(Boolean))].slice(0, 3).join(', ') || 'no readable visual background', 4283 + }; 4284 + } 4285 + 4286 + async function analyzeVisualContrastCandidate(candidate) { 4287 + let el; 4288 + try { 4289 + el = document.querySelector(candidate.selector); 4290 + } catch { 4291 + return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' }; 4292 + } 4293 + if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' }; 4294 + if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' }; 4295 + 4296 + const blockingReason = (candidate.reasons || []).find(reason => 4297 + reason === 'background-clip text' || 4298 + reason === 'blend mode' || 4299 + reason === 'filter' || 4300 + reason === 'backdrop filter' || 4301 + reason === 'opacity stack' || 4302 + reason === 'text shadow' 4303 + ); 4304 + if (blockingReason) { 4305 + return { ...candidate, status: 'unresolved', confidence: 'none', reason: `${blockingReason} needs screenshot pixels` }; 4306 + } 4307 + 4308 + const style = getComputedStyle(el); 4309 + const textColor = parseRgb(style.color) || candidate.textColor; 4310 + if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' }; 4311 + 4312 + const rect = getDirectTextRect(el) || el.getBoundingClientRect(); 4313 + if (!rect || rect.width < 4 || rect.height < 4) { 4314 + return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing text rect' }; 4315 + } 4316 + 4317 + const points = textSamplePoints(rect); 4318 + if (points.length === 0) { 4319 + return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'text outside viewport' }; 4320 + } 4321 + 4322 + const ratios = []; 4323 + const methods = new Set(); 4324 + const unresolved = []; 4325 + for (const point of points) { 4326 + const sample = await sampleVisualBackgroundAtPoint(el, point, textColor); 4327 + if (sample.status !== 'sampled' || !sample.color) { 4328 + unresolved.push(sample.reason); 4329 + continue; 4330 + } 4331 + const fg = blendRgba(textColor, sample.color); 4332 + ratios.push(contrastRatio(fg, sample.color)); 4333 + if (sample.method) methods.add(sample.method); 4334 + } 4335 + 4336 + if (ratios.length < Math.min(3, points.length)) { 4337 + return { 4338 + ...candidate, 4339 + status: 'unresolved', 4340 + confidence: 'none', 4341 + samples: ratios.length, 4342 + reason: [...new Set(unresolved.filter(Boolean))].slice(0, 3).join(', ') || 'not enough readable samples', 4343 + }; 4344 + } 4345 + 4346 + ratios.sort((a, b) => a - b); 4347 + const pick = pct => ratios[Math.min(ratios.length - 1, Math.max(0, Math.floor((pct / 100) * ratios.length)))]; 4348 + const measuredRatio = pick(10); 4349 + const medianRatio = pick(50); 4350 + const status = measuredRatio < candidate.threshold ? 'fail' : 'pass'; 4351 + const method = [...methods].sort().join(', ') || 'browser-visual'; 4352 + const textLabel = candidate.text ? ` "${candidate.text}"` : ''; 4353 + const detail = `browser contrast ${measuredRatio.toFixed(1)}:1 median ${medianRatio.toFixed(1)}:1 (need ${candidate.threshold}:1) via ${method}${textLabel}`; 4354 + return { 4355 + ...candidate, 4356 + status, 4357 + confidence: method.includes('canvas-') ? 'high' : 'medium', 4358 + method, 4359 + ratio: measuredRatio, 4360 + medianRatio, 4361 + samples: ratios.length, 4362 + finding: status === 'fail' ? { id: 'low-contrast', snippet: detail } : null, 4363 + }; 4364 + } 4365 + 4366 + function waitForVisualPaint() { 4367 + return new Promise(resolve => { 4368 + requestAnimationFrame(() => requestAnimationFrame(resolve)); 4369 + }); 4370 + } 4371 + 4372 + async function analyzeVisualContrast(options = {}) { 4373 + const candidates = collectVisualContrastCandidates(options); 4374 + const results = []; 4375 + const shouldScrollOffscreen = options.scrollOffscreen === true; 4376 + const restoreScroll = { x: window.scrollX, y: window.scrollY }; 4377 + for (const candidate of candidates) { 4378 + if (shouldScrollOffscreen && (window.scrollX !== restoreScroll.x || window.scrollY !== restoreScroll.y)) { 4379 + window.scrollTo(restoreScroll.x, restoreScroll.y); 4380 + await waitForVisualPaint(); 4381 + } 4382 + let result = await analyzeVisualContrastCandidate(candidate); 4383 + if (shouldScrollOffscreen && result.status === 'unresolved' && result.reason === 'text outside viewport') { 4384 + let el = null; 4385 + try { 4386 + el = document.querySelector(candidate.selector); 4387 + } catch { 4388 + el = null; 4389 + } 4390 + if (el && typeof el.scrollIntoView === 'function') { 4391 + el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' }); 4392 + await waitForVisualPaint(); 4393 + result = await analyzeVisualContrastCandidate(candidate); 4394 + } 4395 + } 4396 + results.push(result); 4397 + } 4398 + if (shouldScrollOffscreen && (window.scrollX !== restoreScroll.x || window.scrollY !== restoreScroll.y)) { 4399 + window.scrollTo(restoreScroll.x, restoreScroll.y); 4400 + } 4401 + return results; 4402 + } 4403 + 4404 + function isElementHidden(el) { 4405 + if (!el || el === document.body || el === document.documentElement) return false; 4406 + if (typeof el.checkVisibility === 'function') return !el.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true }); 4407 + // Fallback: zero size or no offsetParent (covers display:none and detached subtrees) 4408 + return el.offsetWidth === 0 && el.offsetHeight === 0; 4409 + } 4410 + 4411 + function serializeFindings(allFindings) { 4412 + return allFindings.map(({ el, findings }) => ({ 4413 + selector: generateSelector(el), 4414 + tagName: el.tagName?.toLowerCase() || 'unknown', 4415 + rect: (el !== document.body && el !== document.documentElement && el.getBoundingClientRect) 4416 + ? el.getBoundingClientRect().toJSON() : null, 4417 + isPageLevel: el === document.body || el === document.documentElement, 4418 + isHidden: isElementHidden(el), 4419 + findings: findings.map(f => { 4420 + const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id)); 4421 + return { 4422 + type: f.type || f.id, 4423 + category: ap ? ap.category : 'quality', 4424 + severity: ap?.severity || 'warning', 4425 + detail: f.detail || f.snippet, 4426 + ignoreValue: f.ignoreValue || f.value || '', 4427 + name: ap ? ap.name : (f.type || f.id), 4428 + description: ap ? ap.description : '', 4429 + }; 4430 + }), 4431 + })); 4432 + } 4433 + 4434 + const printSummary = function(allFindings) { 4435 + if (allFindings.length === 0) { 4436 + console.log('%c[impeccable] No anti-patterns found.', 'color: #22c55e; font-weight: bold'); 4437 + return; 4438 + } 4439 + console.group( 4440 + `%c[impeccable] ${allFindings.length} anti-pattern${allFindings.length === 1 ? '' : 's'} found`, 4441 + 'color: oklch(84% 0.19 80.46); font-weight: bold' 4442 + ); 4443 + for (const { el, findings } of allFindings) { 4444 + for (const f of findings) { 4445 + console.log(`%c${f.type || f.id}%c ${f.detail || f.snippet}`, 4446 + 'color: oklch(84% 0.19 80.46); font-weight: bold', 'color: inherit', el); 4447 + } 4448 + } 4449 + console.groupEnd(); 4450 + }; 4451 + 4452 + function addBrowserFindings(groupMap, el, findings) { 4453 + if (!findings || findings.length === 0) return; 4454 + const existing = groupMap.get(el); 4455 + if (existing) existing.push(...findings); 4456 + else groupMap.set(el, [...findings]); 4457 + } 4458 + 4459 + function browserFindingsFromMap(groupMap) { 4460 + return [...groupMap.entries()].map(([el, findings]) => ({ el, findings })); 4461 + } 4462 + 4463 + const DESIGN_COLOR_TOLERANCE = 6; 4464 + const DESIGN_RADIUS_TOLERANCE_PX = 0.5; 4465 + const DESIGN_SKIP_TAGS = new Set(['head', 'title', 'meta', 'link', 'style', 'script', 'noscript', 'template', 'source']); 4466 + 4467 + function normalizeBrowserFontName(value) { 4468 + return String(value || '') 4469 + .trim() 4470 + .replace(/^["']|["']$/g, '') 4471 + .replace(/\+/g, ' ') 4472 + .replace(/\s+/g, ' ') 4473 + .toLowerCase(); 4474 + } 4475 + 4476 + function browserPrimaryFont(stack) { 4477 + if (!stack || /var\(/i.test(stack)) return ''; 4478 + return String(stack || '') 4479 + .split(',') 4480 + .map(normalizeBrowserFontName) 4481 + .find(font => font && !GENERIC_FONTS.has(font)) || ''; 4482 + } 4483 + 4484 + function browserDesignSystemConfig() { 4485 + const raw = window.__IMPECCABLE_CONFIG__?.designSystem; 4486 + if (!raw?.present) return null; 4487 + const allowedFonts = new Set((raw.allowedFonts || []).map(normalizeBrowserFontName).filter(Boolean)); 4488 + const allowedColors = (raw.allowedColors || []) 4489 + .filter(color => color && Number.isFinite(color.r) && Number.isFinite(color.g) && Number.isFinite(color.b)) 4490 + .map(color => ({ r: color.r, g: color.g, b: color.b })); 4491 + const allowedRadii = (raw.allowedRadii || []) 4492 + .map(Number) 4493 + .filter(px => Number.isFinite(px)); 4494 + return { 4495 + present: true, 4496 + hasFonts: raw.hasFonts === true && allowedFonts.size > 0, 4497 + allowedFonts, 4498 + hasColors: raw.hasColors === true && allowedColors.length > 0, 4499 + allowedColors, 4500 + hasRadii: raw.hasRadii === true && allowedRadii.length > 0, 4501 + allowedRadii, 4502 + hasPillRadius: raw.hasPillRadius === true, 4503 + }; 4504 + } 4505 + 4506 + function browserColorsClose(a, b) { 4507 + if (!a || !b) return false; 4508 + return Math.max( 4509 + Math.abs(a.r - b.r), 4510 + Math.abs(a.g - b.g), 4511 + Math.abs(a.b - b.b), 4512 + ) <= DESIGN_COLOR_TOLERANCE; 4513 + } 4514 + 4515 + function isBrowserDesignColorAllowed(raw, designSystem) { 4516 + if (!designSystem?.hasColors) return true; 4517 + const text = String(raw || '').trim().toLowerCase(); 4518 + if (!text || text === 'transparent' || text === 'currentcolor' || text === 'inherit' || text === 'initial') return true; 4519 + if (text.includes('var(')) return true; 4520 + const parsed = parseAnyColor(text); 4521 + if (!parsed) return true; 4522 + if ((parsed.a ?? 1) <= 0.05) return true; 4523 + return designSystem.allowedColors.some(color => browserColorsClose(parsed, color)); 4524 + } 4525 + 4526 + function isBrowserTransparentCss(value) { 4527 + const text = String(value || '').trim().toLowerCase(); 4528 + if (!text || text === 'transparent') return true; 4529 + const parsed = parseAnyColor(text); 4530 + return parsed ? (parsed.a ?? 1) <= 0.05 : false; 4531 + } 4532 + 4533 + function isBrowserDesignRadiusAllowed(raw, designSystem) { 4534 + if (!designSystem?.hasRadii) return true; 4535 + const text = String(raw || '').trim().toLowerCase(); 4536 + if (!text || text === '0' || text === 'none' || text === 'initial' || text === 'inherit') return true; 4537 + if (text.includes('var(') || text.includes('%')) return true; 4538 + const px = resolveLengthPx(text, 16); 4539 + if (px == null || !Number.isFinite(px) || px <= DESIGN_RADIUS_TOLERANCE_PX) return true; 4540 + if (designSystem.hasPillRadius && px >= 99) return true; 4541 + return designSystem.allowedRadii.some(allowed => Math.abs(allowed - px) <= DESIGN_RADIUS_TOLERANCE_PX); 4542 + } 4543 + 4544 + function browserRadiusTokens(value) { 4545 + return String(value || '') 4546 + .replace(/\s*\/\s*/g, ' ') 4547 + .split(/\s+/) 4548 + .map(token => token.trim()) 4549 + .filter(Boolean); 4550 + } 4551 + 4552 + function browserHasDirectText(el) { 4553 + return [...(el.childNodes || [])].some(node => node.nodeType === 3 && node.textContent.trim().length > 0); 4554 + } 4555 + 4556 + function browserSampleText(el) { 4557 + const text = String(el.textContent || '').replace(/\s+/g, ' ').trim(); 4558 + return text ? ` "${text.slice(0, 40)}"` : ''; 4559 + } 4560 + 4561 + function shouldSkipDesignElement(el) { 4562 + const tag = el.tagName?.toLowerCase?.() || ''; 4563 + return DESIGN_SKIP_TAGS.has(tag) || isElementHidden(el); 4564 + } 4565 + 4566 + function checkElementDesignSystemDOM(el, designSystem, seen) { 4567 + if (!designSystem?.present || shouldSkipDesignElement(el)) return []; 4568 + const findings = []; 4569 + const tag = el.tagName?.toLowerCase?.() || 'unknown'; 4570 + const style = getComputedStyle(el); 4571 + 4572 + if (designSystem.hasFonts && browserHasDirectText(el)) { 4573 + const font = browserPrimaryFont(style.fontFamily || ''); 4574 + if (font && !designSystem.allowedFonts.has(font) && !seen.fonts.has(font)) { 4575 + seen.fonts.add(font); 4576 + findings.push({ 4577 + type: 'design-system-font', 4578 + detail: `${tag}${browserSampleText(el)} uses ${font}; not declared in DESIGN.md typography`, 4579 + ignoreValue: font, 4580 + }); 4581 + } 4582 + } 4583 + 4584 + if (designSystem.hasColors) { 4585 + const colorChecks = []; 4586 + if (browserHasDirectText(el)) colorChecks.push(['text color', style.color]); 4587 + if (!isBrowserTransparentCss(style.backgroundColor)) colorChecks.push(['background', style.backgroundColor]); 4588 + for (const side of ['Top', 'Right', 'Bottom', 'Left']) { 4589 + if ((parseFloat(style[`border${side}Width`]) || 0) > 0) { 4590 + colorChecks.push([`border-${side.toLowerCase()}`, style[`border${side}Color`]]); 4591 + } 4592 + } 4593 + if ((parseFloat(style.outlineWidth) || 0) > 0) colorChecks.push(['outline', style.outlineColor]); 4594 + 4595 + for (const [kind, raw] of colorChecks) { 4596 + const label = String(raw || '').trim().replace(/\s+/g, ' '); 4597 + if (isBrowserDesignColorAllowed(label, designSystem)) continue; 4598 + const key = `${kind}:${label}`; 4599 + if (seen.colors.has(key)) continue; 4600 + seen.colors.add(key); 4601 + findings.push({ 4602 + type: 'design-system-color', 4603 + detail: `${kind} ${label} on ${tag}${browserSampleText(el)} is outside DESIGN.md colors`, 4604 + ignoreValue: label, 4605 + }); 4606 + } 4607 + } 4608 + 4609 + if (designSystem.hasRadii) { 4610 + for (const token of browserRadiusTokens(style.borderRadius || '')) { 4611 + if (isBrowserDesignRadiusAllowed(token, designSystem)) continue; 4612 + if (seen.radii.has(token)) continue; 4613 + seen.radii.add(token); 4614 + findings.push({ 4615 + type: 'design-system-radius', 4616 + detail: `border-radius ${token} on ${tag}${browserSampleText(el)} is outside the DESIGN.md rounded scale`, 4617 + ignoreValue: token, 4618 + }); 4619 + } 4620 + } 4621 + 4622 + return findings; 4623 + } 4624 + 4625 + function decodeBrowserGoogleFamily(value) { 4626 + const family = String(value || '').split(':')[0].replace(/\+/g, ' '); 4627 + try { 4628 + return decodeURIComponent(family); 4629 + } catch { 4630 + return family; 4631 + } 4632 + } 4633 + 4634 + function checkBrowserDesignSystemSources(designSystem, seen) { 4635 + if (!designSystem?.hasFonts) return []; 4636 + const findings = []; 4637 + for (const link of document.querySelectorAll('link[href*="fonts.googleapis.com/css"]')) { 4638 + const href = link.getAttribute('href') || ''; 4639 + for (const match of href.matchAll(/[?&]family=([^&]+)/g)) { 4640 + const display = decodeBrowserGoogleFamily(match[1]); 4641 + const font = normalizeBrowserFontName(display); 4642 + if (!font || designSystem.allowedFonts.has(font) || seen.fonts.has(font)) continue; 4643 + seen.fonts.add(font); 4644 + findings.push({ 4645 + type: 'design-system-font', 4646 + detail: `Google Fonts: ${display} is not declared in DESIGN.md typography`, 4647 + ignoreValue: display, 4648 + }); 4649 + } 4650 + } 4651 + return findings; 4652 + } 4653 + 4654 + function collectBrowserFindings() { 4655 + const groupMap = new Map(); 4656 + const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; 4657 + const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); 4658 + const designSystem = browserDesignSystemConfig(); 4659 + const designSeen = { fonts: new Set(), colors: new Set(), radii: new Set() }; 4660 + // Note: provider-gated rules (--gpt / --gemini) are NOT filtered here. In a 4661 + // real browser env (detector page, live overlay, extension) running every 4662 + // check is free, so we always surface them; the gating is purely a CLI 4663 + // output concern, applied in the Node engines' detect* return paths. 4664 + 4665 + for (const el of document.querySelectorAll('*')) { 4666 + // Skip impeccable's own elements and any descendants (overlays, labels, banner, nav buttons) 4667 + if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; 4668 + // Skip browser extension elements (Claude, etc.) 4669 + const elId = el.id || ''; 4670 + if (elId.startsWith('claude-') || elId.startsWith('cic-')) continue; 4671 + // Skip the impeccable live-mode overlay (highlight, tooltip, bar, picker, toast). 4672 + // These are inspector chrome, not part of the user's design. 4673 + if (el.closest('[id^="impeccable-live-"]')) continue; 4674 + // Skip html/body -- page-level findings go in the banner, not a full-page overlay 4675 + if (el === document.body || el === document.documentElement) continue; 4676 + 4677 + const findings = [ 4678 + ...checkElementBordersDOM(el).map(f => ({ type: f.id, detail: f.snippet })), 4679 + ...checkElementColorsDOM(el).map(f => ({ type: f.id, detail: f.snippet })), 4680 + ...checkElementMotionDOM(el).map(f => ({ type: f.id, detail: f.snippet })), 4681 + ...checkElementGlowDOM(el).map(f => ({ type: f.id, detail: f.snippet })), 4682 + ...checkElementAIPaletteDOM(el).map(f => ({ type: f.id, detail: f.snippet })), 4683 + ...checkElementIconTileDOM(el).map(f => ({ type: f.id, detail: f.snippet })), 4684 + ...checkElementItalicSerifDOM(el).map(f => ({ type: f.id, detail: f.snippet })), 4685 + ...checkElementQualityDOM(el).map(f => ({ type: f.id, detail: f.snippet })), 4686 + ...checkElementOversizedH1DOM(el).map(f => ({ type: f.id, detail: f.snippet })), 4687 + ...checkElementClippedOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })), 4688 + ...checkElementGptBorderShadowDOM(el).map(f => ({ type: f.id, detail: f.snippet })), 4689 + ...checkElementTextOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })), 4690 + ...checkElementDesignSystemDOM(el, designSystem, designSeen), 4691 + ].filter(f => _ruleOk(f.type)); 4692 + 4693 + addBrowserFindings(groupMap, el, findings); 4694 + 4695 + // Hero eyebrow: the offending element is the eyebrow above the heading, 4696 + // not the heading itself — highlight the previous sibling instead. 4697 + const eyebrowFindings = checkElementHeroEyebrowDOM(el) 4698 + .map(f => ({ type: f.id, detail: f.snippet })) 4699 + .filter(f => _ruleOk(f.type)); 4700 + if (eyebrowFindings.length > 0 && el.previousElementSibling) { 4701 + addBrowserFindings(groupMap, el.previousElementSibling, eyebrowFindings); 4702 + } 4703 + } 4704 + 4705 + const pageLevelFindings = []; 4706 + 4707 + const designSourceFindings = checkBrowserDesignSystemSources(designSystem, designSeen) 4708 + .filter(f => _ruleOk(f.type)); 4709 + if (designSourceFindings.length > 0) { 4710 + pageLevelFindings.push(...designSourceFindings); 4711 + addBrowserFindings(groupMap, document.body, designSourceFindings); 4712 + } 4713 + 4714 + const typoFindings = checkTypography().filter(f => _ruleOk(f.type)); 4715 + if (typoFindings.length > 0) { 4716 + pageLevelFindings.push(...typoFindings); 4717 + addBrowserFindings(groupMap, document.body, typoFindings); 4718 + } 4719 + 4720 + const sectionKickerFindings = checkRepeatedSectionKickersDOM() 4721 + .map(f => ({ type: f.id, detail: f.snippet })) 4722 + .filter(f => _ruleOk(f.type)); 4723 + if (sectionKickerFindings.length > 0) { 4724 + pageLevelFindings.push(...sectionKickerFindings); 4725 + addBrowserFindings(groupMap, document.body, sectionKickerFindings); 4726 + } 4727 + 4728 + const layoutFindings = checkLayout().filter(f => _ruleOk(f.type)); 4729 + for (const f of layoutFindings) { 4730 + const el = f.el || document.body; 4731 + addBrowserFindings(groupMap, el, [{ type: f.type, detail: f.detail || f.snippet }]); 4732 + } 4733 + 4734 + // Page-level quality checks (headings, etc.) 4735 + const qualityFindings = checkPageQualityDOM().filter(f => _ruleOk(f.type)); 4736 + if (qualityFindings.length > 0) { 4737 + pageLevelFindings.push(...qualityFindings); 4738 + addBrowserFindings(groupMap, document.body, qualityFindings); 4739 + } 4740 + 4741 + const creamFindings = checkCreamPalette(document) 4742 + .map(f => ({ type: f.id, detail: f.snippet })) 4743 + .filter(f => _ruleOk(f.type)); 4744 + if (creamFindings.length > 0) { 4745 + pageLevelFindings.push(...creamFindings); 4746 + addBrowserFindings(groupMap, document.body, creamFindings); 4747 + } 4748 + 4749 + // Regex-on-HTML checks (shared with Node) 4750 + // Clone the document and strip impeccable-live overlay nodes before the 4751 + // regex scan, so the inspector's own inline styles (transitions on top/ 4752 + // left/width/height, etc.) don't register as page anti-patterns. 4753 + const docClone = document.documentElement.cloneNode(true); 4754 + for (const node of docClone.querySelectorAll('[id^="impeccable-live-"]')) { 4755 + node.remove(); 4756 + } 4757 + const htmlPatternFindings = checkHtmlPatterns(docClone.outerHTML); 4758 + if (htmlPatternFindings.length > 0) { 4759 + const mapped = htmlPatternFindings.map(f => ({ type: f.id, detail: f.snippet })).filter(f => _ruleOk(f.type)); 4760 + pageLevelFindings.push(...mapped); 4761 + addBrowserFindings(groupMap, document.body, mapped); 4762 + } 4763 + 4764 + return { 4765 + groupMap, 4766 + allFindings: browserFindingsFromMap(groupMap), 4767 + pageLevelFindings, 4768 + }; 4769 + } 4770 + 4771 + function shouldRunVisualContrast(options = {}) { 4772 + return options.visualContrast === true || window.__IMPECCABLE_CONFIG__?.visualContrast === true; 4773 + } 4774 + 4775 + function visualContrastOptions(options = {}) { 4776 + const config = window.__IMPECCABLE_CONFIG__ || {}; 4777 + const scrollOffscreen = typeof options.scrollOffscreen === 'boolean' 4778 + ? options.scrollOffscreen 4779 + : typeof options.visualContrastScrollOffscreen === 'boolean' 4780 + ? options.visualContrastScrollOffscreen 4781 + : typeof config.visualContrastScrollOffscreen === 'boolean' 4782 + ? config.visualContrastScrollOffscreen 4783 + : false; 4784 + return { 4785 + ...options, 4786 + maxCandidates: Number.isFinite(options.visualContrastMaxCandidates) 4787 + ? options.visualContrastMaxCandidates 4788 + : Number.isFinite(options.maxCandidates) 4789 + ? options.maxCandidates 4790 + : Number.isFinite(config.visualContrastMaxCandidates) 4791 + ? config.visualContrastMaxCandidates 4792 + : undefined, 4793 + scrollOffscreen, 4794 + }; 4795 + } 4796 + 4797 + let lastVisualContrastAnalyses = []; 4798 + let lazyVisualContrastObserver = null; 4799 + let lazyVisualContrastPending = new WeakMap(); 4800 + const lazyVisualContrastResolving = new WeakSet(); 4801 + let scanGeneration = 0; 4802 + 4803 + function rememberVisualContrastAnalysis(result) { 4804 + if (!result?.selector) { 4805 + lastVisualContrastAnalyses.push(result); 4806 + return; 4807 + } 4808 + const idx = lastVisualContrastAnalyses.findIndex(item => item.selector === result.selector); 4809 + if (idx >= 0) lastVisualContrastAnalyses[idx] = result; 4810 + else lastVisualContrastAnalyses.push(result); 4811 + } 4812 + 4813 + function disconnectLazyVisualContrastObserver() { 4814 + if (lazyVisualContrastObserver) { 4815 + lazyVisualContrastObserver.disconnect(); 4816 + lazyVisualContrastObserver = null; 4817 + } 4818 + lazyVisualContrastPending = new WeakMap(); 4819 + } 4820 + 4821 + function addVisualContrastResult(groupMap, result, options = {}) { 4822 + if (result.status !== 'fail' || !result.finding || !result.selector) return false; 4823 + let el = null; 4824 + try { 4825 + el = document.querySelector(result.selector); 4826 + } catch { 4827 + el = null; 4828 + } 4829 + if (!el) return false; 4830 + const findingType = result.finding.type || result.finding.id || 'low-contrast'; 4831 + const existing = groupMap.get(el) || []; 4832 + if (existing.some(f => (f.type || f.id) === findingType)) return false; 4833 + addBrowserFindings(groupMap, el, [{ 4834 + type: findingType, 4835 + detail: result.finding.detail || result.finding.snippet, 4836 + }]); 4837 + if (options.decorate && el !== document.body && el !== document.documentElement) { 4838 + highlight(el, groupMap.get(el) || []); 4839 + } 4840 + return true; 4841 + } 4842 + 4843 + function scanResultMeta(options = {}) { 4844 + const scanId = options.scanId; 4845 + if (typeof scanId !== 'string' && typeof scanId !== 'number') return {}; 4846 + return { scanId: String(scanId) }; 4847 + } 4848 + 4849 + function postSerializedFindings(groupMap, options = {}) { 4850 + if (!EXTENSION_MODE) return; 4851 + const allFindings = browserFindingsFromMap(groupMap); 4852 + window.postMessage({ 4853 + source: 'impeccable-results', 4854 + findings: serializeFindings(allFindings), 4855 + count: allFindings.length, 4856 + ...scanResultMeta(options), 4857 + }, '*'); 4858 + } 4859 + 4860 + function postExtensionError(err) { 4861 + if (!EXTENSION_MODE) return; 4862 + window.postMessage({ 4863 + source: 'impeccable-error', 4864 + message: err?.message || String(err), 4865 + }, '*'); 4866 + } 4867 + 4868 + function reportVisualContrastError(err, detail = {}) { 4869 + window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-error', { 4870 + detail: { 4871 + ...detail, 4872 + message: err?.message || String(err), 4873 + }, 4874 + })); 4875 + if (EXTENSION_MODE) { 4876 + postExtensionError(err); 4877 + } else { 4878 + console.warn('[impeccable] visual contrast scan failed', err); 4879 + } 4880 + } 4881 + 4882 + function scheduleLazyVisualContrast(groupMap, analyses, options = {}, runtime = {}) { 4883 + disconnectLazyVisualContrastObserver(); 4884 + if (options.visualContrastLazy === false || options.scrollOffscreen !== false) return; 4885 + if (typeof IntersectionObserver === 'undefined') return; 4886 + const unresolved = (analyses || []).filter(result => 4887 + result?.status === 'unresolved' && 4888 + result.reason === 'text outside viewport' && 4889 + result.selector 4890 + ); 4891 + if (unresolved.length === 0) return; 4892 + const generation = runtime.generation || scanGeneration; 4893 + 4894 + lazyVisualContrastObserver = new IntersectionObserver((entries) => { 4895 + for (const entry of entries) { 4896 + if (!entry.isIntersecting) continue; 4897 + const el = entry.target; 4898 + const candidate = lazyVisualContrastPending.get(el); 4899 + if (!candidate || lazyVisualContrastResolving.has(el)) continue; 4900 + lazyVisualContrastObserver?.unobserve(el); 4901 + lazyVisualContrastPending.delete(el); 4902 + lazyVisualContrastResolving.add(el); 4903 + waitForVisualPaint() 4904 + .then(() => analyzeVisualContrastCandidate(candidate)) 4905 + .then(result => { 4906 + if (generation !== scanGeneration) return; 4907 + rememberVisualContrastAnalysis(result); 4908 + const added = addVisualContrastResult(groupMap, result, { decorate: true }); 4909 + if (added) { 4910 + postSerializedFindings(groupMap, options); 4911 + window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-resolved', { 4912 + detail: { 4913 + selector: result.selector, 4914 + status: result.status, 4915 + finding: result.finding || null, 4916 + }, 4917 + })); 4918 + } 4919 + }) 4920 + .catch(err => { 4921 + reportVisualContrastError(err, { selector: candidate.selector }); 4922 + }) 4923 + .finally(() => { 4924 + lazyVisualContrastResolving.delete(el); 4925 + }); 4926 + } 4927 + }, { threshold: 0.5 }); 4928 + 4929 + for (const candidate of unresolved) { 4930 + let el = null; 4931 + try { 4932 + el = document.querySelector(candidate.selector); 4933 + } catch { 4934 + el = null; 4935 + } 4936 + if (!el) continue; 4937 + lazyVisualContrastPending.set(el, candidate); 4938 + lazyVisualContrastObserver.observe(el); 4939 + } 4940 + } 4941 + 4942 + async function addVisualContrastFindings(groupMap, options = {}, runtime = {}) { 4943 + if (!shouldRunVisualContrast(options)) { 4944 + lastVisualContrastAnalyses = []; 4945 + disconnectLazyVisualContrastObserver(); 4946 + return []; 4947 + } 4948 + const resolvedOptions = visualContrastOptions(options); 4949 + const analyses = await analyzeVisualContrast(resolvedOptions); 4950 + if (runtime.generation && runtime.generation !== scanGeneration) return analyses; 4951 + lastVisualContrastAnalyses = analyses; 4952 + for (const result of analyses) { 4953 + addVisualContrastResult(groupMap, result, { decorate: runtime.decorate }); 4954 + } 4955 + if (runtime.decorate || runtime.scheduleLazy) scheduleLazyVisualContrast(groupMap, analyses, resolvedOptions, runtime); 4956 + return analyses; 4957 + } 4958 + 4959 + async function collectBrowserFindingsAsync(options = {}, runtime = {}) { 4960 + const collected = collectBrowserFindings(); 4961 + await addVisualContrastFindings(collected.groupMap, options, runtime); 4962 + return { 4963 + ...collected, 4964 + allFindings: browserFindingsFromMap(collected.groupMap), 4965 + visualContrastAnalyses: lastVisualContrastAnalyses, 4966 + }; 4967 + } 4968 + 4969 + function clearOverlays() { 4970 + scanGeneration += 1; 4971 + disconnectLazyVisualContrastObserver(); 4972 + for (const o of [...overlays]) detachOverlay(o); 4973 + overlays.length = 0; 4974 + visibilityObserver.disconnect(); 4975 + overlayIndex = 0; 4976 + } 4977 + 4978 + function renderBrowserFindings(collected, options = {}) { 4979 + const { allFindings, pageLevelFindings } = collected; 4980 + 4981 + for (const { el, findings } of allFindings) { 4982 + if (el === document.body || el === document.documentElement) continue; 4983 + highlight(el, findings); 4984 + } 4985 + 4986 + if (pageLevelFindings.length > 0) { 4987 + showPageBanner(pageLevelFindings); 4988 + } 4989 + 4990 + if (!EXTENSION_MODE) printSummary(allFindings); 4991 + 4992 + // In extension mode, post serialized results for the DevTools panel 4993 + if (EXTENSION_MODE) { 4994 + window.postMessage({ 4995 + source: 'impeccable-results', 4996 + findings: serializeFindings(allFindings), 4997 + count: allFindings.length, 4998 + ...scanResultMeta(options), 4999 + }, '*'); 5000 + } 5001 + 5002 + // After this scan completes, all subsequent reveals are instant (no stagger, no animation) 5003 + setTimeout(() => { firstScanDone = true; }, 1000); 5004 + 5005 + return allFindings; 5006 + } 5007 + 5008 + let firstScanDone = false; 5009 + const scan = function(options = {}) { 5010 + clearOverlays(); 5011 + const generation = scanGeneration; 5012 + const collected = collectBrowserFindings(); 5013 + const allFindings = renderBrowserFindings(collected, options); 5014 + if (shouldRunVisualContrast(options)) { 5015 + addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) 5016 + .then(() => { 5017 + if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); 5018 + }) 5019 + .catch(err => { 5020 + reportVisualContrastError(err); 5021 + }); 5022 + } 5023 + return allFindings; 5024 + }; 5025 + 5026 + const scanAsync = async function(options = {}) { 5027 + clearOverlays(); 5028 + const generation = scanGeneration; 5029 + if (shouldRunVisualContrast(options)) { 5030 + const collected = await collectBrowserFindingsAsync(options, { generation, scheduleLazy: true }); 5031 + if (generation !== scanGeneration) return []; 5032 + return renderBrowserFindings(collected, options); 5033 + } 5034 + lastVisualContrastAnalyses = []; 5035 + return renderBrowserFindings(collectBrowserFindings(), options); 5036 + }; 5037 + 5038 + const detect = function(options = {}) { 5039 + lastVisualContrastAnalyses = []; 5040 + const { allFindings } = collectBrowserFindings(); 5041 + return options.serialize === false ? allFindings : serializeFindings(allFindings); 5042 + }; 5043 + 5044 + const detectAsync = async function(options = {}) { 5045 + if (shouldRunVisualContrast(options)) { 5046 + const { allFindings } = await collectBrowserFindingsAsync(options); 5047 + return options.serialize === false ? allFindings : serializeFindings(allFindings); 5048 + } 5049 + lastVisualContrastAnalyses = []; 5050 + const { allFindings } = collectBrowserFindings(); 5051 + return options.serialize === false ? allFindings : serializeFindings(allFindings); 5052 + }; 5053 + 5054 + if (EXTENSION_MODE) { 5055 + // Extension mode: listen for commands, don't auto-scan 5056 + window.addEventListener('message', (e) => { 5057 + if (e.source !== window || !e.data || e.data.source !== 'impeccable-command') return; 5058 + if (e.data.action === 'scan') { 5059 + if (e.data.config) window.__IMPECCABLE_CONFIG__ = e.data.config; 5060 + try { 5061 + scan(e.data.config || {}); 5062 + } catch (err) { 5063 + postExtensionError(err); 5064 + } 5065 + } 5066 + if (e.data.action === 'toggle-overlays') { 5067 + const visible = !document.body.classList.contains('impeccable-hidden'); 5068 + document.body.classList.toggle('impeccable-hidden', visible); 5069 + window.postMessage({ source: 'impeccable-overlays-toggled', visible: !visible }, '*'); 5070 + } 5071 + if (e.data.action === 'remove') { 5072 + clearOverlays(); 5073 + styleEl.remove(); 5074 + if (spotlightBackdrop) { spotlightBackdrop.remove(); spotlightBackdrop = null; } 5075 + document.body.classList.remove('impeccable-hidden'); 5076 + } 5077 + if (e.data.action === 'highlight') { 5078 + try { 5079 + const target = e.data.selector ? document.querySelector(e.data.selector) : null; 5080 + if (target) { 5081 + // Scroll first so positionOverlay reads the post-scroll rect 5082 + if (!isInViewport(target) && target.scrollIntoView) { 5083 + target.scrollIntoView({ behavior: 'instant', block: 'center' }); 5084 + } 5085 + for (const o of overlays) { 5086 + if (o.classList.contains('impeccable-banner')) continue; 5087 + const isMatch = o._targetEl === target; 5088 + o.classList.toggle('impeccable-spotlight', isMatch); 5089 + o.classList.toggle('impeccable-spotlight-dimmed', !isMatch); 5090 + if (isMatch) { 5091 + // Force the matching overlay visible immediately, don't wait for IntersectionObserver 5092 + o.style.display = ''; 5093 + o.style.animation = 'none'; 5094 + o.classList.add('impeccable-visible'); 5095 + o._revealed = true; 5096 + positionOverlay(o); 5097 + } 5098 + } 5099 + showSpotlight(target); 5100 + } 5101 + } catch { /* invalid selector */ } 5102 + } 5103 + if (e.data.action === 'unhighlight') { 5104 + hideSpotlight(); 5105 + for (const o of overlays) { 5106 + o.classList.remove('impeccable-spotlight'); 5107 + o.classList.remove('impeccable-spotlight-dimmed'); 5108 + } 5109 + } 5110 + }); 5111 + window.postMessage({ source: 'impeccable-ready' }, '*'); 5112 + } else { 5113 + if (window.__IMPECCABLE_CONFIG__?.autoScan !== false) { 5114 + const runAutoScan = () => { 5115 + try { 5116 + scan(); 5117 + } catch (err) { 5118 + console.warn('[impeccable] scan failed', err); 5119 + } 5120 + }; 5121 + if (document.readyState === 'loading') { 5122 + document.addEventListener('DOMContentLoaded', () => setTimeout(runAutoScan, 100)); 5123 + } else { 5124 + setTimeout(runAutoScan, 100); 5125 + } 5126 + } 5127 + } 5128 + 5129 + window.impeccableDetect = detect; 5130 + window.impeccableDetectAsync = detectAsync; 5131 + window.impeccableScan = scan; 5132 + window.impeccableScanAsync = scanAsync; 5133 + window.impeccableCollectVisualContrastCandidates = collectVisualContrastCandidates; 5134 + window.impeccableAnalyzeVisualContrast = analyzeVisualContrast; 5135 + window.impeccableGetLastVisualContrastAnalyses = () => lastVisualContrastAnalyses.slice(); 5136 + } 5137 + 5138 + })();
+50
tools/design-detector/detector/detect-antipatterns.mjs
··· 1 + #!/usr/bin/env node 2 + 3 + /** 4 + * Anti-Pattern Detector for Impeccable 5 + * Copyright (c) 2026 Paul Bakaus 6 + * SPDX-License-Identifier: Apache-2.0 7 + * 8 + * Public API facade. Runtime engines live under cli/engine/engines/. 9 + */ 10 + 11 + import { detectCli } from './cli/main.mjs'; 12 + 13 + export { ANTIPATTERNS, RULE_ENGINE_SUPPORT, getAntipattern, getRulesForCategory, getRuleEngineSupport } from './registry/antipatterns.mjs'; 14 + export { SAFE_TAGS, BORDER_SAFE_TAGS, OVERUSED_FONTS, GENERIC_FONTS, KNOWN_SERIF_FONTS } from './shared/constants.mjs'; 15 + export { isNeutralColor, parseRgb, relativeLuminance, contrastRatio, parseGradientColors, hasChroma, getHue, colorToHex } from './shared/color.mjs'; 16 + export { isFullPage } from './shared/page.mjs'; 17 + export { 18 + checkElementBorders, 19 + checkElementMotion, 20 + checkElementGlow, 21 + checkPageTypography, 22 + checkPageLayout, 23 + checkHtmlPatterns, 24 + } from './rules/checks.mjs'; 25 + export { createDetectorProfile, summarizeDetectorProfile } from './profile/profiler.mjs'; 26 + export { 27 + parseFrontmatter as parseDesignFrontmatter, 28 + normalizeDesignSystem, 29 + loadDesignSystemForCwd, 30 + checkSourceDesignSystem, 31 + collectStaticDesignSystemFindings, 32 + } from './design-system.mjs'; 33 + export { detectHtml } from './engines/static-html/detect-html.mjs'; 34 + export { detectUrl, createBrowserDetector } from './engines/browser/detect-url.mjs'; 35 + export { detectText, extractStyleBlocks, extractCSSinJS } from './engines/regex/detect-text.mjs'; 36 + export { 37 + walkDir, 38 + SCANNABLE_EXTENSIONS, 39 + SKIP_DIRS, 40 + buildImportGraph, 41 + resolveImport, 42 + detectFrameworkConfig, 43 + isPortListening, 44 + FRAMEWORK_CONFIGS, 45 + } from './node/file-system.mjs'; 46 + export { formatFindings, detectCli } from './cli/main.mjs'; 47 + 48 + const isMainModule = process.argv[1]?.endsWith('detect-antipatterns.mjs') || 49 + process.argv[1]?.endsWith('detect-antipatterns.mjs/'); 50 + if (isMainModule) detectCli();
+277
tools/design-detector/detector/engines/browser/detect-url.mjs
··· 1 + import fs from 'node:fs'; 2 + import path from 'node:path'; 3 + import { fileURLToPath } from 'node:url'; 4 + 5 + import { finding } from '../../findings.mjs'; 6 + import { filterByProviders } from '../../registry/antipatterns.mjs'; 7 + import { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs'; 8 + import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs'; 9 + 10 + function serializeDesignSystemForBrowser(designSystem) { 11 + if (!designSystem?.present) return null; 12 + return { 13 + present: true, 14 + hasFonts: designSystem.hasFonts === true, 15 + allowedFonts: Array.from(designSystem.allowedFonts || []), 16 + hasColors: designSystem.hasColors === true, 17 + allowedColors: Array.from(designSystem.allowedColorKeys?.values?.() || []) 18 + .map(entry => entry?.color) 19 + .filter(color => color && Number.isFinite(color.r) && Number.isFinite(color.g) && Number.isFinite(color.b)) 20 + .map(color => ({ r: color.r, g: color.g, b: color.b })), 21 + hasRadii: designSystem.hasRadii === true, 22 + allowedRadii: (designSystem.allowedRadii || []) 23 + .map(entry => Number(entry?.px)) 24 + .filter(px => Number.isFinite(px)), 25 + hasPillRadius: designSystem.hasPillRadius === true, 26 + }; 27 + } 28 + 29 + async function runVisualContrastFallback(page, serializedGroups, options, profile, target) { 30 + if (options?.visualContrast === false) return []; 31 + const maxCandidates = Number.isFinite(options?.visualContrastMaxCandidates) 32 + ? options.visualContrastMaxCandidates 33 + : 12; 34 + const scrollOffscreen = options?.visualContrastScrollOffscreen !== false; 35 + const existingLowContrastSelectors = new Set( 36 + serializedGroups 37 + .filter(group => group.findings?.some(f => f.type === 'low-contrast')) 38 + .map(group => group.selector) 39 + .filter(Boolean) 40 + ); 41 + 42 + let browserAnalyses = []; 43 + const findings = []; 44 + if (options?.visualContrastBrowser !== false) { 45 + const browserFindings = await profileFindingsAsync(profile, { 46 + engine: 'browser', 47 + phase: 'visual-contrast', 48 + ruleId: 'browser-fallback', 49 + target, 50 + }, async () => { 51 + browserAnalyses = await page.evaluate(async ({ maxCandidates, scrollOffscreen }) => { 52 + if (typeof window.impeccableAnalyzeVisualContrast !== 'function') return []; 53 + return window.impeccableAnalyzeVisualContrast({ maxCandidates, scrollOffscreen }); 54 + }, { maxCandidates, scrollOffscreen }); 55 + return browserAnalyses 56 + .filter(result => result.finding && !existingLowContrastSelectors.has(result.selector)) 57 + .map(result => result.finding); 58 + }); 59 + findings.push(...browserFindings); 60 + } 61 + 62 + let candidates = browserAnalyses.length > 0 ? browserAnalyses : []; 63 + if (candidates.length === 0) { 64 + candidates = await profileStepAsync(profile, { 65 + engine: 'browser', 66 + phase: 'visual-contrast', 67 + ruleId: 'collect-candidates', 68 + target, 69 + }, () => page.evaluate(({ maxCandidates }) => { 70 + if (typeof window.impeccableCollectVisualContrastCandidates !== 'function') return []; 71 + return window.impeccableCollectVisualContrastCandidates({ maxCandidates }); 72 + }, { maxCandidates })); 73 + } 74 + 75 + const viewport = options?.viewport || { width: 1280, height: 800 }; 76 + const browserResolvedSelectors = new Set( 77 + browserAnalyses 78 + .filter(result => result.status === 'fail' || result.status === 'pass') 79 + .map(result => result.selector) 80 + .filter(Boolean) 81 + ); 82 + const filtered = candidates.filter(candidate => 83 + !existingLowContrastSelectors.has(candidate.selector) && 84 + !browserResolvedSelectors.has(candidate.selector) 85 + ); 86 + if (options?.visualContrastPixel === false) return findings; 87 + for (const candidate of filtered) { 88 + const result = await profileFindingsAsync(profile, { 89 + engine: 'browser', 90 + phase: 'visual-contrast', 91 + ruleId: 'pixel-diff', 92 + target, 93 + }, async () => { 94 + const finding = await captureVisualContrastCandidate(page, candidate, viewport); 95 + return finding ? [finding] : []; 96 + }); 97 + findings.push(...result); 98 + } 99 + return findings; 100 + } 101 + 102 + // --------------------------------------------------------------------------- 103 + // Puppeteer detection (for URLs) 104 + // --------------------------------------------------------------------------- 105 + 106 + async function detectUrl(url, options = {}) { 107 + const profile = options?.profile; 108 + const waitUntil = options?.waitUntil || 'networkidle0'; 109 + const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0; 110 + const viewport = options?.viewport || { width: 1280, height: 800 }; 111 + const externalBrowser = options?.browser || null; 112 + let puppeteer; 113 + if (!externalBrowser) { 114 + try { 115 + puppeteer = await profileStepAsync(profile, { 116 + engine: 'browser', 117 + phase: 'setup', 118 + ruleId: 'import-puppeteer', 119 + target: url, 120 + }, () => import('puppeteer')); 121 + } catch { 122 + throw new Error('puppeteer is required for URL scanning. Install: npm install puppeteer'); 123 + } 124 + } 125 + 126 + // Read the browser detection script — reuse it instead of reimplementing 127 + const browserScriptPath = path.resolve( 128 + path.dirname(fileURLToPath(import.meta.url)), 129 + '..', 130 + '..', 131 + 'detect-antipatterns-browser.js' 132 + ); 133 + let browserScript; 134 + try { 135 + browserScript = profileStep(profile, { 136 + engine: 'browser', 137 + phase: 'setup', 138 + ruleId: 'read-browser-script', 139 + target: url, 140 + }, () => fs.readFileSync(browserScriptPath, 'utf-8')); 141 + } catch { 142 + throw new Error(`Browser script not found at ${browserScriptPath}`); 143 + } 144 + 145 + // CI runners (GitHub Actions Ubuntu) block unprivileged user namespaces, so 146 + // Chrome can't initialize its sandbox there. Disable the sandbox only when 147 + // running in CI; local users keep the default hardened launch. 148 + const launchArgs = process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : []; 149 + const browser = externalBrowser || await profileStepAsync(profile, { 150 + engine: 'browser', 151 + phase: 'load', 152 + ruleId: 'launch-browser', 153 + target: url, 154 + }, () => puppeteer.default.launch({ headless: true, args: launchArgs })); 155 + const page = await profileStepAsync(profile, { 156 + engine: 'browser', 157 + phase: 'load', 158 + ruleId: 'new-page', 159 + target: url, 160 + }, () => browser.newPage()); 161 + let results = []; 162 + try { 163 + await profileStepAsync(profile, { 164 + engine: 'browser', 165 + phase: 'load', 166 + ruleId: 'set-viewport', 167 + target: url, 168 + }, () => page.setViewport(viewport)); 169 + await profileStepAsync(profile, { 170 + engine: 'browser', 171 + phase: 'load', 172 + ruleId: `goto:${waitUntil}`, 173 + target: url, 174 + }, () => page.goto(url, { waitUntil, timeout: 30000 })); 175 + if (settleMs > 0) { 176 + await profileStepAsync(profile, { 177 + engine: 'browser', 178 + phase: 'load', 179 + ruleId: 'settle', 180 + target: url, 181 + }, () => new Promise(resolve => setTimeout(resolve, settleMs))); 182 + } 183 + 184 + // Inject the browser detection script and collect results 185 + const browserDesignSystem = serializeDesignSystemForBrowser(options?.designSystem); 186 + await profileStepAsync(profile, { 187 + engine: 'browser', 188 + phase: 'scan', 189 + ruleId: 'configure-pure-detect', 190 + target: url, 191 + }, () => page.evaluate((designSystem) => { 192 + window.__IMPECCABLE_CONFIG__ = { 193 + ...(window.__IMPECCABLE_CONFIG__ || {}), 194 + autoScan: false, 195 + ...(designSystem ? { designSystem } : {}), 196 + }; 197 + }, browserDesignSystem)); 198 + await profileStepAsync(profile, { 199 + engine: 'browser', 200 + phase: 'scan', 201 + ruleId: 'inject-browser-script', 202 + target: url, 203 + }, () => page.evaluate(browserScript)); 204 + let serializedGroups = []; 205 + results = await profileFindingsAsync(profile, { 206 + engine: 'browser', 207 + phase: 'scan', 208 + ruleId: 'browser-scan', 209 + target: url, 210 + }, async () => { 211 + serializedGroups = await page.evaluate(() => { 212 + if (!window.impeccableDetect) return []; 213 + return window.impeccableDetect({ decorate: false, serialize: true }); 214 + }); 215 + return serializedGroups.flatMap(({ findings }) => 216 + findings.map(f => ({ id: f.type, snippet: f.detail, ignoreValue: f.ignoreValue || '' })) 217 + ); 218 + }); 219 + const visualFindings = await runVisualContrastFallback(page, serializedGroups, options, profile, url); 220 + results.push(...visualFindings); 221 + } finally { 222 + await profileStepAsync(profile, { 223 + engine: 'browser', 224 + phase: 'load', 225 + ruleId: 'close-page', 226 + target: url, 227 + }, () => page.close().catch(() => {})); 228 + if (!externalBrowser) { 229 + await profileStepAsync(profile, { 230 + engine: 'browser', 231 + phase: 'load', 232 + ruleId: 'close-browser', 233 + target: url, 234 + }, () => browser.close()); 235 + } 236 + } 237 + return filterByProviders(results.map(f => { 238 + const item = finding(f.id, url, f.snippet); 239 + if (f.ignoreValue) item.ignoreValue = f.ignoreValue; 240 + return item; 241 + }), options.providers); 242 + } 243 + 244 + async function createBrowserDetector(options = {}) { 245 + let puppeteer; 246 + try { 247 + puppeteer = await import('puppeteer'); 248 + } catch { 249 + throw new Error('puppeteer is required for URL scanning. Install: npm install puppeteer'); 250 + } 251 + const launchArgs = options.launchArgs || (process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : []); 252 + const browser = options.browser || await puppeteer.default.launch({ 253 + headless: options.headless ?? true, 254 + args: launchArgs, 255 + }); 256 + const ownsBrowser = !options.browser; 257 + const defaults = { 258 + waitUntil: options.waitUntil || 'load', 259 + settleMs: Number.isFinite(options.settleMs) ? options.settleMs : 100, 260 + viewport: options.viewport || { width: 1280, height: 800 }, 261 + }; 262 + return { 263 + browser, 264 + async detectUrl(url, scanOptions = {}) { 265 + return detectUrl(url, { 266 + ...defaults, 267 + ...scanOptions, 268 + browser, 269 + }); 270 + }, 271 + async close() { 272 + if (ownsBrowser) await browser.close().catch(() => {}); 273 + }, 274 + }; 275 + } 276 + 277 + export { runVisualContrastFallback, detectUrl, createBrowserDetector };
+568
tools/design-detector/detector/engines/regex/detect-text.mjs
··· 1 + import { GENERIC_FONTS } from '../../shared/constants.mjs'; 2 + import { isNeutralColor } from '../../shared/color.mjs'; 3 + import { checkSourceDesignSystem } from '../../design-system.mjs'; 4 + import { isFullPage } from '../../shared/page.mjs'; 5 + import { applyInlineIgnores } from '../../shared/inline-ignores.mjs'; 6 + import { finding } from '../../findings.mjs'; 7 + import { filterByProviders } from '../../registry/antipatterns.mjs'; 8 + import { profileFindings, profileStep } from '../../profile/profiler.mjs'; 9 + 10 + // --------------------------------------------------------------------------- 11 + // Regex fallback (non-HTML files: CSS, JSX, TSX, etc.) 12 + // --------------------------------------------------------------------------- 13 + 14 + const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line); 15 + const hasBorderRadius = (line) => /border-radius/i.test(line); 16 + const isSafeElement = (line) => /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line); 17 + 18 + /** Strip HTML to plain text — drops script/style/comments/tags so 19 + * content-text analyzers don't false-positive on code or CSS. */ 20 + function stripHtmlToText(html) { 21 + return html 22 + .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ') 23 + .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ') 24 + .replace(/<!--[\s\S]*?-->/g, ' ') 25 + .replace(/<[^>]+>/g, ' ') 26 + .replace(/\s+/g, ' '); 27 + } 28 + 29 + const PAGE_ANALYZER_EXTS = new Set(['.html', '.htm', '.astro', '.vue', '.svelte']); 30 + 31 + function extFromFilePath(filePath) { 32 + return filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : ''; 33 + } 34 + 35 + function shouldRunPageAnalyzers(content, filePath) { 36 + if (!isFullPage(content)) return false; 37 + const ext = extFromFilePath(filePath); 38 + return !ext || PAGE_ANALYZER_EXTS.has(ext); 39 + } 40 + 41 + function isNeutralBorderColor(str) { 42 + const m = str.match(/solid\s+((?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color)\([^)]*\)|#[0-9a-f]{3,8}\b|[a-z]+)/i); 43 + if (!m) return false; 44 + const c = m[1].toLowerCase(); 45 + if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true; 46 + if (/^(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c); 47 + const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/); 48 + if (hex) { 49 + const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)]; 50 + return (Math.max(r, g, b) - Math.min(r, g, b)) < 30; 51 + } 52 + const shex = c.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/); 53 + if (shex) { 54 + const [r, g, b] = [parseInt(shex[1] + shex[1], 16), parseInt(shex[2] + shex[2], 16), parseInt(shex[3] + shex[3], 16)]; 55 + return (Math.max(r, g, b) - Math.min(r, g, b)) < 30; 56 + } 57 + return false; 58 + } 59 + 60 + const REGEX_MATCHERS = [ 61 + // --- Side-tab --- 62 + { id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g, 63 + test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 2 : n >= 4; }, 64 + fmt: (m) => m[0] }, 65 + { id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi, 66 + test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 2 : n >= 3; }, 67 + fmt: (m) => m[0].replace(/\s*;?\s*$/, '') }, 68 + { id: 'side-tab', regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi, 69 + test: (m, line) => !isSafeElement(line) && +m[1] >= 3, 70 + fmt: (m) => m[0] }, 71 + { id: 'side-tab', regex: /border-inline-(?:start|end)\s*:\s*(\d+)px\s+solid/gi, 72 + test: (m, line) => !isSafeElement(line) && +m[1] >= 3, 73 + fmt: (m) => m[0] }, 74 + { id: 'side-tab', regex: /border-inline-(?:start|end)-width\s*:\s*(\d+)px/gi, 75 + test: (m, line) => !isSafeElement(line) && +m[1] >= 3, 76 + fmt: (m) => m[0] }, 77 + { id: 'side-tab', regex: /border(?:Left|Right)\s*[:=]\s*["'`](\d+)px\s+solid/g, 78 + test: (m) => +m[1] >= 3, 79 + fmt: (m) => m[0] }, 80 + // --- Border accent on rounded --- 81 + { id: 'border-accent-on-rounded', regex: /\bborder-[tb]-(\d+)\b/g, 82 + test: (m, line) => hasRounded(line) && +m[1] >= 1, 83 + fmt: (m) => m[0] }, 84 + { id: 'border-accent-on-rounded', regex: /border-(?:top|bottom)\s*:\s*(\d+)px\s+solid/gi, 85 + test: (m, line) => +m[1] >= 3 && hasBorderRadius(line), 86 + fmt: (m) => m[0] }, 87 + // --- Overused font --- 88 + { id: 'overused-font', regex: /font-family\s*:\s*['"]?(Inter|Roboto|Open Sans|Lato|Montserrat|Arial|Helvetica|Fraunces|Geist Sans|Geist Mono|Geist|Mona Sans|Plus Jakarta Sans|Space Grotesk|Recoleta|Instrument Sans|Instrument Serif)\b/gi, 89 + test: () => true, 90 + fmt: (m) => m[0] }, 91 + { id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?family=(Inter|Roboto|Open\+Sans|Lato|Montserrat|Fraunces|Plus\+Jakarta\+Sans|Space\+Grotesk|Instrument\+Sans|Instrument\+Serif|Mona\+Sans|Geist)\b/gi, 92 + test: () => true, 93 + fmt: (m) => `Google Fonts: ${m[1].replace(/\+/g, ' ')}` }, 94 + // --- Gradient text --- 95 + { id: 'gradient-text', regex: /background-clip\s*:\s*text|-webkit-background-clip\s*:\s*text/gi, 96 + test: (m, line) => /gradient/i.test(line), 97 + fmt: () => 'background-clip: text + gradient' }, 98 + // --- Gradient text (Tailwind) --- 99 + { id: 'gradient-text', regex: /\bbg-clip-text\b/g, 100 + test: (m, line) => /\bbg-gradient-to-/i.test(line), 101 + fmt: () => 'bg-clip-text + bg-gradient' }, 102 + // --- Tailwind gray on colored bg --- 103 + { id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g, 104 + test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line), 105 + fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } }, 106 + // --- Tailwind AI palette --- 107 + { id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g, 108 + test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line), 109 + fmt: (m) => `${m[0]} on heading` }, 110 + { id: 'ai-color-palette', regex: /\bfrom-(?:purple|violet|indigo)-(\d+)\b/g, 111 + test: (m, line) => /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(line), 112 + fmt: (m) => `${m[0]} gradient` }, 113 + // --- Bounce/elastic easing --- 114 + { id: 'bounce-easing', regex: /\banimate-bounce\b/g, 115 + test: () => true, 116 + fmt: () => 'animate-bounce (Tailwind)' }, 117 + { id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi, 118 + test: () => true, 119 + fmt: (m) => { 120 + const token = m[1] 121 + .split(/[,\s]+/) 122 + .find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part)); 123 + return `animation: ${token || m[1].trim()}`; 124 + } }, 125 + { id: 'bounce-easing', regex: /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g, 126 + test: (m) => { 127 + const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]); 128 + return y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1; 129 + }, 130 + fmt: (m) => `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` }, 131 + // --- Layout property transition --- 132 + { id: 'layout-transition', regex: /transition\s*:\s*([^;{}]+)/gi, 133 + test: (m) => { 134 + const val = m[1].toLowerCase(); 135 + if (/\ball\b/.test(val)) return false; 136 + return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val); 137 + }, 138 + fmt: (m) => { 139 + const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi); 140 + return `transition: ${found ? found.join(', ') : m[1].trim()}`; 141 + } }, 142 + { id: 'layout-transition', regex: /transition-property\s*:\s*([^;{}]+)/gi, 143 + test: (m) => { 144 + const val = m[1].toLowerCase(); 145 + if (/\ball\b/.test(val)) return false; 146 + return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val); 147 + }, 148 + fmt: (m) => { 149 + const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi); 150 + return `transition-property: ${found ? found.join(', ') : m[1].trim()}`; 151 + } }, 152 + // --- Broken image: src="" or src="#" or src=" " --- 153 + { id: 'broken-image', regex: /<img\b[^>]*?\bsrc\s*=\s*(?:""|''|"\s+"|'\s+'|"#"|'#')/gi, 154 + test: () => true, 155 + fmt: (m) => m[0].slice(0, 100) }, 156 + // --- Broken image: <img> with no src attribute at all --- 157 + { id: 'broken-image', regex: /<img\b(?:(?!\bsrc\s*=)[^>])*>/gi, 158 + test: (m) => !/\bsrc\s*=/i.test(m[0]), 159 + fmt: (m) => m[0].slice(0, 100) }, 160 + ]; 161 + 162 + const REGEX_ANALYZERS = [ 163 + // Single font 164 + (content, filePath) => { 165 + const fontFamilyRe = /font-family\s*:\s*([^;}]+)/gi; 166 + const fonts = new Set(); 167 + let m; 168 + while ((m = fontFamilyRe.exec(content)) !== null) { 169 + for (const f of m[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) { 170 + if (f && !GENERIC_FONTS.has(f)) fonts.add(f); 171 + } 172 + } 173 + const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi; 174 + while ((m = gfRe.exec(content)) !== null) { 175 + for (const f of m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase())) fonts.add(f); 176 + } 177 + if (fonts.size !== 1 || content.split('\n').length < 20) return []; 178 + const name = [...fonts][0]; 179 + const lines = content.split('\n'); 180 + let line = 1; 181 + for (let i = 0; i < lines.length; i++) { if (lines[i].toLowerCase().includes(name)) { line = i + 1; break; } } 182 + return [finding('single-font', filePath, `only font used is ${name}`, line)]; 183 + }, 184 + // Flat type hierarchy 185 + (content, filePath) => { 186 + const sizes = new Set(); 187 + const REM = 16; 188 + let m; 189 + const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi; 190 + while ((m = sizeRe.exec(content)) !== null) { 191 + const px = m[2] === 'px' ? +m[1] : +m[1] * REM; 192 + if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10); 193 + } 194 + const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi; 195 + while ((m = clampRe.exec(content)) !== null) { 196 + sizes.add(Math.round((m[2] === 'px' ? +m[1] : +m[1] * REM) * 10) / 10); 197 + sizes.add(Math.round((m[4] === 'px' ? +m[3] : +m[3] * REM) * 10) / 10); 198 + } 199 + const TW = { 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, 'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, 'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128 }; 200 + for (const [cls, px] of Object.entries(TW)) { if (new RegExp(`\\b${cls}\\b`).test(content)) sizes.add(px); } 201 + if (sizes.size < 3) return []; 202 + const sorted = [...sizes].sort((a, b) => a - b); 203 + const ratio = sorted[sorted.length - 1] / sorted[0]; 204 + if (ratio >= 2.0) return []; 205 + const lines = content.split('\n'); 206 + let line = 1; 207 + for (let i = 0; i < lines.length; i++) { if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) { line = i + 1; break; } } 208 + return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)]; 209 + }, 210 + // Monotonous spacing (regex) 211 + (content, filePath) => { 212 + const vals = []; 213 + let m; 214 + const pxRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi; 215 + while ((m = pxRe.exec(content)) !== null) { const v = +m[1]; if (v > 0 && v < 200) vals.push(v); } 216 + const remRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi; 217 + while ((m = remRe.exec(content)) !== null) { const v = Math.round(parseFloat(m[1]) * 16); if (v > 0 && v < 200) vals.push(v); } 218 + const gapRe = /gap\s*:\s*(\d+)px/gi; 219 + while ((m = gapRe.exec(content)) !== null) vals.push(+m[1]); 220 + const twRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g; 221 + while ((m = twRe.exec(content)) !== null) vals.push(+m[1] * 4); 222 + const rounded = vals.map(v => Math.round(v / 4) * 4); 223 + if (rounded.length < 10) return []; 224 + const counts = {}; 225 + for (const v of rounded) counts[v] = (counts[v] || 0) + 1; 226 + const maxCount = Math.max(...Object.values(counts)); 227 + const pct = maxCount / rounded.length; 228 + const unique = [...new Set(rounded)].filter(v => v > 0); 229 + if (pct <= 0.6 || unique.length > 3) return []; 230 + const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0]; 231 + return [finding('monotonous-spacing', filePath, `~${dominant}px used ${maxCount}/${rounded.length} times (${Math.round(pct * 100)}%)`)]; 232 + }, 233 + // Em-dash overuse: 5+ em-dashes or "--" in body text content 234 + // (occasional em-dash use in prose is fine; the pattern fires only 235 + // when count crosses into AI-cadence territory). 236 + (content, filePath) => { 237 + const text = stripHtmlToText(content); 238 + let count = 0; 239 + const re = /[—]|--(?=\S)/g; 240 + while (re.exec(text) !== null) count++; 241 + if (count < 5) return []; 242 + return [finding('em-dash-overuse', filePath, `${count} em-dashes in body text`)]; 243 + }, 244 + // Marketing buzzwords: SaaS phrase list 245 + (content, filePath) => { 246 + const text = stripHtmlToText(content); 247 + const lower = text.toLowerCase(); 248 + const BUZZWORDS = [ 249 + 'streamline your', 'empower your', 'supercharge your', 250 + 'unleash your', 'unleash the power', 'leverage the power', 251 + 'built for the modern', 'trusted by leading', 'trusted by the world', 252 + 'best-in-class', 'industry-leading', 'world-class', 'enterprise-grade', 253 + 'next-generation', 'cutting-edge', 'transform your business', 254 + 'revolutionize', 'game-changer', 'game changing', 255 + 'mission-critical', 'best of breed', 'future-proof', 'future proof', 256 + 'seamless experience', 'seamlessly integrate', 257 + 'drive engagement', 'drive growth', 'drive results', 258 + 'harness the power', 259 + ]; 260 + let count = 0; 261 + let firstSample = ''; 262 + for (const phrase of BUZZWORDS) { 263 + let from = 0; 264 + while (true) { 265 + const idx = lower.indexOf(phrase, from); 266 + if (idx === -1) break; 267 + count++; 268 + if (!firstSample) { 269 + firstSample = text.slice(Math.max(0, idx - 12), Math.min(text.length, idx + phrase.length + 12)).trim(); 270 + } 271 + from = idx + phrase.length; 272 + } 273 + } 274 + if (count === 0) return []; 275 + return [finding('marketing-buzzword', filePath, `${count} buzzword phrase${count === 1 ? '' : 's'}: "${firstSample}"`)]; 276 + }, 277 + // Numbered section markers (01 / 02 / 03 ...) 278 + (content, filePath) => { 279 + const text = stripHtmlToText(content); 280 + const re = /\b(0[1-9]|1[0-2])\b/g; 281 + const seen = new Set(); 282 + let m; 283 + while ((m = re.exec(text)) !== null) seen.add(m[1]); 284 + if (seen.size < 3) return []; 285 + const sorted = [...seen].sort(); 286 + let sequential = 0; 287 + for (let i = 1; i < sorted.length; i++) { 288 + if (parseInt(sorted[i], 10) === parseInt(sorted[i - 1], 10) + 1) sequential++; 289 + } 290 + if (sequential < 2) return []; 291 + return [finding('numbered-section-markers', filePath, `Sequence: ${sorted.slice(0, 6).join(', ')}`)]; 292 + }, 293 + // Aphoristic cadence: manufactured-contrast + short-rebuttal 294 + (content, filePath) => { 295 + const text = stripHtmlToText(content); 296 + const NOT_A_RE = /\bNot an? [a-z][^.!?]{1,40}[.!]\s+[A-Z][^.!?]{1,60}[.!]/g; 297 + const SHORT_REBUTTAL_RE = /\b[A-Z][^.!?]{4,80}[.!]\s+(No|Just)\s+[a-z][^.!?]{2,60}[.!]/g; 298 + let count = 0; 299 + let firstSample = ''; 300 + let m; 301 + NOT_A_RE.lastIndex = 0; 302 + while ((m = NOT_A_RE.exec(text)) !== null) { 303 + count++; 304 + if (!firstSample) firstSample = m[0].trim().slice(0, 80); 305 + } 306 + SHORT_REBUTTAL_RE.lastIndex = 0; 307 + while ((m = SHORT_REBUTTAL_RE.exec(text)) !== null) { 308 + count++; 309 + if (!firstSample) firstSample = m[0].trim().slice(0, 80); 310 + } 311 + if (count < 3) return []; 312 + return [finding('aphoristic-cadence', filePath, `${count} aphoristic constructions: "${firstSample}"`)]; 313 + }, 314 + // Dark glow (page-level: dark bg + colored box-shadow with blur) 315 + (content, filePath) => { 316 + // Check if page has a dark background 317 + const darkBgRe = /background(?:-color)?\s*:\s*(?:#(?:0[0-9a-f]|1[0-9a-f]|2[0-3])[0-9a-f]{4}\b|#(?:0|1)[0-9a-f]{2}\b|rgb\(\s*(\d{1,2})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\))/gi; 318 + const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/; 319 + const hasDarkBg = darkBgRe.test(content) || twDarkBg.test(content); 320 + if (!hasDarkBg) return []; 321 + 322 + // Check for colored box-shadow with blur > 4px 323 + const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi; 324 + let m; 325 + while ((m = shadowRe.exec(content)) !== null) { 326 + const val = m[1]; 327 + const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); 328 + if (!colorMatch) continue; 329 + const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]]; 330 + if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue; // skip gray 331 + // Check blur: look for pattern like "0 0 20px" (third number > 4) 332 + const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2])); 333 + if (pxVals.length >= 3 && pxVals[2] > 4) { 334 + const lines = content.substring(0, m.index).split('\n'); 335 + return [finding('dark-glow', filePath, `Colored glow (rgb(${r},${g},${b})) on dark page`, lines.length)]; 336 + } 337 + } 338 + return []; 339 + }, 340 + ]; 341 + 342 + // --------------------------------------------------------------------------- 343 + // Style block extraction (Vue/Svelte <style> blocks) 344 + // --------------------------------------------------------------------------- 345 + 346 + function extractStyleBlocks(content, ext) { 347 + ext = ext.toLowerCase(); 348 + if (ext !== '.vue' && ext !== '.svelte') return []; 349 + const blocks = []; 350 + const re = /<style[^>]*>([\s\S]*?)<\/style>/gi; 351 + let m; 352 + while ((m = re.exec(content)) !== null) { 353 + const before = content.substring(0, m.index); 354 + const startLine = before.split('\n').length + 1; 355 + blocks.push({ content: m[1], startLine }); 356 + } 357 + return blocks; 358 + } 359 + 360 + // --------------------------------------------------------------------------- 361 + // CSS-in-JS extraction (styled-components, emotion) 362 + // --------------------------------------------------------------------------- 363 + 364 + const CSS_IN_JS_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx']); 365 + 366 + function extractCSSinJS(content, ext) { 367 + ext = ext.toLowerCase(); 368 + if (!CSS_IN_JS_EXTENSIONS.has(ext)) return []; 369 + const blocks = []; 370 + const re = /(?:styled(?:\.\w+|\([^)]+\))|css)\s*`([\s\S]*?)`/g; 371 + let m; 372 + while ((m = re.exec(content)) !== null) { 373 + const before = content.substring(0, m.index); 374 + const startLine = before.split('\n').length; 375 + blocks.push({ content: m[1], startLine }); 376 + } 377 + return blocks; 378 + } 379 + 380 + function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, options = {}) { 381 + const { profile, phase = 'regex-matchers' } = options || {}; 382 + const findings = []; 383 + if (!profile) { 384 + for (const matcher of REGEX_MATCHERS) { 385 + for (let i = 0; i < lines.length; i++) { 386 + const line = lines[i]; 387 + matcher.regex.lastIndex = 0; 388 + let m; 389 + while ((m = matcher.regex.exec(line)) !== null) { 390 + // For extracted blocks, use nearby lines as context for multi-line CSS patterns 391 + const context = blockContext 392 + ? lines.slice(Math.max(0, i - 3), Math.min(lines.length, i + 4)).join(' ') 393 + : line; 394 + if (matcher.test(m, context)) { 395 + findings.push(finding(matcher.id, filePath, matcher.fmt(m, context), i + 1 + lineOffset)); 396 + } 397 + } 398 + } 399 + } 400 + return findings; 401 + } 402 + 403 + for (const matcher of REGEX_MATCHERS) { 404 + const matcherFindings = profileFindings(profile, { 405 + engine: 'regex', 406 + phase, 407 + ruleId: matcher.id, 408 + target: filePath, 409 + }, () => { 410 + const matches = []; 411 + for (let i = 0; i < lines.length; i++) { 412 + const line = lines[i]; 413 + matcher.regex.lastIndex = 0; 414 + let m; 415 + while ((m = matcher.regex.exec(line)) !== null) { 416 + // For extracted blocks, use nearby lines as context for multi-line CSS patterns 417 + const context = blockContext 418 + ? lines.slice(Math.max(0, i - 3), Math.min(lines.length, i + 4)).join(' ') 419 + : line; 420 + if (matcher.test(m, context)) { 421 + matches.push(finding(matcher.id, filePath, matcher.fmt(m, context), i + 1 + lineOffset)); 422 + } 423 + } 424 + } 425 + return matches; 426 + }); 427 + findings.push(...matcherFindings); 428 + } 429 + return findings; 430 + } 431 + 432 + /** Page-level analyzers that scan rendered text content (em-dash use, 433 + * buzzword phrases, numbered section markers, aphoristic cadence). 434 + * These are detector-agnostic — they work on any HTML/text source 435 + * and don't need a parsed DOM. Exported so detectHtml can call them 436 + * for `.html` files (which otherwise skip the regex engine). */ 437 + const TEXT_CONTENT_ANALYZER_IDS = [ 438 + 'em-dash-overuse', 439 + 'marketing-buzzword', 440 + 'numbered-section-markers', 441 + 'aphoristic-cadence', 442 + ]; 443 + 444 + function runTextContentAnalyzers(content, filePath, options = {}) { 445 + const profile = options?.profile; 446 + if (!shouldRunPageAnalyzers(content, filePath)) return []; 447 + // The 4 text-content analyzers are at indices 3-6 in REGEX_ANALYZERS. 448 + const findings = []; 449 + for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) { 450 + const analyzer = REGEX_ANALYZERS[3 + i]; 451 + const ruleId = TEXT_CONTENT_ANALYZER_IDS[i]; 452 + findings.push(...profileFindings(profile, { 453 + engine: 'regex', 454 + phase: 'text-content', 455 + ruleId, 456 + target: filePath, 457 + }, () => analyzer(content, filePath))); 458 + } 459 + return findings; 460 + } 461 + 462 + function detectText(content, filePath, options = {}) { 463 + const profile = options?.profile; 464 + const findings = []; 465 + const lines = content.split('\n'); 466 + const ext = extFromFilePath(filePath); 467 + 468 + // Run regex matchers on the full file content (catches Tailwind classes, inline styles) 469 + // Enable block context for CSS files where related properties span multiple lines 470 + const cssLike = new Set(['.css', '.scss', '.sass', '.less']); 471 + findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, { 472 + profile, 473 + phase: 'source', 474 + })); 475 + 476 + // Extract and scan <style> blocks from Vue/Svelte SFCs 477 + const styleBlocks = profile 478 + ? profileStep(profile, { 479 + engine: 'regex', 480 + phase: 'extract', 481 + ruleId: 'style-blocks', 482 + target: filePath, 483 + }, () => extractStyleBlocks(content, ext)) 484 + : extractStyleBlocks(content, ext); 485 + for (const block of styleBlocks) { 486 + const blockLines = block.content.split('\n'); 487 + findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, { 488 + profile, 489 + phase: 'style-block', 490 + })); 491 + } 492 + 493 + // Extract and scan CSS-in-JS template literals 494 + const cssJsBlocks = profile 495 + ? profileStep(profile, { 496 + engine: 'regex', 497 + phase: 'extract', 498 + ruleId: 'css-in-js', 499 + target: filePath, 500 + }, () => extractCSSinJS(content, ext)) 501 + : extractCSSinJS(content, ext); 502 + for (const block of cssJsBlocks) { 503 + const blockLines = block.content.split('\n'); 504 + findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, { 505 + profile, 506 + phase: 'css-in-js', 507 + })); 508 + } 509 + 510 + if (options?.designSystem) { 511 + findings.push(...profileFindings(profile, { 512 + engine: 'regex', 513 + phase: 'source', 514 + ruleId: 'design-system', 515 + target: filePath, 516 + }, () => checkSourceDesignSystem(content, filePath, { designSystem: options.designSystem }))); 517 + } 518 + 519 + // Deduplicate findings (same antipattern + similar snippet, within 2 lines) 520 + const deduped = []; 521 + for (const f of findings) { 522 + const isDupe = deduped.some(d => 523 + d.antipattern === f.antipattern && 524 + d.snippet === f.snippet && 525 + Math.abs(d.line - f.line) <= 2 526 + ); 527 + if (!isDupe) deduped.push(f); 528 + } 529 + 530 + // Page-level analyzers only run on full pages 531 + if (shouldRunPageAnalyzers(content, filePath)) { 532 + const analyzerIds = [ 533 + 'single-font', 534 + 'flat-type-hierarchy', 535 + 'monotonous-spacing', 536 + 'em-dash-overuse', 537 + 'marketing-buzzword', 538 + 'numbered-section-markers', 539 + 'aphoristic-cadence', 540 + 'dark-glow', 541 + ]; 542 + for (let i = 0; i < REGEX_ANALYZERS.length; i++) { 543 + const analyzer = REGEX_ANALYZERS[i]; 544 + deduped.push(...profileFindings(profile, { 545 + engine: 'regex', 546 + phase: 'page-analyzer', 547 + ruleId: analyzerIds[i] || `analyzer-${i + 1}`, 548 + target: filePath, 549 + }, () => analyzer(content, filePath))); 550 + } 551 + } 552 + 553 + const byProvider = filterByProviders(deduped, options?.providers); 554 + // Inline `impeccable-disable*` waivers travel with the file; honor them unless 555 + // explicitly bypassed (`--no-config` / `--no-inline-ignores`). 556 + return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, content); 557 + } 558 + 559 + export { 560 + REGEX_MATCHERS, 561 + REGEX_ANALYZERS, 562 + TEXT_CONTENT_ANALYZER_IDS, 563 + extractStyleBlocks, 564 + extractCSSinJS, 565 + runRegexMatchers, 566 + runTextContentAnalyzers, 567 + detectText, 568 + };
+1015
tools/design-detector/detector/engines/static-html/css-cascade.mjs
··· 1 + import fs from 'node:fs'; 2 + import path from 'node:path'; 3 + 4 + import { profileStep, recordProfileEvent } from '../../profile/profiler.mjs'; 5 + import { parseAnyColor, resolveLengthPx, resolveVarRefs } from '../../rules/checks.mjs'; 6 + 7 + // --------------------------------------------------------------------------- 8 + // jsdom CSS-variable border override map 9 + // --------------------------------------------------------------------------- 10 + // 11 + // jsdom's CSSOM silently drops any border shorthand that contains a var() 12 + // reference — the computed style for the element then shows empty width, 13 + // empty style, and a default black color. That's enough to hide the most 14 + // common real-world side-tab pattern in AI-generated pages: 15 + // 16 + // :root { --brand: #87a8ff; } 17 + // .card { border-left: 5px solid var(--brand); border-radius: 4px; } 18 + // 19 + // Real browsers (and therefore the browser detector path) resolve var() 20 + // natively, so this only affects the Node jsdom path. 21 + // 22 + // This pre-pass walks the stylesheets, finds any rule whose per-side or 23 + // all-sides border property contains var(), resolves the var() against 24 + // :root-level custom properties (read from the documentElement's computed 25 + // style, which jsdom DOES handle correctly), and attaches the resolved 26 + // width+color to every element that matches the rule's selector. The 27 + // Node-side `checkElementBorders` adapter consumes that map as a fallback 28 + // whenever jsdom's computed style came back empty. 29 + // 30 + // Limitations (intentional, to keep the pass simple): 31 + // * Only :root-level custom properties are resolved. Scoped overrides on 32 + // descendants are not tracked — uncommon in practice and would require 33 + // a per-element cascade walk. 34 + // * @media / @supports wrapped rules are ignored (jsdom often mishandles 35 + // these anyway). 36 + // * The fallback only fills sides that jsdom left empty, so any rule 37 + // whose border parses normally still wins via the computed style. 38 + 39 + const BORDER_SHORTHAND_RE = /^(\d+(?:\.\d+)?)px\s+(solid|dashed|dotted|double|groove|ridge|inset|outset)\s+(.+)$/i; 40 + 41 + // isNeutralColor only understands rgba()/oklch()/lch()/lab()/hsl()/hwb(). 42 + // CSS variables typically hold hex or named colors, so normalize those to 43 + // rgb() before handing the value off to the shared check. Anything we don't 44 + // recognise is passed through unchanged — isNeutralColor then treats it as 45 + // non-neutral, which is the safer default (matches the oklch-era bugfix). 46 + const NAMED_COLORS = { 47 + white: [255, 255, 255], black: [0, 0, 0], gray: [128, 128, 128], 48 + grey: [128, 128, 128], silver: [192, 192, 192], red: [255, 0, 0], 49 + green: [0, 128, 0], blue: [0, 0, 255], yellow: [255, 255, 0], 50 + }; 51 + 52 + function normalizeColorForCheck(value) { 53 + if (!value) return value; 54 + const v = value.trim(); 55 + const hex6 = v.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i); 56 + if (hex6) { 57 + const [r, g, b] = [parseInt(hex6[1], 16), parseInt(hex6[2], 16), parseInt(hex6[3], 16)]; 58 + return `rgb(${r}, ${g}, ${b})`; 59 + } 60 + const hex3 = v.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i); 61 + if (hex3) { 62 + const [r, g, b] = [ 63 + parseInt(hex3[1] + hex3[1], 16), 64 + parseInt(hex3[2] + hex3[2], 16), 65 + parseInt(hex3[3] + hex3[3], 16), 66 + ]; 67 + return `rgb(${r}, ${g}, ${b})`; 68 + } 69 + const named = NAMED_COLORS[v.toLowerCase()]; 70 + if (named) return `rgb(${named[0]}, ${named[1]}, ${named[2]})`; 71 + return v; 72 + } 73 + 74 + function buildBorderOverrideMap(document, window) { 75 + const map = new Map(); 76 + const rootStyle = window.getComputedStyle(document.documentElement); 77 + 78 + function resolveVar(value, depth = 0) { 79 + if (!value || depth > 10 || !value.includes('var(')) return value; 80 + return value.replace( 81 + /var\(\s*(--[\w-]+)\s*(?:,\s*([^)]+))?\s*\)/g, 82 + (_, name, fallback) => { 83 + const v = rootStyle.getPropertyValue(name).trim(); 84 + if (v) return resolveVar(v, depth + 1); 85 + if (fallback) return resolveVar(fallback.trim(), depth + 1); 86 + return ''; 87 + } 88 + ); 89 + } 90 + 91 + function parseShorthand(text) { 92 + const m = text.trim().match(BORDER_SHORTHAND_RE); 93 + if (!m) return null; 94 + return { width: parseFloat(m[1]), color: normalizeColorForCheck(m[3]) }; 95 + } 96 + 97 + // Read from the per-property accessors on rule.style. jsdom preserves 98 + // each border-* shorthand it parsed, even when the overall cssText has 99 + // been truncated (e.g. a `border: 1px solid var(...)` followed by a 100 + // `border-left: ...` loses the first declaration but keeps the second). 101 + const SIDE_PROPS = [ 102 + ['borderLeft', 'Left'], 103 + ['borderRight', 'Right'], 104 + ['borderTop', 'Top'], 105 + ['borderBottom', 'Bottom'], 106 + ['borderInlineStart', 'Left'], 107 + ['borderInlineEnd', 'Right'], 108 + ]; 109 + 110 + for (const sheet of document.styleSheets) { 111 + let rules; 112 + try { rules = sheet.cssRules || []; } catch { continue; } 113 + for (const rule of rules) { 114 + // CSSStyleRule only; skip @media / @keyframes / @supports wrappers. 115 + if (rule.type !== 1 || !rule.style || !rule.selectorText) continue; 116 + 117 + const perSide = {}; 118 + 119 + for (const [prop, side] of SIDE_PROPS) { 120 + const val = rule.style[prop]; 121 + if (!val || !val.includes('var(')) continue; 122 + const parsed = parseShorthand(resolveVar(val)); 123 + if (parsed && parsed.color) perSide[side] = parsed; 124 + } 125 + 126 + // Uniform `border: <w> <style> var(...)` applies to every side the 127 + // per-side map didn't already claim. 128 + const borderAll = rule.style.border; 129 + if (borderAll && borderAll.includes('var(')) { 130 + const parsed = parseShorthand(resolveVar(borderAll)); 131 + if (parsed && parsed.color) { 132 + for (const s of ['Top', 'Right', 'Bottom', 'Left']) { 133 + if (!perSide[s]) perSide[s] = parsed; 134 + } 135 + } 136 + } 137 + 138 + // Longhand `border-*-color: var(...)` with width/style in separate 139 + // declarations. Rare in AI-generated pages, but cheap to cover. 140 + for (const [prop, side] of [ 141 + ['borderLeftColor', 'Left'], 142 + ['borderRightColor', 'Right'], 143 + ['borderTopColor', 'Top'], 144 + ['borderBottomColor', 'Bottom'], 145 + ]) { 146 + const val = rule.style[prop]; 147 + if (!val || !val.includes('var(')) continue; 148 + const resolved = resolveVar(val).trim(); 149 + if (!resolved) continue; 150 + // Width may or may not come from this rule — that's fine; the 151 + // adapter only substitutes the color when jsdom left it as a 152 + // literal var() string. 153 + if (!perSide[side]) perSide[side] = { width: 0, color: normalizeColorForCheck(resolved) }; 154 + } 155 + 156 + if (Object.keys(perSide).length === 0) continue; 157 + 158 + let matched; 159 + try { matched = document.querySelectorAll(rule.selectorText); } 160 + catch { continue; } 161 + 162 + for (const el of matched) { 163 + const existing = map.get(el); 164 + if (existing) { 165 + // Later rules overwrite earlier ones — approximates source-order 166 + // cascade for equal-specificity rules and is good enough for the 167 + // uncontested var()-dropped sides we're trying to recover. 168 + Object.assign(existing, perSide); 169 + } else { 170 + map.set(el, { ...perSide }); 171 + } 172 + } 173 + } 174 + } 175 + 176 + return map; 177 + } 178 + 179 + // Strip `@layer NAME { … }` wrappers from a CSS / HTML source, leaving 180 + // the inner rules as flat CSS. jsdom doesn't implement CSS @layer, so 181 + // any rule inside a layer block becomes invisible to getComputedStyle. 182 + // Tailwind v4 makes this ubiquitous: every utility class lives in 183 + // `@layer utilities`, and Preflight lives in `@layer base`. Without 184 + // unwrapping, every Tailwind-styled element returns empty computed 185 + // styles. We walk the source character-by-character, balancing braces 186 + // so we correctly handle nested style rules inside the layer block. 187 + function unwrapCssAtLayer(source) { 188 + if (!source || !source.includes('@layer')) return source; 189 + // Find `@layer <name>? {` openers. The match starts at the @, and 190 + // we then balance braces from the opening { onward. 191 + const re = /@layer\b[^{;]*\{/g; 192 + let out = ''; 193 + let lastIdx = 0; 194 + let m; 195 + while ((m = re.exec(source)) !== null) { 196 + const openStart = m.index; 197 + const openEnd = m.index + m[0].length; // position right after `{` 198 + let depth = 1; 199 + let i = openEnd; 200 + while (i < source.length && depth > 0) { 201 + const c = source.charCodeAt(i); 202 + if (c === 0x7b /* { */) depth++; 203 + else if (c === 0x7d /* } */) depth--; 204 + i++; 205 + } 206 + if (depth !== 0) { 207 + // Unbalanced — bail and return source unchanged. 208 + return source; 209 + } 210 + // Emit everything before the @layer, then the inner contents 211 + // (between the opening { and the matched closing }), then advance. 212 + out += source.slice(lastIdx, openStart); 213 + out += source.slice(openEnd, i - 1); // i-1 = position of the closing } 214 + lastIdx = i; 215 + re.lastIndex = i; 216 + } 217 + out += source.slice(lastIdx); 218 + return out; 219 + } 220 + 221 + // --------------------------------------------------------------------------- 222 + // Static HTML/CSS detection (default for local HTML files) 223 + // --------------------------------------------------------------------------- 224 + 225 + const STATIC_INHERITED_PROPS = new Set([ 226 + 'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', 227 + 'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens', 228 + 'webkitHyphens', 229 + ]); 230 + 231 + const STATIC_DEFAULT_STYLE = { 232 + color: 'rgb(0, 0, 0)', 233 + backgroundColor: 'rgba(0, 0, 0, 0)', 234 + backgroundImage: 'none', 235 + borderTopWidth: '0px', 236 + borderRightWidth: '0px', 237 + borderBottomWidth: '0px', 238 + borderLeftWidth: '0px', 239 + borderTopColor: 'rgb(0, 0, 0)', 240 + borderRightColor: 'rgb(0, 0, 0)', 241 + borderBottomColor: 'rgb(0, 0, 0)', 242 + borderLeftColor: 'rgb(0, 0, 0)', 243 + borderRadius: '0px', 244 + outlineWidth: '0px', 245 + outlineColor: 'rgb(0, 0, 0)', 246 + outlineStyle: 'none', 247 + boxShadow: 'none', 248 + fontFamily: '', 249 + fontSize: '16px', 250 + fontStyle: 'normal', 251 + fontWeight: '400', 252 + lineHeight: 'normal', 253 + letterSpacing: 'normal', 254 + textTransform: 'none', 255 + textAlign: 'start', 256 + hyphens: 'manual', 257 + webkitHyphens: 'manual', 258 + transitionProperty: '', 259 + transitionTimingFunction: '', 260 + animationName: '', 261 + animationTimingFunction: '', 262 + webkitBackgroundClip: '', 263 + backgroundClip: '', 264 + width: '', 265 + height: '', 266 + paddingTop: '0px', 267 + paddingRight: '0px', 268 + paddingBottom: '0px', 269 + paddingLeft: '0px', 270 + marginTop: '0px', 271 + marginRight: '0px', 272 + marginBottom: '0px', 273 + marginLeft: '0px', 274 + position: 'static', 275 + visibility: 'visible', 276 + top: 'auto', 277 + right: 'auto', 278 + bottom: 'auto', 279 + left: 'auto', 280 + inset: '', 281 + display: '', 282 + overflow: 'visible', 283 + overflowX: 'visible', 284 + overflowY: 'visible', 285 + }; 286 + 287 + const STATIC_PROP_MAP = { 288 + 'background-color': 'backgroundColor', 289 + 'background-image': 'backgroundImage', 290 + 'background-clip': 'backgroundClip', 291 + '-webkit-background-clip': 'webkitBackgroundClip', 292 + 'border-radius': 'borderRadius', 293 + 'border-top-width': 'borderTopWidth', 294 + 'border-right-width': 'borderRightWidth', 295 + 'border-bottom-width': 'borderBottomWidth', 296 + 'border-left-width': 'borderLeftWidth', 297 + 'border-top-color': 'borderTopColor', 298 + 'border-right-color': 'borderRightColor', 299 + 'border-bottom-color': 'borderBottomColor', 300 + 'border-left-color': 'borderLeftColor', 301 + 'outline-width': 'outlineWidth', 302 + 'outline-color': 'outlineColor', 303 + 'outline-style': 'outlineStyle', 304 + 'box-shadow': 'boxShadow', 305 + 'font-family': 'fontFamily', 306 + 'font-size': 'fontSize', 307 + 'font-style': 'fontStyle', 308 + 'font-weight': 'fontWeight', 309 + 'line-height': 'lineHeight', 310 + 'letter-spacing': 'letterSpacing', 311 + 'text-transform': 'textTransform', 312 + 'text-align': 'textAlign', 313 + 'hyphens': 'hyphens', 314 + '-webkit-hyphens': 'webkitHyphens', 315 + 'transition-property': 'transitionProperty', 316 + 'transition-timing-function': 'transitionTimingFunction', 317 + 'animation-name': 'animationName', 318 + 'animation-timing-function': 'animationTimingFunction', 319 + 'width': 'width', 320 + 'height': 'height', 321 + 'padding-top': 'paddingTop', 322 + 'padding-right': 'paddingRight', 323 + 'padding-bottom': 'paddingBottom', 324 + 'padding-left': 'paddingLeft', 325 + 'margin-top': 'marginTop', 326 + 'margin-right': 'marginRight', 327 + 'margin-bottom': 'marginBottom', 328 + 'margin-left': 'marginLeft', 329 + 'position': 'position', 330 + 'visibility': 'visibility', 331 + 'top': 'top', 332 + 'right': 'right', 333 + 'bottom': 'bottom', 334 + 'left': 'left', 335 + 'inset': 'inset', 336 + 'display': 'display', 337 + 'overflow': 'overflow', 338 + 'overflow-x': 'overflowX', 339 + 'overflow-y': 'overflowY', 340 + }; 341 + 342 + const STATIC_NAMED_COLORS = { 343 + black: { r: 0, g: 0, b: 0, a: 1 }, 344 + white: { r: 255, g: 255, b: 255, a: 1 }, 345 + transparent: { r: 0, g: 0, b: 0, a: 0 }, 346 + gray: { r: 128, g: 128, b: 128, a: 1 }, 347 + grey: { r: 128, g: 128, b: 128, a: 1 }, 348 + silver: { r: 192, g: 192, b: 192, a: 1 }, 349 + red: { r: 255, g: 0, b: 0, a: 1 }, 350 + green: { r: 0, g: 128, b: 0, a: 1 }, 351 + blue: { r: 0, g: 0, b: 255, a: 1 }, 352 + }; 353 + 354 + function splitCssList(value) { 355 + const parts = []; 356 + let depth = 0, quote = '', start = 0; 357 + for (let i = 0; i < value.length; i++) { 358 + const ch = value[i]; 359 + if (quote) { 360 + if (ch === quote && value[i - 1] !== '\\') quote = ''; 361 + continue; 362 + } 363 + if (ch === '"' || ch === "'") { quote = ch; continue; } 364 + if (ch === '(' || ch === '[') depth++; 365 + else if (ch === ')' || ch === ']') depth = Math.max(0, depth - 1); 366 + else if (ch === ',' && depth === 0) { 367 + parts.push(value.slice(start, i).trim()); 368 + start = i + 1; 369 + } 370 + } 371 + const tail = value.slice(start).trim(); 372 + if (tail) parts.push(tail); 373 + return parts; 374 + } 375 + 376 + function splitCssTokens(value) { 377 + const tokens = []; 378 + let depth = 0, quote = '', current = ''; 379 + for (let i = 0; i < value.length; i++) { 380 + const ch = value[i]; 381 + if (quote) { 382 + current += ch; 383 + if (ch === quote && value[i - 1] !== '\\') quote = ''; 384 + continue; 385 + } 386 + if (ch === '"' || ch === "'") { quote = ch; current += ch; continue; } 387 + if (ch === '(') { depth++; current += ch; continue; } 388 + if (ch === ')') { depth = Math.max(0, depth - 1); current += ch; continue; } 389 + if (/\s/.test(ch) && depth === 0) { 390 + if (current) { tokens.push(current); current = ''; } 391 + continue; 392 + } 393 + current += ch; 394 + } 395 + if (current) tokens.push(current); 396 + return tokens; 397 + } 398 + 399 + function cssPropToCamel(prop) { 400 + if (!prop) return prop; 401 + const mapped = STATIC_PROP_MAP[prop]; 402 + if (mapped) return mapped; 403 + return prop.replace(/-([a-z])/g, (_m, ch) => ch.toUpperCase()); 404 + } 405 + 406 + function staticColorToCss(c) { 407 + if (!c) return ''; 408 + if (c.a != null && c.a < 1) return `rgba(${c.r}, ${c.g}, ${c.b}, ${Number(c.a.toFixed(3))})`; 409 + return `rgb(${c.r}, ${c.g}, ${c.b})`; 410 + } 411 + 412 + function parseStaticColor(value) { 413 + const parsed = parseAnyColor(value); 414 + if (parsed) return parsed; 415 + const named = STATIC_NAMED_COLORS[String(value || '').trim().toLowerCase()]; 416 + return named ? { ...named } : null; 417 + } 418 + 419 + function extractStaticColor(value) { 420 + if (!value) return ''; 421 + const raw = String(value).trim(); 422 + if (/^var\(/i.test(raw)) return raw; 423 + const colorLike = raw.match(/(?:rgba?\([^)]+\)|oklch\([^)]+\)|oklab\([^)]+\)|lch\([^)]+\)|lab\([^)]+\)|hsla?\([^)]+\)|hwb\([^)]+\)|#[0-9a-f]{3,8}\b|\b(?:black|white|gray|grey|silver|red|green|blue|transparent)\b)/i); 424 + if (!colorLike) return ''; 425 + return colorLike[0]; 426 + } 427 + 428 + function normalizeStaticCssValue(prop, value, customProps, parentStyle, currentStyle = null) { 429 + let resolved = resolveVarRefs(String(value || '').trim(), customProps); 430 + if (resolved === 'inherit') return parentStyle?.[prop] || STATIC_DEFAULT_STYLE[prop] || ''; 431 + const isModernBorderColor = /^border[A-Z][a-z]+Color$/.test(prop) && /^(?:oklch|oklab|lch|lab|hsl|hwb)\(/i.test(resolved); 432 + if (!isModernBorderColor && (/color$/i.test(prop) || prop === 'color' || prop === 'backgroundColor')) { 433 + const parsed = parseStaticColor(resolved); 434 + if (parsed) resolved = staticColorToCss(parsed); 435 + } 436 + if (prop === 'fontSize') { 437 + const base = parseFloat(parentStyle?.fontSize) || 16; 438 + const px = resolveLengthPx(resolved, base); 439 + if (px != null) resolved = `${px}px`; 440 + } 441 + if (prop === 'letterSpacing') { 442 + const base = parseFloat(currentStyle?.fontSize || parentStyle?.fontSize) || 16; 443 + const px = resolveLengthPx(resolved, base); 444 + if (px != null) resolved = `${px}px`; 445 + } 446 + if (prop === 'lineHeight' && resolved !== 'normal') { 447 + const base = parseFloat(currentStyle?.fontSize || parentStyle?.fontSize) || 16; 448 + const px = resolveLengthPx(resolved, base); 449 + if (px != null) resolved = `${px}px`; 450 + } 451 + return resolved; 452 + } 453 + 454 + function expandStaticBoxValues(tokens) { 455 + if (tokens.length === 0) return ['0px', '0px', '0px', '0px']; 456 + if (tokens.length === 1) return [tokens[0], tokens[0], tokens[0], tokens[0]]; 457 + if (tokens.length === 2) return [tokens[0], tokens[1], tokens[0], tokens[1]]; 458 + if (tokens.length === 3) return [tokens[0], tokens[1], tokens[2], tokens[1]]; 459 + return [tokens[0], tokens[1], tokens[2], tokens[3]]; 460 + } 461 + 462 + function parseStaticBorder(value) { 463 + const tokens = splitCssTokens(value); 464 + let width = '', color = ''; 465 + for (const token of tokens) { 466 + if (!width && /^-?[\d.]+(?:px|rem|em|%)$/.test(token)) width = token; 467 + if (!color) color = extractStaticColor(token); 468 + } 469 + return { width, color }; 470 + } 471 + 472 + function parseStaticFont(value) { 473 + const out = []; 474 + const slashParts = value.match(/(?:^|\s)([\d.]+(?:px|rem|em|%))(?:\/([^\s]+))?/); 475 + if (/\bitalic\b/i.test(value)) out.push(['fontStyle', 'italic']); 476 + const weight = value.match(/\b([1-9]00|bold|normal|lighter|bolder)\b/i); 477 + if (weight) out.push(['fontWeight', weight[1]]); 478 + if (slashParts) { 479 + out.push(['fontSize', slashParts[1]]); 480 + if (slashParts[2]) out.push(['lineHeight', slashParts[2]]); 481 + const familyStart = value.indexOf(slashParts[0]) + slashParts[0].length; 482 + const family = value.slice(familyStart).trim(); 483 + if (family) out.push(['fontFamily', family]); 484 + } 485 + return out; 486 + } 487 + 488 + function parseStaticTransition(value) { 489 + const props = []; 490 + const timings = []; 491 + for (const item of splitCssList(value)) { 492 + const tokens = splitCssTokens(item); 493 + const timing = tokens.find(token => /^(?:ease|linear|step-|cubic-bezier\()/i.test(token)); 494 + if (timing) timings.push(timing); 495 + const prop = tokens.find(token => /^[a-z-]+$/i.test(token) && !/^(?:ease|linear|infinite|alternate|forwards|backwards|both|normal|none)$/.test(token) && !/s$/.test(token)); 496 + if (prop) props.push(prop); 497 + } 498 + return { 499 + property: props.join(', '), 500 + timing: timings.join(', '), 501 + }; 502 + } 503 + 504 + function parseStaticAnimation(value) { 505 + const names = []; 506 + const timings = []; 507 + for (const item of splitCssList(value)) { 508 + const tokens = splitCssTokens(item); 509 + const timing = tokens.find(token => /^(?:ease|linear|step-|cubic-bezier\()/i.test(token)); 510 + if (timing) timings.push(timing); 511 + const name = tokens.find(token => 512 + /^[a-z_-][\w-]*$/i.test(token) && 513 + !/^(?:ease|linear|infinite|alternate|forwards|backwards|both|normal|none|running|paused)$/.test(token) 514 + ); 515 + if (name) names.push(name); 516 + } 517 + return { 518 + name: names.join(', '), 519 + timing: timings.join(', '), 520 + }; 521 + } 522 + 523 + function expandStaticDeclaration(prop, value) { 524 + const p = prop.toLowerCase(); 525 + const v = String(value || '').trim(); 526 + if (!v) return []; 527 + if (p.startsWith('--')) return [[p, v]]; 528 + if (p === 'background') { 529 + const out = []; 530 + const hasImage = /gradient|url\(/i.test(v); 531 + if (hasImage) out.push(['backgroundImage', v]); 532 + const beforeImage = hasImage ? v.split(/(?:repeating-)?(?:linear|radial|conic)-gradient\(|url\(/i)[0] : v; 533 + const color = extractStaticColor(hasImage ? beforeImage : v); 534 + if (color) out.push(['backgroundColor', color]); 535 + return out; 536 + } 537 + if (p === 'border') { 538 + const parsed = parseStaticBorder(v); 539 + const out = []; 540 + for (const side of ['Top', 'Right', 'Bottom', 'Left']) { 541 + if (parsed.width) out.push([`border${side}Width`, parsed.width]); 542 + if (parsed.color) out.push([`border${side}Color`, parsed.color]); 543 + } 544 + return out; 545 + } 546 + if (p === 'outline') { 547 + // `outline` shorthand: width | style | color, in any order. Reuse the 548 + // border parser for width + color, then sniff a style keyword from the 549 + // tokens (solid|dashed|...). `outline: 0` (single-token zero) zeros 550 + // the width and effectively hides the outline. 551 + const tokens = splitCssTokens(v); 552 + const parsed = parseStaticBorder(v); 553 + const styleToken = tokens.find(t => 554 + /^(none|hidden|solid|dashed|dotted|double|groove|ridge|inset|outset)$/i.test(t) 555 + ); 556 + const out = []; 557 + if (parsed.width) out.push(['outlineWidth', parsed.width]); 558 + if (parsed.color) out.push(['outlineColor', parsed.color]); 559 + if (styleToken) out.push(['outlineStyle', styleToken.toLowerCase()]); 560 + // `outline: 0` with no other tokens: explicit zero width. 561 + if (!parsed.width && /^0(?:px|rem|em|%)?$/.test(v.trim())) { 562 + out.push(['outlineWidth', '0px']); 563 + } 564 + return out; 565 + } 566 + const sideMatch = p.match(/^border-(top|right|bottom|left)$/); 567 + if (sideMatch) { 568 + const parsed = parseStaticBorder(v); 569 + const side = sideMatch[1][0].toUpperCase() + sideMatch[1].slice(1); 570 + return [ 571 + ...(parsed.width ? [[`border${side}Width`, parsed.width]] : []), 572 + ...(parsed.color ? [[`border${side}Color`, parsed.color]] : []), 573 + ]; 574 + } 575 + if (p === 'border-width') { 576 + const vals = expandStaticBoxValues(splitCssTokens(v)); 577 + return [ 578 + ['borderTopWidth', vals[0]], 579 + ['borderRightWidth', vals[1]], 580 + ['borderBottomWidth', vals[2]], 581 + ['borderLeftWidth', vals[3]], 582 + ]; 583 + } 584 + if (p === 'border-color') { 585 + const vals = expandStaticBoxValues(splitCssTokens(v)); 586 + return [ 587 + ['borderTopColor', vals[0]], 588 + ['borderRightColor', vals[1]], 589 + ['borderBottomColor', vals[2]], 590 + ['borderLeftColor', vals[3]], 591 + ]; 592 + } 593 + if (p === 'padding') { 594 + const vals = expandStaticBoxValues(splitCssTokens(v)); 595 + return [ 596 + ['paddingTop', vals[0]], 597 + ['paddingRight', vals[1]], 598 + ['paddingBottom', vals[2]], 599 + ['paddingLeft', vals[3]], 600 + ]; 601 + } 602 + if (p === 'margin') { 603 + const vals = expandStaticBoxValues(splitCssTokens(v)); 604 + return [ 605 + ['marginTop', vals[0]], 606 + ['marginRight', vals[1]], 607 + ['marginBottom', vals[2]], 608 + ['marginLeft', vals[3]], 609 + ]; 610 + } 611 + if (p === 'font') return parseStaticFont(v); 612 + if (p === 'transition') { 613 + const parsed = parseStaticTransition(v); 614 + return [ 615 + ...(parsed.property ? [['transitionProperty', parsed.property]] : []), 616 + ...(parsed.timing ? [['transitionTimingFunction', parsed.timing]] : []), 617 + ]; 618 + } 619 + if (p === 'animation') { 620 + const parsed = parseStaticAnimation(v); 621 + return [ 622 + ...(parsed.name ? [['animationName', parsed.name]] : []), 623 + ...(parsed.timing ? [['animationTimingFunction', parsed.timing]] : []), 624 + ]; 625 + } 626 + const mapped = cssPropToCamel(p); 627 + if (STATIC_DEFAULT_STYLE[mapped] != null || STATIC_INHERITED_PROPS.has(mapped)) { 628 + return [[mapped, v]]; 629 + } 630 + return []; 631 + } 632 + 633 + function compareStaticPriority(a, b) { 634 + if (!a) return true; 635 + if (!!b.important !== !!a.important) return !!b.important; 636 + if (!!b.inline !== !!a.inline) return !!b.inline; 637 + for (let i = 0; i < 3; i++) { 638 + if ((b.specificity[i] || 0) !== (a.specificity[i] || 0)) { 639 + return (b.specificity[i] || 0) > (a.specificity[i] || 0); 640 + } 641 + } 642 + return b.order >= a.order; 643 + } 644 + 645 + function staticSpecificity(selector) { 646 + const noWhere = selector.replace(/:where\([^)]*\)/g, ''); 647 + const ids = (noWhere.match(/#[\w-]+/g) || []).length; 648 + const classes = (noWhere.match(/\.[\w-]+|\[[^\]]+\]|:(?!:)[\w-]+(?:\([^)]*\))?/g) || []).length; 649 + const stripped = noWhere 650 + .replace(/#[\w-]+/g, ' ') 651 + .replace(/\.[\w-]+|\[[^\]]+\]|:{1,2}[\w-]+(?:\([^)]*\))?/g, ' ') 652 + .replace(/[*>+~(),]/g, ' '); 653 + const types = (stripped.match(/\b[a-zA-Z][\w-]*\b/g) || []).length; 654 + return [ids, classes, types]; 655 + } 656 + 657 + function applyStaticDeclaration(specified, node, prop, value, meta) { 658 + let map = specified.get(node); 659 + if (!map) { map = new Map(); specified.set(node, map); } 660 + for (const [expandedProp, expandedValue] of expandStaticDeclaration(prop, value)) { 661 + const existing = map.get(expandedProp); 662 + const next = { ...meta, prop: expandedProp, value: expandedValue }; 663 + if (compareStaticPriority(existing, next)) map.set(expandedProp, next); 664 + } 665 + } 666 + 667 + function parseStaticStyleAttribute(styleText, orderBase = 0) { 668 + const decls = []; 669 + for (const part of String(styleText || '').split(';')) { 670 + const idx = part.indexOf(':'); 671 + if (idx <= 0) continue; 672 + const prop = part.slice(0, idx).trim(); 673 + let value = part.slice(idx + 1).trim(); 674 + const important = /!important\s*$/i.test(value); 675 + value = value.replace(/\s*!important\s*$/i, '').trim(); 676 + decls.push({ prop, value, important, order: orderBase + decls.length }); 677 + } 678 + return decls; 679 + } 680 + 681 + function collectStaticCssRules(cssText, csstree) { 682 + const rules = []; 683 + let ast; 684 + try { 685 + ast = csstree.parse(cssText, { positions: false, parseValue: true, parseCustomProperty: false }); 686 + } catch { 687 + return rules; 688 + } 689 + let order = 0; 690 + const walkList = (list, atRuleStack = []) => { 691 + list?.forEach?.(node => { 692 + if (node.type === 'Rule' && node.block) { 693 + if (atRuleStack.some(name => /keyframes$/i.test(name))) return; 694 + const selectorText = csstree.generate(node.prelude).trim(); 695 + const declarations = []; 696 + node.block.children?.forEach?.(child => { 697 + if (child.type !== 'Declaration') return; 698 + declarations.push({ 699 + prop: child.property, 700 + value: csstree.generate(child.value).trim(), 701 + important: !!child.important, 702 + }); 703 + }); 704 + for (const selector of splitCssList(selectorText)) { 705 + if (selector) rules.push({ selector, declarations, specificity: staticSpecificity(selector), order: order++ }); 706 + } 707 + return; 708 + } 709 + if (node.type === 'Atrule' && node.block) { 710 + const name = String(node.name || '').toLowerCase(); 711 + if (name === 'media' || name === 'supports' || name === 'layer') { 712 + walkList(node.block.children, [...atRuleStack, name]); 713 + } 714 + } 715 + }); 716 + }; 717 + walkList(ast.children); 718 + return rules; 719 + } 720 + 721 + class StaticElement { 722 + constructor(node, doc) { 723 + this.node = node; 724 + this._doc = doc; 725 + this.nodeType = 1; 726 + this.tagName = String(node.name || '').toUpperCase(); 727 + this.nodeName = this.tagName; 728 + } 729 + get parentElement() { 730 + let cur = this.node.parent; 731 + while (cur && cur.type !== 'tag') cur = cur.parent; 732 + return cur ? this._doc.wrap(cur) : null; 733 + } 734 + get previousElementSibling() { 735 + let cur = this.node.prev; 736 + while (cur && cur.type !== 'tag') cur = cur.prev; 737 + return cur ? this._doc.wrap(cur) : null; 738 + } 739 + get children() { 740 + return (this.node.children || []).filter(child => child.type === 'tag').map(child => this._doc.wrap(child)); 741 + } 742 + get childNodes() { 743 + return (this.node.children || []).map(child => { 744 + if (child.type === 'text') return { nodeType: 3, textContent: child.data || '' }; 745 + if (child.type === 'tag') return this._doc.wrap(child); 746 + return { nodeType: 8, textContent: child.data || '' }; 747 + }); 748 + } 749 + get textContent() { 750 + return this._doc.domutils.textContent(this.node); 751 + } 752 + get className() { 753 + return this.getAttribute('class') || ''; 754 + } 755 + get id() { 756 + return this.getAttribute('id') || ''; 757 + } 758 + getAttribute(name) { 759 + return this.node.attribs?.[name] ?? null; 760 + } 761 + querySelector(selector) { 762 + try { 763 + const found = this._doc.selectOne(selector, this.node.children || []); 764 + return found ? this._doc.wrap(found) : null; 765 + } catch { 766 + return null; 767 + } 768 + } 769 + querySelectorAll(selector) { 770 + try { 771 + return this._doc.selectAll(selector, this.node.children || []).map(node => this._doc.wrap(node)); 772 + } catch { 773 + return []; 774 + } 775 + } 776 + closest(selector) { 777 + let cur = this.node; 778 + while (cur && cur.type === 'tag') { 779 + try { 780 + if (this._doc.is(cur, selector)) return this._doc.wrap(cur); 781 + } catch { 782 + return null; 783 + } 784 + cur = cur.parent; 785 + while (cur && cur.type !== 'tag') cur = cur.parent; 786 + } 787 + return null; 788 + } 789 + contains(other) { 790 + let cur = other?.node || null; 791 + while (cur) { 792 + if (cur === this.node) return true; 793 + cur = cur.parent; 794 + } 795 + return false; 796 + } 797 + } 798 + 799 + class StaticDocument { 800 + constructor(root, modules) { 801 + this.root = root; 802 + this.selectAll = modules.selectAll; 803 + this.selectOne = modules.selectOne; 804 + this.is = modules.is; 805 + this.domutils = modules.domutils; 806 + this._wrappers = new WeakMap(); 807 + this._styleMap = new WeakMap(); 808 + } 809 + wrap(node) { 810 + let wrapped = this._wrappers.get(node); 811 + if (!wrapped) { 812 + wrapped = new StaticElement(node, this); 813 + this._wrappers.set(node, wrapped); 814 + } 815 + return wrapped; 816 + } 817 + querySelectorAll(selector) { 818 + try { 819 + return this.selectAll(selector, this.root.children || []).map(node => this.wrap(node)); 820 + } catch { 821 + return []; 822 + } 823 + } 824 + querySelector(selector) { 825 + try { 826 + const found = this.selectOne(selector, this.root.children || []); 827 + return found ? this.wrap(found) : null; 828 + } catch { 829 + return null; 830 + } 831 + } 832 + get documentElement() { 833 + return this.querySelector('html'); 834 + } 835 + get body() { 836 + return this.querySelector('body'); 837 + } 838 + setStyle(node, style) { 839 + this._styleMap.set(node, style); 840 + } 841 + getStyle(el) { 842 + return this._styleMap.get(el.node) || makeStaticStyle(); 843 + } 844 + } 845 + 846 + function makeStaticStyle(values = {}) { 847 + const style = { ...STATIC_DEFAULT_STYLE, ...values }; 848 + style.getPropertyValue = (prop) => { 849 + const key = cssPropToCamel(prop); 850 + return style[key] || style[prop] || ''; 851 + }; 852 + return style; 853 + } 854 + 855 + function buildStaticWindow(staticDoc) { 856 + return { 857 + document: staticDoc, 858 + getComputedStyle: (el) => staticDoc.getStyle(el), 859 + }; 860 + } 861 + 862 + function collectStaticCssText(root, fileDir, profile, filePath, modules) { 863 + const styleTexts = []; 864 + for (const styleEl of modules.selectAll('style', root.children || [])) { 865 + styleTexts.push(modules.domutils.textContent(styleEl)); 866 + } 867 + const links = modules.selectAll('link', root.children || []); 868 + for (const link of links) { 869 + const rel = link.attribs?.rel || ''; 870 + const href = link.attribs?.href || ''; 871 + if (!/\bstylesheet\b/i.test(rel) || !href || /^(https?:)?\/\//i.test(href)) continue; 872 + const cssPath = path.resolve(fileDir, href); 873 + try { 874 + const css = profileStep(profile, { 875 + engine: 'static-html', 876 + phase: 'preprocess', 877 + ruleId: 'inline-linked-stylesheet', 878 + target: filePath, 879 + detail: href, 880 + }, () => fs.readFileSync(cssPath, 'utf-8')); 881 + styleTexts.push(css); 882 + } catch { /* skip unreadable */ } 883 + } 884 + return styleTexts.join('\n'); 885 + } 886 + 887 + function buildStaticStyleMap(root, staticDoc, cssText, modules, profile, filePath) { 888 + const specified = new Map(); 889 + const allNodes = modules.selectAll('*', root.children || []); 890 + const rules = profileStep(profile, { 891 + engine: 'static-html', 892 + phase: 'parse-css', 893 + ruleId: 'css-rules', 894 + target: filePath, 895 + }, () => collectStaticCssRules(cssText, modules.csstree)); 896 + 897 + profileStep(profile, { 898 + engine: 'static-html', 899 + phase: 'selector-match', 900 + ruleId: 'css-selectors', 901 + target: filePath, 902 + }, () => { 903 + for (const rule of rules) { 904 + let matched; 905 + try { 906 + matched = modules.selectAll(rule.selector, root.children || []); 907 + } catch { 908 + recordProfileEvent(profile, { 909 + engine: 'static-html', 910 + phase: 'selector-match', 911 + ruleId: 'unsupported-selector', 912 + target: filePath, 913 + ms: 0, 914 + findings: 0, 915 + detail: rule.selector, 916 + }); 917 + continue; 918 + } 919 + for (const node of matched) { 920 + for (const decl of rule.declarations) { 921 + applyStaticDeclaration(specified, node, decl.prop, decl.value, { 922 + important: decl.important, 923 + specificity: rule.specificity, 924 + order: rule.order, 925 + inline: false, 926 + }); 927 + } 928 + } 929 + } 930 + 931 + let inlineOrder = rules.length + 1; 932 + for (const node of allNodes) { 933 + const styleText = node.attribs?.style; 934 + if (!styleText) continue; 935 + for (const decl of parseStaticStyleAttribute(styleText, inlineOrder)) { 936 + applyStaticDeclaration(specified, node, decl.prop, decl.value, { 937 + important: decl.important, 938 + specificity: [1, 0, 0], 939 + order: decl.order, 940 + inline: true, 941 + }); 942 + } 943 + inlineOrder += 1000; 944 + } 945 + }); 946 + 947 + const computeNode = (node, parentStyle = null, parentCustom = new Map()) => { 948 + const specifiedMap = specified.get(node) || new Map(); 949 + const customProps = new Map(parentCustom); 950 + for (const [prop, decl] of specifiedMap) { 951 + if (prop.startsWith('--')) customProps.set(prop, resolveVarRefs(decl.value, customProps)); 952 + } 953 + const values = {}; 954 + for (const prop of Object.keys(STATIC_DEFAULT_STYLE)) { 955 + if (STATIC_INHERITED_PROPS.has(prop) && parentStyle?.[prop] != null) values[prop] = parentStyle[prop]; 956 + else values[prop] = STATIC_DEFAULT_STYLE[prop]; 957 + } 958 + for (const [prop, decl] of specifiedMap) { 959 + if (prop.startsWith('--')) continue; 960 + values[prop] = normalizeStaticCssValue(prop, decl.value, customProps, parentStyle, values); 961 + } 962 + const style = makeStaticStyle(values); 963 + staticDoc.setStyle(node, style); 964 + for (const child of node.children || []) { 965 + if (child.type === 'tag') computeNode(child, style, customProps); 966 + } 967 + }; 968 + 969 + profileStep(profile, { 970 + engine: 'static-html', 971 + phase: 'cascade', 972 + ruleId: 'compute-styles', 973 + target: filePath, 974 + }, () => { 975 + for (const child of root.children || []) { 976 + if (child.type === 'tag') computeNode(child); 977 + } 978 + }); 979 + } 980 + 981 + export { 982 + BORDER_SHORTHAND_RE, 983 + NAMED_COLORS, 984 + normalizeColorForCheck, 985 + buildBorderOverrideMap, 986 + unwrapCssAtLayer, 987 + STATIC_INHERITED_PROPS, 988 + STATIC_DEFAULT_STYLE, 989 + STATIC_PROP_MAP, 990 + STATIC_NAMED_COLORS, 991 + splitCssList, 992 + splitCssTokens, 993 + cssPropToCamel, 994 + staticColorToCss, 995 + parseStaticColor, 996 + extractStaticColor, 997 + normalizeStaticCssValue, 998 + expandStaticBoxValues, 999 + parseStaticBorder, 1000 + parseStaticFont, 1001 + parseStaticTransition, 1002 + parseStaticAnimation, 1003 + expandStaticDeclaration, 1004 + compareStaticPriority, 1005 + staticSpecificity, 1006 + applyStaticDeclaration, 1007 + parseStaticStyleAttribute, 1008 + collectStaticCssRules, 1009 + StaticElement, 1010 + StaticDocument, 1011 + makeStaticStyle, 1012 + buildStaticWindow, 1013 + collectStaticCssText, 1014 + buildStaticStyleMap, 1015 + };
+234
tools/design-detector/detector/engines/static-html/detect-html.mjs
··· 1 + import fs from 'node:fs'; 2 + import path from 'node:path'; 3 + 4 + import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs'; 5 + import { 6 + checkSourceDesignSystem, 7 + collectStaticDesignSystemFindings, 8 + mergeDesignSystemFindings, 9 + } from '../../design-system.mjs'; 10 + import { isFullPage } from '../../shared/page.mjs'; 11 + import { applyInlineIgnores } from '../../shared/inline-ignores.mjs'; 12 + import { finding } from '../../findings.mjs'; 13 + import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs'; 14 + import { 15 + checkElementBorders, 16 + checkElementClippedOverflow, 17 + checkElementColors, 18 + checkElementGlow, 19 + checkElementGptBorderShadow, 20 + checkElementHeroEyebrow, 21 + checkElementIconTile, 22 + checkElementItalicSerif, 23 + checkElementMotion, 24 + checkElementOversizedH1, 25 + checkElementQuality, 26 + checkCreamPalette, 27 + checkHtmlPatterns, 28 + checkPageLayout, 29 + checkPageQualityFromDoc, 30 + checkRepeatedSectionKickersFromDoc, 31 + resolveBackground, 32 + resolveBorderRadiusPx, 33 + } from '../../rules/checks.mjs'; 34 + import { filterByProviders } from '../../registry/antipatterns.mjs'; 35 + import { detectText, runTextContentAnalyzers } from '../regex/detect-text.mjs'; 36 + import { 37 + StaticDocument, 38 + buildStaticStyleMap, 39 + buildStaticWindow, 40 + collectStaticCssText, 41 + } from './css-cascade.mjs'; 42 + 43 + function checkStaticPageTypography(document, window) { 44 + const findings = []; 45 + const fonts = new Set(); 46 + const overusedFound = new Set(); 47 + for (const el of document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, td, th, dd, blockquote, figcaption, a, button, label, span, div')) { 48 + const hasText = el.childNodes.some(n => n.nodeType === 3 && n.textContent.trim().length > 0); 49 + if (!hasText) continue; 50 + const ff = window.getComputedStyle(el).fontFamily || ''; 51 + const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase()); 52 + const primary = stack.find(f => f && !GENERIC_FONTS.has(f)); 53 + if (!primary) continue; 54 + fonts.add(primary); 55 + if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary); 56 + } 57 + for (const font of overusedFound) { 58 + findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); 59 + } 60 + if (fonts.size === 1 && document.querySelectorAll('*').length >= 20) { 61 + findings.push({ id: 'single-font', snippet: `only font used is ${[...fonts][0]}` }); 62 + } 63 + const sizes = new Set(); 64 + for (const el of document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div')) { 65 + const fontSize = parseFloat(window.getComputedStyle(el).fontSize); 66 + if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); 67 + } 68 + if (sizes.size >= 3) { 69 + const sorted = [...sizes].sort((a, b) => a - b); 70 + const ratio = sorted[sorted.length - 1] / sorted[0]; 71 + if (ratio < 2.0) { 72 + findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); 73 + } 74 + } 75 + return findings; 76 + } 77 + 78 + function checkElementBrokenImage(el) { 79 + const src = (el.getAttribute && el.getAttribute('src')) ?? el.attribs?.src; 80 + // Missing src attribute entirely 81 + if (src === undefined || src === null) { 82 + return [{ id: 'broken-image', snippet: '<img> with no src attribute' }]; 83 + } 84 + const trimmed = String(src).trim(); 85 + // Empty or placeholder-only src values 86 + if (trimmed === '' || trimmed === '#') { 87 + return [{ id: 'broken-image', snippet: `<img src="${src}">` }]; 88 + } 89 + return []; 90 + } 91 + 92 + const STATIC_ELEMENT_RULES = [ 93 + { id: 'border-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementBorders(tag, style, null, resolveBorderRadiusPx(el, style, parseFloat(style.width) || 0, window)) }, 94 + { id: 'color-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementColors(el, style, tag, window, customPropMap, false) }, 95 + { id: 'dark-glow', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementGlow(tag, style, resolveBackground(el.parentElement || el, window, customPropMap)) }, 96 + { id: 'motion-rules', selector: '*', run: (el, tag, style) => checkElementMotion(tag, style) }, 97 + { id: 'icon-tile-stack', selector: 'h1,h2,h3,h4,h5,h6', run: (el, tag, _style, window) => checkElementIconTile(el, tag, window) }, 98 + { id: 'italic-serif-display', selector: 'h1,h2', run: (el, tag, style) => checkElementItalicSerif(el, style, tag) }, 99 + { id: 'hero-eyebrow-chip', selector: 'h1', run: (el, tag, style, window, customPropMap) => checkElementHeroEyebrow(el, style, tag, window, customPropMap) }, 100 + { id: 'broken-image', selector: 'img', run: (el) => checkElementBrokenImage(el) }, 101 + { id: 'quality-rules', selector: '*', run: (el, tag, style, window) => checkElementQuality(el, style, tag, window) }, 102 + { id: 'oversized-h1', selector: 'h1', run: (el, tag, style, window) => checkElementOversizedH1(el, style, tag, window) }, 103 + { id: 'clipped-overflow-container', selector: '*', run: (el, tag, style, window) => checkElementClippedOverflow(el, style, tag, window) }, 104 + { id: 'gpt-thin-border-wide-shadow', selector: '*', run: (el, tag, style) => checkElementGptBorderShadow(el, style) }, 105 + ]; 106 + 107 + async function detectHtml(filePath, options = {}) { 108 + const profile = options?.profile; 109 + const html = profileStep(profile, { 110 + engine: 'static-html', 111 + phase: 'setup', 112 + ruleId: 'read-html', 113 + target: filePath, 114 + }, () => fs.readFileSync(filePath, 'utf-8')); 115 + 116 + let modules; 117 + try { 118 + modules = await profileStepAsync(profile, { 119 + engine: 'static-html', 120 + phase: 'setup', 121 + ruleId: 'import-static-parser', 122 + target: filePath, 123 + }, async () => { 124 + const [htmlparser2, cssSelect, csstree, domutils] = await Promise.all([ 125 + import('htmlparser2'), 126 + import('css-select'), 127 + import('css-tree'), 128 + import('domutils'), 129 + ]); 130 + return { 131 + parseDocument: htmlparser2.parseDocument, 132 + selectAll: cssSelect.selectAll, 133 + selectOne: cssSelect.selectOne, 134 + is: cssSelect.is, 135 + csstree, 136 + domutils, 137 + }; 138 + }); 139 + } catch { 140 + return detectText(html, filePath, options); 141 + } 142 + 143 + const resolvedPath = path.resolve(filePath); 144 + const fileDir = path.dirname(resolvedPath); 145 + const root = profileStep(profile, { 146 + engine: 'static-html', 147 + phase: 'parse-html', 148 + ruleId: 'parse-document', 149 + target: filePath, 150 + }, () => modules.parseDocument(html, { lowerCaseAttributeNames: false, lowerCaseTags: true })); 151 + 152 + const cssText = collectStaticCssText(root, fileDir, profile, filePath, modules); 153 + const document = new StaticDocument(root, modules); 154 + buildStaticStyleMap(root, document, cssText, modules, profile, filePath); 155 + const window = buildStaticWindow(document); 156 + 157 + const customPropMap = null; 158 + 159 + const findings = []; 160 + const runElementCheck = (ruleId, callback) => profile 161 + ? profileFindings(profile, { engine: 'static-html', phase: 'element', ruleId, target: filePath }, callback) 162 + : callback(); 163 + 164 + const visitedByRule = new Map(); 165 + for (const rule of STATIC_ELEMENT_RULES) { 166 + const elements = document.querySelectorAll(rule.selector); 167 + visitedByRule.set(rule.id, elements.length); 168 + for (const el of elements) { 169 + const tag = el.tagName.toLowerCase(); 170 + const style = window.getComputedStyle(el); 171 + for (const f of runElementCheck(rule.id, () => rule.run(el, tag, style, window, customPropMap))) { 172 + findings.push(finding(f.id, filePath, f.snippet)); 173 + } 174 + } 175 + } 176 + 177 + if (options?.designSystem) { 178 + const sourceDesignFindings = profileFindings(profile, { 179 + engine: 'static-html', 180 + phase: 'source', 181 + ruleId: 'design-system', 182 + target: filePath, 183 + }, () => checkSourceDesignSystem(html, filePath, { designSystem: options.designSystem })); 184 + const staticDesignFindings = profileFindings(profile, { 185 + engine: 'static-html', 186 + phase: 'page', 187 + ruleId: 'design-system', 188 + target: filePath, 189 + }, () => collectStaticDesignSystemFindings(document, window, filePath, options.designSystem)); 190 + findings.push(...mergeDesignSystemFindings(staticDesignFindings, sourceDesignFindings)); 191 + } 192 + 193 + if (isFullPage(html)) { 194 + const runPageCheck = (ruleId, callback) => profile 195 + ? profileFindings(profile, { engine: 'static-html', phase: 'page', ruleId, target: filePath }, callback) 196 + : callback(); 197 + for (const f of runPageCheck('typography-rules', () => checkStaticPageTypography(document, window))) { 198 + findings.push(finding(f.id, filePath, f.snippet)); 199 + } 200 + for (const f of runPageCheck('repeated-section-kickers', () => checkRepeatedSectionKickersFromDoc(document, window))) { 201 + findings.push(finding(f.id, filePath, f.snippet)); 202 + } 203 + for (const f of runPageCheck('layout-rules', () => checkPageLayout(document, window))) { 204 + findings.push(finding(f.id, filePath, f.snippet)); 205 + } 206 + for (const f of runPageCheck('cream-palette', () => checkCreamPalette(document, window))) { 207 + findings.push(finding(f.id, filePath, f.snippet)); 208 + } 209 + for (const f of runPageCheck('skipped-heading', () => checkPageQualityFromDoc(document))) { 210 + findings.push(finding(f.id, filePath, f.snippet)); 211 + } 212 + for (const f of runPageCheck('html-patterns', () => checkHtmlPatterns(html).filter(item => 213 + item.id !== 'bounce-easing' && item.id !== 'layout-transition' 214 + ))) { 215 + findings.push(finding(f.id, filePath, f.snippet)); 216 + } 217 + // Text-content analyzers (em-dash overuse, marketing buzzwords, 218 + // numbered section markers, aphoristic cadence) live in the regex 219 + // engine. Call them from here so .html files get the same coverage 220 + // as .css/.tsx files. These are scoped to text content only and 221 + // don't overlap with static-html's element/page rules. 222 + for (const f of runPageCheck('text-content', () => runTextContentAnalyzers(html, filePath, options))) { 223 + findings.push(finding(f.antipattern, filePath, f.snippet)); 224 + } 225 + } 226 + 227 + const byProvider = filterByProviders(findings, options.providers); 228 + // Static-HTML findings carry no line number, so only whole-file 229 + // `impeccable-disable` directives apply here — exactly the standalone-document 230 + // waiver this primitive targets. Bypassed by `--no-config` / `--no-inline-ignores`. 231 + return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, html); 232 + } 233 + 234 + export { checkStaticPageTypography, STATIC_ELEMENT_RULES, detectHtml };
+189
tools/design-detector/detector/engines/visual/screenshot-contrast.mjs
··· 1 + function sanitizeScreenshotClip(clip, viewport) { 2 + if (!clip) return null; 3 + const x = Math.max(0, Math.floor(clip.x || 0)); 4 + const y = Math.max(0, Math.floor(clip.y || 0)); 5 + const width = Math.min( 6 + Math.max(1, Math.ceil(clip.width || 0)), 7 + Math.max(1, viewport?.width || 1600), 8 + ); 9 + const height = Math.min( 10 + Math.max(1, Math.ceil(clip.height || 0)), 11 + 320, 12 + ); 13 + if (width < 1 || height < 1) return null; 14 + return { x, y, width, height }; 15 + } 16 + 17 + async function compareScreenshotContrast(page, beforeBase64, afterBase64, candidate) { 18 + return page.evaluate(async ({ beforeBase64, afterBase64, candidate }) => { 19 + const loadImage = (base64) => new Promise((resolve, reject) => { 20 + const img = new Image(); 21 + img.onload = () => resolve(img); 22 + img.onerror = () => reject(new Error('Could not decode contrast screenshot')); 23 + img.src = `data:image/png;base64,${base64}`; 24 + }); 25 + const [before, after] = await Promise.all([loadImage(beforeBase64), loadImage(afterBase64)]); 26 + const width = Math.min(before.width, after.width); 27 + const height = Math.min(before.height, after.height); 28 + if (width < 1 || height < 1) return null; 29 + 30 + const canvas = document.createElement('canvas'); 31 + canvas.width = width; 32 + canvas.height = height; 33 + const ctx = canvas.getContext('2d', { willReadFrequently: true }); 34 + if (!ctx) return null; 35 + 36 + ctx.drawImage(before, 0, 0, width, height); 37 + const beforePixels = ctx.getImageData(0, 0, width, height).data; 38 + ctx.clearRect(0, 0, width, height); 39 + ctx.drawImage(after, 0, 0, width, height); 40 + const afterPixels = ctx.getImageData(0, 0, width, height).data; 41 + 42 + const luminance = ({ r, g, b }) => { 43 + const convert = c => { 44 + const v = c / 255; 45 + return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; 46 + }; 47 + return 0.2126 * convert(r) + 0.7152 * convert(g) + 0.0722 * convert(b); 48 + }; 49 + const ratio = (a, b) => { 50 + const l1 = luminance(a); 51 + const l2 = luminance(b); 52 + return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); 53 + }; 54 + 55 + const cssTextColor = candidate.textColor && !candidate.preferRenderedForeground 56 + ? { 57 + r: candidate.textColor.r, 58 + g: candidate.textColor.g, 59 + b: candidate.textColor.b, 60 + } 61 + : null; 62 + const ratios = []; 63 + let glyphPixels = 0; 64 + let strongestDelta = 0; 65 + for (let i = 0; i < beforePixels.length; i += 4) { 66 + const delta = Math.abs(beforePixels[i] - afterPixels[i]) 67 + + Math.abs(beforePixels[i + 1] - afterPixels[i + 1]) 68 + + Math.abs(beforePixels[i + 2] - afterPixels[i + 2]) 69 + + Math.abs(beforePixels[i + 3] - afterPixels[i + 3]); 70 + strongestDelta = Math.max(strongestDelta, delta); 71 + if (delta < 10) continue; 72 + glyphPixels++; 73 + const fg = cssTextColor || { 74 + r: beforePixels[i], 75 + g: beforePixels[i + 1], 76 + b: beforePixels[i + 2], 77 + }; 78 + const bg = { 79 + r: afterPixels[i], 80 + g: afterPixels[i + 1], 81 + b: afterPixels[i + 2], 82 + }; 83 + ratios.push(ratio(fg, bg)); 84 + } 85 + 86 + if (ratios.length < 8) { 87 + return { 88 + glyphPixels, 89 + strongestDelta, 90 + worstRatio: null, 91 + p10Ratio: null, 92 + medianRatio: null, 93 + }; 94 + } 95 + 96 + ratios.sort((a, b) => a - b); 97 + const pick = pct => ratios[Math.min(ratios.length - 1, Math.max(0, Math.floor((pct / 100) * ratios.length)))]; 98 + return { 99 + glyphPixels, 100 + strongestDelta, 101 + worstRatio: ratios[0], 102 + p10Ratio: pick(10), 103 + medianRatio: pick(50), 104 + }; 105 + }, { beforeBase64, afterBase64, candidate }); 106 + } 107 + 108 + async function captureVisualContrastCandidate(page, candidate, viewport) { 109 + const clip = sanitizeScreenshotClip(candidate.clip, viewport); 110 + if (!clip) return null; 111 + 112 + const beforeBase64 = await page.screenshot({ 113 + encoding: 'base64', 114 + clip, 115 + captureBeyondViewport: true, 116 + }); 117 + const token = `impeccable-contrast-${Date.now()}-${Math.random().toString(36).slice(2)}`; 118 + const applied = await page.evaluate(({ selector, token, backgroundClipText }) => { 119 + let el; 120 + try { 121 + el = document.querySelector(selector); 122 + } catch { 123 + return false; 124 + } 125 + if (!el) return false; 126 + let style = document.getElementById('impeccable-visual-contrast-hide-style'); 127 + if (!style) { 128 + style = document.createElement('style'); 129 + style.id = 'impeccable-visual-contrast-hide-style'; 130 + style.textContent = [ 131 + '[data-impeccable-visual-contrast-target] {', 132 + ' color: transparent !important;', 133 + ' -webkit-text-fill-color: transparent !important;', 134 + ' text-shadow: none !important;', 135 + '}', 136 + '[data-impeccable-visual-contrast-target][data-impeccable-bgclip-text="true"] {', 137 + ' background-image: none !important;', 138 + '}', 139 + ].join('\n'); 140 + document.head.appendChild(style); 141 + } 142 + el.setAttribute('data-impeccable-visual-contrast-target', token); 143 + if (backgroundClipText) el.setAttribute('data-impeccable-bgclip-text', 'true'); 144 + return true; 145 + }, { 146 + selector: candidate.selector, 147 + token, 148 + backgroundClipText: candidate.backgroundClipText, 149 + }); 150 + if (!applied) return null; 151 + 152 + let afterBase64; 153 + try { 154 + afterBase64 = await page.screenshot({ 155 + encoding: 'base64', 156 + clip, 157 + captureBeyondViewport: true, 158 + }); 159 + } finally { 160 + await page.evaluate(({ selector }) => { 161 + try { 162 + const el = document.querySelector(selector); 163 + if (el) { 164 + el.removeAttribute('data-impeccable-visual-contrast-target'); 165 + el.removeAttribute('data-impeccable-bgclip-text'); 166 + } 167 + } catch { 168 + // Ignore invalid or stale selectors during cleanup. 169 + } 170 + }, { selector: candidate.selector }).catch(() => {}); 171 + } 172 + 173 + const metrics = await compareScreenshotContrast(page, beforeBase64, afterBase64, candidate); 174 + if (!metrics || !Number.isFinite(metrics.p10Ratio) || metrics.glyphPixels < 8) return null; 175 + const measuredRatio = metrics.p10Ratio; 176 + if (measuredRatio >= candidate.threshold) return null; 177 + const textLabel = candidate.text ? ` "${candidate.text}"` : ''; 178 + const reasonLabel = (candidate.reasons || []).slice(0, 3).join(', ') || 'visual background'; 179 + return { 180 + id: 'low-contrast', 181 + snippet: `pixel contrast ${measuredRatio.toFixed(1)}:1 median ${metrics.medianRatio.toFixed(1)}:1 (need ${candidate.threshold}:1) on ${reasonLabel}${textLabel}`, 182 + }; 183 + } 184 + 185 + export { 186 + sanitizeScreenshotClip, 187 + compareScreenshotContrast, 188 + captureVisualContrastCandidate, 189 + };
+12
tools/design-detector/detector/findings.mjs
··· 1 + import { getAntipattern } from './registry/antipatterns.mjs'; 2 + 3 + function getAP(id) { 4 + return getAntipattern(id); 5 + } 6 + 7 + function finding(id, filePath, snippet, line = 0) { 8 + const ap = getAP(id); 9 + return { antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', file: filePath, line, snippet }; 10 + } 11 + 12 + export { getAP, finding };
+198
tools/design-detector/detector/node/file-system.mjs
··· 1 + import fs from 'node:fs'; 2 + import path from 'node:path'; 3 + 4 + // --------------------------------------------------------------------------- 5 + // File walker 6 + // --------------------------------------------------------------------------- 7 + 8 + const SKIP_DIRS = new Set([ 9 + 'node_modules', '.git', 'dist', 'build', '.next', '.nuxt', '.output', 10 + '.svelte-kit', '__pycache__', '.turbo', '.vercel', 11 + ]); 12 + 13 + const SCANNABLE_EXTENSIONS = new Set([ 14 + '.html', '.htm', '.css', '.scss', '.sass', '.less', 15 + '.jsx', '.tsx', '.js', '.ts', 16 + '.vue', '.svelte', '.astro', 17 + ]); 18 + 19 + const HTML_EXTENSIONS = new Set(['.html', '.htm']); 20 + 21 + function walkDir(dir) { 22 + const files = []; 23 + let entries; 24 + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; } 25 + for (const entry of entries) { 26 + if (SKIP_DIRS.has(entry.name)) continue; 27 + const full = path.join(dir, entry.name); 28 + if (entry.isDirectory()) files.push(...walkDir(full)); 29 + else if (SCANNABLE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(full); 30 + } 31 + return files; 32 + } 33 + 34 + 35 + // --------------------------------------------------------------------------- 36 + // Import graph (multi-file awareness) 37 + // --------------------------------------------------------------------------- 38 + 39 + function resolveImport(specifier, fromDir, fileSet) { 40 + if (!/^[./]/.test(specifier)) return null; // skip bare specifiers 41 + const base = path.resolve(fromDir, specifier); 42 + if (fileSet.has(base)) return base; 43 + for (const ext of SCANNABLE_EXTENSIONS) { 44 + const withExt = base + ext; 45 + if (fileSet.has(withExt)) return withExt; 46 + } 47 + // index file convention 48 + for (const ext of SCANNABLE_EXTENSIONS) { 49 + const indexFile = path.join(base, 'index' + ext); 50 + if (fileSet.has(indexFile)) return indexFile; 51 + } 52 + return null; 53 + } 54 + 55 + function buildImportGraph(files) { 56 + const fileSet = new Set(files); 57 + const graph = new Map(); 58 + 59 + for (const file of files) { 60 + const content = fs.readFileSync(file, 'utf-8'); 61 + const dir = path.dirname(file); 62 + const imports = new Set(); 63 + 64 + // ES imports: import ... from '...' and import '...' 65 + const esRe = /import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g; 66 + let m; 67 + while ((m = esRe.exec(content)) !== null) { 68 + const resolved = resolveImport(m[1], dir, fileSet); 69 + if (resolved) imports.add(resolved); 70 + } 71 + 72 + // CSS @import 73 + const cssRe = /@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g; 74 + while ((m = cssRe.exec(content)) !== null) { 75 + const resolved = resolveImport(m[1], dir, fileSet); 76 + if (resolved) imports.add(resolved); 77 + } 78 + 79 + // SCSS @use / @forward 80 + const scssRe = /@(?:use|forward)\s+['"]([^'"]+)['"]/g; 81 + while ((m = scssRe.exec(content)) !== null) { 82 + const resolved = resolveImport(m[1], dir, fileSet); 83 + if (resolved) imports.add(resolved); 84 + } 85 + 86 + graph.set(file, imports); 87 + } 88 + return graph; 89 + } 90 + 91 + // --------------------------------------------------------------------------- 92 + // Framework dev server detection 93 + // --------------------------------------------------------------------------- 94 + 95 + const FRAMEWORK_CONFIGS = [ 96 + { name: 'Next.js', files: ['next.config.js', 'next.config.mjs', 'next.config.ts'], defaultPort: 3000, 97 + portRe: /port\s*[:=]\s*(\d+)/, 98 + fingerprint: { header: 'x-powered-by', value: /next/i } }, 99 + { name: 'SvelteKit', files: ['svelte.config.js', 'svelte.config.ts'], defaultPort: 5173, 100 + portRe: /port\s*[:=]\s*(\d+)/, 101 + fingerprint: { header: 'x-sveltekit-page', value: null } }, 102 + { name: 'Nuxt', files: ['nuxt.config.js', 'nuxt.config.ts'], defaultPort: 3000, 103 + portRe: /port\s*[:=]\s*(\d+)/, 104 + fingerprint: { header: 'x-powered-by', value: /nuxt/i } }, 105 + { name: 'Vite', files: ['vite.config.js', 'vite.config.ts', 'vite.config.mjs'], defaultPort: 5173, 106 + portRe: /port\s*[:=]\s*(\d+)/, 107 + fingerprint: { body: /@vite\/client/ } }, 108 + { name: 'Astro', files: ['astro.config.js', 'astro.config.ts', 'astro.config.mjs'], defaultPort: 4321, 109 + portRe: /port\s*[:=]\s*(\d+)/, 110 + fingerprint: { body: /astro/i } }, 111 + { name: 'Angular', files: ['angular.json'], defaultPort: 4200, 112 + portRe: /"port"\s*:\s*(\d+)/, 113 + fingerprint: { body: /ng-version/i } }, 114 + { name: 'Remix', files: ['remix.config.js', 'remix.config.ts'], defaultPort: 3000, 115 + portRe: /port\s*[:=]\s*(\d+)/, 116 + fingerprint: { header: 'x-powered-by', value: /remix/i } }, 117 + ]; 118 + 119 + function detectFrameworkConfig(dir) { 120 + let entries; 121 + try { entries = fs.readdirSync(dir); } catch { return null; } 122 + const entrySet = new Set(entries); 123 + 124 + for (const cfg of FRAMEWORK_CONFIGS) { 125 + const match = cfg.files.find(f => entrySet.has(f)); 126 + if (!match) continue; 127 + 128 + const configPath = path.join(dir, match); 129 + let port = cfg.defaultPort; 130 + try { 131 + const content = fs.readFileSync(configPath, 'utf-8'); 132 + const portMatch = content.match(cfg.portRe); 133 + if (portMatch) port = parseInt(portMatch[1], 10); 134 + } catch { /* use default */ } 135 + 136 + return { name: cfg.name, port, configPath, fingerprint: cfg.fingerprint }; 137 + } 138 + return null; 139 + } 140 + 141 + /** 142 + * Check if a port is listening and optionally verify it matches the expected framework. 143 + * Returns { listening: true, matched: true/false } or { listening: false }. 144 + */ 145 + async function isPortListening(port, fingerprint = null) { 146 + if (!fingerprint) { 147 + // Simple TCP probe fallback 148 + const net = await import('node:net'); 149 + return new Promise((resolve) => { 150 + const sock = net.default.createConnection({ port, host: '127.0.0.1' }); 151 + sock.setTimeout(500); 152 + sock.on('connect', () => { sock.destroy(); resolve({ listening: true, matched: true }); }); 153 + sock.on('error', () => resolve({ listening: false })); 154 + sock.on('timeout', () => { sock.destroy(); resolve({ listening: false }); }); 155 + }); 156 + } 157 + 158 + // HTTP probe with fingerprint matching 159 + try { 160 + const controller = new AbortController(); 161 + const timeout = setTimeout(() => controller.abort(), 2000); 162 + const res = await fetch(`http://localhost:${port}/`, { signal: controller.signal, redirect: 'follow' }); 163 + clearTimeout(timeout); 164 + 165 + // Check header fingerprint 166 + if (fingerprint.header) { 167 + const val = res.headers.get(fingerprint.header); 168 + if (val && (!fingerprint.value || fingerprint.value.test(val))) { 169 + return { listening: true, matched: true }; 170 + } 171 + } 172 + 173 + // Check body fingerprint 174 + if (fingerprint.body) { 175 + const body = await res.text(); 176 + if (fingerprint.body.test(body)) { 177 + return { listening: true, matched: true }; 178 + } 179 + } 180 + 181 + // Port is listening but doesn't match the expected framework 182 + return { listening: true, matched: false }; 183 + } catch { 184 + return { listening: false }; 185 + } 186 + } 187 + 188 + export { 189 + SKIP_DIRS, 190 + SCANNABLE_EXTENSIONS, 191 + HTML_EXTENSIONS, 192 + walkDir, 193 + resolveImport, 194 + buildImportGraph, 195 + FRAMEWORK_CONFIGS, 196 + detectFrameworkConfig, 197 + isPortListening, 198 + };
+166
tools/design-detector/detector/profile/profiler.mjs
··· 1 + function profileNow() { 2 + return typeof performance !== 'undefined' && performance.now 3 + ? performance.now() 4 + : Date.now(); 5 + } 6 + 7 + function createDetectorProfile() { 8 + return { events: [] }; 9 + } 10 + 11 + function recordProfileEvent(profile, event) { 12 + if (!profile) return; 13 + const normalized = { 14 + engine: event.engine || 'unknown', 15 + phase: event.phase || 'unknown', 16 + ruleId: event.ruleId || 'unknown', 17 + target: event.target || '', 18 + ms: Number.isFinite(event.ms) ? event.ms : 0, 19 + findings: Number.isFinite(event.findings) ? event.findings : 0, 20 + }; 21 + if (event.detail) normalized.detail = event.detail; 22 + if (Array.isArray(event.findingIds) && event.findingIds.length) { 23 + normalized.findingIds = event.findingIds; 24 + } 25 + if (typeof profile === 'function') { 26 + profile(normalized); 27 + } else if (typeof profile.record === 'function') { 28 + profile.record(normalized); 29 + } else if (Array.isArray(profile.events)) { 30 + profile.events.push(normalized); 31 + } else if (Array.isArray(profile)) { 32 + profile.push(normalized); 33 + } 34 + } 35 + 36 + function extractFindingIds(findings) { 37 + if (!Array.isArray(findings) || findings.length === 0) return []; 38 + return [...new Set(findings.map(f => f?.id || f?.type || f?.antipattern).filter(Boolean))]; 39 + } 40 + 41 + function profileFindings(profile, meta, callback) { 42 + if (!profile) return callback(); 43 + const started = profileNow(); 44 + const findings = callback(); 45 + recordProfileEvent(profile, { 46 + ...meta, 47 + ms: profileNow() - started, 48 + findings: Array.isArray(findings) ? findings.length : 0, 49 + findingIds: extractFindingIds(findings), 50 + }); 51 + return findings; 52 + } 53 + 54 + function profileStep(profile, meta, callback) { 55 + if (!profile) return callback(); 56 + const started = profileNow(); 57 + try { 58 + return callback(); 59 + } finally { 60 + recordProfileEvent(profile, { 61 + ...meta, 62 + ms: profileNow() - started, 63 + findings: 0, 64 + }); 65 + } 66 + } 67 + 68 + async function profileFindingsAsync(profile, meta, callback) { 69 + if (!profile) return callback(); 70 + const started = profileNow(); 71 + const findings = await callback(); 72 + recordProfileEvent(profile, { 73 + ...meta, 74 + ms: profileNow() - started, 75 + findings: Array.isArray(findings) ? findings.length : 0, 76 + findingIds: extractFindingIds(findings), 77 + }); 78 + return findings; 79 + } 80 + 81 + async function profileStepAsync(profile, meta, callback) { 82 + if (!profile) return callback(); 83 + const started = profileNow(); 84 + try { 85 + return await callback(); 86 + } finally { 87 + recordProfileEvent(profile, { 88 + ...meta, 89 + ms: profileNow() - started, 90 + findings: 0, 91 + }); 92 + } 93 + } 94 + 95 + function percentile(sortedValues, pct) { 96 + if (!sortedValues.length) return 0; 97 + const idx = Math.min( 98 + sortedValues.length - 1, 99 + Math.max(0, Math.ceil((pct / 100) * sortedValues.length) - 1), 100 + ); 101 + return sortedValues[idx]; 102 + } 103 + 104 + function summarizeDetectorProfile(profile) { 105 + const events = Array.isArray(profile) 106 + ? profile 107 + : (Array.isArray(profile?.events) ? profile.events : []); 108 + const groups = new Map(); 109 + for (const event of events) { 110 + const key = [ 111 + event.engine || 'unknown', 112 + event.phase || 'unknown', 113 + event.ruleId || 'unknown', 114 + event.target || '', 115 + ].join('\u0000'); 116 + let group = groups.get(key); 117 + if (!group) { 118 + group = { 119 + engine: event.engine || 'unknown', 120 + phase: event.phase || 'unknown', 121 + ruleId: event.ruleId || 'unknown', 122 + target: event.target || '', 123 + calls: 0, 124 + totalMs: 0, 125 + findings: 0, 126 + samples: [], 127 + }; 128 + groups.set(key, group); 129 + } 130 + const ms = Number.isFinite(event.ms) ? event.ms : 0; 131 + group.calls += 1; 132 + group.totalMs += ms; 133 + group.findings += Number.isFinite(event.findings) ? event.findings : 0; 134 + group.samples.push(ms); 135 + } 136 + return [...groups.values()] 137 + .map(group => { 138 + const samples = group.samples.sort((a, b) => a - b); 139 + return { 140 + engine: group.engine, 141 + phase: group.phase, 142 + ruleId: group.ruleId, 143 + target: group.target, 144 + calls: group.calls, 145 + totalMs: Number(group.totalMs.toFixed(3)), 146 + avgMs: Number((group.totalMs / group.calls).toFixed(3)), 147 + p50: Number(percentile(samples, 50).toFixed(3)), 148 + p95: Number(percentile(samples, 95).toFixed(3)), 149 + findings: group.findings, 150 + }; 151 + }) 152 + .sort((a, b) => b.totalMs - a.totalMs); 153 + } 154 + 155 + export { 156 + profileNow, 157 + createDetectorProfile, 158 + recordProfileEvent, 159 + extractFindingIds, 160 + profileFindings, 161 + profileStep, 162 + profileFindingsAsync, 163 + profileStepAsync, 164 + percentile, 165 + summarizeDetectorProfile, 166 + };
+448
tools/design-detector/detector/registry/antipatterns.mjs
··· 1 + const ANTIPATTERNS = [ 2 + // ── AI slop: tells that something was AI-generated ── 3 + { 4 + id: 'side-tab', 5 + category: 'slop', 6 + name: 'Side-tab accent border', 7 + description: 8 + 'Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.', 9 + skillSection: 'Visual Details', 10 + skillGuideline: 'colored accent stripe', 11 + }, 12 + { 13 + id: 'border-accent-on-rounded', 14 + category: 'slop', 15 + name: 'Border accent on rounded element', 16 + description: 17 + 'Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.', 18 + skillSection: 'Visual Details', 19 + skillGuideline: 'colored accent stripe', 20 + }, 21 + { 22 + id: 'overused-font', 23 + category: 'slop', 24 + name: 'Overused font', 25 + description: 26 + 'Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.', 27 + skillSection: 'Typography', 28 + skillGuideline: 'overused fonts like Inter', 29 + }, 30 + { 31 + id: 'single-font', 32 + category: 'slop', 33 + name: 'Single font for everything', 34 + description: 35 + 'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.', 36 + skillSection: 'Typography', 37 + skillGuideline: 'only one font family for the entire page', 38 + }, 39 + { 40 + id: 'flat-type-hierarchy', 41 + category: 'slop', 42 + name: 'Flat type hierarchy', 43 + description: 44 + 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', 45 + skillSection: 'Typography', 46 + skillGuideline: 'flat type hierarchy', 47 + }, 48 + { 49 + id: 'gradient-text', 50 + category: 'slop', 51 + name: 'Gradient text', 52 + description: 53 + 'Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.', 54 + skillSection: 'Color & Contrast', 55 + skillGuideline: 'gradient text for', 56 + }, 57 + { 58 + id: 'ai-color-palette', 59 + category: 'slop', 60 + name: 'AI color palette', 61 + description: 62 + 'Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.', 63 + skillSection: 'Color & Contrast', 64 + skillGuideline: 'AI color palette', 65 + }, 66 + { 67 + id: 'cream-palette', 68 + category: 'slop', 69 + name: 'Cream / beige palette', 70 + description: 71 + 'A warm cream or beige page background has become the default "tasteful" AI surface, reached for by reflex. Choose a background that comes from a deliberate palette, not the safe warm off-white.', 72 + skillSection: 'Color & Contrast', 73 + skillGuideline: 'cream and beige as the default surface', 74 + }, 75 + { 76 + id: 'nested-cards', 77 + category: 'slop', 78 + name: 'Nested cards', 79 + description: 80 + 'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.', 81 + skillSection: 'Layout & Space', 82 + skillGuideline: 'Nest cards inside cards', 83 + }, 84 + { 85 + id: 'monotonous-spacing', 86 + category: 'slop', 87 + name: 'Monotonous spacing', 88 + description: 89 + 'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.', 90 + skillSection: 'Layout & Space', 91 + skillGuideline: 'same spacing everywhere', 92 + }, 93 + { 94 + id: 'bounce-easing', 95 + category: 'slop', 96 + name: 'Bounce or elastic easing', 97 + description: 98 + 'Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.', 99 + skillSection: 'Motion', 100 + skillGuideline: 'bounce or elastic easing', 101 + }, 102 + { 103 + id: 'dark-glow', 104 + category: 'slop', 105 + name: 'Dark mode with glowing accents', 106 + description: 107 + 'Dark backgrounds with colored box-shadow glows are the default "cool" look of AI-generated UIs. Use subtle, purposeful lighting instead — or skip the dark theme entirely.', 108 + skillSection: 'Color & Contrast', 109 + skillGuideline: 'dark mode with glowing accents', 110 + }, 111 + { 112 + id: 'icon-tile-stack', 113 + category: 'slop', 114 + name: 'Icon tile stacked above heading', 115 + description: 116 + 'A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.', 117 + skillSection: 'Typography', 118 + skillGuideline: 'large icons with rounded corners above every heading', 119 + }, 120 + { 121 + id: 'italic-serif-display', 122 + category: 'slop', 123 + name: 'Italic serif display headline', 124 + description: 125 + 'Oversized italic serif (Fraunces, Recoleta, Playfair, Newsreader-italic) as the primary hero headline reads as taste in isolation but has become the universal AI-startup landing page hero. Set roman, or move to a non-serif display face. Editorial / magazine register may legitimately want this — judge by context.', 126 + skillSection: 'Typography', 127 + skillGuideline: 'oversized italic serif as the hero headline', 128 + }, 129 + { 130 + id: 'hero-eyebrow-chip', 131 + category: 'slop', 132 + name: 'Hero eyebrow / pill chip', 133 + description: 134 + 'A tiny uppercase letter-spaced label sitting immediately above an oversized hero headline — or the same shape rendered as a pill chip — is now the default AI SaaS hero. Drop the eyebrow, integrate the kicker into the headline, or run it as a navigation breadcrumb instead.', 135 + skillSection: 'Typography', 136 + skillGuideline: 'tiny uppercase tracked label above the hero headline', 137 + }, 138 + { 139 + id: 'repeated-section-kickers', 140 + category: 'slop', 141 + severity: 'advisory', 142 + name: 'Repeated section kicker labels', 143 + description: 144 + 'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.', 145 + skillSection: 'Typography', 146 + skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding', 147 + }, 148 + { 149 + id: 'numbered-section-markers', 150 + category: 'slop', 151 + severity: 'advisory', 152 + name: 'Numbered section markers (01 / 02 / 03)', 153 + description: 154 + 'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.', 155 + skillSection: 'Layout & Space', 156 + skillGuideline: 'numbered section markers', 157 + }, 158 + { 159 + id: 'em-dash-overuse', 160 + category: 'slop', 161 + name: 'Em-dash overuse', 162 + description: 163 + 'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.', 164 + skillSection: 'Copy', 165 + skillGuideline: 'no em dashes', 166 + }, 167 + { 168 + id: 'marketing-buzzword', 169 + category: 'slop', 170 + name: 'Marketing buzzword', 171 + description: 172 + 'Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.', 173 + skillSection: 'Copy', 174 + skillGuideline: 'marketing buzzwords', 175 + }, 176 + { 177 + id: 'aphoristic-cadence', 178 + category: 'slop', 179 + name: 'Aphoristic-cadence copy', 180 + description: 181 + 'Three or more sections landing on a short rebuttal sentence ("X. No Y." / "X. Just Y.") or a manufactured-contrast aphorism ("Not a feature. A platform.") reads as AI cadence, not voice. Once is fine; the pattern is the tell.', 182 + skillSection: 'Copy', 183 + skillGuideline: 'aphoristic cadence', 184 + }, 185 + { 186 + id: 'oversized-h1', 187 + category: 'slop', 188 + name: 'Oversized hero headline', 189 + description: 190 + 'A full-sentence headline set at display size ends up dominating the viewport, leaving no room for anything else above the fold. A punchy one- or two-word headline at that size is fine — the problem is a long headline blown up too large. Set long headlines smaller, or tighten the copy.', 191 + skillSection: 'Typography', 192 + skillGuideline: 'long headline set at display size', 193 + }, 194 + { 195 + id: 'extreme-negative-tracking', 196 + category: 'slop', 197 + name: 'Crushed letter spacing', 198 + description: 199 + 'Letter-spacing pulled tighter than the point where characters keep their own shapes costs legibility. Tighten display type optically, not destructively.', 200 + skillSection: 'Typography', 201 + skillGuideline: 'letter spacing crushed past legibility', 202 + }, 203 + { 204 + id: 'broken-image', 205 + category: 'quality', 206 + name: 'Broken or placeholder image', 207 + description: 208 + '<img> tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.', 209 + skillSection: 'Imagery', 210 + skillGuideline: 'broken image references', 211 + }, 212 + 213 + // ── Quality: general design and accessibility issues ── 214 + { 215 + id: 'gray-on-color', 216 + category: 'quality', 217 + name: 'Gray text on colored background', 218 + description: 219 + 'Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.', 220 + skillSection: 'Color & Contrast', 221 + skillGuideline: 'gray text on colored backgrounds', 222 + }, 223 + { 224 + id: 'low-contrast', 225 + category: 'quality', 226 + name: 'Low contrast text', 227 + description: 228 + 'Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.', 229 + }, 230 + { 231 + id: 'layout-transition', 232 + category: 'quality', 233 + name: 'Layout property animation', 234 + description: 235 + 'Animating width, height, padding, or margin causes layout thrash and janky performance. Use transform and opacity instead, or grid-template-rows for height animations.', 236 + skillSection: 'Motion', 237 + skillGuideline: 'Animate layout properties', 238 + }, 239 + { 240 + id: 'line-length', 241 + category: 'quality', 242 + name: 'Line length too long', 243 + description: 244 + 'Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line. Add a max-width (65ch to 75ch) to text containers.', 245 + skillSection: 'Layout & Space', 246 + skillGuideline: 'wrap beyond ~80 characters', 247 + }, 248 + { 249 + id: 'cramped-padding', 250 + category: 'quality', 251 + name: 'Cramped padding', 252 + description: 253 + 'Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 12–16px) of padding inside bordered, outlined, or colored containers.', 254 + skillSection: 'Layout & Space', 255 + skillGuideline: 'inside bordered or colored containers', 256 + }, 257 + { 258 + id: 'body-text-viewport-edge', 259 + category: 'quality', 260 + name: 'Body text touching viewport edge', 261 + description: 262 + 'Body paragraphs render flush against the left or right viewport edge with no container providing horizontal padding. Wrap content in a container with at least 16px (ideally 24-32px) of horizontal padding, or apply max-width with mx-auto.', 263 + }, 264 + { 265 + id: 'tight-leading', 266 + category: 'quality', 267 + name: 'Tight line height', 268 + description: 269 + 'Line height below 1.3x the font size makes multi-line text hard to read. Use 1.5 to 1.7 for body text so lines have room to breathe.', 270 + }, 271 + { 272 + id: 'skipped-heading', 273 + category: 'quality', 274 + name: 'Skipped heading level', 275 + description: 276 + 'Heading levels should not skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation. Skipping levels breaks the document outline.', 277 + }, 278 + { 279 + id: 'justified-text', 280 + category: 'quality', 281 + name: 'Justified text', 282 + description: 283 + 'Justified text without hyphenation creates uneven word spacing ("rivers of white"). Use text-align: left for body text, or enable hyphens: auto if you must justify.', 284 + }, 285 + { 286 + id: 'tiny-text', 287 + category: 'quality', 288 + name: 'Tiny body text', 289 + description: 290 + 'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.', 291 + }, 292 + { 293 + id: 'all-caps-body', 294 + category: 'quality', 295 + name: 'All-caps body text', 296 + description: 297 + 'Long passages in uppercase are hard to read. We recognize words by shape (ascenders and descenders), which all-caps removes. Reserve uppercase for short labels and headings.', 298 + skillSection: 'Typography', 299 + skillGuideline: 'long body passages in uppercase', 300 + }, 301 + { 302 + id: 'wide-tracking', 303 + category: 'quality', 304 + name: 'Wide letter spacing on body text', 305 + description: 306 + 'Letter spacing above 0.05em on body text disrupts natural character groupings and slows reading. Reserve wide tracking for short uppercase labels only.', 307 + }, 308 + { 309 + id: 'text-overflow', 310 + category: 'quality', 311 + name: 'Content overflowing its container', 312 + description: 313 + 'Content renders wider than its container, spilling out or forcing a horizontal scrollbar. Let text wrap, constrain widths, or give the region a deliberate scroll affordance.', 314 + skillSection: 'Layout & Space', 315 + skillGuideline: 'content wider than its container', 316 + }, 317 + { 318 + id: 'clipped-overflow-container', 319 + category: 'quality', 320 + name: 'Positioned child clipped by overflow container', 321 + description: 322 + 'A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.', 323 + skillSection: 'Layout & Space', 324 + skillGuideline: 'overflow container clipping positioned children', 325 + }, 326 + { 327 + id: 'design-system-font', 328 + category: 'quality', 329 + name: 'Font outside DESIGN.md', 330 + description: 331 + 'A font is used that is not declared in DESIGN.md typography. Use the documented type system or update DESIGN.md if this is an intentional brand addition.', 332 + skillSection: 'Typography', 333 + skillGuideline: 'font family outside the project design system', 334 + }, 335 + { 336 + id: 'design-system-color', 337 + category: 'quality', 338 + severity: 'advisory', 339 + name: 'Color outside DESIGN.md', 340 + description: 341 + 'A literal color is outside the DESIGN.md palette and sidecar tonal ramps. This may be legitimate, but it should be an intentional design-system addition rather than drift.', 342 + skillSection: 'Color & Contrast', 343 + skillGuideline: 'literal color outside the project design system', 344 + }, 345 + { 346 + id: 'design-system-radius', 347 + category: 'quality', 348 + severity: 'advisory', 349 + name: 'Radius outside DESIGN.md', 350 + description: 351 + 'A border-radius value is outside the DESIGN.md rounded scale. Use a documented radius token or update the design system if the new shape is intentional.', 352 + skillSection: 'Visual Details', 353 + skillGuideline: 'border radius outside the project design system', 354 + }, 355 + 356 + // ── Provider tells: opt-in via --gpt / --gemini (gated off by default) ── 357 + { 358 + id: 'gpt-thin-border-wide-shadow', 359 + category: 'slop', 360 + severity: 'advisory', 361 + gated: 'gpt', 362 + name: 'Hairline border with wide shadow', 363 + description: 364 + 'A hairline border paired with a wide, diffuse shadow is a recurring generated-UI signature. Commit to one — a defined edge or a soft elevation — rather than both at once.', 365 + skillSection: 'Visual Details', 366 + skillGuideline: 'hairline border plus wide diffuse shadow', 367 + }, 368 + { 369 + id: 'repeating-stripes-gradient', 370 + category: 'slop', 371 + severity: 'advisory', 372 + gated: 'gpt', 373 + name: 'Repeating-gradient stripes', 374 + description: 375 + 'Repeating-gradient stripes used as surface decoration are a recurring generated-UI signature. Reach for a deliberate texture or leave the surface plain.', 376 + skillSection: 'Visual Details', 377 + skillGuideline: 'repeating-gradient decorative stripes', 378 + }, 379 + { 380 + id: 'theater-slop-phrase', 381 + category: 'slop', 382 + severity: 'advisory', 383 + gated: 'gpt', 384 + name: 'Theater framing copy', 385 + description: 386 + 'Dismissing something as "theater" is a recurring generated-copy tic. Say plainly what the thing does or does not do.', 387 + skillSection: 'Copy', 388 + skillGuideline: 'theater framing copy', 389 + }, 390 + { 391 + id: 'image-hover-transform', 392 + category: 'slop', 393 + severity: 'advisory', 394 + gated: 'gemini', 395 + name: 'Image hover transform', 396 + description: 397 + 'Scaling or rotating an image on hover is a recurring generated-UI signature. Let imagery sit still, or use a subtler, purposeful interaction.', 398 + skillSection: 'Motion', 399 + skillGuideline: 'image scale or rotate on hover', 400 + }, 401 + ]; 402 + 403 + const RULE_ENGINE_SUPPORT = { 404 + regex: new Set(['source', 'page-analyzer']), 405 + 'static-html': new Set(['element', 'page']), 406 + browser: new Set(['element', 'page', 'layout']), 407 + visual: new Set(['visual-contrast']), 408 + }; 409 + 410 + function getAntipattern(id) { 411 + return ANTIPATTERNS.find(rule => rule.id === id); 412 + } 413 + 414 + function getRulesForCategory(category) { 415 + return ANTIPATTERNS.filter(rule => rule.category === category); 416 + } 417 + 418 + function getRuleEngineSupport(engine) { 419 + return RULE_ENGINE_SUPPORT[engine] || new Set(); 420 + } 421 + 422 + // Set of provider tags that gate rules off by default (e.g. 'gpt', 'gemini'). 423 + const GATED_PROVIDERS = new Set( 424 + ANTIPATTERNS.map(rule => rule.gated).filter(Boolean), 425 + ); 426 + 427 + // Drop findings for rules gated behind a provider tag unless that provider 428 + // was explicitly enabled (CLI --gpt / --gemini). Non-gated findings always 429 + // pass through. `findings` carry the rule id on `.antipattern`. 430 + function filterByProviders(findings, providers = []) { 431 + const enabled = new Set(providers || []); 432 + if (!GATED_PROVIDERS.size) return findings; 433 + return findings.filter(f => { 434 + const rule = getAntipattern(f.antipattern); 435 + if (!rule || !rule.gated) return true; 436 + return enabled.has(rule.gated); 437 + }); 438 + } 439 + 440 + export { 441 + ANTIPATTERNS, 442 + RULE_ENGINE_SUPPORT, 443 + GATED_PROVIDERS, 444 + getAntipattern, 445 + getRulesForCategory, 446 + getRuleEngineSupport, 447 + filterByProviders, 448 + };
+2671
tools/design-detector/detector/rules/checks.mjs
··· 1 + import { 2 + BORDER_SAFE_TAGS, 3 + GENERIC_FONTS, 4 + KNOWN_SERIF_FONTS, 5 + OVERUSED_FONTS, 6 + SAFE_TAGS, 7 + WCAG_LARGE_BOLD_TEXT_PX, 8 + WCAG_LARGE_TEXT_PX, 9 + isBrandFontOnOwnDomain, 10 + } from '../shared/constants.mjs'; 11 + import { 12 + colorToHex, 13 + contrastRatio, 14 + getHue, 15 + hasChroma, 16 + isNeutralColor, 17 + parseGradientColors, 18 + parseRgb, 19 + relativeLuminance, 20 + } from '../shared/color.mjs'; 21 + 22 + const DETECTOR_IS_BROWSER = typeof window !== 'undefined'; 23 + 24 + // ─── Section 3: Pure Detection ────────────────────────────────────────────── 25 + 26 + function checkBorders(tag, widths, colors, radius) { 27 + if (BORDER_SAFE_TAGS.has(tag)) return []; 28 + const findings = []; 29 + const sides = ['Top', 'Right', 'Bottom', 'Left']; 30 + 31 + for (const side of sides) { 32 + const w = widths[side]; 33 + if (w < 1 || isNeutralColor(colors[side])) continue; 34 + 35 + const otherSides = sides.filter(s => s !== side); 36 + const maxOther = Math.max(...otherSides.map(s => widths[s])); 37 + if (!(w >= 2 && (maxOther <= 1 || w >= maxOther * 2))) continue; 38 + 39 + const sn = side.toLowerCase(); 40 + const isSide = side === 'Left' || side === 'Right'; 41 + 42 + if (isSide) { 43 + if (radius > 0) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` }); 44 + else if (w >= 3) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` }); 45 + } else { 46 + if (radius > 0 && w >= 2) findings.push({ id: 'border-accent-on-rounded', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` }); 47 + } 48 + } 49 + 50 + return findings; 51 + } 52 + 53 + // Returns true if the given text is composed entirely of emoji characters 54 + // (plus whitespace / variation selectors). Emojis render as multicolor glyphs 55 + // regardless of CSS `color`, so contrast checks against the element's text 56 + // color are meaningless for these nodes. 57 + const EMOJI_CHAR_RE = /[\u{1F1E6}-\u{1F1FF}\u{1F300}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}\u{FE0F}\u{200D}\u{1F3FB}-\u{1F3FF}]/u; 58 + const EMOJI_CHARS_GLOBAL = /[\u{1F1E6}-\u{1F1FF}\u{1F300}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}\u{FE0F}\u{200D}\u{1F3FB}-\u{1F3FF}]/gu; 59 + function isEmojiOnlyText(text) { 60 + if (!text) return false; 61 + if (!EMOJI_CHAR_RE.test(text)) return false; 62 + return text.replace(EMOJI_CHARS_GLOBAL, '').trim() === ''; 63 + } 64 + 65 + function checkColors(opts) { 66 + const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, isEmojiOnly, bgClip, bgImage, classList } = opts; 67 + if (SAFE_TAGS.has(tag)) { 68 + // Exception for <a> and <button> elements styled as buttons. SAFE_TAGS 69 + // exists to suppress contrast noise on inline links and unstyled controls, 70 + // where the element has no own background and the contrast against the 71 + // ancestor surface is already the intended visual. When the element has 72 + // its own opaque background and direct text, it is a styled button — and 73 + // contrast on its own surface is a real, frequent bug worth flagging. 74 + const isStyledButton = (tag === 'a' || tag === 'button') 75 + && hasDirectText 76 + && bgColor && bgColor.a > 0.5; 77 + if (!isStyledButton) return []; 78 + } 79 + const findings = []; 80 + 81 + if (hasDirectText && textColor && !isEmojiOnly) { 82 + // Run background-dependent checks against either a solid bg or, if the 83 + // ancestor is a gradient, against every gradient stop (use the worst case). 84 + const bgs = effectiveBg ? [effectiveBg] : (effectiveBgStops && effectiveBgStops.length ? effectiveBgStops : null); 85 + if (bgs) { 86 + // Gray on colored background — flag if every stop is chromatic 87 + const textLum = relativeLuminance(textColor); 88 + const isGray = !hasChroma(textColor, 20) && textLum > 0.05 && textLum < 0.85; 89 + if (isGray && bgs.every(b => hasChroma(b, 40))) { 90 + const bgLabel = effectiveBg ? colorToHex(effectiveBg) : `gradient(${bgs.map(colorToHex).join(', ')})`; 91 + findings.push({ id: 'gray-on-color', snippet: `text ${colorToHex(textColor)} on bg ${bgLabel}` }); 92 + } 93 + 94 + // Low contrast (WCAG AA) — worst case across all bg stops 95 + const ratios = bgs.map(b => contrastRatio(textColor, b)); 96 + let worstIdx = 0; 97 + for (let i = 1; i < ratios.length; i++) if (ratios[i] < ratios[worstIdx]) worstIdx = i; 98 + const ratio = ratios[worstIdx]; 99 + const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700); 100 + const threshold = isLargeText ? 3.0 : 4.5; 101 + if (ratio < threshold) { 102 + // Skip the false-positive class where text has alpha < 1 AND we 103 + // couldn't find an opaque ancestor (effectiveBg is null, we're 104 + // comparing against gradient-stop fallback). In jsdom mode the 105 + // detector can't resolve `var(--X)` color tokens, so a dark 106 + // section sitting between the text and the body's decorative 107 + // gradient is invisible to us — we end up measuring contrast 108 + // against the body's paper-grain noise instead of the real 109 + // local bg. Real low-contrast bugs use alpha=1 and have a 110 + // resolvable opaque ancestor; semi-transparent Tailwind tokens 111 + // like `text-paper/60` on `bg-ink` sections are the FP pattern. 112 + const isAlphaFallbackFP = !DETECTOR_IS_BROWSER && !effectiveBg && (textColor.a != null && textColor.a < 1); 113 + if (!isAlphaFallbackFP) { 114 + findings.push({ id: 'low-contrast', snippet: `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}` }); 115 + } 116 + } 117 + } 118 + 119 + // AI palette: purple/violet on headings 120 + if (hasChroma(textColor, 50)) { 121 + const hue = getHue(textColor); 122 + if (hue >= 260 && hue <= 310 && (['h1', 'h2', 'h3'].includes(tag) || fontSize >= 20)) { 123 + findings.push({ id: 'ai-color-palette', snippet: `Purple/violet text (${colorToHex(textColor)}) on heading` }); 124 + } 125 + } 126 + } 127 + 128 + // Gradient text 129 + if (bgClip === 'text' && bgImage && bgImage.includes('gradient')) { 130 + findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' }); 131 + } 132 + 133 + // Tailwind class checks 134 + if (classList) { 135 + const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' '); 136 + 137 + const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/); 138 + const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); 139 + if (grayMatch && colorBgMatch) { 140 + findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` }); 141 + } 142 + 143 + if (/\bbg-clip-text\b/.test(classStr) && /\bbg-gradient-to-/.test(classStr)) { 144 + findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' }); 145 + } 146 + 147 + const purpleText = classStr.match(/\btext-(?:purple|violet|indigo)-\d+\b/); 148 + if (purpleText && (['h1', 'h2', 'h3'].includes(tag) || /\btext-(?:[2-9]xl)\b/.test(classStr))) { 149 + findings.push({ id: 'ai-color-palette', snippet: `${purpleText[0]} on heading` }); 150 + } 151 + 152 + if (/\bfrom-(?:purple|violet|indigo)-\d+\b/.test(classStr) && /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(classStr)) { 153 + findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient (Tailwind)' }); 154 + } 155 + } 156 + 157 + return findings; 158 + } 159 + 160 + function isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg) { 161 + if (!hasShadow && !hasBorder) return false; 162 + return hasRadius || hasBg; 163 + } 164 + 165 + const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']); 166 + 167 + // Pure check: given a heading and metrics about its previousElementSibling, 168 + // decide if the sibling is the canonical "icon-tile-stacked-above-heading" shape. 169 + // 170 + // Triggers when ALL of the following hold for the sibling: 171 + // • size 32–128px on both axes (not too small, not a hero image) 172 + // • aspect ratio 0.7–1.4 (squarish — excludes wide thumbnails / pill badges) 173 + // • has a non-transparent background-color, background-image, OR a visible border 174 + // (covers solid colors, white-with-border, gradients — anything that visually 175 + // defines a tile) 176 + // • border-radius < width/2 (excludes round avatars; rounded squares pass) 177 + // • contains an <svg> or icon-class <i> element that's smaller than the tile 178 + // • the tile sits above the heading (its bottom is above the heading's top) 179 + function checkIconTile(opts) { 180 + const { headingTag, headingText, headingTop, 181 + siblingTag, siblingWidth, siblingHeight, siblingBottom, 182 + siblingBgColor, siblingBgImage, siblingBorderWidth, siblingBorderRadius, 183 + hasIconChild, iconChildWidth } = opts; 184 + if (!HEADING_TAGS.has(headingTag)) return []; 185 + if (!siblingTag) return []; 186 + // Don't recurse into nested headings (e.g. h2 above h3 in a section header) 187 + if (HEADING_TAGS.has(siblingTag)) return []; 188 + 189 + // Size window: 32–128px on each axis 190 + if (!(siblingWidth >= 32 && siblingWidth <= 128)) return []; 191 + if (!(siblingHeight >= 32 && siblingHeight <= 128)) return []; 192 + 193 + // Squarish aspect ratio 194 + const ratio = siblingWidth / siblingHeight; 195 + if (ratio < 0.7 || ratio > 1.4) return []; 196 + 197 + // Must have something that visually defines the tile 198 + const bgVisible = (siblingBgColor && siblingBgColor.a > 0.1) 199 + || (siblingBgImage && siblingBgImage !== 'none' && siblingBgImage !== ''); 200 + const borderVisible = siblingBorderWidth > 0; 201 + if (!bgVisible && !borderVisible) return []; 202 + 203 + // Exclude circles (avatars). Rounded squares pass. 204 + if (siblingBorderRadius >= siblingWidth / 2) return []; 205 + 206 + // Must contain an icon element smaller than the tile 207 + if (!hasIconChild) return []; 208 + if (iconChildWidth && iconChildWidth >= siblingWidth * 0.95) return []; 209 + 210 + // Vertical stacking: tile must end above where the heading starts. 211 + // (Allow the check to skip when both top/bottom are 0 — jsdom layout case.) 212 + if (headingTop && siblingBottom && siblingBottom > headingTop + 4) return []; 213 + 214 + const text = (headingText || '').trim().slice(0, 60); 215 + return [{ 216 + id: 'icon-tile-stack', 217 + snippet: `${Math.round(siblingWidth)}x${Math.round(siblingHeight)}px icon tile above ${headingTag} "${text}"`, 218 + }]; 219 + } 220 + 221 + // Resolve the primary (non-generic) face from a font-family string and return 222 + // whether the resolved primary is serif. Two paths: 223 + // 1. Primary face is in KNOWN_SERIF_FONTS → serif. 224 + // 2. Primary face is unknown but the stack ends in the generic `serif` 225 + // token → treat as serif. Authors who declare `font-family: 'X', serif` 226 + // almost always have a serif primary; a sans declared with a serif 227 + // fallback is a code smell, not the common case. 228 + // Returns { primary, isSerif } so the snippet can name the face. 229 + function resolveSerif(fontFamily) { 230 + if (!fontFamily) return { primary: null, isSerif: false }; 231 + const tokens = fontFamily.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase()); 232 + const primary = tokens.find(f => f && !GENERIC_FONTS.has(f)) || null; 233 + if (!primary) return { primary: null, isSerif: false }; 234 + if (KNOWN_SERIF_FONTS.has(primary)) return { primary, isSerif: true }; 235 + if (tokens.includes('serif')) return { primary, isSerif: true }; 236 + return { primary, isSerif: false }; 237 + } 238 + 239 + function checkItalicSerif(opts) { 240 + const { tag, fontStyle, fontFamily, fontSize, headingText } = opts; 241 + if (fontStyle !== 'italic') return []; 242 + // Anchor the rule on hero-scale text. h1 is the canonical hero element; 243 + // h2 ≥ 48px catches the cases where the design demotes the visual hero 244 + // to an h2 but keeps the size. 245 + if (tag !== 'h1' && !(tag === 'h2' && fontSize >= 48)) return []; 246 + if (fontSize < 48) return []; 247 + const { primary, isSerif } = resolveSerif(fontFamily); 248 + if (!isSerif) return []; 249 + 250 + const text = (headingText || '').trim().slice(0, 60); 251 + return [{ 252 + id: 'italic-serif-display', 253 + snippet: `italic serif ${tag} (${primary || 'serif'}) at ${Math.round(fontSize)}px "${text}"`, 254 + }]; 255 + } 256 + 257 + // Color saturation check. Returns true when the color has visible 258 + // chroma — i.e., it's an "accent color" rather than near-neutral. 259 + // Handles rgb()/rgba(), #hex, oklch(), and hsl(). var() refs are 260 + // expected to be pre-resolved by the caller. 261 + function isAccentColor(cssColor) { 262 + if (!cssColor) return false; 263 + const s = String(cssColor).trim(); 264 + // rgb / rgba — direct channel-distance check. 265 + const rgbM = /rgba?\(\s*(\d+)\s*,?\s+|\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s.replace(/rgba?\(\s*/, 'rgb(').replace(/,/g, ', ')); 266 + const rgbStrict = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s); 267 + if (rgbStrict) { 268 + const r = +rgbStrict[1], g = +rgbStrict[2], b = +rgbStrict[3]; 269 + return (Math.max(r, g, b) - Math.min(r, g, b)) >= 40; 270 + } 271 + // #hex — 3, 4, 6, or 8 digit. 272 + const hexM = /^#([0-9a-f]{3,8})\b/i.exec(s); 273 + if (hexM) { 274 + let h = hexM[1]; 275 + if (h.length === 3 || h.length === 4) h = h.split('').map((c) => c + c).join('').slice(0, 6); 276 + else h = h.slice(0, 6); 277 + if (h.length === 6) { 278 + const r = parseInt(h.slice(0, 2), 16); 279 + const g = parseInt(h.slice(2, 4), 16); 280 + const b = parseInt(h.slice(4, 6), 16); 281 + return (Math.max(r, g, b) - Math.min(r, g, b)) >= 40; 282 + } 283 + } 284 + // oklch(L C H) — chroma C is what matters. Typical neutral grays 285 + // have C < 0.02; visible accents are 0.05+. CSS minification can 286 + // collapse spaces between L% and C ("oklch(43%.15 34)"), so we 287 + // extract all numbers and take the second rather than matching a 288 + // strict L-then-whitespace-then-C pattern. 289 + if (/^oklch\(/i.test(s)) { 290 + const nums = s.match(/\d*\.\d+|\d+/g); 291 + if (nums && nums.length >= 2) { 292 + const c = parseFloat(nums[1]); 293 + return !Number.isNaN(c) && c >= 0.05; 294 + } 295 + } 296 + // hsl(H, S%, L%) — saturation > 20% reads as accent. 297 + const hslM = /hsla?\(\s*[\d.]+\s*,\s*([\d.]+)%/i.exec(s); 298 + if (hslM) { 299 + const sat = parseFloat(hslM[1]); 300 + return !Number.isNaN(sat) && sat >= 20; 301 + } 302 + return false; 303 + } 304 + 305 + // Sibling-relationship rule. Anchor on a hero-scale h1, look at the 306 + // previousElementSibling, and gate on EITHER the classic tracked- 307 + // uppercase eyebrow OR the modern accent-colored bold eyebrow. 308 + function checkHeroEyebrow(opts) { 309 + const { 310 + headingTag, headingText, headingFontSize, 311 + siblingTag, siblingText, siblingTextTransform, 312 + siblingFontSize, siblingLetterSpacing, 313 + siblingFontWeight, siblingColor, 314 + } = opts; 315 + if (headingTag !== 'h1') return []; 316 + // We previously gated on headingFontSize >= 48 to anchor "hero scale". 317 + // But modern hero h1s use clamp() / vw / var(--text-*), none of which 318 + // jsdom can resolve — the computed value comes back as "2em" or 319 + // "var(--text-9xl)" and parseFloat returns 2 or NaN. The gate fails 320 + // on virtually every Tailwind v4 / framework build. The other gates 321 + // (sibling text 2-60 chars, font-size ≤ 14px, accent-bold OR 322 + // tracked-caps) are tight enough to avoid false positives on non- 323 + // hero h1s — a tiny tan label directly above any h1 is the 324 + // antipattern regardless of how big the h1 ends up. 325 + if (!siblingTag) return []; 326 + // An h2 above an h1 is a different anti-pattern (heading hierarchy / dual 327 + // headings) — never an eyebrow. 328 + if (HEADING_TAGS.has(siblingTag)) return []; 329 + 330 + const text = (siblingText || '').trim(); 331 + if (text.length < 2 || text.length > 60) return []; 332 + if (!(siblingFontSize > 0 && siblingFontSize <= 14)) return []; 333 + 334 + // Branch A: classic tracked-uppercase eyebrow. 335 + const isUppercased = siblingTextTransform === 'uppercase' 336 + || (/[A-Z]/.test(text) && !/[a-z]/.test(text)); 337 + const isClassicTracked = isUppercased && siblingLetterSpacing >= 1.6; 338 + 339 + // Branch B: modern accent-bold eyebrow — sentence case, low 340 + // tracking, but bold + accent-colored. The style choices changed; 341 + // the pattern is the same kicker-above-headline anti-pattern. 342 + const weight = Number(siblingFontWeight) || 400; 343 + const isAccentBold = weight >= 700 && isAccentColor(siblingColor || ''); 344 + 345 + if (!isClassicTracked && !isAccentBold) return []; 346 + 347 + const headingTextSnippet = (headingText || '').trim().slice(0, 60); 348 + const eyebrowSnippet = text.slice(0, 40); 349 + const style = isClassicTracked ? 'tracked-caps' : 'accent-bold'; 350 + return [{ 351 + id: 'hero-eyebrow-chip', 352 + snippet: `eyebrow chip (${style}) "${eyebrowSnippet}" above ${headingTag} "${headingTextSnippet}"`, 353 + }]; 354 + } 355 + 356 + function checkRepeatedSectionKickers(opts) { 357 + const { candidates, minCount = 3 } = opts; 358 + if (!Array.isArray(candidates) || candidates.length < minCount) return []; 359 + return candidates.map(candidate => ({ 360 + id: 'repeated-section-kickers', 361 + snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`, 362 + })); 363 + } 364 + 365 + const LAYOUT_TRANSITION_PROPS = new Set([ 366 + 'width', 'height', 'padding', 'margin', 367 + 'max-height', 'max-width', 'min-height', 'min-width', 368 + 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 369 + 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', 370 + ]); 371 + 372 + function checkMotion(opts) { 373 + const { tag, transitionProperty, animationName, timingFunctions, classList } = opts; 374 + if (SAFE_TAGS.has(tag)) return []; 375 + const findings = []; 376 + 377 + // --- Bounce/elastic easing --- 378 + if (animationName && animationName !== 'none' && /bounce|elastic|wobble|jiggle|spring/i.test(animationName)) { 379 + findings.push({ id: 'bounce-easing', snippet: `animation: ${animationName}` }); 380 + } 381 + if (classList && /\banimate-bounce\b/.test(classList)) { 382 + findings.push({ id: 'bounce-easing', snippet: 'animate-bounce (Tailwind)' }); 383 + } 384 + 385 + // Check timing functions for overshoot cubic-bezier (y values outside [0, 1]) 386 + if (timingFunctions) { 387 + const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g; 388 + let m; 389 + while ((m = bezierRe.exec(timingFunctions)) !== null) { 390 + const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]); 391 + if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) { 392 + findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` }); 393 + break; 394 + } 395 + } 396 + } 397 + 398 + // --- Layout property transition --- 399 + if (transitionProperty && transitionProperty !== 'all' && transitionProperty !== 'none') { 400 + const props = transitionProperty.split(',').map(p => p.trim().toLowerCase()); 401 + const layoutFound = props.filter(p => LAYOUT_TRANSITION_PROPS.has(p)); 402 + if (layoutFound.length > 0) { 403 + findings.push({ id: 'layout-transition', snippet: `transition: ${layoutFound.join(', ')}` }); 404 + } 405 + } 406 + 407 + return findings; 408 + } 409 + 410 + function checkGlow(opts) { 411 + const { boxShadow, effectiveBg } = opts; 412 + if (!boxShadow || boxShadow === 'none') return []; 413 + if (!effectiveBg) return []; 414 + 415 + // Only flag on dark backgrounds (luminance < 0.1) 416 + const bgLum = relativeLuminance(effectiveBg); 417 + if (bgLum >= 0.1) return []; 418 + 419 + // Split multiple shadows (commas not inside parentheses) 420 + const parts = boxShadow.split(/,(?![^(]*\))/); 421 + for (const shadow of parts) { 422 + const colorMatch = shadow.match(/rgba?\([^)]+\)/); 423 + if (!colorMatch) continue; 424 + const color = parseRgb(colorMatch[0]); 425 + if (!color || !hasChroma(color, 30)) continue; 426 + 427 + // Extract px values — in computed style: "color Xpx Ypx BLURpx [SPREADpx]" 428 + const afterColor = shadow.substring(shadow.indexOf(colorMatch[0]) + colorMatch[0].length); 429 + const beforeColor = shadow.substring(0, shadow.indexOf(colorMatch[0])); 430 + const pxVals = [...beforeColor.matchAll(/([\d.]+)px/g), ...afterColor.matchAll(/([\d.]+)px/g)] 431 + .map(m => parseFloat(m[1])); 432 + 433 + // Third value is blur (offset-x, offset-y, blur, [spread]) 434 + if (pxVals.length >= 3 && pxVals[2] > 4) { 435 + return [{ id: 'dark-glow', snippet: `Colored glow (${colorToHex(color)}) on dark background` }]; 436 + } 437 + } 438 + 439 + return []; 440 + } 441 + 442 + /** 443 + * Regex-on-HTML checks shared between browser and Node page-level detection. 444 + * These don't need DOM access, just the raw HTML string. 445 + */ 446 + function checkHtmlPatterns(html) { 447 + const findings = []; 448 + 449 + // --- Color --- 450 + 451 + // AI color palette: purple/violet 452 + const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi; 453 + if (purpleHexRe.test(html)) { 454 + const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi; 455 + if (purpleTextRe.test(html)) { 456 + findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' }); 457 + } 458 + } 459 + 460 + // Gradient text (background-clip: text + gradient) 461 + const gradientRe = /(?:-webkit-)?background-clip\s*:\s*text/gi; 462 + let gm; 463 + while ((gm = gradientRe.exec(html)) !== null) { 464 + const start = Math.max(0, gm.index - 200); 465 + const context = html.substring(start, gm.index + gm[0].length + 200); 466 + if (/gradient/i.test(context)) { 467 + findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' }); 468 + break; 469 + } 470 + } 471 + if (/\bbg-clip-text\b/.test(html) && /\bbg-gradient-to-/.test(html)) { 472 + findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' }); 473 + } 474 + 475 + // --- Layout --- 476 + 477 + // Monotonous spacing 478 + const spacingValues = []; 479 + const spacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi; 480 + let sm; 481 + while ((sm = spacingRe.exec(html)) !== null) { 482 + const v = parseInt(sm[1], 10); 483 + if (v > 0 && v < 200) spacingValues.push(v); 484 + } 485 + const gapRe = /gap\s*:\s*(\d+)px/gi; 486 + while ((sm = gapRe.exec(html)) !== null) { 487 + spacingValues.push(parseInt(sm[1], 10)); 488 + } 489 + const twSpaceRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g; 490 + while ((sm = twSpaceRe.exec(html)) !== null) { 491 + spacingValues.push(parseInt(sm[1], 10) * 4); 492 + } 493 + const remSpacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi; 494 + while ((sm = remSpacingRe.exec(html)) !== null) { 495 + const v = Math.round(parseFloat(sm[1]) * 16); 496 + if (v > 0 && v < 200) spacingValues.push(v); 497 + } 498 + const roundedSpacing = spacingValues.map(v => Math.round(v / 4) * 4); 499 + if (roundedSpacing.length >= 10) { 500 + const counts = {}; 501 + for (const v of roundedSpacing) counts[v] = (counts[v] || 0) + 1; 502 + const maxCount = Math.max(...Object.values(counts)); 503 + const dominantPct = maxCount / roundedSpacing.length; 504 + const unique = [...new Set(roundedSpacing)].filter(v => v > 0); 505 + if (dominantPct > 0.6 && unique.length <= 3) { 506 + const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0]; 507 + findings.push({ 508 + id: 'monotonous-spacing', 509 + snippet: `~${dominant}px used ${maxCount}/${roundedSpacing.length} times (${Math.round(dominantPct * 100)}%)`, 510 + }); 511 + } 512 + } 513 + 514 + // --- Motion --- 515 + 516 + // Bounce/elastic animation names 517 + const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi; 518 + const bounceMatch = bounceRe.exec(html); 519 + if (bounceMatch) { 520 + const animationToken = bounceMatch[1] 521 + .split(/[,\s]+/) 522 + .find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part)); 523 + findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` }); 524 + } 525 + 526 + // Overshoot cubic-bezier 527 + const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g; 528 + let bm; 529 + while ((bm = bezierRe.exec(html)) !== null) { 530 + const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]); 531 + if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) { 532 + findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})` }); 533 + break; 534 + } 535 + } 536 + 537 + // Layout property transitions 538 + const transRe = /transition(?:-property)?\s*:\s*([^;{}]+)/gi; 539 + let tm; 540 + while ((tm = transRe.exec(html)) !== null) { 541 + const val = tm[1].toLowerCase(); 542 + if (/\ball\b/.test(val)) continue; 543 + const found = val.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi); 544 + if (found) { 545 + findings.push({ id: 'layout-transition', snippet: `transition: ${found.join(', ')}` }); 546 + break; 547 + } 548 + } 549 + 550 + // --- Dark glow --- 551 + 552 + const darkBgRe = /background(?:-color)?\s*:\s*(?:#(?:0[0-9a-f]|1[0-9a-f]|2[0-3])[0-9a-f]{4}\b|#(?:0|1)[0-9a-f]{2}\b|rgb\(\s*(\d{1,2})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\))/gi; 553 + const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/; 554 + if (darkBgRe.test(html) || twDarkBg.test(html)) { 555 + const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi; 556 + let shm; 557 + while ((shm = shadowRe.exec(html)) !== null) { 558 + const val = shm[1]; 559 + const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); 560 + if (!colorMatch) continue; 561 + const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]]; 562 + if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue; 563 + const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2])); 564 + if (pxVals.length >= 3 && pxVals[2] > 4) { 565 + findings.push({ id: 'dark-glow', snippet: `Colored glow (rgb(${r},${g},${b})) on dark page` }); 566 + break; 567 + } 568 + } 569 + } 570 + 571 + // --- Provider tells (gated): repeating-gradient stripes (GPT) --- 572 + if (/repeating-(?:linear|radial|conic)-gradient\s*\(/i.test(html)) { 573 + findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes' }); 574 + } 575 + 576 + // --- Provider tells (gated): "X theater" framing copy (GPT) --- 577 + // Lives here (regex-on-HTML) rather than in the text-content analyzers so it 578 + // runs in the bundled browser path too, not just the CLI/static path. 579 + { 580 + const bodyText = html 581 + .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ') 582 + .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ') 583 + .replace(/<[^>]+>/g, ' '); 584 + const tm = /\b(\w+)\s+theater\b/i.exec(bodyText); 585 + if (tm) findings.push({ id: 'theater-slop-phrase', snippet: `"${tm[0].trim()}"` }); 586 + } 587 + 588 + // --- Provider tells (gated): image hover transform (Gemini) --- 589 + // A CSS `img...:hover { transform: ... }` rule, or a Tailwind hover:scale / 590 + // hover:rotate / hover:translate utility on an <img>. Each distinct 591 + // mechanism is its own finding. 592 + const imgHoverCss = /\bimg\b[^,{}]*:hover\b[^{}]*\{[^}]*\btransform\s*:\s*(?:scale|rotate|translate|matrix|skew)/i; 593 + if (imgHoverCss.test(html)) { 594 + findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule' }); 595 + } 596 + const imgTagRe = /<img\b[^>]*\bclass\s*=\s*"([^"]*)"/gi; 597 + let im; 598 + while ((im = imgTagRe.exec(html)) !== null) { 599 + if (/\bhover:(?:scale|rotate|translate|skew)-/.test(im[1])) { 600 + findings.push({ id: 'image-hover-transform', snippet: 'Tailwind hover transform on <img>' }); 601 + } 602 + } 603 + 604 + return findings; 605 + } 606 + 607 + // ─── Section 4: resolveBackground (unified) ───────────────────────────────── 608 + 609 + // Read the element's own background color, computed-style first, with a 610 + // jsdom-friendly fallback that parses the inline `background:` shorthand 611 + // from the raw style attribute. jsdom (~v29) does not decompose the 612 + // shorthand into `backgroundColor`, so without this fallback the CLI silently 613 + // returns null for any element styled via `background: rgb(...)` or 614 + // `background: #abc`. Real browsers always decompose, so the fallback is 615 + // a no-op there. 616 + function readOwnBackgroundColor(el, computedStyle) { 617 + const bg = parseRgb(computedStyle.backgroundColor); 618 + if (DETECTOR_IS_BROWSER || (bg && bg.a >= 0.1)) return bg; 619 + const rawStyle = el.getAttribute?.('style') || ''; 620 + const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i); 621 + const inlineBg = bgMatch ? bgMatch[1].trim() : ''; 622 + if (!inlineBg) return bg; 623 + if (/gradient/i.test(inlineBg) || /url\s*\(/i.test(inlineBg)) return bg; 624 + const fromRgb = parseRgb(inlineBg); 625 + if (fromRgb) return fromRgb; 626 + const hexMatch = inlineBg.match(/#([0-9a-f]{6}|[0-9a-f]{3})\b/i); 627 + if (hexMatch) { 628 + const h = hexMatch[1]; 629 + if (h.length === 6) { 630 + return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16), a: 1 }; 631 + } 632 + return { r: parseInt(h[0] + h[0], 16), g: parseInt(h[1] + h[1], 16), b: parseInt(h[2] + h[2], 16), a: 1 }; 633 + } 634 + return bg; 635 + } 636 + 637 + function resolveBackground(el, win, customPropMap) { 638 + let current = el; 639 + while (current && current.nodeType === 1) { 640 + const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current); 641 + const bgImage = style.backgroundImage || ''; 642 + const hasGradientOrUrl = bgImage && bgImage !== 'none' && (/gradient/i.test(bgImage) || /url\s*\(/i.test(bgImage)); 643 + 644 + // Try the solid bg-color FIRST. If the element has both a solid color 645 + // and a gradient/url overlay (a common pattern: `background: var(--paper) 646 + // radial-gradient(...)` for paper-grain texture), the solid color is the 647 + // dominant visible surface for contrast purposes; the overlay is 648 + // decorative. The old behavior bailed on any gradient ancestor, which 649 + // caused massive false-positive contrast findings on grain-textured 650 + // body backgrounds. 651 + let bg = parseRgb(style.backgroundColor); 652 + if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) { 653 + // jsdom returns literal "var(--X)" / "oklch(...)" strings. Resolve 654 + // through customPropMap so Tailwind v4 color tokens become RGB. 655 + if (customPropMap) { 656 + bg = parseColorResolved(style.backgroundColor, customPropMap); 657 + } 658 + if (!bg || bg.a < 0.1) { 659 + // Inline-style fallback. jsdom doesn't decompose background 660 + // shorthand, so colors set via inline style are otherwise invisible. 661 + const rawStyle = current.getAttribute?.('style') || ''; 662 + const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i); 663 + const inlineBg = bgMatch ? bgMatch[1].trim() : ''; 664 + if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) { 665 + bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg); 666 + } 667 + } 668 + } 669 + 670 + if (bg && bg.a > 0.1) { 671 + if (DETECTOR_IS_BROWSER || bg.a >= 0.5) return bg; 672 + } 673 + // No solid bg-color at this level. If THIS level has a gradient/url 674 + // with no underlying solid color we can read: 675 + // • on body/html: assume white. Body-level gradients are almost 676 + // always decorative texture (paper grain, noise) on top of a 677 + // solid bg-color the page set via `background: var(--paper)` 678 + // shorthand — which jsdom can't decompose into bg-color. The 679 + // downstream gradient-stops fallback path produces catastrophic 680 + // false positives in this case (gradient noise stops have 681 + // accidental browns/blacks that look like card backgrounds). 682 + // • on other elements: bail to null and let the caller fall back 683 + // to gradient stops (gradient buttons / hero sections are real 684 + // bgs worth checking against). 685 + if (hasGradientOrUrl) { 686 + if (current.tagName === 'BODY' || current.tagName === 'HTML') { 687 + return { r: 255, g: 255, b: 255, a: 1 }; 688 + } 689 + return null; 690 + } 691 + current = current.parentElement; 692 + } 693 + return { r: 255, g: 255, b: 255 }; 694 + } 695 + 696 + // Walk parents looking for a gradient background and return its color stops. 697 + // Used as a fallback when resolveBackground() returns null because the 698 + // effective background is a gradient (no single solid color to compare against). 699 + function resolveGradientStops(el, win) { 700 + let current = el; 701 + while (current && current.nodeType === 1) { 702 + const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current); 703 + const bgImage = style.backgroundImage || ''; 704 + if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) { 705 + const stops = parseGradientColors(bgImage); 706 + if (stops.length > 0) return stops; 707 + } 708 + if (!DETECTOR_IS_BROWSER) { 709 + // jsdom doesn't decompose `background:` shorthand — peek at the raw inline style 710 + const rawStyle = current.getAttribute?.('style') || ''; 711 + const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i); 712 + if (bgMatch && /gradient/i.test(bgMatch[1])) { 713 + const stops = parseGradientColors(bgMatch[1]); 714 + if (stops.length > 0) return stops; 715 + } 716 + } 717 + current = current.parentElement; 718 + } 719 + return null; 720 + } 721 + 722 + // Parse a single CSS length token to pixels. Accepts "12px", "50%", a 723 + // shorthand like "12px 4px" (uses the first value), or empty / null. 724 + // Returns the pixel value, or null when the input is unparseable. 725 + // Percentages convert against `widthPx` when one is supplied. Without a 726 + // usable width (jsdom returns "auto" for many real-world elements, 727 + // which parseFloat collapses to 0), fall back to the raw percentage 728 + // number so callers gating on `> 0` (border-accent-on-rounded, 729 + // isCardLike's hasRadius) still see a positive value, matching the 730 + // original parseFloat("50%") === 50 behavior. 731 + function parseRadiusToPx(value, widthPx) { 732 + if (!value || typeof value !== 'string') return null; 733 + const trimmed = value.trim(); 734 + if (!trimmed) return null; 735 + const first = trimmed.split(/\s+/)[0]; 736 + const num = parseFloat(first); 737 + if (Number.isNaN(num)) return null; 738 + if (/%$/.test(first)) { 739 + if (widthPx && widthPx > 0) return (num / 100) * widthPx; 740 + return num; 741 + } 742 + return num; 743 + } 744 + 745 + function resolveBorderRadiusPx(el, style, widthPx, win) { 746 + const fromComputed = parseRadiusToPx(style.borderRadius, widthPx); 747 + if (fromComputed !== null) return fromComputed; 748 + return 0; 749 + } 750 + 751 + // ─── Section 5: Element Adapters ──────────────────────────────────────────── 752 + 753 + // Browser adapters — call getComputedStyle/getBoundingClientRect on live DOM 754 + 755 + function checkElementBordersDOM(el) { 756 + const tag = el.tagName.toLowerCase(); 757 + if (BORDER_SAFE_TAGS.has(tag)) return []; 758 + const rect = el.getBoundingClientRect(); 759 + if (rect.width < 20 || rect.height < 20) return []; 760 + const style = getComputedStyle(el); 761 + const sides = ['Top', 'Right', 'Bottom', 'Left']; 762 + const widths = {}, colors = {}; 763 + for (const s of sides) { 764 + widths[s] = parseFloat(style[`border${s}Width`]) || 0; 765 + colors[s] = style[`border${s}Color`] || ''; 766 + } 767 + return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0); 768 + } 769 + 770 + function checkElementColorsDOM(el) { 771 + const tag = el.tagName.toLowerCase(); 772 + // No early SAFE_TAGS bail here — checkColors() does its own gating that 773 + // includes the styled-button exception for <a> / <button> with their own 774 + // opaque background. Bailing here would prevent that exception from firing. 775 + const rect = el.getBoundingClientRect(); 776 + if (rect.width < 10 || rect.height < 10) return []; 777 + const style = getComputedStyle(el); 778 + const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join(''); 779 + const hasDirectText = directText.trim().length > 0; 780 + const effectiveBg = resolveBackground(el); 781 + return checkColors({ 782 + tag, 783 + textColor: parseRgb(style.color), 784 + bgColor: readOwnBackgroundColor(el, style), 785 + effectiveBg, 786 + effectiveBgStops: effectiveBg ? null : resolveGradientStops(el), 787 + fontSize: parseFloat(style.fontSize) || 16, 788 + fontWeight: parseInt(style.fontWeight) || 400, 789 + hasDirectText, 790 + isEmojiOnly: isEmojiOnlyText(directText), 791 + bgClip: style.webkitBackgroundClip || style.backgroundClip || '', 792 + bgImage: style.backgroundImage || '', 793 + classList: el.getAttribute('class') || '', 794 + }); 795 + } 796 + 797 + function checkElementIconTileDOM(el) { 798 + const tag = el.tagName.toLowerCase(); 799 + if (!HEADING_TAGS.has(tag)) return []; 800 + const sibling = el.previousElementSibling; 801 + if (!sibling) return []; 802 + 803 + const sibRect = sibling.getBoundingClientRect(); 804 + const headRect = el.getBoundingClientRect(); 805 + const sibStyle = getComputedStyle(sibling); 806 + 807 + // The tile may either contain an <svg>/<i> icon child, OR the tile itself 808 + // may contain an emoji/symbol character directly as its only text content 809 + // (the "card-icon" pattern from many AI-generated demos). 810 + const iconChild = sibling.querySelector('svg, i[data-lucide], i[class*="fa-"], i[class*="icon"]'); 811 + const iconRect = iconChild?.getBoundingClientRect(); 812 + const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join(''); 813 + const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText); 814 + 815 + return checkIconTile({ 816 + headingTag: tag, 817 + headingText: el.textContent || '', 818 + headingTop: headRect.top, 819 + siblingTag: sibling.tagName.toLowerCase(), 820 + siblingWidth: sibRect.width, 821 + siblingHeight: sibRect.height, 822 + siblingBottom: sibRect.bottom, 823 + siblingBgColor: parseRgb(sibStyle.backgroundColor), 824 + siblingBgImage: sibStyle.backgroundImage || '', 825 + siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0, 826 + siblingBorderRadius: parseFloat(sibStyle.borderRadius) || 0, 827 + hasIconChild: !!iconChild || hasInlineEmojiIcon, 828 + iconChildWidth: iconRect?.width || 0, 829 + }); 830 + } 831 + 832 + function checkElementItalicSerifDOM(el) { 833 + const tag = el.tagName.toLowerCase(); 834 + if (tag !== 'h1' && tag !== 'h2') return []; 835 + const style = getComputedStyle(el); 836 + return checkItalicSerif({ 837 + tag, 838 + fontStyle: style.fontStyle || '', 839 + fontFamily: style.fontFamily || '', 840 + fontSize: parseFloat(style.fontSize) || 0, 841 + headingText: el.textContent || '', 842 + }); 843 + } 844 + 845 + function checkElementHeroEyebrowDOM(el) { 846 + const tag = el.tagName.toLowerCase(); 847 + if (tag !== 'h1') return []; 848 + const sibling = el.previousElementSibling; 849 + if (!sibling) return []; 850 + const headStyle = getComputedStyle(el); 851 + const sibStyle = getComputedStyle(sibling); 852 + return checkHeroEyebrow({ 853 + headingTag: tag, 854 + headingText: el.textContent || '', 855 + headingFontSize: parseFloat(headStyle.fontSize) || 0, 856 + siblingTag: sibling.tagName.toLowerCase(), 857 + siblingText: sibling.textContent || '', 858 + siblingTextTransform: sibStyle.textTransform || '', 859 + siblingFontSize: parseFloat(sibStyle.fontSize) || 0, 860 + siblingLetterSpacing: parseFloat(sibStyle.letterSpacing) || 0, 861 + siblingFontWeight: sibStyle.fontWeight || '', 862 + siblingColor: sibStyle.color || '', 863 + }); 864 + } 865 + 866 + // Build a map of CSS custom properties declared on :root / :host / html. 867 + // Used to resolve var(--X) refs that jsdom returns verbatim in 868 + // getComputedStyle. Tailwind v4 routes every utility class through 869 + // CSS vars (font-weight: var(--font-weight-bold), font-size: 870 + // var(--text-xs), letter-spacing: var(--tracking-widest)), so without 871 + // resolution every style-based check silently fails on Tailwind v4 872 + // builds — the values come back as literal "var(--font-weight-bold)" 873 + // strings and parseFloat returns NaN. 874 + function buildCustomPropMap(document) { 875 + const map = new Map(); 876 + let sheets; 877 + try { sheets = Array.from(document.styleSheets || []); } 878 + catch { return map; } 879 + for (const sheet of sheets) { 880 + let rules; 881 + try { rules = Array.from(sheet.cssRules || []); } 882 + catch { continue; } 883 + for (const rule of rules) { 884 + // Style rules only (type 1). Walk @media / @supports if present. 885 + if (rule.type === 4 /* MEDIA_RULE */ || rule.type === 12 /* SUPPORTS_RULE */) { 886 + try { rules.push(...Array.from(rule.cssRules || [])); } catch { /* ignore */ } 887 + continue; 888 + } 889 + if (rule.type !== 1 /* STYLE_RULE */) continue; 890 + const sel = rule.selectorText || ''; 891 + if (!/(^|,\s*)(:root|html|:host)\b/i.test(sel)) continue; 892 + const style = rule.style; 893 + if (!style) continue; 894 + for (let i = 0; i < style.length; i++) { 895 + const prop = style[i]; 896 + if (!prop || !prop.startsWith('--')) continue; 897 + const val = style.getPropertyValue(prop).trim(); 898 + if (val) map.set(prop, val); 899 + } 900 + } 901 + } 902 + return map; 903 + } 904 + 905 + // Resolve var(--X[, fallback]) refs in a computed-style value string. 906 + // Recurses up to 8 levels for chained refs (--a: var(--b)). Returns 907 + // the original string when no refs are present or the chain doesn't 908 + // resolve. Safe to call on already-resolved values. 909 + function resolveVarRefs(raw, customPropMap, depth = 0) { 910 + if (typeof raw !== 'string' || !raw.includes('var(')) return raw; 911 + if (depth > 8) return raw; 912 + return raw.replace(/var\(\s*(--[a-zA-Z0-9_-]+)\s*(?:,\s*([^)]+))?\)/g, (_m, name, fallback) => { 913 + const v = customPropMap.get(name); 914 + if (v != null) return resolveVarRefs(v, customPropMap, depth + 1); 915 + return fallback ? resolveVarRefs(fallback.trim(), customPropMap, depth + 1) : _m; 916 + }); 917 + } 918 + 919 + // OKLCH → sRGB conversion (Björn Ottosson's matrices). L in 0..1 (or %), 920 + // C in 0..~0.4 typical, H in degrees. Returns clamped {r,g,b,a:1} in 0..255. 921 + // Needed because jsdom doesn't compute oklch() values — getComputedStyle 922 + // returns the literal "oklch(...)" string. Without this, the entire 923 + // Tailwind v4 color palette (which is OKLCH-based) is invisible to the 924 + // detector's contrast / color checks. 925 + function oklchToRgb(L, C, H) { 926 + const hRad = (H * Math.PI) / 180; 927 + const a = C * Math.cos(hRad); 928 + const b = C * Math.sin(hRad); 929 + const l_ = L + 0.3963377774 * a + 0.2158037573 * b; 930 + const m_ = L - 0.1055613458 * a - 0.0638541728 * b; 931 + const s_ = L - 0.0894841775 * a - 1.2914855480 * b; 932 + const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_; 933 + const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc; 934 + const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc; 935 + const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc; 936 + const enc = (x) => { 937 + const c = Math.max(0, Math.min(1, x)); 938 + return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055; 939 + }; 940 + return { 941 + r: Math.round(enc(rLin) * 255), 942 + g: Math.round(enc(gLin) * 255), 943 + b: Math.round(enc(bLin) * 255), 944 + a: 1, 945 + }; 946 + } 947 + 948 + // Extended color parser: rgb/rgba/hex/oklch. Returns null on no match. 949 + // Use this when the input might be any CSS color form; use plain parseRgb 950 + // when you only expect computed rgb() values from real browsers. 951 + function parseAnyColor(s) { 952 + if (!s || typeof s !== 'string') return null; 953 + const str = s.trim(); 954 + if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null; 955 + let m; 956 + m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+))?\s*\)/); 957 + if (m) return { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: m[4] !== undefined ? +m[4] : 1 }; 958 + m = str.match(/^#([0-9a-f]{3,8})$/i); 959 + if (m) { 960 + const h = m[1]; 961 + if (h.length === 3 || h.length === 4) { 962 + return { 963 + r: parseInt(h[0] + h[0], 16), 964 + g: parseInt(h[1] + h[1], 16), 965 + b: parseInt(h[2] + h[2], 16), 966 + a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1, 967 + }; 968 + } 969 + if (h.length === 6 || h.length === 8) { 970 + return { 971 + r: parseInt(h.slice(0, 2), 16), 972 + g: parseInt(h.slice(2, 4), 16), 973 + b: parseInt(h.slice(4, 6), 16), 974 + a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1, 975 + }; 976 + } 977 + } 978 + // OKLCH parser. Tailwind v4's CSS minifier squishes the space after 979 + // `%` ("21.5%.02 50"), so the separator between L and C may be absent. 980 + // Match L (with optional %), then C and H separated permissively. 981 + m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i); 982 + if (m) { 983 + const Lnum = parseFloat(m[1]); 984 + const L = m[2] === '%' ? Lnum / 100 : Lnum; 985 + const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); 986 + if (m[5] !== undefined) { 987 + const alpha = parseFloat(m[5]); 988 + rgb.a = m[6] === '%' ? alpha / 100 : alpha; 989 + } 990 + return rgb; 991 + } 992 + return null; 993 + } 994 + 995 + // Resolve var() refs in a color string (via customPropMap), then parse. 996 + // Returns null on any failure. Used in jsdom-mode paths where 997 + // getComputedStyle returns literal "var(--X)" or "oklch(...)" strings. 998 + function parseColorResolved(str, customPropMap) { 999 + if (!str) return null; 1000 + const resolved = customPropMap ? resolveVarRefs(str, customPropMap) : str; 1001 + return parseAnyColor(resolved); 1002 + } 1003 + 1004 + const REPEATED_KICKER_SKIP_SELECTOR = [ 1005 + 'nav', 1006 + 'form', 1007 + 'table', 1008 + 'thead', 1009 + 'tbody', 1010 + 'tfoot', 1011 + 'figure', 1012 + 'figcaption', 1013 + 'ol', 1014 + 'ul', 1015 + 'li', 1016 + '[role="navigation"]', 1017 + '[aria-label*="breadcrumb" i]', 1018 + '[class*="breadcrumb" i]', 1019 + '[aria-hidden="true"]', 1020 + '[data-impeccable-allow-kickers]', 1021 + ].join(','); 1022 + 1023 + const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [ 1024 + 'article', 1025 + 'button', 1026 + 'a', 1027 + 'li', 1028 + '[role="listitem"]', 1029 + '[role="option"]', 1030 + ].join(','); 1031 + 1032 + function cleanInlineText(el) { 1033 + return [...el.childNodes] 1034 + .filter(n => n.nodeType === 3) 1035 + .map(n => n.textContent) 1036 + .join(' ') 1037 + .replace(/\s+/g, ' ') 1038 + .trim(); 1039 + } 1040 + 1041 + function isRepeatedKickerCardContext(heading, kicker) { 1042 + const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR); 1043 + return Boolean(item && (!item.contains || item.contains(kicker))); 1044 + } 1045 + 1046 + function isRepeatedKickerCandidate(opts) { 1047 + const { 1048 + headingTag, 1049 + headingText, 1050 + headingFontSize, 1051 + kickerTag, 1052 + kickerText, 1053 + kickerTextTransform, 1054 + kickerFontSize, 1055 + kickerLetterSpacing, 1056 + } = opts; 1057 + if (!['h2', 'h3', 'h4'].includes(headingTag)) return false; 1058 + if (!headingText || headingText.length < 3) return false; 1059 + if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false; 1060 + if (!(headingFontSize >= 20)) return false; 1061 + if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false; 1062 + if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false; 1063 + if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false; 1064 + if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false; 1065 + 1066 + const isUppercased = kickerTextTransform === 'uppercase' 1067 + || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText)); 1068 + if (!isUppercased) return false; 1069 + if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false; 1070 + const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08); 1071 + if (!(kickerLetterSpacing >= minTrackedSpacing)) return false; 1072 + return true; 1073 + } 1074 + 1075 + function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) { 1076 + const candidates = []; 1077 + for (const heading of doc.querySelectorAll('h2, h3, h4')) { 1078 + if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; 1079 + const kicker = heading.previousElementSibling; 1080 + if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; 1081 + if (isRepeatedKickerCardContext(heading, kicker)) continue; 1082 + 1083 + const headingStyle = getStyle(heading); 1084 + const kickerStyle = getStyle(kicker); 1085 + const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim(); 1086 + const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim(); 1087 + const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0; 1088 + const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0; 1089 + const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize); 1090 + 1091 + if (!isRepeatedKickerCandidate({ 1092 + headingTag: heading.tagName.toLowerCase(), 1093 + headingText, 1094 + headingFontSize, 1095 + kickerTag: kicker.tagName.toLowerCase(), 1096 + kickerText, 1097 + kickerTextTransform: kickerStyle.textTransform || '', 1098 + kickerFontSize, 1099 + kickerLetterSpacing, 1100 + })) { 1101 + continue; 1102 + } 1103 + 1104 + candidates.push({ 1105 + headingTag: heading.tagName.toLowerCase(), 1106 + headingText: headingText.replace(/^"|"$/g, '').slice(0, 60), 1107 + kickerText: kickerText.slice(0, 40), 1108 + }); 1109 + } 1110 + return candidates; 1111 + } 1112 + 1113 + function checkRepeatedSectionKickersDOM() { 1114 + const candidates = collectRepeatedSectionKickerCandidates( 1115 + document, 1116 + (el) => getComputedStyle(el), 1117 + (value, fontSize) => resolveLengthPx(value, fontSize) || 0, 1118 + ); 1119 + return checkRepeatedSectionKickers({ candidates }); 1120 + } 1121 + 1122 + function checkElementMotionDOM(el) { 1123 + const tag = el.tagName.toLowerCase(); 1124 + if (SAFE_TAGS.has(tag)) return []; 1125 + const style = getComputedStyle(el); 1126 + return checkMotion({ 1127 + tag, 1128 + transitionProperty: style.transitionProperty || '', 1129 + animationName: style.animationName || '', 1130 + timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '), 1131 + classList: el.getAttribute('class') || '', 1132 + }); 1133 + } 1134 + 1135 + function checkElementGlowDOM(el) { 1136 + const tag = el.tagName.toLowerCase(); 1137 + const style = getComputedStyle(el); 1138 + if (!style.boxShadow || style.boxShadow === 'none') return []; 1139 + // Use parent's background — glow radiates outward, so the surrounding context matters 1140 + // If resolveBackground returns null (gradient), try to infer from the gradient colors 1141 + let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el); 1142 + if (!parentBg) { 1143 + // Gradient background — sample its colors to determine if it's dark 1144 + let cur = el.parentElement; 1145 + while (cur && cur.nodeType === 1) { 1146 + const bgImage = getComputedStyle(cur).backgroundImage || ''; 1147 + const gradColors = parseGradientColors(bgImage); 1148 + if (gradColors.length > 0) { 1149 + // Average the gradient colors 1150 + const avg = { r: 0, g: 0, b: 0 }; 1151 + for (const c of gradColors) { avg.r += c.r; avg.g += c.g; avg.b += c.b; } 1152 + avg.r = Math.round(avg.r / gradColors.length); 1153 + avg.g = Math.round(avg.g / gradColors.length); 1154 + avg.b = Math.round(avg.b / gradColors.length); 1155 + parentBg = avg; 1156 + break; 1157 + } 1158 + cur = cur.parentElement; 1159 + } 1160 + } 1161 + return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg: parentBg }); 1162 + } 1163 + 1164 + function checkElementAIPaletteDOM(el) { 1165 + const style = getComputedStyle(el); 1166 + const findings = []; 1167 + 1168 + // Check gradient backgrounds for purple/violet or cyan 1169 + const bgImage = style.backgroundImage || ''; 1170 + const gradColors = parseGradientColors(bgImage); 1171 + for (const c of gradColors) { 1172 + if (hasChroma(c, 50)) { 1173 + const hue = getHue(c); 1174 + if (hue >= 260 && hue <= 310) { 1175 + findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient background' }); 1176 + break; 1177 + } 1178 + if (hue >= 160 && hue <= 200) { 1179 + findings.push({ id: 'ai-color-palette', snippet: 'Cyan gradient background' }); 1180 + break; 1181 + } 1182 + } 1183 + } 1184 + 1185 + // Check for neon text (vivid cyan/purple color on dark background) 1186 + const textColor = parseRgb(style.color); 1187 + if (textColor && hasChroma(textColor, 80)) { 1188 + const hue = getHue(textColor); 1189 + const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310); 1190 + if (isAIPalette) { 1191 + const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null; 1192 + // Also check gradient parents 1193 + let effectiveBg = parentBg; 1194 + if (!effectiveBg) { 1195 + let cur = el.parentElement; 1196 + while (cur && cur.nodeType === 1) { 1197 + const gi = getComputedStyle(cur).backgroundImage || ''; 1198 + const gc = parseGradientColors(gi); 1199 + if (gc.length > 0) { 1200 + const avg = { r: 0, g: 0, b: 0 }; 1201 + for (const c of gc) { avg.r += c.r; avg.g += c.g; avg.b += c.b; } 1202 + avg.r = Math.round(avg.r / gc.length); 1203 + avg.g = Math.round(avg.g / gc.length); 1204 + avg.b = Math.round(avg.b / gc.length); 1205 + effectiveBg = avg; 1206 + break; 1207 + } 1208 + cur = cur.parentElement; 1209 + } 1210 + } 1211 + if (effectiveBg && relativeLuminance(effectiveBg) < 0.1) { 1212 + const label = hue >= 260 ? 'Purple/violet' : 'Cyan'; 1213 + findings.push({ id: 'ai-color-palette', snippet: `${label} neon text on dark background` }); 1214 + } 1215 + } 1216 + } 1217 + 1218 + return findings; 1219 + } 1220 + 1221 + const QUALITY_TEXT_TAGS = new Set(['p', 'li', 'td', 'th', 'dd', 'blockquote', 'figcaption']); 1222 + 1223 + // Resolve a CSS font-size value to pixels by walking up the parent chain. 1224 + // Browsers resolve em/rem/% to px in getComputedStyle, but jsdom returns the 1225 + // specified value verbatim — so for the Node path we walk parents ourselves. 1226 + function resolveFontSizePx(el, win) { 1227 + const chain = []; // raw font-size strings, leaf → root 1228 + let cur = el; 1229 + while (cur && cur.nodeType === 1) { 1230 + const fs = (win ? win.getComputedStyle(cur) : getComputedStyle(cur)).fontSize; 1231 + chain.push(fs || ''); 1232 + cur = cur.parentElement; 1233 + } 1234 + // Walk root → leaf, resolving each value relative to its parent context. 1235 + let px = 16; // root default 1236 + for (let i = chain.length - 1; i >= 0; i--) { 1237 + const v = chain[i]; 1238 + if (!v || v === 'inherit') continue; 1239 + const num = parseFloat(v); 1240 + if (isNaN(num)) continue; 1241 + if (v.endsWith('px')) px = num; 1242 + else if (v.endsWith('rem')) px = num * 16; 1243 + else if (v.endsWith('em')) px = num * px; 1244 + else if (v.endsWith('%')) px = (num / 100) * px; 1245 + else px = num; // unitless — already resolved 1246 + } 1247 + return px; 1248 + } 1249 + 1250 + // Resolve a CSS length value (line-height, letter-spacing, etc.) given a 1251 + // known font-size context. Returns null for "normal" / unparseable values. 1252 + function resolveLengthPx(value, fontSizePx) { 1253 + if (!value || value === 'normal' || value === 'auto' || value === 'inherit') return null; 1254 + const num = parseFloat(value); 1255 + if (isNaN(num)) return null; 1256 + if (value.endsWith('px')) return num; 1257 + if (value.endsWith('rem')) return num * 16; 1258 + if (value.endsWith('em')) return num * fontSizePx; 1259 + if (value.endsWith('%')) return (num / 100) * fontSizePx; 1260 + // Unitless line-height = multiplier, return px equivalent 1261 + return num * fontSizePx; 1262 + } 1263 + 1264 + function cssColorIsTransparent(value) { 1265 + if (!value) return true; 1266 + const str = String(value).trim().toLowerCase(); 1267 + if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true; 1268 + const parsed = parseAnyColor(str); 1269 + if (parsed) return (parsed.a ?? 1) <= 0.05; 1270 + return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str); 1271 + } 1272 + 1273 + function colorsNearlyMatch(a, b) { 1274 + const ca = parseAnyColor(a); 1275 + const cb = parseAnyColor(b); 1276 + if (!ca || !cb) return false; 1277 + const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1)); 1278 + const channelDelta = Math.max( 1279 + Math.abs(ca.r - cb.r), 1280 + Math.abs(ca.g - cb.g), 1281 + Math.abs(ca.b - cb.b), 1282 + ); 1283 + return alphaDelta <= 0.03 && channelDelta <= 3; 1284 + } 1285 + 1286 + function getComputedStyleFor(win, el) { 1287 + if (win && typeof win.getComputedStyle === 'function') { 1288 + try { return win.getComputedStyle(el); } catch {} 1289 + } 1290 + if (typeof getComputedStyle === 'function') { 1291 + try { return getComputedStyle(el); } catch {} 1292 + } 1293 + return null; 1294 + } 1295 + 1296 + function hasVisibleBackgroundBoundary(style, el, win) { 1297 + const bg = style?.backgroundColor || ''; 1298 + if (cssColorIsTransparent(bg)) return false; 1299 + 1300 + let parent = el?.parentElement || null; 1301 + while (parent) { 1302 + const parentStyle = getComputedStyleFor(win, parent); 1303 + const parentBg = parentStyle?.backgroundColor || ''; 1304 + if (!cssColorIsTransparent(parentBg)) { 1305 + return !colorsNearlyMatch(bg, parentBg); 1306 + } 1307 + parent = parent.parentElement; 1308 + } 1309 + 1310 + return true; 1311 + } 1312 + 1313 + const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']); 1314 + 1315 + function hasMeaningfulDirectText(node) { 1316 + if (!node?.childNodes) return false; 1317 + for (const child of node.childNodes) { 1318 + if (child.nodeType === 3 && child.textContent.trim().length > 4) return true; 1319 + } 1320 + return false; 1321 + } 1322 + 1323 + function textDescendantsFlushSides(el, rect) { 1324 + const flush = { top: false, right: false, bottom: false, left: false }; 1325 + if (!rect || !el?.querySelectorAll) return flush; 1326 + const TEXT_EDGE_THRESHOLD = 4; 1327 + const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th'); 1328 + for (const node of candidates) { 1329 + if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue; 1330 + let nodeRect = null; 1331 + try { nodeRect = node.getBoundingClientRect(); } catch {} 1332 + if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue; 1333 + if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue; 1334 + if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true; 1335 + if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true; 1336 + if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true; 1337 + if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true; 1338 + } 1339 + return flush; 1340 + } 1341 + 1342 + // Pure quality checks. Most run on computed CSS and DOM-only inputs (work in 1343 + // jsdom and the browser). Two checks (line-length, cramped-padding) gate on 1344 + // element rect dimensions, which jsdom can't compute — pass `rect: null` from 1345 + // the Node adapter to skip those. 1346 + // 1347 + // Both adapters resolve font-size, line-height and letter-spacing to pixels 1348 + // before calling this so the pure function only deals with numbers. 1349 + function checkQuality(opts) { 1350 + const { el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax = 80, viewportWidth = 0, win = null } = opts; 1351 + const findings = []; 1352 + // Skip browser extension injected elements 1353 + const elId = el.id || ''; 1354 + if (elId.startsWith('claude-') || elId.startsWith('cic-')) return findings; 1355 + 1356 + // --- Line length too long --- (browser-only: needs rect.width) 1357 + if (rect && hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > lineMax) { 1358 + const charsPerLine = rect.width / (fontSize * 0.5); 1359 + if (charsPerLine > lineMax + 5) { 1360 + findings.push({ id: 'line-length', snippet: `~${Math.round(charsPerLine)} chars/line (aim for <${lineMax})` }); 1361 + } 1362 + } 1363 + 1364 + // --- Cramped padding --- (browser-only: needs rect to skip small badges/labels) 1365 + // Vertical and horizontal thresholds are independent because line-height 1366 + // already provides built-in vertical breathing room (the line box is taller 1367 + // than the cap height), but horizontal has no equivalent. Both scale with 1368 + // font-size — bigger text demands proportionally more padding. 1369 + // vertical: max(4px, fontSize × 0.3) 1370 + // horizontal: max(8px, fontSize × 0.5) 1371 + const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre')); 1372 + if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { 1373 + const borders = { 1374 + top: parseFloat(style.borderTopWidth) || 0, 1375 + right: parseFloat(style.borderRightWidth) || 0, 1376 + bottom: parseFloat(style.borderBottomWidth) || 0, 1377 + left: parseFloat(style.borderLeftWidth) || 0, 1378 + }; 1379 + const borderCount = Object.values(borders).filter(w => w > 0).length; 1380 + const hasBg = hasVisibleBackgroundBoundary(style, el, win); 1381 + if (borderCount >= 2 || hasBg) { 1382 + const vPads = [], hPads = []; 1383 + if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0); 1384 + if (hasBg || borders.bottom > 0) vPads.push(parseFloat(style.paddingBottom) || 0); 1385 + if (hasBg || borders.left > 0) hPads.push(parseFloat(style.paddingLeft) || 0); 1386 + if (hasBg || borders.right > 0) hPads.push(parseFloat(style.paddingRight) || 0); 1387 + 1388 + const vMin = vPads.length ? Math.min(...vPads) : Infinity; 1389 + const hMin = hPads.length ? Math.min(...hPads) : Infinity; 1390 + const vThresh = Math.max(4, fontSize * 0.3); 1391 + const hThresh = Math.max(8, fontSize * 0.5); 1392 + 1393 + // Emit at most one finding per element — pick whichever axis is worse. 1394 + if (vMin < vThresh) { 1395 + findings.push({ id: 'cramped-padding', snippet: `${vMin}px vertical padding (need ≥${vThresh.toFixed(1)}px for ${fontSize}px text)` }); 1396 + } else if (hMin < hThresh) { 1397 + findings.push({ id: 'cramped-padding', snippet: `${hMin}px horizontal padding (need ≥${hThresh.toFixed(1)}px for ${fontSize}px text)` }); 1398 + } 1399 + } 1400 + } 1401 + 1402 + // --- Flush against a visible boundary --- 1403 + // Fires when a container has a visible boundary (border, outline, OR a 1404 + // non-transparent background) AND near-zero padding on the bounded 1405 + // side(s) AND text-bearing children land flush against the boundary. 1406 + // 1407 + // Distinct from cramped-padding: that rule needs the element itself to 1408 + // have direct text (hasDirectText). This rule targets the OPPOSITE 1409 + // shape — a container with NO direct text, only children — which is 1410 + // exactly what cramped-padding misses (a section wrapping a label + 1411 + // list lands a free pass). 1412 + // 1413 + // The classic shape: agent writes `padding: 28px 0 0` shorthand on a 1414 + // section that also has a border, zeroing horizontal padding so the 1415 + // text-bearing children touch the side borders. Background and 1416 + // outline count too: a colored card with zero padding has the same 1417 + // visual failure mode. 1418 + { 1419 + const FLUSH_SKIP_TAGS = new Set(['HTML', 'BODY', 'MAIN', 'HEADER', 'FOOTER', 'NAV', 'ARTICLE', 'ASIDE', 'BUTTON', 'A', 'LABEL', 'SUMMARY', 'CODE', 'PRE', 'INPUT', 'TEXTAREA', 'SELECT', 'FORM', 'FIGURE', 'TABLE', 'TBODY', 'THEAD', 'TR', 'TD', 'TH']); 1420 + const upperTag = tag ? tag.toUpperCase() : ''; 1421 + const elPosition = style.position || ''; 1422 + if ( 1423 + !FLUSH_SKIP_TAGS.has(upperTag) && 1424 + !hasDirectText && 1425 + !['fixed', 'absolute'].includes(elPosition) && 1426 + el.children && el.children.length > 0 1427 + ) { 1428 + const borderW = { 1429 + top: parseFloat(style.borderTopWidth) || 0, 1430 + right: parseFloat(style.borderRightWidth) || 0, 1431 + bottom: parseFloat(style.borderBottomWidth) || 0, 1432 + left: parseFloat(style.borderLeftWidth) || 0, 1433 + }; 1434 + const borderVisible = { 1435 + top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor), 1436 + right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor), 1437 + bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor), 1438 + left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor), 1439 + }; 1440 + // Outline detection. jsdom decomposes `border` shorthand into 1441 + // border{Top,…}Width/Color but does NOT decompose `outline` — 1442 + // the longhands come back empty when the value was set via the 1443 + // shorthand. Fall back to parsing `style.outline` ourselves. 1444 + let outlineW = parseFloat(style.outlineWidth) || 0; 1445 + let outlineStyleVal = style.outlineStyle || ''; 1446 + let outlineColorVal = style.outlineColor || ''; 1447 + if (!outlineW && style.outline) { 1448 + const wMatch = style.outline.match(/(\d+(?:\.\d+)?)\s*px/); 1449 + if (wMatch) outlineW = parseFloat(wMatch[1]) || 0; 1450 + if (!outlineStyleVal) { 1451 + outlineStyleVal = /\b(solid|dashed|dotted|double|groove|ridge|inset|outset)\b/.test(style.outline) ? 'solid' : ''; 1452 + } 1453 + if (!outlineColorVal) { 1454 + const cMatch = style.outline.match(/(rgba?\([^)]+\)|#[0-9a-fA-F]{3,8}|[a-zA-Z]+)\s*$/); 1455 + if (cMatch) outlineColorVal = cMatch[1]; 1456 + } 1457 + } 1458 + const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; 1459 + const bgVisible = hasVisibleBackgroundBoundary(style, el, win); 1460 + 1461 + const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible; 1462 + if (anyVisible) { 1463 + // Resolve padding to px (jsdom returns raw "1.5rem" etc., not the 1464 + // computed px value; parseFloat would strip the unit and treat 1465 + // 1.5rem as 1.5px, false-flagging legitimate insets). 1466 + const pad = { 1467 + top: resolveLengthPx(style.paddingTop, fontSize) ?? 0, 1468 + right: resolveLengthPx(style.paddingRight, fontSize) ?? 0, 1469 + bottom: resolveLengthPx(style.paddingBottom, fontSize) ?? 0, 1470 + left: resolveLengthPx(style.paddingLeft, fontSize) ?? 0, 1471 + }; 1472 + const PAD_THRESHOLD = 2; 1473 + // Children-insulate-this-side: a side is insulated if ANY direct 1474 + // child has its own padding ≥ 4px on that side. Rationale: in 1475 + // typical flow, only the first/last (or leftmost/rightmost) 1476 + // children actually sit at the parent's edges. If even one of 1477 + // them has its own padding, the visual flush is broken on that 1478 + // side. Classic example: a column-flow card frame where the 1479 + // top child (header) has padding-top:12 and the bottom child 1480 + // (footer) has padding-bottom:8 — the parent's padding:0 doesn't 1481 + // matter; nothing is actually flush. The `any-child-insulates` 1482 + // heuristic accepts some false negatives (a card with one heavily 1483 + // padded middle child won't flag) for far fewer false positives. 1484 + const CHILD_INSULATE_THRESHOLD = 4; 1485 + const childrenInsulate = { top: false, right: false, bottom: false, left: false }; 1486 + for (const child of el.children) { 1487 + let childStyle = getComputedStyleFor(win, child); 1488 + if (!childStyle) continue; 1489 + const childPad = { 1490 + top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0, 1491 + right: resolveLengthPx(childStyle.paddingRight, fontSize) ?? 0, 1492 + bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0, 1493 + left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0, 1494 + }; 1495 + const childMargin = { 1496 + top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0, 1497 + right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0, 1498 + bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0, 1499 + left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0, 1500 + }; 1501 + if (rect && typeof child.getBoundingClientRect === 'function') { 1502 + try { 1503 + const childRect = child.getBoundingClientRect(); 1504 + if (childRect && childRect.width > 0 && childRect.height > 0) { 1505 + if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true; 1506 + if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true; 1507 + if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true; 1508 + if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true; 1509 + } 1510 + } catch {} 1511 + } 1512 + for (const s of ['top', 'right', 'bottom', 'left']) { 1513 + if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) { 1514 + childrenInsulate[s] = true; 1515 + } 1516 + } 1517 + } 1518 + 1519 + const textFlush = rect ? textDescendantsFlushSides(el, rect) : null; 1520 + const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible; 1521 + const flushSides = []; 1522 + for (const side of ['top', 'right', 'bottom', 'left']) { 1523 + const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right')); 1524 + const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide; 1525 + if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) { 1526 + flushSides.push(side); 1527 + } 1528 + } 1529 + 1530 + if (flushSides.length > 0) { 1531 + // Confirm at least one direct child has substantial text content 1532 + // (> 4 chars). Without this, the flush is harmless: e.g. an 1533 + // image-only card. 1534 + let hasTextChild = false; 1535 + for (const child of el.children) { 1536 + const childText = (child.textContent || '').trim(); 1537 + if (childText.length > 4) { hasTextChild = true; break; } 1538 + } 1539 + if (hasTextChild) { 1540 + const cls = (typeof el.className === 'string' && el.className.trim()) 1541 + ? el.className.trim().split(/\s+/)[0] 1542 + : ''; 1543 + const boundaryParts = []; 1544 + const borderSidesVisible = ['top', 'right', 'bottom', 'left'].filter(s => borderVisible[s]); 1545 + if (borderSidesVisible.length === 4) boundaryParts.push('border'); 1546 + else if (borderSidesVisible.length > 0) boundaryParts.push(`border-${borderSidesVisible.join('/')}`); 1547 + if (outlineVisible) boundaryParts.push('outline'); 1548 + if (bgVisible) boundaryParts.push('bg'); 1549 + const sidesLabel = flushSides.length === 4 ? 'all sides' : flushSides.join('/'); 1550 + const ident = cls 1551 + ? `<${tag.toLowerCase()}> "${cls}"` 1552 + : `<${tag.toLowerCase()}>`; 1553 + findings.push({ 1554 + id: 'cramped-padding', 1555 + snippet: `${ident}: children flush against ${boundaryParts.join('+')} on ${sidesLabel} (no inset)`, 1556 + }); 1557 + } 1558 + } 1559 + } 1560 + } 1561 + } 1562 + 1563 + // --- Body text touching viewport edge --- (browser-only: needs rect) 1564 + // Catches the failure mode where the agent ships body paragraphs 1565 + // with NO container providing horizontal padding — text bleeds 1566 + // directly to the viewport edge. Different from cramped-padding, 1567 + // which requires a colored/bordered container. Here the failure 1568 + // is the absence of the container entirely. 1569 + // 1570 + // Gate aggressively to avoid false positives: 1571 + // - <p> or <li> only (body content; not headings, not nav, not 1572 + // wrappers) 1573 + // - text > 40 chars (paragraph-like, not a label) 1574 + // - rect.width > 50% of viewport (real body, not a pull-quote) 1575 + // - rect.left < 16 OR rect.right > viewport - 16 (actually 1576 + // touching the edge) 1577 + // - not inside <nav> or <header> (those legitimately bleed) 1578 + // - element itself has no background-color (intentional full-bleed 1579 + // sections set a bg-color and provide their own internal padding) 1580 + if (rect && hasDirectText && textLen > 40 && ['P', 'LI'].includes(tag.toUpperCase()) && viewportWidth > 0) { 1581 + const inNavHeader = el.closest && (el.closest('nav') || el.closest('header')); 1582 + const hasOwnBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)' && style.backgroundColor !== 'transparent'; 1583 + const isPositioned = ['fixed', 'absolute'].includes(style.position || ''); 1584 + const widthRatio = rect.width / viewportWidth; 1585 + const leftClose = rect.left < 16; 1586 + const rightClose = rect.right > viewportWidth - 16; 1587 + if (!inNavHeader && !hasOwnBg && !isPositioned && widthRatio > 0.5 && (leftClose || rightClose)) { 1588 + const which = leftClose && rightClose 1589 + ? `left ${Math.round(rect.left)}px / right ${Math.round(viewportWidth - rect.right)}px` 1590 + : leftClose 1591 + ? `left ${Math.round(rect.left)}px` 1592 + : `right ${Math.round(viewportWidth - rect.right)}px`; 1593 + findings.push({ id: 'body-text-viewport-edge', snippet: `<${tag.toLowerCase()}> with ${textLen}-char body bleeds to viewport edge (${which})` }); 1594 + } 1595 + } 1596 + 1597 + // --- Tight line height --- 1598 + if (hasDirectText && textLen > 50 && !['h1','h2','h3','h4','h5','h6'].includes(tag)) { 1599 + if (lineHeightPx != null && fontSize > 0) { 1600 + const ratio = lineHeightPx / fontSize; 1601 + if (ratio > 0 && ratio < 1.3) { 1602 + findings.push({ id: 'tight-leading', snippet: `line-height ${ratio.toFixed(2)}x (need >=1.3)` }); 1603 + } 1604 + } 1605 + } 1606 + 1607 + // --- Justified text (without hyphens) --- 1608 + if (hasDirectText && style.textAlign === 'justify') { 1609 + const hyphens = style.hyphens || style.webkitHyphens || ''; 1610 + if (hyphens !== 'auto') { 1611 + findings.push({ id: 'justified-text', snippet: 'text-align: justify without hyphens: auto' }); 1612 + } 1613 + } 1614 + 1615 + // --- Tiny body text --- 1616 + // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.) 1617 + if (hasDirectText && textLen > 20 && fontSize < 12) { 1618 + const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption']; 1619 + const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]'); 1620 + const isUppercase = style.textTransform === 'uppercase'; 1621 + if (!skipTags.includes(tag) && !inUIContext && !isUppercase) { 1622 + findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` }); 1623 + } 1624 + } 1625 + 1626 + // --- All-caps body text --- 1627 + if (hasDirectText && textLen > 30 && style.textTransform === 'uppercase') { 1628 + if (!['h1','h2','h3','h4','h5','h6'].includes(tag)) { 1629 + findings.push({ id: 'all-caps-body', snippet: `text-transform: uppercase on ${textLen} chars of body text` }); 1630 + } 1631 + } 1632 + 1633 + // --- Wide letter spacing on body text --- 1634 + if (hasDirectText && textLen > 20 && style.textTransform !== 'uppercase') { 1635 + if (letterSpacingPx != null && letterSpacingPx > 0 && fontSize > 0) { 1636 + const trackingEm = letterSpacingPx / fontSize; 1637 + if (trackingEm > 0.05) { 1638 + findings.push({ id: 'wide-tracking', snippet: `letter-spacing: ${trackingEm.toFixed(2)}em on body text` }); 1639 + } 1640 + } 1641 + } 1642 + 1643 + // --- Crushed letter spacing (mirror of wide-tracking) --- 1644 + // Tracking pulled tighter than ~-0.05em crushes characters into each other. 1645 + // Optical tightening that display type legitimately wants (around -0.02em) 1646 + // stays well above this floor. 1647 + if (hasDirectText && textLen > 20 && fontSize > 0) { 1648 + if (letterSpacingPx != null && letterSpacingPx < 0) { 1649 + const trackingEm = letterSpacingPx / fontSize; 1650 + if (trackingEm <= -0.05) { 1651 + const excerpt = (el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 40); 1652 + findings.push({ id: 'extreme-negative-tracking', snippet: `letter-spacing: ${trackingEm.toFixed(2)}em — "${excerpt}"` }); 1653 + } 1654 + } 1655 + } 1656 + 1657 + return findings; 1658 + } 1659 + 1660 + function checkElementQualityDOM(el) { 1661 + const tag = el.tagName.toLowerCase(); 1662 + const style = getComputedStyle(el); 1663 + const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 10); 1664 + const textLen = el.textContent?.trim().length || 0; 1665 + // Browser getComputedStyle resolves everything to px — direct parseFloat 1666 + // works. 1667 + const fontSize = parseFloat(style.fontSize) || 16; 1668 + const lineHeightPx = resolveLengthPx(style.lineHeight, fontSize); 1669 + const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize); 1670 + const rect = el.getBoundingClientRect(); 1671 + const lineMax = (typeof window !== 'undefined' && window.__IMPECCABLE_CONFIG__?.lineLengthMax) || 80; 1672 + const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0; 1673 + return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax, viewportWidth, win: typeof window !== 'undefined' ? window : null }); 1674 + } 1675 + 1676 + // Pure page-level skipped-heading walk. Takes a Document so it works in both 1677 + // the browser and jsdom. 1678 + function checkPageQualityFromDoc(doc) { 1679 + const findings = []; 1680 + const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6'); 1681 + let prevLevel = 0; 1682 + let prevText = ''; 1683 + for (const h of headings) { 1684 + const level = parseInt(h.tagName[1]); 1685 + const text = (h.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60); 1686 + if (prevLevel > 0 && level > prevLevel + 1) { 1687 + findings.push({ 1688 + id: 'skipped-heading', 1689 + snippet: `<h${prevLevel}> "${prevText}" followed by <h${level}> "${text}" (missing h${prevLevel + 1})`, 1690 + }); 1691 + } 1692 + prevLevel = level; 1693 + prevText = text; 1694 + } 1695 + return findings; 1696 + } 1697 + 1698 + // Browser adapter (returns the legacy { type, detail } shape used by the overlay loop) 1699 + function checkPageQualityDOM() { 1700 + return checkPageQualityFromDoc(document).map(f => ({ type: f.id, detail: f.snippet })); 1701 + } 1702 + 1703 + // Node adapters — take pre-extracted jsdom computed style 1704 + 1705 + // jsdom doesn't lay out OR resolve em/rem/% to px — so we pre-resolve every 1706 + // CSS length the rule needs ourselves (walking the parent chain for 1707 + // font-size inheritance), and pass `rect: null` to skip the two rules that 1708 + // genuinely need element rects (line-length, cramped-padding). 1709 + function checkElementQuality(el, style, tag, window) { 1710 + const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 10); 1711 + const textLen = el.textContent?.trim().length || 0; 1712 + const fontSize = resolveFontSizePx(el, window); 1713 + const lineHeightPx = resolveLengthPx(style.lineHeight, fontSize); 1714 + const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize); 1715 + return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect: null, win: window }); 1716 + } 1717 + 1718 + function checkElementBorders(tag, style, overrides, resolvedRadius) { 1719 + const sides = ['Top', 'Right', 'Bottom', 'Left']; 1720 + const widths = {}, colors = {}; 1721 + for (const s of sides) { 1722 + widths[s] = parseFloat(style[`border${s}Width`]) || 0; 1723 + colors[s] = style[`border${s}Color`] || ''; 1724 + // jsdom silently drops any border shorthand containing var(), leaving 1725 + // both width and color empty on the computed style. When the detectHtml 1726 + // pre-pass pulled a resolved value off the rule, use it to fill in the 1727 + // missing side so the side-tab check can run. Real browsers resolve 1728 + // var() natively, so this fallback is a no-op in the browser path. 1729 + if (widths[s] === 0 && overrides && overrides[s]) { 1730 + widths[s] = overrides[s].width; 1731 + colors[s] = overrides[s].color; 1732 + } else if (colors[s] && colors[s].startsWith('var(') && overrides && overrides[s]) { 1733 + // Longhand case: jsdom kept the width but left the color as the 1734 + // literal `var(...)` string. Substitute the resolved color. 1735 + colors[s] = overrides[s].color; 1736 + } 1737 + } 1738 + // resolvedRadius lets the caller pre-resolve the radius via 1739 + // resolveBorderRadiusPx so the value survives jsdom 29.1.0's broken 1740 + // shorthand serialization. Falls back to the computed value for tests 1741 + // and browser callers that don't pre-resolve. 1742 + const radius = resolvedRadius != null 1743 + ? resolvedRadius 1744 + : (parseFloat(style.borderRadius) || 0); 1745 + return checkBorders(tag, widths, colors, radius); 1746 + } 1747 + 1748 + function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule) { 1749 + const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join(''); 1750 + const hasDirectText = directText.trim().length > 0; 1751 + 1752 + const effectiveBg = resolveBackground(el, window, customPropMap); 1753 + // jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain 1754 + // parseRgb misses Tailwind-tokenized text colors. Resolve through the 1755 + // customPropMap first; fall back to parseRgb for vanilla rgb() pages. 1756 + let textColor = customPropMap ? parseColorResolved(style.color, customPropMap) : null; 1757 + if (!textColor) textColor = parseRgb(style.color); 1758 + 1759 + // Anchor-inherit FP workaround: jsdom's UA stylesheet has `:link { color: 1760 + // blue }` at high specificity. The page's `a { color: inherit }` rule 1761 + // (Tailwind v4 preflight) loses to jsdom even though it WINS in real 1762 + // browsers (Chrome's UA wraps :link in :where() — zero specificity). 1763 + // When the page declares the inherit rule AND we see jsdom's default 1764 + // link blue on an anchor, walk to the nearest non-anchor ancestor and 1765 + // use its color instead. 1766 + if ( 1767 + hasAnchorInheritRule && 1768 + textColor && 1769 + textColor.r === 0 && textColor.g === 0 && textColor.b === 238 && 1770 + (tag === 'a' || el.closest?.('a')) 1771 + ) { 1772 + let cur = el.parentElement; 1773 + while (cur && cur.tagName !== 'HTML') { 1774 + if (cur.tagName !== 'A') { 1775 + const ps = window.getComputedStyle(cur); 1776 + const inh = (customPropMap ? parseColorResolved(ps.color, customPropMap) : null) || parseRgb(ps.color); 1777 + if (inh && !(inh.r === 0 && inh.g === 0 && inh.b === 238)) { 1778 + textColor = inh; 1779 + break; 1780 + } 1781 + } 1782 + cur = cur.parentElement; 1783 + } 1784 + } 1785 + 1786 + return checkColors({ 1787 + tag, 1788 + textColor, 1789 + bgColor: readOwnBackgroundColor(el, style), 1790 + effectiveBg, 1791 + effectiveBgStops: effectiveBg ? null : resolveGradientStops(el, window), 1792 + fontSize: parseFloat(style.fontSize) || 16, 1793 + fontWeight: parseInt(style.fontWeight) || 400, 1794 + hasDirectText, 1795 + isEmojiOnly: isEmojiOnlyText(directText), 1796 + bgClip: style.webkitBackgroundClip || style.backgroundClip || '', 1797 + bgImage: style.backgroundImage || '', 1798 + classList: el.getAttribute?.('class') || el.className || '', 1799 + }); 1800 + } 1801 + 1802 + function checkElementIconTile(el, tag, window) { 1803 + if (!HEADING_TAGS.has(tag)) return []; 1804 + const sibling = el.previousElementSibling; 1805 + if (!sibling) return []; 1806 + 1807 + const sibStyle = window.getComputedStyle(sibling); 1808 + // jsdom doesn't lay out — read explicit pixel dimensions from CSS instead. 1809 + const sibWidth = parseFloat(sibStyle.width) || 0; 1810 + const sibHeight = parseFloat(sibStyle.height) || 0; 1811 + 1812 + const iconChild = sibling.querySelector('svg, i[data-lucide], i[class*="fa-"], i[class*="icon"]'); 1813 + let iconWidth = 0; 1814 + if (iconChild) { 1815 + const iconStyle = window.getComputedStyle(iconChild); 1816 + iconWidth = parseFloat(iconStyle.width) || parseFloat(iconChild.getAttribute('width')) || 0; 1817 + } 1818 + // Or: tile contains an emoji/symbol character directly as its only content 1819 + const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join(''); 1820 + const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText); 1821 + 1822 + return checkIconTile({ 1823 + headingTag: tag, 1824 + headingText: el.textContent || '', 1825 + headingTop: 0, // jsdom: no layout, skip vertical-stacking gate 1826 + siblingTag: sibling.tagName.toLowerCase(), 1827 + siblingWidth: sibWidth, 1828 + siblingHeight: sibHeight, 1829 + siblingBottom: 0, 1830 + siblingBgColor: parseRgb(sibStyle.backgroundColor), 1831 + siblingBgImage: sibStyle.backgroundImage || '', 1832 + siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0, 1833 + siblingBorderRadius: resolveBorderRadiusPx(sibling, sibStyle, sibWidth, window), 1834 + hasIconChild: !!iconChild || hasInlineEmojiIcon, 1835 + iconChildWidth: iconWidth, 1836 + }); 1837 + } 1838 + 1839 + function checkElementItalicSerif(el, style, tag) { 1840 + if (tag !== 'h1' && tag !== 'h2') return []; 1841 + return checkItalicSerif({ 1842 + tag, 1843 + fontStyle: style.fontStyle || '', 1844 + fontFamily: style.fontFamily || '', 1845 + fontSize: parseFloat(style.fontSize) || 0, 1846 + headingText: el.textContent || '', 1847 + }); 1848 + } 1849 + 1850 + function checkElementHeroEyebrow(el, style, tag, window, customPropMap) { 1851 + if (tag !== 'h1') return []; 1852 + const sibling = el.previousElementSibling; 1853 + if (!sibling) return []; 1854 + const sibStyle = window.getComputedStyle(sibling); 1855 + // Resolve Tailwind v4 CSS-variable wrappers (font-weight:var(--font-weight-bold) 1856 + // etc.) before parsing. jsdom returns these verbatim from getComputedStyle; 1857 + // without resolution every style-based gate fails silently on Tailwind v4 builds. 1858 + const fontSizeRaw = customPropMap ? resolveVarRefs(sibStyle.fontSize, customPropMap) : sibStyle.fontSize; 1859 + const fontWeightRaw = customPropMap ? resolveVarRefs(sibStyle.fontWeight, customPropMap) : sibStyle.fontWeight; 1860 + const letterSpacingRaw = customPropMap ? resolveVarRefs(sibStyle.letterSpacing, customPropMap) : sibStyle.letterSpacing; 1861 + const colorRaw = customPropMap ? resolveVarRefs(sibStyle.color, customPropMap) : sibStyle.color; 1862 + const headingFontSizeRaw = customPropMap ? resolveVarRefs(style.fontSize, customPropMap) : style.fontSize; 1863 + const siblingFontSize = parseFloat(fontSizeRaw) || 0; 1864 + // resolveLengthPx returns null for 'normal' / 'auto'; coerce to 0 so the 1865 + // gate falls through cleanly. jsdom returns letter-spacing verbatim 1866 + // (e.g. '0.15em'), unlike real browsers, so this conversion is required. 1867 + return checkHeroEyebrow({ 1868 + headingTag: tag, 1869 + headingText: el.textContent || '', 1870 + headingFontSize: parseFloat(headingFontSizeRaw) || 0, 1871 + siblingTag: sibling.tagName.toLowerCase(), 1872 + siblingText: sibling.textContent || '', 1873 + siblingTextTransform: sibStyle.textTransform || '', 1874 + siblingFontSize, 1875 + siblingLetterSpacing: resolveLengthPx(letterSpacingRaw, siblingFontSize) || 0, 1876 + siblingFontWeight: fontWeightRaw || '', 1877 + siblingColor: colorRaw || '', 1878 + }); 1879 + } 1880 + 1881 + function checkRepeatedSectionKickersFromDoc(doc, win) { 1882 + const candidates = collectRepeatedSectionKickerCandidates( 1883 + doc, 1884 + (el) => win.getComputedStyle(el), 1885 + (value, fontSize) => resolveLengthPx(value, fontSize) || 0, 1886 + ); 1887 + return checkRepeatedSectionKickers({ candidates }); 1888 + } 1889 + 1890 + function checkElementMotion(tag, style) { 1891 + return checkMotion({ 1892 + tag, 1893 + transitionProperty: style.transitionProperty || '', 1894 + animationName: style.animationName || '', 1895 + timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '), 1896 + classList: '', 1897 + }); 1898 + } 1899 + 1900 + function checkElementGlow(tag, style, effectiveBg) { 1901 + if (!style.boxShadow || style.boxShadow === 'none') return []; 1902 + return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg }); 1903 + } 1904 + 1905 + // ─── Section 6: Page-Level Checks ─────────────────────────────────────────── 1906 + 1907 + // Browser page-level checks — use document/getComputedStyle globals 1908 + 1909 + function checkTypography() { 1910 + const findings = []; 1911 + 1912 + // Walk actual text-bearing elements and tally font usage by *computed style*. 1913 + // This is much more accurate than scanning CSS rules — it ignores rules that 1914 + // exist in the stylesheet but apply to nothing (e.g. demo classes showing 1915 + // anti-patterns), and counts what the user actually sees. 1916 + const fontUsage = new Map(); // primary font name → count of elements 1917 + let totalTextElements = 0; 1918 + for (const el of document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, td, th, dd, blockquote, figcaption, a, button, label, span')) { 1919 + // Skip impeccable's own elements 1920 + if (el.closest && el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; 1921 + // Only count elements that actually have visible direct text 1922 + const hasText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); 1923 + if (!hasText) continue; 1924 + const style = getComputedStyle(el); 1925 + const ff = style.fontFamily; 1926 + if (!ff) continue; 1927 + const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase()); 1928 + const primary = stack.find(f => f && !GENERIC_FONTS.has(f)); 1929 + if (!primary) continue; 1930 + fontUsage.set(primary, (fontUsage.get(primary) || 0) + 1); 1931 + totalTextElements++; 1932 + } 1933 + 1934 + if (totalTextElements >= 20) { 1935 + // A font is "primary" if it's used by at least 15% of text elements 1936 + const PRIMARY_THRESHOLD = 0.15; 1937 + for (const [font, count] of fontUsage) { 1938 + const share = count / totalTextElements; 1939 + if (share < PRIMARY_THRESHOLD) continue; 1940 + if (!OVERUSED_FONTS.has(font)) continue; 1941 + if (isBrandFontOnOwnDomain(font)) continue; 1942 + findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` }); 1943 + } 1944 + 1945 + // Single-font check: only one distinct primary font across all text 1946 + if (fontUsage.size === 1) { 1947 + const only = [...fontUsage.keys()][0]; 1948 + findings.push({ type: 'single-font', detail: `only font used is ${only}` }); 1949 + } 1950 + } 1951 + 1952 + const sizes = new Set(); 1953 + for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { 1954 + const fs = parseFloat(getComputedStyle(el).fontSize); 1955 + if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); 1956 + } 1957 + if (sizes.size >= 3) { 1958 + const sorted = [...sizes].sort((a, b) => a - b); 1959 + const ratio = sorted[sorted.length - 1] / sorted[0]; 1960 + if (ratio < 2.0) { 1961 + findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); 1962 + } 1963 + } 1964 + 1965 + return findings; 1966 + } 1967 + 1968 + function isCardLikeDOM(el) { 1969 + const tag = el.tagName.toLowerCase(); 1970 + if (SAFE_TAGS.has(tag) || ['input','select','textarea','img','video','canvas','picture'].includes(tag)) return false; 1971 + const style = getComputedStyle(el); 1972 + const cls = el.getAttribute('class') || ''; 1973 + const hasShadow = (style.boxShadow && style.boxShadow !== 'none') || /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls); 1974 + const hasBorder = /\bborder\b/.test(cls); 1975 + const hasRadius = parseFloat(style.borderRadius) > 0 || /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls); 1976 + const hasBg = (style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)') || /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls); 1977 + return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg); 1978 + } 1979 + 1980 + function checkLayout() { 1981 + const findings = []; 1982 + const flaggedEls = new Set(); 1983 + 1984 + for (const el of document.querySelectorAll('*')) { 1985 + if (!isCardLikeDOM(el) || flaggedEls.has(el)) continue; 1986 + const cls = el.getAttribute('class') || ''; 1987 + const style = getComputedStyle(el); 1988 + if (style.position === 'absolute' || style.position === 'fixed') continue; 1989 + if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue; 1990 + if ((el.textContent?.trim().length || 0) < 10) continue; 1991 + const rect = el.getBoundingClientRect(); 1992 + if (rect.width < 50 || rect.height < 30) continue; 1993 + 1994 + let parent = el.parentElement; 1995 + while (parent) { 1996 + if (isCardLikeDOM(parent)) { flaggedEls.add(el); break; } 1997 + parent = parent.parentElement; 1998 + } 1999 + } 2000 + 2001 + for (const el of flaggedEls) { 2002 + let isAncestor = false; 2003 + for (const other of flaggedEls) { 2004 + if (other !== el && el.contains(other)) { isAncestor = true; break; } 2005 + } 2006 + if (!isAncestor) findings.push({ type: 'nested-cards', detail: 'Card inside card', el }); 2007 + } 2008 + 2009 + return findings; 2010 + } 2011 + 2012 + // Node page-level checks — take document/window as parameters 2013 + 2014 + function checkPageTypography(doc, win) { 2015 + const findings = []; 2016 + 2017 + const fonts = new Set(); 2018 + const overusedFound = new Set(); 2019 + 2020 + for (const sheet of doc.styleSheets) { 2021 + let rules; 2022 + try { rules = sheet.cssRules || sheet.rules; } catch { continue; } 2023 + if (!rules) continue; 2024 + for (const rule of rules) { 2025 + if (rule.type !== 1) continue; 2026 + const ff = rule.style?.fontFamily; 2027 + if (!ff) continue; 2028 + const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase()); 2029 + const primary = stack.find(f => f && !GENERIC_FONTS.has(f)); 2030 + if (primary) { 2031 + fonts.add(primary); 2032 + if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary); 2033 + } 2034 + } 2035 + } 2036 + 2037 + // Check Google Fonts links in HTML 2038 + const html = doc.documentElement?.outerHTML || ''; 2039 + const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi; 2040 + let m; 2041 + while ((m = gfRe.exec(html)) !== null) { 2042 + const families = m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase()); 2043 + for (const f of families) { 2044 + fonts.add(f); 2045 + if (OVERUSED_FONTS.has(f)) overusedFound.add(f); 2046 + } 2047 + } 2048 + 2049 + // Also parse raw HTML/style content for font-family (jsdom may not expose all via CSSOM) 2050 + const ffRe = /font-family\s*:\s*([^;}]+)/gi; 2051 + let fm; 2052 + while ((fm = ffRe.exec(html)) !== null) { 2053 + for (const f of fm[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) { 2054 + if (f && !GENERIC_FONTS.has(f)) { 2055 + fonts.add(f); 2056 + if (OVERUSED_FONTS.has(f)) overusedFound.add(f); 2057 + } 2058 + } 2059 + } 2060 + 2061 + for (const font of overusedFound) { 2062 + findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); 2063 + } 2064 + 2065 + // Single font 2066 + if (fonts.size === 1) { 2067 + const els = doc.querySelectorAll('*'); 2068 + if (els.length >= 20) { 2069 + findings.push({ id: 'single-font', snippet: `only font used is ${[...fonts][0]}` }); 2070 + } 2071 + } 2072 + 2073 + // Flat type hierarchy 2074 + const sizes = new Set(); 2075 + const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div'); 2076 + for (const el of textEls) { 2077 + const fontSize = parseFloat(win.getComputedStyle(el).fontSize); 2078 + // Filter out sub-8px values (jsdom doesn't resolve relative units properly) 2079 + if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); 2080 + } 2081 + if (sizes.size >= 3) { 2082 + const sorted = [...sizes].sort((a, b) => a - b); 2083 + const ratio = sorted[sorted.length - 1] / sorted[0]; 2084 + if (ratio < 2.0) { 2085 + findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); 2086 + } 2087 + } 2088 + 2089 + return findings; 2090 + } 2091 + 2092 + function isCardLike(el, win) { 2093 + const tag = el.tagName.toLowerCase(); 2094 + if (SAFE_TAGS.has(tag) || ['input', 'select', 'textarea', 'img', 'video', 'canvas', 'picture'].includes(tag)) return false; 2095 + 2096 + const style = win.getComputedStyle(el); 2097 + const rawStyle = el.getAttribute?.('style') || ''; 2098 + const cls = el.getAttribute?.('class') || ''; 2099 + 2100 + const hasShadow = (style.boxShadow && style.boxShadow !== 'none') || 2101 + /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls) || /box-shadow/i.test(rawStyle); 2102 + const hasBorder = /\bborder\b/.test(cls); 2103 + const widthPx = parseFloat(style.width) || 0; 2104 + const hasRadius = resolveBorderRadiusPx(el, style, widthPx, win) > 0 || 2105 + /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls) || /border-radius/i.test(rawStyle); 2106 + const hasBg = /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls) || 2107 + /background(?:-color)?\s*:\s*(?!transparent)/i.test(rawStyle); 2108 + 2109 + return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg); 2110 + } 2111 + 2112 + function checkPageLayout(doc, win) { 2113 + const findings = []; 2114 + 2115 + // Nested cards 2116 + const allEls = doc.querySelectorAll('*'); 2117 + const flaggedEls = new Set(); 2118 + for (const el of allEls) { 2119 + if (!isCardLike(el, win)) continue; 2120 + if (flaggedEls.has(el)) continue; 2121 + 2122 + const tag = el.tagName.toLowerCase(); 2123 + const cls = el.getAttribute?.('class') || ''; 2124 + const rawStyle = el.getAttribute?.('style') || ''; 2125 + 2126 + if (['pre', 'code'].includes(tag)) continue; 2127 + if (/\b(?:absolute|fixed)\b/.test(cls) || /position\s*:\s*(?:absolute|fixed)/i.test(rawStyle)) continue; 2128 + if ((el.textContent?.trim().length || 0) < 10) continue; 2129 + if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue; 2130 + 2131 + // Walk up to find card-like ancestor 2132 + let parent = el.parentElement; 2133 + while (parent) { 2134 + if (isCardLike(parent, win)) { 2135 + flaggedEls.add(el); 2136 + break; 2137 + } 2138 + parent = parent.parentElement; 2139 + } 2140 + } 2141 + 2142 + // Only report innermost nested cards 2143 + for (const el of flaggedEls) { 2144 + let isAncestorOfFlagged = false; 2145 + for (const other of flaggedEls) { 2146 + if (other !== el && el.contains(other)) { 2147 + isAncestorOfFlagged = true; 2148 + break; 2149 + } 2150 + } 2151 + if (!isAncestorOfFlagged) { 2152 + findings.push({ id: 'nested-cards', snippet: `Card inside card (${el.tagName.toLowerCase()})` }); 2153 + } 2154 + } 2155 + 2156 + return findings; 2157 + } 2158 + 2159 + // ─── Cream / beige palette (the default "tasteful" AI surface) ──────────────── 2160 + // A warm, lightly-tinted off-white page background — light, with R≥G≥B and a 2161 + // small warm tint (not white, not a strong color). The current reflex surface. 2162 + function isCreamColor(rgb) { 2163 + if (!rgb) return false; 2164 + const { r, g, b } = rgb; 2165 + if (Math.min(r, g, b) < 209) return false; // must be light 2166 + if (!(r >= g && g >= b)) return false; // warm ordering 2167 + const warmth = r - b; 2168 + return warmth >= 6 && warmth <= 48; // tinted, not white, not strong 2169 + } 2170 + 2171 + // Tailwind background utilities that render as a warm off-white surface. The 2172 + // static engine doesn't fetch Tailwind's CSS, so a `bg-amber-50` on <body> 2173 + // resolves to nothing in computed style — catch it from the class list 2174 + // instead. Candidate tokens map to their actual Tailwind hex and are still 2175 + // filtered through isCreamColor, so neutral grays (stone) and over-saturated 2176 + // shades drop out on their own. 2177 + const TAILWIND_BG_HEX = { 2178 + 'bg-amber-50': '#fffbeb', 'bg-amber-100': '#fef3c7', 2179 + 'bg-orange-50': '#fff7ed', 'bg-orange-100': '#ffedd5', 2180 + 'bg-yellow-50': '#fefce8', 2181 + 'bg-stone-50': '#fafaf9', 'bg-stone-100': '#f5f5f4', 'bg-stone-200': '#e7e5e4', 2182 + }; 2183 + 2184 + function creamFromClassList(cls) { 2185 + if (!cls) return null; 2186 + // Arbitrary value: bg-[#f5f0e6] / bg-[rgb(245_240_230)] (underscores = spaces). 2187 + const arb = cls.match(/\bbg-\[([^\]]+)\]/); 2188 + if (arb && isCreamColor(parseAnyColor(arb[1].replace(/_/g, ' ')))) return `bg-[${arb[1]}]`; 2189 + // Named warm-light utilities. 2190 + for (const [tok, hex] of Object.entries(TAILWIND_BG_HEX)) { 2191 + if (new RegExp(`(^|\\s)${tok}($|\\s)`).test(cls) && isCreamColor(parseAnyColor(hex))) return tok; 2192 + } 2193 + return null; 2194 + } 2195 + 2196 + function checkCreamPalette(doc, win) { 2197 + const findings = []; 2198 + const body = doc.body || (doc.querySelector ? doc.querySelector('body') : null); 2199 + if (!body) return findings; 2200 + const html = doc.documentElement; 2201 + const getCS = (el) => (win ? win.getComputedStyle(el) : getComputedStyle(el)); 2202 + 2203 + // 1. Computed background — covers inline / <style> / linked CSS, and Tailwind 2204 + // once it's actually rendered (browser path). 2205 + let bg = readOwnBackgroundColor(body, getCS(body)); 2206 + if (!bg || bg.a === 0) { 2207 + if (html) bg = readOwnBackgroundColor(html, getCS(html)); 2208 + } 2209 + if (isCreamColor(bg)) { 2210 + findings.push({ id: 'cream-palette', snippet: `cream/beige page background rgb(${bg.r}, ${bg.g}, ${bg.b})` }); 2211 + return findings; 2212 + } 2213 + 2214 + // 2. Tailwind class fallback — for the static path, where utility classes 2215 + // never resolve to computed CSS. 2216 + for (const el of [body, html]) { 2217 + const tok = creamFromClassList(el && el.getAttribute ? el.getAttribute('class') : ''); 2218 + if (tok) { 2219 + findings.push({ id: 'cream-palette', snippet: `cream/beige page background (Tailwind ${tok})` }); 2220 + break; 2221 + } 2222 + } 2223 + return findings; 2224 + } 2225 + 2226 + // ─── Oversized hero headline ──────────────────────────────────────────────── 2227 + // Fires when a *long* headline is set at display size and actually dominates 2228 + // the viewport. A punchy one- or two-word headline at the same size is a 2229 + // legitimate stylistic choice, and a large-but-contained two-line hero should 2230 + // pass too — length and viewport share together are the tell. 2231 + const OVERSIZED_H1_FONT_PX = 72; 2232 + const OVERSIZED_H1_MIN_CHARS = 40; 2233 + const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28; 2234 + const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25; 2235 + function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) { 2236 + if (tag !== 'h1') return []; 2237 + const textLen = headingText.length; 2238 + if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) { 2239 + let viewportDetail = ''; 2240 + if (rect && viewportWidth > 0 && viewportHeight > 0) { 2241 + const heightRatio = rect.height / viewportHeight; 2242 + const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight); 2243 + const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO 2244 + || areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO; 2245 + if (!dominatesViewport) return []; 2246 + viewportDetail = `, ${Math.round(heightRatio * 100)}vh`; 2247 + } 2248 + return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }]; 2249 + } 2250 + return []; 2251 + } 2252 + 2253 + function checkElementOversizedH1(el, style, tag, window) { 2254 + if (tag !== 'h1') return []; 2255 + const fontSize = resolveFontSizePx(el, window); 2256 + const headingText = (el.textContent || '').trim().replace(/\s+/g, ' '); 2257 + return checkOversizedH1({ tag, fontSize, headingText }); 2258 + } 2259 + 2260 + function checkElementOversizedH1DOM(el) { 2261 + const tag = el.tagName.toLowerCase(); 2262 + if (tag !== 'h1') return []; 2263 + const style = getComputedStyle(el); 2264 + const fontSize = parseFloat(style.fontSize) || 0; 2265 + const headingText = (el.textContent || '').trim().replace(/\s+/g, ' '); 2266 + const rect = el.getBoundingClientRect(); 2267 + const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0; 2268 + const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0; 2269 + return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight }); 2270 + } 2271 + 2272 + // ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ──────────── 2273 + const CSS_COLOR_TOKEN_RE = /(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\([^)]*\)|#[0-9a-fA-F]{3,8}\b|\b(?:black|white|transparent|currentcolor)\b/gi; 2274 + 2275 + function shadowLayerAlpha(layer) { 2276 + CSS_COLOR_TOKEN_RE.lastIndex = 0; 2277 + const match = CSS_COLOR_TOKEN_RE.exec(layer); 2278 + if (!match) return 1; 2279 + if (match[0].toLowerCase() === 'transparent') return 0; 2280 + const parsed = parseAnyColor(match[0]); 2281 + return parsed ? (parsed.a ?? 1) : 1; 2282 + } 2283 + 2284 + function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) { 2285 + if (!boxShadow || boxShadow === 'none') return 0; 2286 + let maxBlur = 0; 2287 + // Split into layers on commas not inside parentheses (rgba(...) etc.). 2288 + for (const layer of boxShadow.split(/,(?![^()]*\))/)) { 2289 + if (shadowLayerAlpha(layer) < minAlpha) continue; 2290 + // Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the 2291 + // ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps 2292 + // unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") — 2293 + // both reduce to the same numbers here. 2294 + const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' '); 2295 + const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0])); 2296 + if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]); 2297 + } 2298 + return maxBlur; 2299 + } 2300 + 2301 + function cssColorAlpha(value) { 2302 + if (cssColorIsTransparent(value)) return 0; 2303 + const parsed = parseAnyColor(value); 2304 + return parsed ? (parsed.a ?? 1) : 1; 2305 + } 2306 + 2307 + function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) { 2308 + const visibleThinBorders = borderWidths 2309 + .map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') })) 2310 + .filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28); 2311 + const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width)); 2312 + const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 }); 2313 + if (visibleThinBorders.length >= 2 && blur >= 16) { 2314 + return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }]; 2315 + } 2316 + return []; 2317 + } 2318 + 2319 + function borderWidthsFromStyle(style) { 2320 + return [ 2321 + parseFloat(style.borderTopWidth) || 0, 2322 + parseFloat(style.borderRightWidth) || 0, 2323 + parseFloat(style.borderBottomWidth) || 0, 2324 + parseFloat(style.borderLeftWidth) || 0, 2325 + ]; 2326 + } 2327 + 2328 + function borderColorsFromStyle(style) { 2329 + return [ 2330 + style.borderTopColor || '', 2331 + style.borderRightColor || '', 2332 + style.borderBottomColor || '', 2333 + style.borderLeftColor || '', 2334 + ]; 2335 + } 2336 + 2337 + function checkElementGptBorderShadow(el, style) { 2338 + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); 2339 + } 2340 + 2341 + function checkElementGptBorderShadowDOM(el) { 2342 + const style = getComputedStyle(el); 2343 + return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' }); 2344 + } 2345 + 2346 + // ─── Clipped overflow container ─────────────────────────────────────────────── 2347 + // A clipping container (overflow hidden/clip, not a scroll region) wrapping an 2348 + // absolutely/fixed-positioned descendant clips popovers/menus that must escape. 2349 + function classSelector(el) { 2350 + const cls = (el.getAttribute ? el.getAttribute('class') : el.className) || ''; 2351 + const tokens = String(cls).trim().split(/\s+/).filter(Boolean); 2352 + const tag = el.tagName ? el.tagName.toLowerCase() : 'el'; 2353 + return tokens.length ? `${tag}.${tokens.join('.')}` : tag; 2354 + } 2355 + 2356 + function positionedChildIsDecorative(child) { 2357 + if (!child || typeof child.getAttribute !== 'function') return false; 2358 + if (child.closest?.('[aria-hidden="true"]')) return true; 2359 + const role = (child.getAttribute('role') || '').toLowerCase(); 2360 + if (role === 'none' || role === 'presentation') return true; 2361 + const tag = child.tagName ? child.tagName.toLowerCase() : ''; 2362 + if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true; 2363 + const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`; 2364 + if ( 2365 + /\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) && 2366 + !positionedChildHasSubstantiveContent(child) 2367 + ) { 2368 + return true; 2369 + } 2370 + return false; 2371 + } 2372 + 2373 + const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [ 2374 + 'a[href]', 2375 + 'button', 2376 + 'input', 2377 + 'select', 2378 + 'summary', 2379 + 'textarea', 2380 + '[tabindex]:not([tabindex="-1"])', 2381 + '[role="button"]', 2382 + '[role="dialog"]', 2383 + '[role="link"]', 2384 + '[role="listbox"]', 2385 + '[role="menu"]', 2386 + '[role="menuitem"]', 2387 + '[role="option"]', 2388 + '[role="tooltip"]', 2389 + ].join(','); 2390 + 2391 + function positionedChildHasSubstantiveContent(child) { 2392 + const text = (child.textContent || '').replace(/\s+/g, ' ').trim(); 2393 + if (text.length > 0) return true; 2394 + if (typeof child.matches === 'function') { 2395 + try { 2396 + if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; 2397 + } catch {} 2398 + } 2399 + if (typeof child.querySelector === 'function') { 2400 + try { 2401 + if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true; 2402 + } catch {} 2403 + } 2404 + return false; 2405 + } 2406 + 2407 + function clippingContainerIsIntentionalViewport(el) { 2408 + if (!el || typeof el.getAttribute !== 'function') return false; 2409 + const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase(); 2410 + if (/\b(carousel|slider)\b/.test(roleDescription)) return true; 2411 + const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase(); 2412 + return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) || 2413 + /\b(demo-area|demo-stage|demo-viewport)\b/.test(ident); 2414 + } 2415 + 2416 + function elementRect(el) { 2417 + if (!el || typeof el.getBoundingClientRect !== 'function') return null; 2418 + try { 2419 + const rect = el.getBoundingClientRect(); 2420 + if (!rect) return null; 2421 + const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height]; 2422 + if (!values.every(Number.isFinite)) return null; 2423 + if (rect.width <= 0 && rect.height <= 0) return null; 2424 + return rect; 2425 + } catch { 2426 + return null; 2427 + } 2428 + } 2429 + 2430 + function positionedStyleImpliesEscape(style) { 2431 + const values = [ 2432 + style.top, 2433 + style.right, 2434 + style.bottom, 2435 + style.left, 2436 + style.inset, 2437 + style.insetBlock, 2438 + style.insetInline, 2439 + style.insetBlockStart, 2440 + style.insetBlockEnd, 2441 + style.insetInlineStart, 2442 + style.insetInlineEnd, 2443 + ].filter(Boolean).map(value => String(value).trim().toLowerCase()); 2444 + for (const value of values) { 2445 + if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true; 2446 + if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true; 2447 + } 2448 + return false; 2449 + } 2450 + 2451 + function positionedChildEscapesClip(el, child, clipX, clipY) { 2452 + const parentRect = elementRect(el); 2453 + const childRect = elementRect(child); 2454 + if (!parentRect || !childRect) return null; 2455 + const threshold = 2; 2456 + return Boolean( 2457 + (clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) || 2458 + (clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold)) 2459 + ); 2460 + } 2461 + 2462 + function checkClippedOverflow(el, style, getStyle) { 2463 + const clips = (v) => v === 'hidden' || v === 'clip'; 2464 + const scrolls = (v) => v === 'auto' || v === 'scroll'; 2465 + const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || ''; 2466 + const clipX = clips(ox) || clips(ov); 2467 + const clipY = clips(oy) || clips(ov); 2468 + const anyClip = clipX || clipY; 2469 + const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov); 2470 + if (!anyClip || anyScroll) return []; 2471 + if (clippingContainerIsIntentionalViewport(el)) return []; 2472 + if (!el.querySelectorAll) return []; 2473 + for (const child of el.querySelectorAll('*')) { 2474 + const childStyle = getStyle(child); 2475 + const pos = childStyle.position || ''; 2476 + if (pos === 'absolute' || pos === 'fixed') { 2477 + if (positionedChildIsDecorative(child)) continue; 2478 + const escapes = positionedChildEscapesClip(el, child, clipX, clipY); 2479 + if (escapes === false) continue; 2480 + if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue; 2481 + return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }]; 2482 + } 2483 + } 2484 + return []; 2485 + } 2486 + 2487 + function checkElementClippedOverflow(el, style, tag, window) { 2488 + return checkClippedOverflow(el, style, (n) => window.getComputedStyle(n)); 2489 + } 2490 + 2491 + function checkElementClippedOverflowDOM(el) { 2492 + const style = getComputedStyle(el); 2493 + return checkClippedOverflow(el, style, (n) => getComputedStyle(n)); 2494 + } 2495 + 2496 + // ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ────────────── 2497 + const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']); 2498 + 2499 + function metricLengthPx(value, fontSizePx = 16) { 2500 + if (typeof value === 'number' && Number.isFinite(value)) return value; 2501 + if (typeof value !== 'string') return null; 2502 + return resolveLengthPx(value, fontSizePx); 2503 + } 2504 + 2505 + function firstMetricLengthPx(fontSizePx, ...values) { 2506 + for (const value of values) { 2507 + const parsed = metricLengthPx(value, fontSizePx); 2508 + if (parsed !== null) return parsed; 2509 + } 2510 + return null; 2511 + } 2512 + 2513 + function expandBoxShorthand(parts) { 2514 + if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]]; 2515 + if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]]; 2516 + if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]]; 2517 + return [parts[0], parts[1], parts[2], parts[3]]; 2518 + } 2519 + 2520 + function clippedByInset(clipPath) { 2521 + const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/); 2522 + if (!match) return false; 2523 + const beforeRound = match[1].split(/\s+round\s+/)[0].trim(); 2524 + if (!beforeRound) return false; 2525 + const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4)); 2526 + const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/)); 2527 + if (percents.some(match => !match)) return false; 2528 + const [top, right, bottom, left] = percents.map(match => parseFloat(match[1])); 2529 + return top + bottom >= 100 || left + right >= 100; 2530 + } 2531 + 2532 + function clippedByRect(clip) { 2533 + const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/); 2534 + if (!match) return false; 2535 + const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean); 2536 + if (values.length !== 4) return false; 2537 + const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16)); 2538 + if ([top, right, bottom, left].some(value => value === null)) return false; 2539 + return bottom <= top || right <= left; 2540 + } 2541 + 2542 + function isScreenReaderOnlyTextStyle(style, metrics = {}) { 2543 + if (!style) return false; 2544 + const overflowValues = [style.overflow, style.overflowX, style.overflowY] 2545 + .map(value => String(value || '').toLowerCase()); 2546 + const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip'); 2547 + 2548 + const fontSize = metricLengthPx(style.fontSize, 16) || 16; 2549 + const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize); 2550 + const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize); 2551 + const isTiny = width !== null && height !== null && width <= 2 && height <= 2; 2552 + const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow; 2553 + 2554 + const clipPath = String(style.clipPath || style.webkitClipPath || '').trim(); 2555 + const clip = String(style.clip || '').trim(); 2556 + return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip); 2557 + } 2558 + 2559 + function isRenderedForBrowserRule(el) { 2560 + for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) { 2561 + if (cur.getAttribute?.('aria-hidden') === 'true') return false; 2562 + const style = getComputedStyle(cur); 2563 + const visibility = String(style.visibility || '').toLowerCase(); 2564 + if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false; 2565 + if ((parseFloat(style.opacity) || 0) <= 0.01) return false; 2566 + if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false; 2567 + } 2568 + return true; 2569 + } 2570 + 2571 + function checkElementTextOverflowDOM(el) { 2572 + const tag = el.tagName.toLowerCase(); 2573 + if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; 2574 + if (!isRenderedForBrowserRule(el)) return []; 2575 + // Only the element that actually owns overflowing text — not its ancestors, 2576 + // which inherit a wider scrollWidth from the spilling descendant. 2577 + const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); 2578 + if (!hasDirectText) return []; 2579 + const style = getComputedStyle(el); 2580 + const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null; 2581 + if (isScreenReaderOnlyTextStyle(style, { 2582 + width: rect?.width, 2583 + height: rect?.height, 2584 + clientWidth: el.clientWidth, 2585 + clientHeight: el.clientHeight, 2586 + })) return []; 2587 + const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || ''); 2588 + if (isScrollRegion(style)) return []; 2589 + // A scrollable ancestor means this overflow is intentional and scrollable. 2590 + for (let p = el.parentElement; p; p = p.parentElement) { 2591 + if (isScrollRegion(getComputedStyle(p))) return []; 2592 + } 2593 + const delta = el.scrollWidth - el.clientWidth; 2594 + if (el.clientWidth > 0 && delta >= 16) { 2595 + return [{ id: 'text-overflow', snippet: `${classSelector(el)} overflows its box by ${Math.round(delta)}px` }]; 2596 + } 2597 + return []; 2598 + } 2599 + 2600 + export { 2601 + checkBorders, 2602 + isEmojiOnlyText, 2603 + checkColors, 2604 + isCardLikeFromProps, 2605 + checkIconTile, 2606 + resolveSerif, 2607 + checkItalicSerif, 2608 + isAccentColor, 2609 + checkHeroEyebrow, 2610 + checkRepeatedSectionKickers, 2611 + checkMotion, 2612 + checkGlow, 2613 + checkHtmlPatterns, 2614 + readOwnBackgroundColor, 2615 + resolveBackground, 2616 + resolveGradientStops, 2617 + parseRadiusToPx, 2618 + resolveBorderRadiusPx, 2619 + checkElementBordersDOM, 2620 + checkElementColorsDOM, 2621 + checkElementIconTileDOM, 2622 + checkElementItalicSerifDOM, 2623 + checkElementHeroEyebrowDOM, 2624 + buildCustomPropMap, 2625 + resolveVarRefs, 2626 + oklchToRgb, 2627 + parseAnyColor, 2628 + parseColorResolved, 2629 + cleanInlineText, 2630 + isRepeatedKickerCandidate, 2631 + collectRepeatedSectionKickerCandidates, 2632 + checkRepeatedSectionKickersDOM, 2633 + checkElementMotionDOM, 2634 + checkElementGlowDOM, 2635 + checkElementAIPaletteDOM, 2636 + resolveFontSizePx, 2637 + resolveLengthPx, 2638 + checkQuality, 2639 + checkElementQualityDOM, 2640 + checkPageQualityFromDoc, 2641 + checkPageQualityDOM, 2642 + checkElementQuality, 2643 + checkElementBorders, 2644 + checkElementColors, 2645 + checkElementIconTile, 2646 + checkElementItalicSerif, 2647 + checkElementHeroEyebrow, 2648 + checkRepeatedSectionKickersFromDoc, 2649 + checkElementMotion, 2650 + checkElementGlow, 2651 + checkTypography, 2652 + isCardLikeDOM, 2653 + checkLayout, 2654 + checkPageTypography, 2655 + isCardLike, 2656 + checkPageLayout, 2657 + isCreamColor, 2658 + checkCreamPalette, 2659 + checkOversizedH1, 2660 + checkElementOversizedH1, 2661 + checkElementOversizedH1DOM, 2662 + shadowMaxBlurPx, 2663 + checkGptThinBorderWideShadow, 2664 + checkElementGptBorderShadow, 2665 + checkElementGptBorderShadowDOM, 2666 + checkClippedOverflow, 2667 + checkElementClippedOverflow, 2668 + checkElementClippedOverflowDOM, 2669 + isScreenReaderOnlyTextStyle, 2670 + checkElementTextOverflowDOM, 2671 + };
+124
tools/design-detector/detector/shared/color.mjs
··· 1 + // ─── Section 2: Color Utilities ───────────────────────────────────────────── 2 + 3 + function isNeutralColor(color) { 4 + if (!color || color === 'transparent') return true; 5 + 6 + // rgb/rgba — use channel spread. Threshold 30 ≈ 11.7% of the 0–255 range. 7 + const rgb = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); 8 + if (rgb) { 9 + return (Math.max(+rgb[1], +rgb[2], +rgb[3]) - Math.min(+rgb[1], +rgb[2], +rgb[3])) < 30; 10 + } 11 + 12 + // oklch()/lch() — chroma is the second numeric component. 13 + // oklch chroma is ~0–0.4 in sRGB gamut; >= 0.02 reads as tinted, not gray. 14 + // lch chroma is ~0–150; >= 3 reads as tinted. jsdom emits both formats 15 + // literally (it does NOT convert them to rgb). 16 + const oklch = color.match(/oklch\(\s*[\d.]+%?\s*([\d.-]+)/i); 17 + if (oklch) return parseFloat(oklch[1]) < 0.02; 18 + const lch = color.match(/lch\(\s*[\d.]+%?\s*([\d.-]+)/i); 19 + if (lch) return parseFloat(lch[1]) < 3; 20 + 21 + // oklab()/lab() — a and b are signed axes; chroma = sqrt(a² + b²). 22 + // oklab a/b are ~-0.4..0.4, threshold 0.02. lab a/b are ~-128..127, threshold 3. 23 + const oklab = color.match(/oklab\(\s*[\d.]+%?\s*([\d.-]+)\s+([\d.-]+)/i); 24 + if (oklab) { 25 + const a = parseFloat(oklab[1]), b = parseFloat(oklab[2]); 26 + return Math.hypot(a, b) < 0.02; 27 + } 28 + const lab = color.match(/lab\(\s*[\d.]+%?\s*([\d.-]+)\s+([\d.-]+)/i); 29 + if (lab) { 30 + const a = parseFloat(lab[1]), b = parseFloat(lab[2]); 31 + return Math.hypot(a, b) < 3; 32 + } 33 + 34 + // hsl/hsla — saturation is the second numeric component (percent). 35 + // Modern jsdom usually converts hsl() to rgb, but handle it directly for 36 + // safety across versions and for any engine that preserves the format. 37 + const hsl = color.match(/hsla?\(\s*[\d.-]+\s*,?\s*([\d.]+)%/i); 38 + if (hsl) return parseFloat(hsl[1]) < 10; 39 + 40 + // hwb(hue whiteness% blackness%) — a pixel is fully gray when 41 + // whiteness + blackness >= 100; chroma-like saturation = 1 - (w+b)/100. 42 + const hwb = color.match(/hwb\(\s*[\d.-]+\s+([\d.]+)%\s+([\d.]+)%/i); 43 + if (hwb) { 44 + const w = parseFloat(hwb[1]), b = parseFloat(hwb[2]); 45 + return (1 - Math.min(100, w + b) / 100) < 0.1; 46 + } 47 + 48 + // Unknown / unrecognized format — err on the side of DETECTING rather 49 + // than silently skipping. This is the opposite of the previous default, 50 + // which was the root cause of the oklch bug. 51 + return false; 52 + } 53 + 54 + function parseRgb(color) { 55 + if (!color || color === 'transparent') return null; 56 + const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/); 57 + if (!m) return null; 58 + return { r: +m[1], g: +m[2], b: +m[3], a: m[4] !== undefined ? +m[4] : 1 }; 59 + } 60 + 61 + function relativeLuminance({ r, g, b }) { 62 + const [rs, gs, bs] = [r / 255, g / 255, b / 255].map(c => 63 + c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 64 + ); 65 + return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs; 66 + } 67 + 68 + function contrastRatio(c1, c2) { 69 + const l1 = relativeLuminance(c1); 70 + const l2 = relativeLuminance(c2); 71 + return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); 72 + } 73 + 74 + function parseGradientColors(bgImage) { 75 + if (!bgImage || !bgImage.includes('gradient')) return []; 76 + const colors = []; 77 + for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) { 78 + const c = parseRgb(m[0]); 79 + if (c) colors.push(c); 80 + } 81 + for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) { 82 + const h = m[1]; 83 + if (h.length === 6) { 84 + colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 }); 85 + } else { 86 + colors.push({ r: parseInt(h[0]+h[0],16), g: parseInt(h[1]+h[1],16), b: parseInt(h[2]+h[2],16), a: 1 }); 87 + } 88 + } 89 + return colors; 90 + } 91 + 92 + function hasChroma(c, threshold = 30) { 93 + if (!c) return false; 94 + return (Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b)) >= threshold; 95 + } 96 + 97 + function getHue(c) { 98 + if (!c) return 0; 99 + const r = c.r / 255, g = c.g / 255, b = c.b / 255; 100 + const max = Math.max(r, g, b), min = Math.min(r, g, b); 101 + if (max === min) return 0; 102 + const d = max - min; 103 + let h; 104 + if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6; 105 + else if (max === g) h = ((b - r) / d + 2) / 6; 106 + else h = ((r - g) / d + 4) / 6; 107 + return Math.round(h * 360); 108 + } 109 + 110 + function colorToHex(c) { 111 + if (!c) return '?'; 112 + return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join(''); 113 + } 114 + 115 + export { 116 + isNeutralColor, 117 + parseRgb, 118 + relativeLuminance, 119 + contrastRatio, 120 + parseGradientColors, 121 + hasChroma, 122 + getHue, 123 + colorToHex, 124 + };
+101
tools/design-detector/detector/shared/constants.mjs
··· 1 + // ─── Section 1: Constants ─────────────────────────────────────────────────── 2 + 3 + const SAFE_TAGS = new Set([ 4 + 'blockquote', 'nav', 'a', 'input', 'textarea', 'select', 5 + 'pre', 'code', 'span', 'th', 'td', 'tr', 'li', 'label', 6 + 'button', 'hr', 'html', 'head', 'body', 'script', 'style', 7 + 'link', 'meta', 'title', 'br', 'img', 'svg', 'path', 'circle', 8 + 'rect', 'line', 'polyline', 'polygon', 'g', 'defs', 'use', 9 + ]); 10 + 11 + // Per-check safe-tags override for the border (side-tab / border-accent) 12 + // rule. We intentionally re-allow <label> here because card-shaped clickable 13 + // labels (e.g. .checklist-item wrapping a checkbox + content) are one of the 14 + // canonical side-tab anti-pattern shapes and must be detected. The rule's 15 + // other preconditions (non-neutral color, width >= 2px on a single side, 16 + // radius > 0 or width >= 3, element size >= 20x20 in the browser path) 17 + // already filter out plain inline form labels so this does not introduce 18 + // false positives. See modern-color-borders.html for the test matrix. 19 + const BORDER_SAFE_TAGS = new Set( 20 + [...SAFE_TAGS].filter(t => t !== 'label') 21 + ); 22 + 23 + const OVERUSED_FONTS = new Set([ 24 + // Older monoculture (still ubiquitous): 25 + 'inter', 'roboto', 'open sans', 'lato', 'montserrat', 'arial', 'helvetica', 26 + // Newer monoculture (the Anthropic-skill / Vercel / GitHub default wave): 27 + 'fraunces', 'instrument sans', 'instrument serif', 28 + 'geist', 'geist sans', 'geist mono', 29 + 'mona sans', 30 + 'plus jakarta sans', 'space grotesk', 'recoleta', 31 + ]); 32 + 33 + // Brand-associated fonts: don't flag these as "overused" on the brand's own domains. 34 + // Keys are font names, values are arrays of hostname suffixes where the font is allowed. 35 + const GOOGLE_DOMAINS = [ 36 + 'google.com', 'youtube.com', 'android.com', 'chromium.org', 37 + 'chrome.com', 'web.dev', 'gstatic.com', 'firebase.google.com', 38 + ]; 39 + const VERCEL_DOMAINS = ['vercel.com', 'nextjs.org', 'v0.app']; 40 + const GITHUB_DOMAINS = ['github.com', 'githubnext.com']; 41 + const BRAND_FONT_DOMAINS = { 42 + 'roboto': GOOGLE_DOMAINS, 43 + 'google sans': GOOGLE_DOMAINS, 44 + 'product sans': GOOGLE_DOMAINS, 45 + 'geist': VERCEL_DOMAINS, 46 + 'geist sans': VERCEL_DOMAINS, 47 + 'geist mono': VERCEL_DOMAINS, 48 + 'mona sans': GITHUB_DOMAINS, 49 + }; 50 + 51 + function isBrandFontOnOwnDomain(font) { 52 + if (typeof location === 'undefined') return false; 53 + const allowed = BRAND_FONT_DOMAINS[font]; 54 + if (!allowed) return false; 55 + const host = location.hostname.toLowerCase(); 56 + return allowed.some(suffix => host === suffix || host.endsWith('.' + suffix)); 57 + } 58 + 59 + const GENERIC_FONTS = new Set([ 60 + 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 61 + 'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded', 62 + '-apple-system', 'blinkmacsystemfont', 'segoe ui', 63 + 'inherit', 'initial', 'unset', 'revert', 64 + ]); 65 + 66 + // WCAG large text thresholds are defined in points: 18pt normal text and 67 + // 14pt bold text. Browsers expose font-size in CSS pixels at 96px per inch. 68 + const WCAG_LARGE_TEXT_PX = 18 * (96 / 72); 69 + const WCAG_LARGE_BOLD_TEXT_PX = 14 * (96 / 72); 70 + 71 + // Serif faces that show up in italic-display heroes. The rule also fires when 72 + // the primary face is unknown but the stack ends in the generic `serif` token, 73 + // which catches custom/private faces with a serif fallback. 74 + const KNOWN_SERIF_FONTS = new Set([ 75 + 'fraunces', 'recoleta', 'newsreader', 'playfair display', 'playfair', 76 + 'cormorant', 'cormorant garamond', 'garamond', 'eb garamond', 77 + 'tiempos', 'tiempos headline', 'tiempos text', 78 + 'lora', 'vollkorn', 'spectral', 79 + 'source serif pro', 'source serif 4', 'source serif', 80 + 'ibm plex serif', 'merriweather', 81 + 'libre caslon', 'libre baskerville', 'baskerville', 82 + 'georgia', 'times new roman', 'times', 83 + 'dm serif display', 'dm serif text', 84 + 'instrument serif', 'gt sectra', 'ogg', 'canela', 85 + 'freight display', 'freight text', 86 + ]); 87 + 88 + export { 89 + SAFE_TAGS, 90 + BORDER_SAFE_TAGS, 91 + OVERUSED_FONTS, 92 + GOOGLE_DOMAINS, 93 + VERCEL_DOMAINS, 94 + GITHUB_DOMAINS, 95 + BRAND_FONT_DOMAINS, 96 + isBrandFontOnOwnDomain, 97 + GENERIC_FONTS, 98 + WCAG_LARGE_TEXT_PX, 99 + WCAG_LARGE_BOLD_TEXT_PX, 100 + KNOWN_SERIF_FONTS, 101 + };
+148
tools/design-detector/detector/shared/inline-ignores.mjs
··· 1 + /** 2 + * Inline, in-file ignore directives — eslint-disable-style waivers that live at 3 + * the point they apply and travel with the artifact instead of (or alongside) 4 + * an ignore in `.impeccable/config.json`. 5 + * 6 + * A config ignore is the right default for repo-wide policy. This complements it 7 + * for the one case config can't cover: a waiver that belongs to a single file and 8 + * needs to follow that file when it leaves the repo — a generated/exported 9 + * standalone document, an emailed HTML file, a snippet scanned out of context. 10 + * 11 + * Comment-syntax-agnostic: the directive is a raw token matched anywhere on a 12 + * line, so the same marker works across every comment style impeccable scans — 13 + * `//`, `/* *\/`, `<!-- -->`, `#`, `{/* *\/}`, `{# #}`. Trailing comment closers 14 + * are stripped before the rule list is parsed. 15 + * 16 + * Syntax (reason optional; eslint `--` or biome `:` separator): 17 + * 18 + * impeccable-disable <rule>[, <rule>...] [-- reason] whole file 19 + * impeccable-disable-line <rule>... [-- reason] the same line 20 + * impeccable-disable-next-line <rule>... [-- reason] the following line 21 + * impeccable-disable bare / `*` = every rule 22 + * 23 + * Examples: 24 + * 25 + * <!-- impeccable-disable overused-font -- exported brand doc, font is first-party --> 26 + * .brand { font-family: Inter; } /* impeccable-disable-line overused-font *\/ 27 + * // impeccable-disable-next-line bounce-easing: intentional playful affordance 28 + * 29 + * Behavior is suppression, for parity with config ignores: a matched directive 30 + * drops the finding. The inline reason is self-documenting in the diff; it is not 31 + * required and is discarded at scan time (only used here to keep reason words out 32 + * of the parsed rule list). 33 + */ 34 + 35 + const DIRECTIVE_RE = /impeccable-(disable-next-line|disable-line|disable)\b[ \t]*([^\n\r]*)/gi; 36 + 37 + // Trailing comment closers, so `*/`, `*/}`, `-->`, `*}`, `#}`, `%>`, `}}` don't 38 + // leak into the rule list. Anchored to end-of-line; the leading `\s*` mops up the 39 + // space before the closer. `--+>` covers `-->` and any longer dash run. 40 + const TRAILING_CLOSER_RE = /\s*(?:\*\/\}?|--+>|\*\}|#\}|%>|\}\})\s*$/; 41 + 42 + function normalizeRule(token) { 43 + return String(token || '').trim().toLowerCase(); 44 + } 45 + 46 + // Split the directive remainder into rule tokens, dropping any human reason that 47 + // follows an eslint-style `--` or biome-style `:` separator. Rule ids only ever 48 + // contain single hyphens (`overused-font`, `bounce-easing`), so `--` and `:` 49 + // are unambiguous separators. 50 + function parseRuleList(remainder) { 51 + let text = String(remainder || '').replace(TRAILING_CLOSER_RE, '').trim(); 52 + // Cut off a human reason at the first `--` (eslint) or `:` (biome) separator. 53 + const reasonSep = text.match(/\s*(?:--+|:)\s*/); 54 + if (reasonSep) text = text.slice(0, reasonSep.index); 55 + const tokens = text.split(/[\s,]+/).map(normalizeRule).filter(Boolean); 56 + if (tokens.length === 0 || tokens.includes('*')) return ['*']; 57 + return tokens; 58 + } 59 + 60 + function addRules(set, rules) { 61 + for (const rule of rules) set.add(rule); 62 + } 63 + 64 + function getSet(map, key) { 65 + let set = map.get(key); 66 + if (!set) { 67 + set = new Set(); 68 + map.set(key, set); 69 + } 70 + return set; 71 + } 72 + 73 + /** 74 + * Parse every inline ignore directive in a file's raw text. 75 + * 76 + * Returns sets keyed by the 1-based line the directive *targets* so matching is a 77 + * direct lookup: 78 + * - file: rules disabled for the whole file 79 + * - line: line -> rules disabled on that exact line (disable-line) 80 + * - nextLine: line -> rules disabled on that line (disable-next-line on line-1) 81 + * 82 + * `*` in any set means "every rule". 83 + */ 84 + function parseInlineIgnores(content) { 85 + const result = { file: new Set(), line: new Map(), nextLine: new Map() }; 86 + const text = typeof content === 'string' ? content : ''; 87 + // Cheap bail-out: the substring must be present for any directive to exist. 88 + // Case-insensitive to match DIRECTIVE_RE's `i` flag (e.g. `Impeccable-Disable`). 89 + if (!/impeccable-disable/i.test(text)) return result; 90 + 91 + // Split on `\n` only, exactly as detectText numbers lines, so directive line 92 + // keys line up with finding `line` values (incl. on `\r`-only line endings). 93 + // The directive regex excludes `\r`, so a trailing `\r` on `\r\n` files is 94 + // never captured into the rule list. 95 + const lines = text.split('\n'); 96 + for (let i = 0; i < lines.length; i++) { 97 + DIRECTIVE_RE.lastIndex = 0; 98 + let m; 99 + while ((m = DIRECTIVE_RE.exec(lines[i])) !== null) { 100 + const variant = m[1].toLowerCase(); 101 + const rules = parseRuleList(m[2]); 102 + if (variant === 'disable') { 103 + addRules(result.file, rules); 104 + } else if (variant === 'disable-line') { 105 + addRules(getSet(result.line, i + 1), rules); 106 + } else { 107 + // disable-next-line on line i+1 targets line i+2. 108 + addRules(getSet(result.nextLine, i + 2), rules); 109 + } 110 + } 111 + } 112 + return result; 113 + } 114 + 115 + function setMatches(set, rule) { 116 + return Boolean(set) && (set.has('*') || set.has(rule)); 117 + } 118 + 119 + function isInlineIgnored(finding, directives) { 120 + const rule = normalizeRule(finding && finding.antipattern); 121 + if (!rule) return false; 122 + if (setMatches(directives.file, rule)) return true; 123 + const line = Number(finding && finding.line) || 0; 124 + if (line > 0) { 125 + if (setMatches(directives.line.get(line), rule)) return true; 126 + if (setMatches(directives.nextLine.get(line), rule)) return true; 127 + } 128 + return false; 129 + } 130 + 131 + function hasDirectives(directives) { 132 + return directives.file.size > 0 || directives.line.size > 0 || directives.nextLine.size > 0; 133 + } 134 + 135 + /** 136 + * Drop findings waived by an inline directive in the same file's source text. 137 + * Findings without a usable line number (e.g. static-HTML page-level findings) 138 + * are only matched by whole-file directives — which is the standalone-document 139 + * case this primitive exists for. 140 + */ 141 + function applyInlineIgnores(findings, content) { 142 + if (!Array.isArray(findings) || findings.length === 0) return findings; 143 + const directives = parseInlineIgnores(content); 144 + if (!hasDirectives(directives)) return findings; 145 + return findings.filter((finding) => !isInlineIgnored(finding, directives)); 146 + } 147 + 148 + export { parseInlineIgnores, applyInlineIgnores, isInlineIgnored };
+7
tools/design-detector/detector/shared/page.mjs
··· 1 + /** Check if content looks like a full page (not a component/partial) */ 2 + function isFullPage(content) { 3 + const stripped = content.replace(/<!--[\s\S]*?-->/g, ''); 4 + return /<!doctype\s|<html[\s>]|<head[\s>]/i.test(stripped); 5 + } 6 + 7 + export { isFullPage };
+842
tools/design-detector/lib/design-parser.mjs
··· 1 + // Parse a DESIGN.md (Stitch-spec format) into a structured JSON model that 2 + // the live-mode design-system panel can render. Deterministic, dependency-free. 3 + // 4 + // Two-layer: YAML frontmatter (machine-readable tokens) + markdown body 5 + // (prose with six canonical H2 sections). When frontmatter is present, it's 6 + // exposed on `model.frontmatter` alongside the prose-scraped sections; 7 + // consumers can prefer frontmatter values and fall back to prose. 8 + 9 + const CANONICAL_SECTIONS = [ 10 + 'Overview', 11 + 'Colors', 12 + 'Typography', 13 + 'Elevation', 14 + 'Components', 15 + "Do's and Don'ts", 16 + ]; 17 + 18 + // ---------- Frontmatter (Stitch YAML subset) ---------- 19 + 20 + function parseFrontmatter(md) { 21 + const lines = md.split(/\r?\n/); 22 + if (lines[0]?.trim() !== '---') return { frontmatter: null, body: md }; 23 + 24 + let end = -1; 25 + for (let i = 1; i < lines.length; i++) { 26 + if (lines[i].trim() === '---') { end = i; break; } 27 + } 28 + if (end === -1) return { frontmatter: null, body: md }; 29 + 30 + const yaml = lines.slice(1, end).join('\n'); 31 + const body = lines.slice(end + 1).join('\n'); 32 + try { 33 + return { frontmatter: parseYamlSubset(yaml), body }; 34 + } catch { 35 + return { frontmatter: null, body: md }; 36 + } 37 + } 38 + 39 + // Minimal YAML reader for the Stitch frontmatter subset: scalar maps with 40 + // one level of nested objects (typography roles, components). Indent-based, 41 + // 2-space convention. No arrays, no anchors, no multi-line scalars — Stitch's 42 + // schema doesn't need them and accepting them would require a real YAML 43 + // dependency we don't want to vendor. 44 + function parseYamlSubset(yaml) { 45 + const lines = yaml.split(/\r?\n/); 46 + const root = {}; 47 + const stack = [{ indent: -1, obj: root }]; 48 + 49 + for (const raw of lines) { 50 + // Skip blanks and line-only comments. Don't strip inline comments: 51 + // unquoted hex values start with `#` and can't be safely distinguished 52 + // from a comment after whitespace. 53 + if (!raw.trim() || /^\s*#/.test(raw)) continue; 54 + 55 + const indent = raw.match(/^\s*/)[0].length; 56 + const content = raw.slice(indent); 57 + 58 + const colonIdx = findTopLevelColon(content); 59 + if (colonIdx === -1) continue; 60 + 61 + while (stack.length > 1 && stack[stack.length - 1].indent >= indent) { 62 + stack.pop(); 63 + } 64 + 65 + const key = unquoteYamlKey(content.slice(0, colonIdx).trim()); 66 + const rest = stripInlineYamlComment(content.slice(colonIdx + 1).trim()); 67 + const parent = stack[stack.length - 1].obj; 68 + 69 + if (rest === '') { 70 + const obj = {}; 71 + parent[key] = obj; 72 + stack.push({ indent, obj }); 73 + } else { 74 + parent[key] = parseScalar(rest); 75 + } 76 + } 77 + 78 + return root; 79 + } 80 + 81 + function findTopLevelColon(s) { 82 + let inQuote = null; 83 + for (let i = 0; i < s.length; i++) { 84 + const ch = s[i]; 85 + if (inQuote) { 86 + if (ch === inQuote && s[i - 1] !== '\\') inQuote = null; 87 + } else if (ch === '"' || ch === "'") { 88 + inQuote = ch; 89 + } else if (ch === ':') { 90 + return i; 91 + } 92 + } 93 + return -1; 94 + } 95 + 96 + function unquoteYamlKey(key) { 97 + if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) { 98 + return key.slice(1, -1); 99 + } 100 + return key; 101 + } 102 + 103 + function stripInlineYamlComment(s) { 104 + let inQuote = null; 105 + for (let i = 0; i < s.length; i++) { 106 + const ch = s[i]; 107 + if (inQuote) { 108 + if (ch === inQuote && s[i - 1] !== '\\') inQuote = null; 109 + } else if (ch === '"' || ch === "'") { 110 + inQuote = ch; 111 + } else if (ch === '#' && i > 0 && /\s/.test(s[i - 1])) { 112 + return s.slice(0, i).trimEnd(); 113 + } 114 + } 115 + return s; 116 + } 117 + 118 + function parseScalar(raw) { 119 + const s = raw.trim(); 120 + if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) { 121 + return s.slice(1, -1); 122 + } 123 + if (s === 'true') return true; 124 + if (s === 'false') return false; 125 + if (s === 'null' || s === '~') return null; 126 + if (/^-?\d+$/.test(s)) return Number(s); 127 + if (/^-?\d*\.\d+$/.test(s)) return Number(s); 128 + return s; 129 + } 130 + 131 + const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g; 132 + const OKLCH_RE = /oklch\([^)]+\)/gi; 133 + const RGBA_RE = /rgba?\([^)]+\)/gi; 134 + const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/; 135 + const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/; 136 + 137 + // ---------- Section splitting ---------- 138 + 139 + function splitSections(md) { 140 + const lines = md.split(/\r?\n/); 141 + let title = null; 142 + const sections = {}; 143 + let current = null; 144 + 145 + for (const raw of lines) { 146 + const line = raw.trimEnd(); 147 + 148 + if (!title && line.startsWith('# ') && !line.startsWith('## ')) { 149 + title = line.replace(/^#\s+/, '').trim(); 150 + continue; 151 + } 152 + 153 + const h2 = line.match(/^##\s+(?:\d+\.\s*)?([^:\n]+?)(?::\s*(.+))?$/); 154 + if (h2) { 155 + const rawName = normalizeApostrophes(h2[1].trim()); 156 + const subtitle = h2[2] ? h2[2].trim() : null; 157 + const canonical = matchCanonicalSection(rawName); 158 + if (canonical) { 159 + current = { name: canonical, subtitle, lines: [] }; 160 + sections[canonical] = current; 161 + continue; 162 + } 163 + // non-canonical H2 — ignore but stop feeding into current 164 + current = null; 165 + continue; 166 + } 167 + 168 + if (current) current.lines.push(raw); 169 + } 170 + 171 + return { title, sections }; 172 + } 173 + 174 + function normalizeApostrophes(s) { 175 + return s.replace(/[\u2018\u2019]/g, "'"); 176 + } 177 + 178 + function matchCanonicalSection(name) { 179 + const normalized = normalizeApostrophes(name).toLowerCase(); 180 + // Exact match first 181 + for (const c of CANONICAL_SECTIONS) { 182 + if (normalizeApostrophes(c).toLowerCase() === normalized) return c; 183 + } 184 + // Keyword-contained match: "Overview & Creative North Star" -> "Overview", 185 + // "Elevation & Depth" -> "Elevation", etc. 186 + for (const c of CANONICAL_SECTIONS) { 187 + const key = normalizeApostrophes(c).toLowerCase(); 188 + const pattern = new RegExp(`\\b${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`); 189 + if (pattern.test(normalized)) return c; 190 + } 191 + return null; 192 + } 193 + 194 + // ---------- Subsection splitting (inside a canonical section) ---------- 195 + 196 + function splitSubsections(lines) { 197 + const subs = []; 198 + let current = { name: null, lines: [] }; 199 + subs.push(current); 200 + 201 + for (const raw of lines) { 202 + const h3 = raw.match(/^###\s+(.+?)\s*$/); 203 + if (h3) { 204 + current = { name: h3[1].trim(), lines: [] }; 205 + subs.push(current); 206 + continue; 207 + } 208 + current.lines.push(raw); 209 + } 210 + 211 + return subs; 212 + } 213 + 214 + // ---------- Generic helpers ---------- 215 + 216 + function collectParagraphs(lines) { 217 + const paragraphs = []; 218 + let buf = []; 219 + const flush = () => { 220 + if (buf.length) { 221 + paragraphs.push(buf.join(' ').trim()); 222 + buf = []; 223 + } 224 + }; 225 + for (const raw of lines) { 226 + const trimmed = raw.trim(); 227 + if (trimmed === '') { flush(); continue; } 228 + // Horizontal rules (---, ***) and headings/bullets end a paragraph. 229 + if (/^(?:-{3,}|\*{3,}|_{3,})$/.test(trimmed)) { flush(); continue; } 230 + if (raw.startsWith('#') || raw.match(/^[-*]\s/)) { flush(); continue; } 231 + buf.push(trimmed); 232 + } 233 + flush(); 234 + return paragraphs.filter(Boolean); 235 + } 236 + 237 + function collectBullets(lines) { 238 + const bullets = []; 239 + let current = null; 240 + for (const raw of lines) { 241 + const m = raw.match(/^\s*[-*]\s+(.+)$/); 242 + if (m) { 243 + if (current) bullets.push(current); 244 + current = m[1]; 245 + continue; 246 + } 247 + // continuation of a bullet (indented line) 248 + if (current && raw.match(/^\s{2,}\S/)) { 249 + current += ' ' + raw.trim(); 250 + continue; 251 + } 252 + // blank line ends a bullet 253 + if (raw.trim() === '' && current) { 254 + bullets.push(current); 255 + current = null; 256 + } 257 + } 258 + if (current) bullets.push(current); 259 + return bullets; 260 + } 261 + 262 + function stripBold(s) { 263 + return s.replace(/\*\*(.+?)\*\*/g, '$1'); 264 + } 265 + 266 + function extractNamedRules(lines) { 267 + const rules = []; 268 + const seen = new Set(); 269 + 270 + // Style A (Impeccable): "**The X Rule.** body body body" — can span lines. 271 + const joined = lines.join('\n'); 272 + const inlineStart = /\*\*(The [^*]+?Rule)\.\*\*/g; 273 + const inlineMatches = []; 274 + let m; 275 + while ((m = inlineStart.exec(joined)) !== null) { 276 + inlineMatches.push({ name: m[1], start: m.index, end: inlineStart.lastIndex }); 277 + } 278 + for (let i = 0; i < inlineMatches.length; i++) { 279 + const mm = inlineMatches[i]; 280 + const bodyEnd = i + 1 < inlineMatches.length ? inlineMatches[i + 1].start : joined.length; 281 + const body = joined 282 + .slice(mm.end, bodyEnd) 283 + .replace(/\n##[^\n]*$/s, '') 284 + .replace(/\n###[^\n]*$/s, '') 285 + .trim(); 286 + const name = stripBold(mm.name).trim(); 287 + seen.add(name.toLowerCase()); 288 + rules.push({ name, body: stripBold(body) }); 289 + } 290 + 291 + // Style B (Stitch): `### The "X" Rule` or `### The X Fallback`, body is the 292 + // bullets/paragraphs until the next heading. Accept Rule / Fallback / Principle. 293 + for (let i = 0; i < lines.length; i++) { 294 + const h3 = lines[i].match(/^###\s+(.+?)\s*$/); 295 + if (!h3) continue; 296 + const headerName = stripBold(h3[1]).replace(/["“”]/g, '').trim(); 297 + if (!/^The\b.*\b(Rule|Fallback|Principle)\b/i.test(headerName)) continue; 298 + if (seen.has(headerName.toLowerCase())) continue; 299 + 300 + const bodyLines = []; 301 + for (let j = i + 1; j < lines.length; j++) { 302 + if (/^##\s|^###\s/.test(lines[j])) break; 303 + bodyLines.push(lines[j]); 304 + } 305 + const body = stripBold(bodyLines.join('\n').replace(/\n+/g, ' ')).trim(); 306 + if (body) { 307 + seen.add(headerName.toLowerCase()); 308 + rules.push({ name: headerName, body }); 309 + } 310 + } 311 + 312 + // Style C (Stitch bullet form): "* **The Layering Principle:** body" 313 + // Colon/period lives inside the bold, so match "**...**" then inspect. 314 + for (const b of collectBullets(lines)) { 315 + const mm = b.match(/^\*\*([^*]+?)\*\*\s*(.+)$/); 316 + if (!mm) continue; 317 + const nameRaw = mm[1].replace(/[.:]\s*$/, '').replace(/["“”]/g, '').trim(); 318 + if (!/^The\b.+\b(Rule|Fallback|Principle)$/i.test(nameRaw)) continue; 319 + if (seen.has(nameRaw.toLowerCase())) continue; 320 + seen.add(nameRaw.toLowerCase()); 321 + rules.push({ name: nameRaw, body: stripBold(mm[2]).trim() }); 322 + } 323 + 324 + return rules; 325 + } 326 + 327 + // ---------- Per-section extractors ---------- 328 + 329 + function extractOverview(section) { 330 + if (!section) return null; 331 + const text = section.lines.join('\n'); 332 + const northStar = text.match(/\*\*Creative North Star:\s*"([^"]+)"\*\*/); 333 + const keyChars = []; 334 + const keyCharMatch = text.match(/\*\*Key Characteristics:\*\*\s*\n([\s\S]+?)(?:\n##|\n###|$)/); 335 + if (keyCharMatch) { 336 + for (const line of keyCharMatch[1].split('\n')) { 337 + const m = line.match(/^\s*[-*]\s+(.+)$/); 338 + if (m) keyChars.push(stripBold(m[1].trim())); 339 + } 340 + } 341 + 342 + // Philosophy paragraphs: everything that isn't a rule header or key-char block 343 + const paragraphs = collectParagraphs(section.lines).filter( 344 + (p) => 345 + !p.startsWith('**Creative North Star') && 346 + !p.startsWith('**Key Characteristics') 347 + ); 348 + 349 + return { 350 + subtitle: section.subtitle, 351 + creativeNorthStar: northStar ? northStar[1] : null, 352 + philosophy: paragraphs, 353 + keyCharacteristics: keyChars, 354 + }; 355 + } 356 + 357 + function extractColors(section) { 358 + if (!section) return null; 359 + const subs = splitSubsections(section.lines); 360 + 361 + const description = collectParagraphs(subs[0].lines).join(' '); 362 + const groups = []; 363 + const ROLE_KEYWORDS = /^(primary|secondary|tertiary|neutral|accent)\b/i; 364 + 365 + for (const sub of subs.slice(1)) { 366 + if (!sub.name || /Named Rules?/i.test(sub.name) || /^The\s/i.test(sub.name)) continue; 367 + 368 + const bullets = collectBullets(sub.lines); 369 + const parsed = bullets.map((b) => parseColorBullet(b)).filter(Boolean); 370 + if (parsed.length === 0) continue; 371 + 372 + // If every bullet starts with a role keyword (Primary/Secondary/...), promote 373 + // each bullet to its own group. Otherwise keep the subsection as the group. 374 + const allRoleBullets = 375 + parsed.length > 0 && parsed.every((p) => p.name && ROLE_KEYWORDS.test(p.name)); 376 + 377 + if (allRoleBullets) { 378 + for (const p of parsed) { 379 + groups.push({ role: p.name, colors: [p] }); 380 + } 381 + } else { 382 + groups.push({ role: sub.name, colors: parsed }); 383 + } 384 + } 385 + 386 + // If the Colors section has no subsections at all (unlikely), fall back to 387 + // scanning the whole section as a flat bullet list. 388 + if (groups.length === 0) { 389 + const flat = collectBullets(section.lines) 390 + .map((b) => parseColorBullet(b)) 391 + .filter(Boolean); 392 + if (flat.length) { 393 + for (const p of flat) { 394 + if (p.name && ROLE_KEYWORDS.test(p.name)) { 395 + groups.push({ role: p.name, colors: [p] }); 396 + } else { 397 + const fallback = groups.find((g) => g.role === 'Palette'); 398 + if (fallback) fallback.colors.push(p); 399 + else groups.push({ role: 'Palette', colors: [p] }); 400 + } 401 + } 402 + } 403 + } 404 + 405 + return { 406 + subtitle: section.subtitle, 407 + description: description || null, 408 + groups, 409 + rules: extractNamedRules(section.lines), 410 + }; 411 + } 412 + 413 + function parseColorBullet(bullet) { 414 + const text = bullet.trim(); 415 + 416 + // Case 1 (Impeccable): **Name** (value-with-maybe-nested-parens): description 417 + const bold = text.match(/^\*\*(.+?)\*\*\s*(.*)$/); 418 + if (bold && bold[2].startsWith('(')) { 419 + const value = extractParenGroup(bold[2]); 420 + if (value !== null) { 421 + const after = bold[2].slice(value.length + 2).trimStart(); 422 + if (after.startsWith(':')) { 423 + return buildColor(bold[1], value, after.slice(1).trim()); 424 + } 425 + } 426 + } 427 + 428 + // Case 2 (Stitch): **Name (values):** description — value embedded in bold. 429 + const stitch = text.match(/^\*\*([^*]+?)\s*\(([^)]+)\):\*\*\s*(.*)$/); 430 + if (stitch) { 431 + return buildColor(stitch[1].trim(), stitch[2], stitch[3]); 432 + } 433 + 434 + // Case 3: bullet without bold, just hex/oklch inside. 435 + const values = collectColorValues(text); 436 + if (values.length) { 437 + return buildColor(null, values.join(' to '), text); 438 + } 439 + return null; 440 + } 441 + 442 + function extractParenGroup(s) { 443 + if (s[0] !== '(') return null; 444 + let depth = 0; 445 + for (let i = 0; i < s.length; i++) { 446 + if (s[i] === '(') depth++; 447 + else if (s[i] === ')') { 448 + depth--; 449 + if (depth === 0) return s.slice(1, i); 450 + } 451 + } 452 + return null; 453 + } 454 + 455 + function buildColor(name, rawValue, description) { 456 + const values = collectColorValues(rawValue); 457 + const primary = values[0] ?? rawValue.trim(); 458 + return { 459 + name: name ? stripBold(name).trim() : null, 460 + value: primary, 461 + valueRange: values.length > 1 ? values : null, 462 + format: detectFormat(primary), 463 + description: stripBold(description || '').trim() || null, 464 + }; 465 + } 466 + 467 + function collectColorValues(s) { 468 + const out = []; 469 + s.replace(HEX_RE, (v) => { 470 + out.push(v); 471 + return v; 472 + }); 473 + s.replace(OKLCH_RE, (v) => { 474 + out.push(v); 475 + return v; 476 + }); 477 + return out; 478 + } 479 + 480 + function detectFormat(v) { 481 + if (!v) return 'unknown'; 482 + if (v.startsWith('#')) return 'hex'; 483 + if (/^oklch/i.test(v)) return 'oklch'; 484 + if (/^rgb/i.test(v)) return 'rgb'; 485 + return 'unknown'; 486 + } 487 + 488 + function scanInlineColors(lines) { 489 + const out = []; 490 + for (const line of lines) { 491 + if (!/^\s*[-*]\s/.test(line)) continue; 492 + const trimmed = line.replace(/^\s*[-*]\s+/, ''); 493 + const color = parseColorBullet(trimmed); 494 + if (color) out.push(color); 495 + } 496 + return out; 497 + } 498 + 499 + function parseStitchInlineGroups(lines) { 500 + // Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."` 501 + // Each bullet IS its own role. Group them under the spoken role name. 502 + const out = []; 503 + for (const line of lines) { 504 + if (!/^\s*[-*]\s/.test(line)) continue; 505 + const trimmed = line.replace(/^\s*[-*]\s+/, '').trim(); 506 + const m = trimmed.match( 507 + /^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/ 508 + ); 509 + if (m) { 510 + const role = m[1]; 511 + const color = buildColor(role, m[2], m[3]); 512 + out.push({ role, colors: [color] }); 513 + } 514 + } 515 + return out; 516 + } 517 + 518 + function extractTypography(section) { 519 + if (!section) return null; 520 + const text = section.lines.join('\n'); 521 + 522 + const fonts = {}; 523 + // Pattern A: **Display Font:** Family (with fallback) 524 + const fontLineRe = /\*\*([\w\s/]+?)Font:\*\*\s*([^\n(]+?)(?:\s*\(with\s+([^)]+)\))?\s*$/gm; 525 + let fm; 526 + while ((fm = fontLineRe.exec(text)) !== null) { 527 + const rawRole = fm[1].trim().toLowerCase().replace(/\s+/g, '-'); 528 + const role = normalizeFontRole(rawRole) || 'display'; 529 + fonts[role] = { 530 + family: fm[2].trim(), 531 + fallback: fm[3] ? fm[3].trim() : null, 532 + }; 533 + } 534 + 535 + // Pattern B (Stitch): * **Display & Headlines (Noto Serif):** description 536 + if (Object.keys(fonts).length === 0) { 537 + const stitchRe = /\*\*([\w\s&/]+?)\s*\(([^)]+)\):\*\*\s*(.+)/g; 538 + let sm; 539 + while ((sm = stitchRe.exec(text)) !== null) { 540 + const rawRole = sm[1] 541 + .trim() 542 + .toLowerCase() 543 + .replace(/\s*&\s*/g, '-') 544 + .replace(/\s+/g, '-'); 545 + const role = normalizeFontRole(rawRole) || rawRole; 546 + fonts[role] = { family: sm[2].trim(), fallback: null, purpose: sm[3].trim() }; 547 + } 548 + } 549 + 550 + // Character paragraph — either a **Character:** label, or fall back to the 551 + // first free paragraph under the section header (Stitch style). 552 + const characterMatch = text.match(/\*\*Character:\*\*\s*([^\n]+(?:\n[^\n]+)*?)(?=\n\n|\n###|\n##|$)/); 553 + let character = characterMatch ? characterMatch[1].replace(/\n/g, ' ').trim() : null; 554 + if (!character) { 555 + const paragraphs = collectParagraphs(section.lines).filter( 556 + (p) => !/^\*\*[\w\s/&]+Font/i.test(p) && !/^\*\*[\w\s/&]+\([^)]+\)/.test(p) 557 + ); 558 + if (paragraphs.length) character = paragraphs[0]; 559 + } 560 + 561 + // Hierarchy bullets under ### Hierarchy 562 + const subs = splitSubsections(section.lines); 563 + let hierarchy = []; 564 + const hierSub = subs.find((s) => s.name && /hierarch/i.test(s.name)); 565 + if (hierSub) { 566 + const bullets = collectBullets(hierSub.lines); 567 + hierarchy = bullets.map(parseTypeBullet).filter(Boolean); 568 + } 569 + 570 + return { 571 + subtitle: section.subtitle, 572 + fonts, 573 + character, 574 + hierarchy, 575 + rules: extractNamedRules(section.lines), 576 + }; 577 + } 578 + 579 + function normalizeFontRole(raw) { 580 + // Canonical roles the panel cares about: display, body, label, mono. 581 + // Stitch often writes compound roles like "display-&-headlines" or "ui-&-body" 582 + // — collapse them to the first canonical role present. 583 + const tokens = raw.split(/[-/&\s]+/).filter(Boolean); 584 + const priority = ['display', 'headline', 'body', 'ui', 'label', 'mono']; 585 + const canonical = { headline: 'display', ui: 'body' }; 586 + for (const p of priority) { 587 + if (tokens.includes(p)) return canonical[p] || p; 588 + } 589 + return null; 590 + } 591 + 592 + function parseTypeBullet(bullet) { 593 + // - **Display** (family, weight 300, italic, clamp(...), line-height 1): purpose 594 + const m = bullet.match(/^\*\*(.+?)\*\*\s*\(([^)]+)\):\s*(.*)$/); 595 + if (!m) return null; 596 + const name = m[1].trim(); 597 + const specs = m[2].split(',').map((s) => s.trim()); 598 + return { 599 + name, 600 + specs, 601 + purpose: stripBold(m[3] || '').trim() || null, 602 + }; 603 + } 604 + 605 + function extractElevation(section) { 606 + if (!section) return null; 607 + const subs = splitSubsections(section.lines); 608 + 609 + const description = collectParagraphs(subs[0].lines).join(' ') || null; 610 + 611 + const shadows = []; 612 + const seen = new Set(); 613 + const dedupe = (entry) => { 614 + const key = (entry.name || '') + '::' + entry.value; 615 + if (seen.has(key)) return; 616 + seen.add(key); 617 + shadows.push(entry); 618 + }; 619 + 620 + for (const b of collectBullets(section.lines)) { 621 + const parsed = parseShadowBullet(b); 622 + if (parsed) dedupe(parsed); 623 + } 624 + 625 + // Fallback: extract shadows written inline in prose. Stitch style is 626 + // "...use an extra-diffused shadow: `box-shadow: 0 12px 40px rgba(...)`." 627 + for (const p of collectParagraphs(section.lines)) { 628 + for (const inline of extractInlineShadows(p)) dedupe(inline); 629 + } 630 + for (const b of collectBullets(section.lines)) { 631 + for (const inline of extractInlineShadows(b)) dedupe(inline); 632 + } 633 + 634 + return { 635 + subtitle: section.subtitle, 636 + description, 637 + shadows, 638 + rules: extractNamedRules(section.lines), 639 + }; 640 + } 641 + 642 + function extractInlineShadows(text) { 643 + // Find `box-shadow: ...` anywhere in prose and capture the value. Work on the 644 + // raw string so it handles both backtick-fenced and unfenced variants. 645 + const out = []; 646 + const re = /box-shadow\s*:\s*([^`;\n]+)/gi; 647 + let m; 648 + while ((m = re.exec(text)) !== null) { 649 + const value = m[1].replace(/[`.)]+$/, '').trim(); 650 + if (!value) continue; 651 + // Name heuristic: the noun immediately before the shadow phrase. 652 + // e.g. "an extra-diffused shadow: ..." -> "extra-diffused shadow" 653 + const before = text.slice(0, m.index); 654 + const nameMatch = before.match(/\b([A-Za-z][A-Za-z\- ]{2,40})\s+shadow\b[^A-Za-z0-9]*$/i); 655 + let name = null; 656 + if (nameMatch) { 657 + const stripped = nameMatch[1] 658 + .replace(/^(?:use|using|apply|applying|is|are|looks? like)\s+/i, '') 659 + .replace(/^(?:a|an|the)\s+/i, '') 660 + .trim(); 661 + if (stripped) { 662 + name = 663 + stripped.charAt(0).toUpperCase() + stripped.slice(1) + ' shadow'; 664 + } 665 + } 666 + out.push({ 667 + name, 668 + value, 669 + purpose: null, 670 + }); 671 + } 672 + return out; 673 + } 674 + 675 + function parseShadowBullet(bullet) { 676 + // - **Name** (`box-shadow: value`): purpose 677 + // - **Name** (`value`): purpose 678 + // Only accept if the paren content looks like a shadow value (contains px, 679 + // rem, rgba, or box-shadow). This filters out `**Rule Name:**` bullets. 680 + const m = bullet.match(/^\*\*(.+?)\*\*\s*\(`?([^`]+?)`?\):\s*(.*)$/); 681 + if (!m) return null; 682 + const rawValue = m[2].replace(/^box-shadow:\s*/i, '').trim(); 683 + const looksLikeShadow = 684 + /box-shadow|rgba?\(|\bpx\b|\brem\b|^-?\d+\s/i.test(rawValue) && 685 + /\d/.test(rawValue); 686 + if (!looksLikeShadow) return null; 687 + const name = stripBold(m[1]).trim(); 688 + return { 689 + name, 690 + value: rawValue, 691 + purpose: stripBold(m[3] || '').trim() || null, 692 + }; 693 + } 694 + 695 + function extractComponents(section) { 696 + if (!section) return null; 697 + const subs = splitSubsections(section.lines); 698 + const components = []; 699 + 700 + for (const sub of subs.slice(1)) { 701 + if (!sub.name) continue; 702 + 703 + const bullets = collectBullets(sub.lines); 704 + const paragraphs = collectParagraphs(sub.lines); 705 + 706 + const variants = []; 707 + const properties = {}; 708 + 709 + for (const b of bullets) { 710 + // - **Key:** value 711 + const m = b.match(/^\*\*(.+?):?\*\*:?\s*(.+)$/); 712 + if (m) { 713 + const key = stripBold(m[1]).trim(); 714 + const value = stripBold(m[2]).trim(); 715 + // Heuristic: "Primary", "Secondary", "Hover", "Focus" etc are variants; 716 + // "Shape", "Background", "Padding" are properties. 717 + if (/^(primary|secondary|tertiary|ghost|hover|focus|active|disabled|default|error|selected|unselected|state)$/i.test(key.split(/[\s/]/)[0])) { 718 + variants.push({ name: key, description: value }); 719 + } else { 720 + properties[key.toLowerCase()] = value; 721 + } 722 + } 723 + } 724 + 725 + components.push({ 726 + name: sub.name, 727 + description: paragraphs.join(' ') || null, 728 + properties, 729 + variants, 730 + }); 731 + } 732 + 733 + return { 734 + subtitle: section.subtitle, 735 + components, 736 + }; 737 + } 738 + 739 + function extractDosDonts(section) { 740 + if (!section) return null; 741 + const subs = splitSubsections(section.lines); 742 + const dos = []; 743 + const donts = []; 744 + 745 + for (const sub of subs.slice(1)) { 746 + if (!sub.name) continue; 747 + const subName = normalizeApostrophes(sub.name); 748 + const bullets = collectBullets(sub.lines).map((b) => stripBold(b).trim()); 749 + if (/^do'?t?:?$/i.test(subName) || /^do:?$/i.test(subName)) { 750 + dos.push(...bullets); 751 + } else if (/^don'?t:?$/i.test(subName)) { 752 + donts.push(...bullets); 753 + } 754 + } 755 + 756 + // Classify by bullet prefix as a backup (catches loose bullets outside H3 wrappers) 757 + for (const b of collectBullets(section.lines)) { 758 + const stripped = normalizeApostrophes(stripBold(b).trim()); 759 + if (/^don'?t\b/i.test(stripped)) { 760 + if (!donts.some((d) => normalizeApostrophes(d) === stripped)) donts.push(stripped); 761 + } else if (/^do\b/i.test(stripped)) { 762 + if (!dos.some((d) => normalizeApostrophes(d) === stripped)) dos.push(stripped); 763 + } 764 + } 765 + 766 + return { dos, donts }; 767 + } 768 + 769 + // ---------- Coverage assessment ---------- 770 + 771 + function assessCoverage(model) { 772 + const report = {}; 773 + 774 + report.overview = model.overview 775 + ? { 776 + northStar: Boolean(model.overview.creativeNorthStar), 777 + philosophy: model.overview.philosophy.length > 0, 778 + keyCharacteristics: model.overview.keyCharacteristics.length, 779 + } 780 + : 'missing'; 781 + 782 + report.colors = model.colors 783 + ? { 784 + groups: model.colors.groups.length, 785 + totalColors: model.colors.groups.reduce((n, g) => n + g.colors.length, 0), 786 + rules: model.colors.rules.length, 787 + } 788 + : 'missing'; 789 + 790 + report.typography = model.typography 791 + ? { 792 + fonts: Object.keys(model.typography.fonts).length, 793 + hierarchyEntries: model.typography.hierarchy.length, 794 + character: Boolean(model.typography.character), 795 + rules: model.typography.rules.length, 796 + } 797 + : 'missing'; 798 + 799 + report.elevation = model.elevation 800 + ? { 801 + shadows: model.elevation.shadows.length, 802 + rules: model.elevation.rules.length, 803 + description: Boolean(model.elevation.description), 804 + } 805 + : 'missing'; 806 + 807 + report.components = model.components 808 + ? { 809 + count: model.components.components.length, 810 + variantTotal: model.components.components.reduce((n, c) => n + c.variants.length, 0), 811 + } 812 + : 'missing'; 813 + 814 + report.dosDonts = model.dosDonts 815 + ? { 816 + dos: model.dosDonts.dos.length, 817 + donts: model.dosDonts.donts.length, 818 + } 819 + : 'missing'; 820 + 821 + return report; 822 + } 823 + 824 + // ---------- Main ---------- 825 + 826 + export function parseDesignMd(md) { 827 + const { frontmatter, body } = parseFrontmatter(md); 828 + const { title, sections } = splitSections(body); 829 + return { 830 + schemaVersion: 2, 831 + title, 832 + frontmatter, 833 + overview: extractOverview(sections['Overview']), 834 + colors: extractColors(sections['Colors']), 835 + typography: extractTypography(sections['Typography']), 836 + elevation: extractElevation(sections['Elevation']), 837 + components: extractComponents(sections['Components']), 838 + dosDonts: extractDosDonts(sections["Do's and Don'ts"]), 839 + }; 840 + } 841 + 842 + export { assessCoverage };
+638
tools/design-detector/lib/impeccable-config.mjs
··· 1 + /** 2 + * CLI-side reader/writer for the unified `.impeccable` config. 3 + * 4 + * The CLI (published to npm) and the skill scripts (bundled into the install) 5 + * live in separate trees and cannot share runtime code, so this duplicates a 6 + * small slice of skill/scripts/hook-lib.mjs — the config-path layout, detector 7 + * ignore semantics, and the `.git/info/exclude` handling. Keep the schema, 8 + * ignore filtering, and exclude marker in sync if either side changes. 9 + * 10 + * Schema (config.json shared / config.local.json gitignored, per-developer): 11 + * { 12 + * "detector": { "ignoreRules": [], "ignoreFiles": [], "ignoreValues": [], "designSystem": { "enabled": true } }, 13 + * "hook": { "consent": "accepted" | "declined", ... }, 14 + * "updateCheck": bool 15 + * } 16 + */ 17 + 18 + import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs'; 19 + import { join, dirname, isAbsolute, relative, resolve, sep } from 'node:path'; 20 + 21 + export function getConfigPath(root) { 22 + return join(root, '.impeccable', 'config.json'); 23 + } 24 + 25 + export function getLocalConfigPath(root) { 26 + return join(root, '.impeccable', 'config.local.json'); 27 + } 28 + 29 + function safeReadJson(filePath) { 30 + try { 31 + const raw = JSON.parse(readFileSync(filePath, 'utf-8')); 32 + return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null; 33 + } catch { 34 + return null; 35 + } 36 + } 37 + 38 + function hookSection(raw) { 39 + return raw && raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null; 40 + } 41 + 42 + function detectorSection(raw) { 43 + return raw && raw.detector && typeof raw.detector === 'object' && !Array.isArray(raw.detector) ? raw.detector : null; 44 + } 45 + 46 + const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem']); 47 + 48 + const DEFAULT_DETECTION_CONFIG = Object.freeze({ 49 + ignoreRules: [], 50 + ignoreFiles: [], 51 + ignoreValues: [], 52 + designSystem: { enabled: true }, 53 + }); 54 + 55 + function cloneDetectionConfig() { 56 + return { 57 + ignoreRules: [], 58 + ignoreFiles: [], 59 + ignoreValues: [], 60 + designSystem: { ...DEFAULT_DETECTION_CONFIG.designSystem }, 61 + }; 62 + } 63 + 64 + function cloneRawDetectionConfig() { 65 + return { 66 + ignoreRules: [], 67 + ignoreFiles: [], 68 + ignoreValues: [], 69 + }; 70 + } 71 + 72 + function applyDetectionConfigSource(config, raw) { 73 + if (!raw || typeof raw !== 'object') return config; 74 + if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) { 75 + config.designSystem = { 76 + ...config.designSystem, 77 + enabled: raw.designSystem.enabled === false ? false : true, 78 + }; 79 + } 80 + if (Array.isArray(raw.ignoreRules)) { 81 + config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]); 82 + } 83 + if (Array.isArray(raw.ignoreFiles)) { 84 + config.ignoreFiles = uniqueStrings([...config.ignoreFiles, ...raw.ignoreFiles]); 85 + } 86 + if (Array.isArray(raw.ignoreValues)) { 87 + config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues); 88 + } 89 + return config; 90 + } 91 + 92 + function uniqueStrings(values) { 93 + return Array.from(new Set(values.map(String))); 94 + } 95 + 96 + /** 97 + * Detector filters shared by `npx impeccable detect` and the design hook. 98 + * `hook.enabled` remains hook lifecycle state; manual CLI scans still run when 99 + * the hook is disabled, but they honor the same ignore rules and design-system 100 + * toggle. 101 + */ 102 + export function readDetectionConfig(root) { 103 + const config = cloneDetectionConfig(); 104 + for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) { 105 + const raw = safeReadJson(filePath); 106 + // Back-compat: old builds stored detector filters under hook.*. 107 + applyDetectionConfigSource(config, hookSection(raw)); 108 + applyDetectionConfigSource(config, detectorSection(raw)); 109 + } 110 + return config; 111 + } 112 + 113 + export function readRawDetectionConfig(root, opts = {}) { 114 + const raw = safeReadJson(opts.local ? getLocalConfigPath(root) : getConfigPath(root)); 115 + const config = cloneRawDetectionConfig(); 116 + applyDetectionConfigSource(config, hookSection(raw)); 117 + applyDetectionConfigSource(config, detectorSection(raw)); 118 + return config; 119 + } 120 + 121 + export function writeDetectionConfig(root, detectorConfig, opts = {}) { 122 + const filePath = opts.local ? getLocalConfigPath(root) : getConfigPath(root); 123 + if (opts.local) ensureConfigGitExclude(root); 124 + const existing = safeReadJson(filePath) || {}; 125 + const existingHook = hookSection(existing); 126 + const nextHook = stripDetectorKeys(existingHook); 127 + const nextDetector = { 128 + ...(detectorSection(existing) || {}), 129 + ...normalizeDetectionConfigForWrite(detectorConfig), 130 + }; 131 + const next = { 132 + ...existing, 133 + detector: nextDetector, 134 + }; 135 + if (nextHook && Object.keys(nextHook).length > 0) { 136 + next.hook = nextHook; 137 + } else { 138 + delete next.hook; 139 + } 140 + mkdirSync(dirname(filePath), { recursive: true }); 141 + writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`); 142 + return filePath; 143 + } 144 + 145 + function normalizeDetectionConfigForWrite(config) { 146 + const out = {}; 147 + if (Array.isArray(config?.ignoreRules)) { 148 + out.ignoreRules = uniqueStrings(config.ignoreRules.map((rule) => normalizeIgnoreRule(rule)).filter(Boolean)); 149 + } 150 + if (Array.isArray(config?.ignoreFiles)) { 151 + out.ignoreFiles = uniqueStrings(config.ignoreFiles.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim())); 152 + } 153 + out.ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []); 154 + if (config?.designSystem && typeof config.designSystem === 'object' && !Array.isArray(config.designSystem)) { 155 + out.designSystem = { 156 + enabled: config.designSystem.enabled === false ? false : true, 157 + }; 158 + } 159 + return out; 160 + } 161 + 162 + function stripDetectorKeys(raw) { 163 + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; 164 + const out = {}; 165 + for (const [key, value] of Object.entries(raw)) { 166 + if (!DETECTOR_CONFIG_KEYS.has(key)) out[key] = value; 167 + } 168 + return out; 169 + } 170 + 171 + export function normalizeIgnoreValue(value) { 172 + return String(value || '') 173 + .trim() 174 + .replace(/^["']|["']$/g, '') 175 + .replace(/\+/g, ' ') 176 + .replace(/\s+/g, ' ') 177 + .toLowerCase(); 178 + } 179 + 180 + function normalizeIgnoreRule(rule) { 181 + return String(rule || '').trim().toLowerCase(); 182 + } 183 + 184 + function colorIgnoreKey(value) { 185 + const color = parseIgnoreColor(value); 186 + if (!color) return ''; 187 + return `${color.r},${color.g},${color.b},${Math.round(color.a * 255)}`; 188 + } 189 + 190 + function parseIgnoreColor(value) { 191 + const text = String(value || '').trim().toLowerCase(); 192 + if (!text) return null; 193 + 194 + const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i); 195 + if (hex) return parseHexIgnoreColor(hex[1]); 196 + 197 + const rgb = text.match(/^rgba?\((.*)\)$/i); 198 + if (rgb) { 199 + const parts = splitColorArgs(rgb[1]); 200 + if (parts.length < 3 || parts.length > 4) return null; 201 + const r = parseRgbChannel(parts[0]); 202 + const g = parseRgbChannel(parts[1]); 203 + const b = parseRgbChannel(parts[2]); 204 + const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]); 205 + if ([r, g, b, a].some((v) => v === null)) return null; 206 + return { r, g, b, a }; 207 + } 208 + 209 + const hsl = text.match(/^hsla?\((.*)\)$/i); 210 + if (hsl) { 211 + const parts = splitColorArgs(hsl[1]); 212 + if (parts.length < 3 || parts.length > 4) return null; 213 + const h = parseHueChannel(parts[0]); 214 + const s = parsePercentChannel(parts[1]); 215 + const l = parsePercentChannel(parts[2]); 216 + const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]); 217 + if ([h, s, l, a].some((v) => v === null)) return null; 218 + return hslToRgb(h, s, l, a); 219 + } 220 + 221 + return null; 222 + } 223 + 224 + function parseHexIgnoreColor(hex) { 225 + if (hex.length === 3 || hex.length === 4) { 226 + const r = parseInt(hex[0] + hex[0], 16); 227 + const g = parseInt(hex[1] + hex[1], 16); 228 + const b = parseInt(hex[2] + hex[2], 16); 229 + const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1; 230 + return { r, g, b, a }; 231 + } 232 + const r = parseInt(hex.slice(0, 2), 16); 233 + const g = parseInt(hex.slice(2, 4), 16); 234 + const b = parseInt(hex.slice(4, 6), 16); 235 + const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1; 236 + return { r, g, b, a }; 237 + } 238 + 239 + function splitColorArgs(body) { 240 + const text = String(body || '').trim(); 241 + if (!text) return []; 242 + if (text.includes(',')) { 243 + const parts = text.split(',').map((part) => part.trim()).filter(Boolean); 244 + const last = parts[parts.length - 1]; 245 + if (last && last.includes('/')) { 246 + const split = last.split('/').map((part) => part.trim()).filter(Boolean); 247 + return [...parts.slice(0, -1), ...split]; 248 + } 249 + return parts; 250 + } 251 + return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/'); 252 + } 253 + 254 + function parseRgbChannel(raw) { 255 + const text = String(raw || '').trim(); 256 + const match = text.match(/^(-?\d*\.?\d+)(%)?$/); 257 + if (!match) return null; 258 + const value = Number.parseFloat(match[1]); 259 + if (!Number.isFinite(value)) return null; 260 + const scaled = match[2] ? value * 2.55 : value; 261 + if (scaled < 0 || scaled > 255) return null; 262 + return Math.round(scaled); 263 + } 264 + 265 + function parseAlphaChannel(raw) { 266 + const text = String(raw || '').trim(); 267 + const match = text.match(/^(-?\d*\.?\d+)(%)?$/); 268 + if (!match) return null; 269 + const value = Number.parseFloat(match[1]); 270 + if (!Number.isFinite(value)) return null; 271 + const alpha = match[2] ? value / 100 : value; 272 + return alpha >= 0 && alpha <= 1 ? alpha : null; 273 + } 274 + 275 + function parseHueChannel(raw) { 276 + const text = String(raw || '').trim(); 277 + const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/); 278 + if (!match) return null; 279 + const value = Number.parseFloat(match[1]); 280 + if (!Number.isFinite(value)) return null; 281 + const unit = match[2] || 'deg'; 282 + if (unit === 'turn') return value * 360; 283 + if (unit === 'rad') return value * (180 / Math.PI); 284 + if (unit === 'grad') return value * 0.9; 285 + return value; 286 + } 287 + 288 + function parsePercentChannel(raw) { 289 + const text = String(raw || '').trim(); 290 + const match = text.match(/^(-?\d*\.?\d+)%$/); 291 + if (!match) return null; 292 + const value = Number.parseFloat(match[1]); 293 + if (!Number.isFinite(value)) return null; 294 + return value >= 0 && value <= 100 ? value / 100 : null; 295 + } 296 + 297 + function hslToRgb(hue, saturation, lightness, alpha) { 298 + const h = (((hue % 360) + 360) % 360) / 360; 299 + if (saturation === 0) { 300 + const gray = clampByte(Math.round(lightness * 255)); 301 + return { r: gray, g: gray, b: gray, a: alpha }; 302 + } 303 + const q = lightness < 0.5 304 + ? lightness * (1 + saturation) 305 + : lightness + saturation - lightness * saturation; 306 + const p = 2 * lightness - q; 307 + const toRgb = (t) => { 308 + let channel = t; 309 + if (channel < 0) channel += 1; 310 + if (channel > 1) channel -= 1; 311 + if (channel < 1 / 6) return p + (q - p) * 6 * channel; 312 + if (channel < 1 / 2) return q; 313 + if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6; 314 + return p; 315 + }; 316 + return { 317 + r: clampByte(Math.round(toRgb(h + 1 / 3) * 255)), 318 + g: clampByte(Math.round(toRgb(h) * 255)), 319 + b: clampByte(Math.round(toRgb(h - 1 / 3) * 255)), 320 + a: alpha, 321 + }; 322 + } 323 + 324 + function clampByte(value) { 325 + return Math.min(255, Math.max(0, value)); 326 + } 327 + 328 + function ignoreValueMatches(rule, entryValue, findingValue) { 329 + if (entryValue === findingValue) return true; 330 + if (rule !== 'design-system-color') return false; 331 + const entryColor = colorIgnoreKey(entryValue); 332 + return Boolean(entryColor && entryColor === colorIgnoreKey(findingValue)); 333 + } 334 + 335 + export function normalizeIgnoreValueEntries(entries) { 336 + if (!Array.isArray(entries)) return []; 337 + const out = []; 338 + for (const entry of entries) { 339 + if (!entry || typeof entry !== 'object') continue; 340 + const rule = normalizeIgnoreRule(entry.rule); 341 + const value = normalizeIgnoreValue(entry.value); 342 + if (!rule || !value) continue; 343 + const normalized = { rule, value }; 344 + const files = uniqueStrings([ 345 + ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []), 346 + ...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []), 347 + ]); 348 + if (files.length > 0) normalized.files = files; 349 + if (typeof entry.reason === 'string' && entry.reason.trim()) { 350 + normalized.reason = entry.reason.trim(); 351 + } 352 + if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) { 353 + normalized.createdAt = entry.createdAt.trim(); 354 + } 355 + out.push(normalized); 356 + } 357 + return out; 358 + } 359 + 360 + function mergeIgnoreValues(existing, incoming) { 361 + const map = new Map(); 362 + for (const entry of normalizeIgnoreValueEntries(existing)) { 363 + map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry); 364 + } 365 + for (const entry of normalizeIgnoreValueEntries(incoming)) { 366 + map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry); 367 + } 368 + return Array.from(map.values()); 369 + } 370 + 371 + function ignoreValueFilesKey(files) { 372 + return Array.isArray(files) && files.length > 0 ? files.join('\x1f') : ''; 373 + } 374 + 375 + // Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation. 376 + function globToRegex(glob) { 377 + let re = '^'; 378 + let i = 0; 379 + while (i < glob.length) { 380 + const c = glob[i]; 381 + if (c === '*') { 382 + if (glob[i + 1] === '*') { 383 + re += '.*'; 384 + i += 2; 385 + if (glob[i] === '/') i += 1; 386 + } else { 387 + re += '[^/]*'; 388 + i += 1; 389 + } 390 + } else if (c === '?') { 391 + re += '[^/]'; 392 + i += 1; 393 + } else if (c === '{') { 394 + const end = glob.indexOf('}', i); 395 + if (end === -1) { re += '\\{'; i += 1; continue; } 396 + const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&')); 397 + re += `(?:${parts.join('|')})`; 398 + i = end + 1; 399 + } else if (/[.+^$()|[\]\\]/.test(c)) { 400 + re += `\\${c}`; 401 + i += 1; 402 + } else { 403 + re += c; 404 + i += 1; 405 + } 406 + } 407 + re += '$'; 408 + return new RegExp(re); 409 + } 410 + 411 + export function matchesAnyGlob(filePath, globs) { 412 + if (!Array.isArray(globs) || globs.length === 0) return false; 413 + const normalized = String(filePath || '').split(sep).join('/'); 414 + for (const glob of globs) { 415 + try { 416 + const re = globToRegex(String(glob)); 417 + if (re.test(normalized)) return true; 418 + const base = normalized.split('/').pop(); 419 + if (re.test(base)) return true; 420 + } catch { 421 + /* malformed glob, skip */ 422 + } 423 + } 424 + return false; 425 + } 426 + 427 + export function shouldIgnoreDetectionFile(filePath, root, config) { 428 + const globs = config?.ignoreFiles || []; 429 + if (!Array.isArray(globs) || globs.length === 0) return false; 430 + const raw = String(filePath || '').trim(); 431 + if (!raw) return false; 432 + if (matchesAnyGlob(raw, globs)) return true; 433 + 434 + try { 435 + const abs = isAbsolute(raw) ? raw : resolve(root, raw); 436 + if (matchesAnyGlob(abs, globs)) return true; 437 + const rel = relative(root, abs); 438 + if (rel && !rel.startsWith('..') && !isAbsolute(rel)) { 439 + return matchesAnyGlob(rel, globs); 440 + } 441 + } catch { 442 + /* ignore */ 443 + } 444 + return false; 445 + } 446 + 447 + export function filterDetectionFindings(findings, config) { 448 + if (!Array.isArray(findings) || findings.length === 0) return []; 449 + const ignoreRules = new Set((config?.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule))); 450 + const ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []); 451 + return findings.filter((finding) => { 452 + if (!finding || typeof finding !== 'object') return false; 453 + if (ignoreRules.has(normalizeIgnoreRule(finding.antipattern))) return false; 454 + if (isIgnoredFindingValue(finding, ignoreValues)) return false; 455 + return true; 456 + }); 457 + } 458 + 459 + function isIgnoredFindingValue(finding, ignoreValues) { 460 + if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false; 461 + const rule = normalizeIgnoreRule(finding.antipattern); 462 + const value = extractFindingIgnoreValue(finding); 463 + if (!rule || !value) return false; 464 + return ignoreValues.some((entry) => { 465 + const wildcardValue = entry.value === '*'; 466 + if (entry.rule !== rule || (!wildcardValue && !ignoreValueMatches(rule, entry.value, value))) return false; 467 + if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue; 468 + return findingMatchesScopedIgnoreFile(finding, entry.files); 469 + }); 470 + } 471 + 472 + function findingMatchesScopedIgnoreFile(finding, globs) { 473 + const filePath = String(finding?.file || '').trim(); 474 + if (!filePath) return false; 475 + if (matchesAnyGlob(filePath, globs)) return true; 476 + 477 + const normalized = filePath.split(sep).join('/'); 478 + const parts = normalized.split('/').filter(Boolean); 479 + for (let i = 0; i < parts.length; i++) { 480 + const suffix = parts.slice(i).join('/'); 481 + if (matchesAnyGlob(suffix, globs)) return true; 482 + } 483 + return false; 484 + } 485 + 486 + export function extractFindingIgnoreValue(finding) { 487 + if (!finding || typeof finding !== 'object') return ''; 488 + const rule = normalizeIgnoreRule(finding.antipattern); 489 + const directValueRules = new Set([ 490 + 'overused-font', 491 + 'bounce-easing', 492 + 'design-system-font', 493 + 'design-system-color', 494 + 'design-system-radius', 495 + ]); 496 + if (!directValueRules.has(rule)) return ''; 497 + return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule)); 498 + } 499 + 500 + function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) { 501 + const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || ''); 502 + if (direct) return direct; 503 + 504 + const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v); 505 + for (const text of candidates) { 506 + if (rule === 'bounce-easing') { 507 + const motion = extractMotionIgnoreValue(text); 508 + if (motion) return motion; 509 + continue; 510 + } 511 + 512 + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); 513 + if (primary) return cleanIgnoreValueDisplay(primary[1]); 514 + 515 + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); 516 + if (family) return cleanIgnoreValueDisplay(family[1]); 517 + 518 + const google = text.match(/[?&]family=([^&:;\n]+)/i); 519 + if (google) { 520 + try { 521 + return cleanIgnoreValueDisplay(decodeURIComponent(google[1])); 522 + } catch { 523 + return cleanIgnoreValueDisplay(google[1]); 524 + } 525 + } 526 + } 527 + 528 + return ''; 529 + } 530 + 531 + function extractMotionIgnoreValue(text) { 532 + const tailwind = text.match(/\banimate-bounce\b/i); 533 + if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]); 534 + 535 + const bezier = text.match(/cubic-bezier\([^)]+\)/i); 536 + if (bezier) return cleanIgnoreValueDisplay(bezier[0]); 537 + 538 + const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i); 539 + if (animation) { 540 + const token = animation[1] 541 + .split(/[,\s]+/) 542 + .find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part)); 543 + if (token) return cleanIgnoreValueDisplay(token); 544 + } 545 + 546 + return ''; 547 + } 548 + 549 + function cleanIgnoreValueDisplay(value) { 550 + return String(value || '') 551 + .trim() 552 + .replace(/^["']|["']$/g, '') 553 + .replace(/\+/g, ' ') 554 + .replace(/\s+/g, ' '); 555 + } 556 + 557 + /** 558 + * The recorded design-hook decision: 'accepted' | 'declined' | undefined. 559 + * config.local.json (per-developer) overrides config.json. 560 + */ 561 + export function getHookConsent(root) { 562 + let consent; 563 + for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) { 564 + const hook = hookSection(safeReadJson(filePath)); 565 + if (hook && (hook.consent === 'accepted' || hook.consent === 'declined')) consent = hook.consent; 566 + } 567 + return consent; 568 + } 569 + 570 + /** 571 + * Persist the per-developer decision to config.local.json, preserving any 572 + * sibling keys, and ensure the file is gitignored. 573 + */ 574 + export function setHookConsent(root, value) { 575 + const filePath = getLocalConfigPath(root); 576 + const existing = safeReadJson(filePath) || {}; 577 + const hook = hookSection(existing) || {}; 578 + const next = { ...existing, hook: { ...hook, consent: value } }; 579 + mkdirSync(dirname(filePath), { recursive: true }); 580 + writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`); 581 + ensureConfigGitExclude(root); 582 + return filePath; 583 + } 584 + 585 + const EXCLUDE_OPEN = '# impeccable-config-ignore-start'; 586 + const EXCLUDE_CLOSE = '# impeccable-config-ignore-end'; 587 + const EXCLUDE_PATTERNS = ['.impeccable/config.local.json']; 588 + 589 + /** 590 + * Add config.local.json to `.git/info/exclude` so a developer's decision is 591 + * never committed. Idempotent via marker comments. Best-effort; returns false 592 + * when there is no resolvable git dir. 593 + */ 594 + export function ensureConfigGitExclude(root) { 595 + try { 596 + const gitDir = resolveGitDir(root); 597 + if (!gitDir) return false; 598 + const target = join(gitDir, 'info', 'exclude'); 599 + const existing = existsSync(target) ? readFileSync(target, 'utf-8') : ''; 600 + const block = [EXCLUDE_OPEN, ...EXCLUDE_PATTERNS, EXCLUDE_CLOSE].join('\n'); 601 + const markerRe = new RegExp(`${escapeRegExp(EXCLUDE_OPEN)}[\\s\\S]*?${escapeRegExp(EXCLUDE_CLOSE)}`); 602 + let updated; 603 + if (markerRe.test(existing)) { 604 + updated = existing.replace(markerRe, block); 605 + } else { 606 + const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : `${existing}\n`; 607 + updated = `${prefix}${block}\n`; 608 + } 609 + if (updated !== existing) { 610 + mkdirSync(dirname(target), { recursive: true }); 611 + writeFileSync(target, updated); 612 + } 613 + return true; 614 + } catch { 615 + return false; 616 + } 617 + } 618 + 619 + function resolveGitDir(root) { 620 + const dotGit = join(root, '.git'); 621 + if (!existsSync(dotGit)) return null; 622 + try { 623 + if (statSync(dotGit).isDirectory()) return dotGit; 624 + // A `.git` file (worktree/submodule) points elsewhere: "gitdir: <path>". 625 + const match = readFileSync(dotGit, 'utf-8').match(/gitdir:\s*(.+)/); 626 + if (match) { 627 + const resolved = match[1].trim(); 628 + return isAbsolute(resolved) ? resolved : join(root, resolved); 629 + } 630 + } catch { 631 + /* fall through */ 632 + } 633 + return null; 634 + } 635 + 636 + function escapeRegExp(value) { 637 + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 638 + }
+128
tools/design-detector/lib/impeccable-paths.mjs
··· 1 + import fs from 'node:fs'; 2 + import path from 'node:path'; 3 + import { resolveProjectRoot } from '../context.mjs'; 4 + 5 + export const IMPECCABLE_DIR = '.impeccable'; 6 + export const LIVE_DIR = 'live'; 7 + export const CRITIQUE_DIR = 'critique'; 8 + 9 + export function getImpeccableDir(cwd = process.cwd(), options = {}) { 10 + return path.join(resolveProjectRoot(cwd, options), IMPECCABLE_DIR); 11 + } 12 + 13 + export function getDesignSidecarPath(cwd = process.cwd(), options = {}) { 14 + return path.join(getImpeccableDir(cwd, options), 'design.json'); 15 + } 16 + 17 + export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd, options = {}) { 18 + const projectRoot = resolveProjectRoot(cwd, options); 19 + const candidates = [ 20 + getDesignSidecarPath(cwd, options), 21 + path.join(projectRoot, 'DESIGN.json'), 22 + ]; 23 + const contextLegacy = path.join(contextDir, 'DESIGN.json'); 24 + if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy); 25 + return candidates; 26 + } 27 + 28 + export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd, options = {}) { 29 + return firstExisting(getDesignSidecarCandidates(cwd, contextDir, options)); 30 + } 31 + 32 + export function getLiveDir(cwd = process.cwd(), options = {}) { 33 + return path.join(getImpeccableDir(cwd, options), LIVE_DIR); 34 + } 35 + 36 + export function getLiveConfigPath(cwd = process.cwd(), options = {}) { 37 + return path.join(getLiveDir(cwd, options), 'config.json'); 38 + } 39 + 40 + export function getLegacyLiveConfigPath(scriptsDir) { 41 + return path.join(scriptsDir, 'config.json'); 42 + } 43 + 44 + export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env, targetPath } = {}) { 45 + if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) { 46 + const configured = env.IMPECCABLE_LIVE_CONFIG.trim(); 47 + return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured); 48 + } 49 + const primary = getLiveConfigPath(cwd, { targetPath }); 50 + if (fs.existsSync(primary)) return primary; 51 + if (scriptsDir) { 52 + const legacy = getLegacyLiveConfigPath(scriptsDir); 53 + if (fs.existsSync(legacy)) return legacy; 54 + } 55 + return primary; 56 + } 57 + 58 + export function getLiveServerPath(cwd = process.cwd(), options = {}) { 59 + return path.join(getLiveDir(cwd, options), 'server.json'); 60 + } 61 + 62 + export function getLegacyLiveServerPath(cwd = process.cwd(), options = {}) { 63 + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live.json'); 64 + } 65 + 66 + export function readLiveServerInfo(cwd = process.cwd(), options = {}) { 67 + for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) { 68 + try { 69 + const info = JSON.parse(fs.readFileSync(filePath, 'utf-8')); 70 + if (info && typeof info.pid === 'number' && !isLiveServerPidReachable(info.pid)) { 71 + try { fs.unlinkSync(filePath); } catch {} 72 + continue; 73 + } 74 + return { info, path: filePath }; 75 + } catch { 76 + /* try next */ 77 + } 78 + } 79 + return null; 80 + } 81 + 82 + export function isLiveServerPidReachable(pid) { 83 + try { 84 + process.kill(pid, 0); 85 + return true; 86 + } catch (err) { 87 + // ESRCH means "no such process". EPERM means the process exists but this 88 + // user cannot signal it, so the live server info is still valid. 89 + return err?.code !== 'ESRCH'; 90 + } 91 + } 92 + 93 + export function writeLiveServerInfo(cwd = process.cwd(), info, options = {}) { 94 + const filePath = getLiveServerPath(cwd, options); 95 + fs.mkdirSync(path.dirname(filePath), { recursive: true }); 96 + fs.writeFileSync(filePath, JSON.stringify(info)); 97 + return filePath; 98 + } 99 + 100 + export function removeLiveServerInfo(cwd = process.cwd(), options = {}) { 101 + for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) { 102 + try { fs.unlinkSync(filePath); } catch {} 103 + } 104 + } 105 + 106 + export function getLiveSessionsDir(cwd = process.cwd(), options = {}) { 107 + return path.join(getLiveDir(cwd, options), 'sessions'); 108 + } 109 + 110 + export function getLegacyLiveSessionsDir(cwd = process.cwd(), options = {}) { 111 + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'sessions'); 112 + } 113 + 114 + export function getLiveAnnotationsDir(cwd = process.cwd(), options = {}) { 115 + return path.join(getLiveDir(cwd, options), 'annotations'); 116 + } 117 + 118 + export function getCritiqueDir(cwd = process.cwd(), options = {}) { 119 + return path.join(getImpeccableDir(cwd, options), CRITIQUE_DIR); 120 + } 121 + 122 + export function getLegacyLiveAnnotationsDir(cwd = process.cwd(), options = {}) { 123 + return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'annotations'); 124 + } 125 + 126 + function firstExisting(paths) { 127 + return paths.find((filePath) => fs.existsSync(filePath)) || null; 128 + }
+69
tools/design-detector/lib/is-generated.mjs
··· 1 + /** 2 + * Decide whether a given file is "generated" (regenerated by a build step, 3 + * unsafe to write variants into) or "source" (safe to edit, changes persist). 4 + * 5 + * Why this matters: when the user picks an element on a page whose underlying 6 + * file is regenerated by a build step (e.g. `scripts/build-sub-pages.js` 7 + * rewriting `public/docs/*.html`), writing variants or accepted changes into 8 + * that file is silent data loss — the next build wipes them. 9 + * 10 + * Signals, in order of reliability: 11 + * 1. Git check-ignore: gitignored files are assumed generated. 12 + * 2. File-header markers ("GENERATED", "DO NOT EDIT", "AUTO-GENERATED") 13 + * within the first ~300 characters — catches non-git projects. 14 + */ 15 + 16 + import { execSync } from 'node:child_process'; 17 + import fs from 'node:fs'; 18 + import path from 'node:path'; 19 + 20 + const HEADER_SCAN_BYTES = 300; 21 + const HEADER_MARKERS = [ 22 + /@generated\b/i, 23 + /\bGENERATED\s+FILE\b/, 24 + /\bAUTO-?GENERATED\b/i, 25 + /\bDO\s+NOT\s+EDIT\b/i, 26 + ]; 27 + 28 + /** 29 + * @param {string} filePath - absolute or cwd-relative path 30 + * @param {object} [options] 31 + * @param {string} [options.cwd] - project root (defaults to process.cwd()) 32 + */ 33 + export function isGeneratedFile(filePath, options = {}) { 34 + const cwd = options.cwd || process.cwd(); 35 + const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath); 36 + 37 + if (isGitIgnored(absPath, cwd)) return true; 38 + if (hasGeneratedHeader(absPath)) return true; 39 + return false; 40 + } 41 + 42 + function isGitIgnored(absPath, cwd) { 43 + try { 44 + execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, { 45 + cwd, 46 + stdio: 'ignore', 47 + }); 48 + return true; // exit 0 = ignored 49 + } catch (err) { 50 + // Exit code 1 = not ignored. Exit code 128 = not a git repo or other error. 51 + // In both cases, treat as "not known to be ignored." 52 + return false; 53 + } 54 + } 55 + 56 + function hasGeneratedHeader(absPath) { 57 + let fd; 58 + try { 59 + fd = fs.openSync(absPath, 'r'); 60 + const buf = Buffer.alloc(HEADER_SCAN_BYTES); 61 + const bytesRead = fs.readSync(fd, buf, 0, HEADER_SCAN_BYTES, 0); 62 + const head = buf.slice(0, bytesRead).toString('utf-8'); 63 + return HEADER_MARKERS.some((re) => re.test(head)); 64 + } catch { 65 + return false; 66 + } finally { 67 + if (fd !== undefined) { try { fs.closeSync(fd); } catch {} } 68 + } 69 + }
+42
tools/design-detector/lib/target-args.mjs
··· 1 + class TargetArgError extends Error { 2 + constructor(message, code) { 3 + super(message); 4 + this.name = 'TargetArgError'; 5 + this.code = code; 6 + } 7 + } 8 + 9 + export function parseTargetPath(args = [], { strict = false } = {}) { 10 + let targetPath = null; 11 + for (let i = 0; i < args.length; i++) { 12 + const arg = String(args[i]); 13 + if (arg === '--target' || arg === '-t') { 14 + const next = args[i + 1]; 15 + if (next && !String(next).startsWith('-')) { 16 + targetPath = String(next); 17 + i++; 18 + continue; 19 + } 20 + if (strict) { 21 + throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING'); 22 + } 23 + continue; 24 + } 25 + if (arg.startsWith('--target=')) { 26 + const value = arg.slice('--target='.length); 27 + if (value) { 28 + targetPath = value; 29 + continue; 30 + } 31 + if (strict) { 32 + throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING'); 33 + } 34 + } 35 + } 36 + return targetPath; 37 + } 38 + 39 + export function parseTargetOptions(args = [], options = {}) { 40 + const targetPath = parseTargetPath(args, options); 41 + return targetPath ? { targetPath } : {}; 42 + }