···11+---
22+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.
33+polytoken:
44+ tags: [frontend, design, ui]
55+---
66+# Polymodel front-end design
77+88+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.
99+1010+Announce: "using the frontend-design skill for Polymodel UI work".
1111+1212+**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:
1313+1414+- **`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`.
1515+- **`tools/design-detector/`** — the vendored deterministic linter (see Detector below).
1616+- **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.
1717+1818+Read `DESIGN.md` and the relevant `assets/styling/*.css` before non-trivial UI work.
1919+2020+---
2121+2222+## 1. Setup: orient before you design
2323+2424+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.
2525+2. **Know the register.** Polymodel is **product UI** — a tool people browse and publish with — not a marketing/brand surface. Design *serves* the task.
2626+3. **Preserve the blueprint identity** (§3). It is committed and distinctive. Do not "fix" it toward generic flat-SaaS or dark-terminal defaults.
2727+2828+---
2929+3030+## 2. The register: product, not brand
3131+3232+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.
3333+3434+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.
3535+3636+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.
3737+3838+---
3939+4040+## 3. The blueprint identity (committed — preserve it)
4141+4242+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.
4343+4444+- **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.
4545+- **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.
4646+- **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.
4747+- **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.
4848+- **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.
4949+- **Both schemes first-class.** Every token has a dark variant; verify light *and* dark.
5050+5151+---
5252+5353+## 4. General rules
5454+5555+### Color
5656+- **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`.
5757+- **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.
5858+- **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.
5959+- **State vocabulary, standardized:** hover, focus, active, disabled, selected, loading, error, warning, success, info. Accent is for primary action / current selection / state indicators only.
6060+6161+### Typography
6262+- **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.
6363+- **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.
6464+- **Prose line length 65–75ch.** Dense data/tables may run wider.
6565+- **`text-wrap: balance`** on headings; `pretty` on long prose.
6666+6767+### Layout
6868+- **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.
6969+- **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.
7070+- **Vary spacing for rhythm.** Build a semantic z-index scale (dropdown → sticky → modal → toast → tooltip); never `999`/`9999`.
7171+- **Responsive behavior is structural** (collapse nav, responsive tables, breakpoint columns), not fluid typography.
7272+7373+### Motion
7474+- **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.
7575+- **`prefers-reduced-motion` is not optional** — every animation needs a reduce fallback (crossfade or instant).
7676+- **Don't animate layout properties** or `<img>` on hover; animate background/border/shadow instead.
7777+7878+### Components & states
7979+- **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`).
8080+- **Skeletons, not center spinners,** for content loading (`.skeleton-*` exist).
8181+- **Empty states teach** the interface ("publish your first thing" + CTA), never "nothing here."
8282+- **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.
8383+8484+### Interaction
8585+- Dropdowns with `position: absolute` inside `overflow: hidden/auto` get clipped — use `position: fixed`, the popover/`<dialog>` API, or a portal.
8686+- Exhaust inline/progressive alternatives before a modal; modal-as-first-thought is usually laziness.
8787+8888+---
8989+9090+## 5. Anti-pattern catalog (rewrite if you catch yourself writing these)
9191+9292+Cross-register slop tells:
9393+9494+- **Side-stripe borders** — `border-left/right` >1px as a colored accent. Use full borders or background tints.
9595+- **Gradient text** (`background-clip: text` + gradient). Solid color; emphasis via weight/size.
9696+- **Glassmorphism as default** decoration.
9797+- **The hero-metric template** (big number + label + gradient) and **identical icon-card grids**.
9898+- **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.)
9999+- **Text that overflows its container** at any breakpoint — test thing names/titles at mobile/tablet.
100100+- **Ghost-card**: a 1px border + a wide (≥16px) soft shadow on the same element. Pick one.
101101+- **Over-rounding** (24px+ on cards), **sketchy/hand-drawn SVG**, **repeating-stripe backgrounds**.
102102+103103+Product-specific bans:
104104+105105+- **Decorative motion** that doesn't convey state.
106106+- **Inconsistent component vocabulary** across screens.
107107+- **Display fonts in UI** labels/buttons/data.
108108+- **Reinvented standard affordances** for flavor (custom scrollbars beyond our thin styling, weird form controls, non-standard modals).
109109+- **Heavy/full-saturation accents on inactive states.**
110110+111111+### The slop test (two altitudes)
112112+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.
113113+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.
114114+115115+Polymodel's answer to both is the **blueprint identity** — lean into it deliberately rather than landing on flat-SaaS or dark-terminal.
116116+117117+---
118118+119119+## 6. Evaluation method (review / critique / audit)
120120+121121+When reviewing a surface rather than building one, run two passes and synthesize.
122122+123123+### Design critique (judgment)
124124+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:
125125+- **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.
126126+- **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.
127127+- **AI-slop verdict** against §5.
128128+129129+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.
130130+131131+### Technical audit (measurable)
132132+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.
133133+134134+---
135135+136136+## 7. The detector
137137+138138+`tools/design-detector/` is the vendored deterministic linter (44 rules; Apache-2.0; see its README):
139139+140140+```sh
141141+node tools/design-detector/detect.mjs --json assets/styling/ # static CSS scan (plain Node)
142142+node tools/design-detector/detect.mjs --json http://localhost:8080 # rendered route (real browser; `just serve` running)
143143+```
144144+145145+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.
146146+147147+---
148148+149149+## 8. Workflow & checklist
150150+151151+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.
152152+153153+- [ ] Reused existing tokens/components; no parallel system invented.
154154+- [ ] Blueprint identity preserved (paper base, near-square corners, teal/one-orange-action, mono metadata, grid-line media).
155155+- [ ] Body/placeholder contrast ≥ 4.5:1 in both schemes; no gray-on-tint mud.
156156+- [ ] All interactive states present (default/hover/focus/active/disabled/loading/error); focus visible.
157157+- [ ] Skeleton loading + teaching empty state + actionable error state.
158158+- [ ] One UI family + mono for metadata only; no display fonts in UI; fixed rem heading scale.
159159+- [ ] No §5 anti-patterns; motion conveys state, quick, ease-out, with reduced-motion fallback.
160160+- [ ] Headings don't overflow at any breakpoint.
161161+- [ ] Detector run on changed CSS / affected route; findings fixed or justified.
162162+- [ ] Verified light/dark + mobile/desktop in-browser.
+1
AGENTS.md
···65656666### Front-end implementation notes
67676868+- **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).
6869- 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.
6970- 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.
7071- 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
···11+---
22+name: Polymodel
33+description: Blueprint/technical-drawing design system for an AT Protocol model-sharing app.
44+colors:
55+ base: "#f5f1e8"
66+ surface: "#fffdf7"
77+ overlay: "#e8edf0"
88+ ink: "#17252f"
99+ emphasis: "#0c1f2c"
1010+ subtle: "#445461"
1111+ muted: "#6a747b"
1212+ primary: "#1f6d8a"
1313+ secondary: "#5a8797"
1414+ tertiary: "#0f4159"
1515+ action: "#c5652a"
1616+ link: "#a24d1f"
1717+ border: "#a9b8bf"
1818+ highlight: "#d8e5e9"
1919+ error: "#a13f32"
2020+ warning: "#b36a16"
2121+ success: "#2d7358"
2222+typography:
2323+ heading:
2424+ fontFamily: "Proza Libre, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
2525+ fontWeight: 600
2626+ lineHeight: 1.3
2727+ body:
2828+ fontFamily: "Proza Libre, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
2929+ fontWeight: 400
3030+ fontSize: "16px"
3131+ lineHeight: 1.55
3232+ mono:
3333+ fontFamily: "Ioskeley Mono, IBM Plex Mono, Consolas, monospace"
3434+ fontSize: "0.72rem"
3535+ letterSpacing: "0.04em"
3636+rounded:
3737+ panel: "2px"
3838+ square: "0px"
3939+spacing:
4040+ base: "16px"
4141+components:
4242+ button:
4343+ backgroundColor: "{colors.surface}"
4444+ textColor: "{colors.ink}"
4545+ rounded: "{rounded.panel}"
4646+ padding: "0.55rem 0.8rem"
4747+ button-primary:
4848+ backgroundColor: "{colors.action}"
4949+ textColor: "{colors.base}"
5050+ rounded: "{rounded.panel}"
5151+ padding: "0.55rem 0.8rem"
5252+ button-secondary:
5353+ backgroundColor: "{colors.surface}"
5454+ textColor: "{colors.tertiary}"
5555+ rounded: "{rounded.panel}"
5656+ input:
5757+ backgroundColor: "{colors.base}"
5858+ textColor: "{colors.ink}"
5959+ rounded: "{rounded.panel}"
6060+ padding: "0.75rem 0.85rem"
6161+ card:
6262+ backgroundColor: "{colors.surface}"
6363+ textColor: "{colors.ink}"
6464+ rounded: "{rounded.panel}"
6565+ status-pill:
6666+ backgroundColor: "{colors.surface}"
6767+ textColor: "{colors.subtle}"
6868+ rounded: "{rounded.panel}"
6969+ typography: "{typography.mono}"
7070+---
7171+7272+# Design System: Polymodel
7373+7474+## 1. Overview
7575+7676+**Creative North Star: "The Drafting Table"**
7777+7878+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.
7979+8080+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.
8181+8282+**Key Characteristics:**
8383+- Drafting-paper base, drawing-ink teal, single warm-orange action accent.
8484+- Near-square corners (2px panels, 0px controls); crisp 1px borders over shadow.
8585+- Monospace for all technical metadata (dimensions, formats, DIDs/handles, counts, status).
8686+- Drafting-grid placeholders instead of gray boxes.
8787+- First-class light **and** dark schemes; restrained, state-driven color and motion.
8888+8989+## 2. Colors
9090+9191+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).
9292+9393+### Primary
9494+- **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.
9595+9696+### Secondary
9797+- **Slate Teal** (#5a8797): Supporting teal for secondary controls and quieter structural accents.
9898+- **Deep Teal** (#0f4159): High-emphasis teal for hover ink and strong labels against light surfaces.
9999+100100+### Tertiary
101101+- **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.**
102102+- **Rust Link** (#a24d1f): Inline text links, distinct from the action button so links don't read as buttons.
103103+104104+### Neutral
105105+- **Drafting Paper** (#f5f1e8): The body background. A warm paper tone tinted toward the brand — the blueprint substrate, not a generic off-white.
106106+- **Sheet** (#fffdf7): Raised panel/card/surface background.
107107+- **Blueprint Overlay** (#e8edf0): Cool inset background for media wells and grid placeholders.
108108+- **Ink** (#17252f): Body text.
109109+- **Emphasis** (#0c1f2c) / **Subtle** (#445461) / **Muted** (#6a747b): The text-emphasis ramp; pick by importance, never drop body to muted-gray on the paper base.
110110+- **Border** (#a9b8bf): The 1px line on every panel, control, and pill.
111111+- **Highlight** (#d8e5e9): Selection/skeleton shimmer tint.
112112+113113+### Status
114114+- **Error** (#a13f32) / **Warning** (#b36a16) / **Success** (#2d7358): Semantic state only, paired with shape/label, never color-alone.
115115+116116+### Named Rules
117117+**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.
118118+119119+**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.
120120+121121+**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.
122122+123123+## 3. Typography
124124+125125+**Heading Font:** Proza Libre (with system-ui, -apple-system, Segoe UI, sans-serif)
126126+**Body Font:** Proza Libre (with system-ui, -apple-system, Segoe UI, sans-serif)
127127+**Label/Mono Font:** Ioskeley Mono (with IBM Plex Mono, Consolas, monospace)
128128+129129+**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.
130130+131131+### Hierarchy
132132+- **Heading** (Proza Libre, 600, ~1rem–1.18rem, line-height 1.3): Card titles, section and page headings. Fixed rem scale, tight ratio.
133133+- **Body** (Proza Libre, 400, 16px, line-height 1.55): Paragraphs and descriptions; cap prose at 65–75ch.
134134+- **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.
135135+- **Meta/Label** (Ioskeley Mono, ~0.72rem, letter-spacing 0.04–0.08em, often uppercase): Dimensions, file formats, DIDs/handles, counts, status pills, eyebrows/kickers.
136136+137137+### Named Rules
138138+**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.
139139+140140+**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.
141141+142142+**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.
143143+144144+## 4. Elevation
145145+146146+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.
147147+148148+### Shadow Vocabulary
149149+- **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.
150150+151151+### Named Rules
152152+**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).
153153+154154+## 5. Components
155155+156156+### Buttons
157157+- **Shape:** Near-square (2px radius); base controls render at 0px corners. Never round to pill (except deliberate tags) or 12px+.
158158+- **Default:** Sheet background, 1px border, ink text. Hover shifts border to teal and text to deep teal.
159159+- **Primary:** Redline Orange fill, paper-colored text — the one action. Hover brightens slightly (`filter: brightness(1.04)`), no transform bounce.
160160+- **Secondary / Ghost:** Teal border + deep-teal text (secondary); transparent background (ghost).
161161+162162+### Chips / Tabs / Status pills
163163+- **Style:** 1px border, near-square, mono text, ~0.72rem. Transparent at rest.
164164+- **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.
165165+166166+### Cards / Containers
167167+- **Corner Style:** 2px (panel radius).
168168+- **Background:** Sheet (#fffdf7) over the paper base.
169169+- **Border:** 1px Border (#a9b8bf) — the defining line.
170170+- **Shadow Strategy:** Paper hairline only (see Elevation).
171171+- **Internal Padding:** ~1rem; metadata rows in mono, subordinate to title/creator.
172172+- **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.
173173+174174+### Inputs / Fields
175175+- **Style:** Paper-base fill, 1px border, 2px radius, body sans.
176176+- **Focus:** Border shifts to teal with a 2px translucent teal outline (`outline: 2px solid color-mix(... primary 25% ...)`). Focus is always visible.
177177+- **Error / Disabled:** Error recolors border+text to Error and adds a faint wash; disabled drops contrast and removes the pointer affordance.
178178+179179+### Navigation
180180+- 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.
181181+182182+### Signature: the blueprint preview / skeleton
183183+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.
184184+185185+## 6. Do's and Don'ts
186186+187187+### Do:
188188+- **Do** reuse the tokens in `assets/styling/theme.css` and the component vocabulary in `base.css`/`cards.css`; extend, don't fork.
189189+- **Do** set all technical metadata (dimensions, formats, DIDs/handles, counts, status) in Ioskeley Mono.
190190+- **Do** keep body/placeholder contrast ≥ 4.5:1 in both schemes; move text toward Ink, not Muted, when close.
191191+- **Do** ship every interactive state — default, hover, focus, active, disabled, loading, error — with visible keyboard focus.
192192+- **Do** use drafting-grid placeholders and skeletons for loading/empty media; write empty states that teach (publish-your-first-thing).
193193+- **Do** keep motion to 150–250ms, ease-out, for state/feedback/loading/viewer-readiness only, with a `prefers-reduced-motion` fallback.
194194+195195+### Don't:
196196+- **Don't** swap the drafting-paper base for a flat off-white, or rename it `--paper`/`--cream`/`--sand`.
197197+- **Don't** round cards/inputs to 12px+ or reach for full pills outside tags; the near-square corner is the identity.
198198+- **Don't** use more than one Redline-Orange action per view, or use the action color for decoration.
199199+- **Don't** use gradient text, glassmorphism-by-default, hero-metric templates, identical icon-card grids, or an uppercase eyebrow above every section.
200200+- **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.
201201+- **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.
202202+- **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
···11+# Design detector
22+33+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`.
44+55+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*).
66+77+## Usage
88+99+```sh
1010+# Static scan of our CSS (plain Node, no browser)
1111+node tools/design-detector/detect.mjs --json assets/styling/
1212+1313+# Scan a rendered route (drives a real browser; needs `just serve` running)
1414+node tools/design-detector/detect.mjs --json http://localhost:8080
1515+```
1616+1717+Exit code `0` = clean, `2` = findings. JSON entries carry `antipattern`, `severity` (advisory/warning), `file`, `line`, `snippet`.
1818+1919+## Coverage notes
2020+2121+- 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.
2222+- `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.
2323+- Scratch/config (`.impeccable/`) is gitignored.
2424+2525+## Updating
2626+2727+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
···11+/**
22+ * Context loader: prints PRODUCT.md (and DESIGN.md if present) as one
33+ * markdown block on stdout, or exits with empty stdout when no PRODUCT.md
44+ * is found anywhere. The skill keys off "empty stdout" to branch into the
55+ * init flow.
66+ *
77+ * Path resolution (first match wins):
88+ * 1. Active project root, if PRODUCT.md or DESIGN.md is there
99+ * 2. Active project .agents/context/ then docs/
1010+ * 3. Monorepo root context, using the same order, as a per-file fallback
1111+ * 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
1212+ * escape hatch, only consulted when defaults are empty
1313+ * 5. Active project root as a "nothing found" default
1414+ *
1515+ * `resolveContextDir()` and `loadContext()` are also exported for the
1616+ * server-side scripts (live.mjs, live-server.mjs) that need the structured
1717+ * shape rather than the markdown block.
1818+ */
1919+import fs from 'node:fs';
2020+import os from 'node:os';
2121+import path from 'node:path';
2222+import { fileURLToPath } from 'node:url';
2323+import { parseTargetOptions } from './lib/target-args.mjs';
2424+2525+const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];
2626+const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
2727+const FALLBACK_DIRS = ['.agents/context', 'docs'];
2828+const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
2929+const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
3030+const WORKSPACE_DISCOVERY_IGNORED_DIRS = new Set([
3131+ 'node_modules',
3232+ '.git',
3333+ 'dist',
3434+ 'build',
3535+ '.next',
3636+ '.nuxt',
3737+ '.svelte-kit',
3838+ '.turbo',
3939+ '.cache',
4040+ 'coverage',
4141+]);
4242+4343+// ─── Update check ──────────────────────────────────────────────────────────
4444+// Piggyback a lightweight skill-version check on the once-per-session boot.
4545+// When a newer skill ships, append an UPDATE_AVAILABLE directive so the agent
4646+// can offer `npx impeccable update`. Everything here is best-effort and
4747+// silent on failure: a network problem, sandbox, or missing cache must never
4848+// block context output or print an error.
4949+5050+const UPDATE_HOST = (process.env.IMPECCABLE_UPDATE_HOST || 'https://impeccable.style').replace(/\/$/, '');
5151+const UPDATE_CACHE_PATH =
5252+ process.env.IMPECCABLE_UPDATE_CACHE || path.join(os.homedir(), '.impeccable', 'update-check.json');
5353+const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to once a day
5454+const RENOTIFY_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // don't re-surface the same version for a week
5555+const FETCH_TIMEOUT_MS = 1200;
5656+5757+export function resolveContextDir(cwd = process.cwd(), options = {}) {
5858+ return resolveContext(cwd, options).contextDir;
5959+}
6060+6161+export function loadContext(cwd = process.cwd(), options = {}) {
6262+ const resolved = resolveContext(cwd, options);
6363+ const absCwd = path.resolve(cwd);
6464+ const productPath = resolved.productPath;
6565+ const designPath = resolved.designPath;
6666+ const product = productPath ? safeRead(productPath) : null;
6767+ const design = designPath ? safeRead(designPath) : null;
6868+ return {
6969+ hasProduct: !!product,
7070+ product,
7171+ productPath: productPath ? path.relative(absCwd, productPath) : null,
7272+ hasDesign: !!design,
7373+ design,
7474+ designPath: designPath ? path.relative(absCwd, designPath) : null,
7575+ contextDir: resolved.contextDir,
7676+ productContextDir: productPath ? path.dirname(productPath) : null,
7777+ designContextDir: designPath ? path.dirname(designPath) : null,
7878+ projectRoot: resolved.projectRoot,
7979+ repoRoot: resolved.repoRoot,
8080+ isMonorepo: resolved.isMonorepo,
8181+ };
8282+}
8383+8484+function resolveContext(cwd = process.cwd(), options = {}) {
8585+ const absCwd = path.resolve(cwd);
8686+ const project = resolveProject(absCwd, options);
8787+ const projectContextDir = resolveLocalContextDir(project.projectRoot);
8888+ const rootContextDir = project.isMonorepo && project.repoRoot !== project.projectRoot
8989+ ? resolveLocalContextDir(project.repoRoot)
9090+ : null;
9191+9292+ let productPath =
9393+ (projectContextDir ? firstExisting(projectContextDir, PRODUCT_NAMES) : null)
9494+ || (rootContextDir ? firstExisting(rootContextDir, PRODUCT_NAMES) : null);
9595+ let designPath =
9696+ (projectContextDir ? firstExisting(projectContextDir, DESIGN_NAMES) : null)
9797+ || (rootContextDir ? firstExisting(rootContextDir, DESIGN_NAMES) : null);
9898+9999+ let envContextDir = null;
100100+ if (!productPath && !designPath) {
101101+ envContextDir = resolveEnvContextDir(absCwd);
102102+ if (envContextDir) {
103103+ productPath = firstExisting(envContextDir, PRODUCT_NAMES);
104104+ designPath = firstExisting(envContextDir, DESIGN_NAMES);
105105+ }
106106+ }
107107+108108+ return {
109109+ contextDir: productPath
110110+ ? path.dirname(productPath)
111111+ : designPath
112112+ ? path.dirname(designPath)
113113+ : envContextDir || project.projectRoot,
114114+ productPath,
115115+ designPath,
116116+ projectRoot: project.projectRoot,
117117+ repoRoot: project.repoRoot,
118118+ isMonorepo: project.isMonorepo,
119119+ targetDir: project.targetDir,
120120+ };
121121+}
122122+123123+export function resolveProjectRoot(cwd = process.cwd(), options = {}) {
124124+ return resolveProject(cwd, options).projectRoot;
125125+}
126126+127127+export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
128128+ if (hasTargetOption(options)) return null;
129129+ const project = resolveProject(cwd);
130130+ if (
131131+ !project.isMonorepo
132132+ || !project.projectRoot
133133+ || !project.repoRoot
134134+ || path.resolve(project.projectRoot) !== path.resolve(project.repoRoot)
135135+ ) {
136136+ return null;
137137+ }
138138+ const targetCandidates = discoverTargetCandidates(project.repoRoot);
139139+ // No discoverable child apps (e.g. `workspaces: ["."]`, a root-only workspace,
140140+ // or a marker file with no apps/packages children): there is nothing to choose,
141141+ // so treat the repo root as the active project rather than blocking on an empty
142142+ // selection prompt that the user cannot answer.
143143+ if (targetCandidates.length === 0) return null;
144144+ return {
145145+ targetPath: null,
146146+ projectRoot: project.projectRoot,
147147+ repoRoot: project.repoRoot,
148148+ targetCandidates,
149149+ };
150150+}
151151+152152+function resolveProject(cwd = process.cwd(), options = {}) {
153153+ const absCwd = path.resolve(cwd);
154154+ const targetDir = resolveTargetDir(absCwd, options);
155155+ let repoRoot = findMonorepoRoot(targetDir);
156156+ if (!repoRoot && targetDir !== absCwd) {
157157+ const cwdRepoRoot = findMonorepoRoot(absCwd);
158158+ if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
159159+ repoRoot = cwdRepoRoot;
160160+ }
161161+ }
162162+ if (!repoRoot) {
163163+ return {
164164+ targetDir,
165165+ projectRoot: absCwd,
166166+ repoRoot: absCwd,
167167+ isMonorepo: false,
168168+ };
169169+ }
170170+ return {
171171+ targetDir,
172172+ projectRoot: resolveWorkspaceProjectRoot(repoRoot, targetDir) || repoRoot,
173173+ repoRoot,
174174+ isMonorepo: true,
175175+ };
176176+}
177177+178178+function isPathInside(candidate, root) {
179179+ const rel = path.relative(root, candidate);
180180+ return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
181181+}
182182+183183+function resolveLocalContextDir(root) {
184184+ if (firstExisting(root, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
185185+ return root;
186186+ }
187187+ for (const rel of FALLBACK_DIRS) {
188188+ const candidate = path.resolve(root, rel);
189189+ if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
190190+ return candidate;
191191+ }
192192+ }
193193+ return null;
194194+}
195195+196196+function resolveEnvContextDir(cwd) {
197197+ const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
198198+ if (!envDir || !envDir.trim()) return null;
199199+ const trimmed = envDir.trim();
200200+ return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
201201+}
202202+203203+function resolveTargetDir(cwd, options = {}) {
204204+ const targetPath = options && typeof options === 'object' ? options.targetPath : null;
205205+ if (!targetPath || !String(targetPath).trim()) return cwd;
206206+ const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
207207+ try {
208208+ const stat = fs.statSync(abs);
209209+ return stat.isDirectory() ? abs : path.dirname(abs);
210210+ } catch {
211211+ return path.extname(abs) ? path.dirname(abs) : abs;
212212+ }
213213+}
214214+215215+function findMonorepoRoot(startDir) {
216216+ let dir = path.resolve(startDir);
217217+ const homeDir = path.resolve(os.homedir());
218218+ while (true) {
219219+ if (dir === homeDir) return null;
220220+ // isMonorepoRoot is checked before hasGitBoundary on purpose: a workspace
221221+ // root that also carries its own .git is still recognized. The trade-off is
222222+ // deliberate — a directory with a monorepo *marker* but no workspace patterns
223223+ // and no apps/packages children is not a monorepo root, so its .git stops
224224+ // traversal and a further-up root is not searched. The nested .git is treated
225225+ // as an independent project boundary, which is the intended isolation.
226226+ if (isMonorepoRoot(dir)) return dir;
227227+ if (hasGitBoundary(dir)) return null;
228228+ const parent = path.dirname(dir);
229229+ if (parent === dir) return null;
230230+ dir = parent;
231231+ }
232232+}
233233+234234+function isMonorepoRoot(dir) {
235235+ if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true;
236236+ if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false;
237237+ return hasFallbackWorkspaceChildren(dir);
238238+}
239239+240240+function hasGitBoundary(dir) {
241241+ return fs.existsSync(path.join(dir, '.git'));
242242+}
243243+244244+function hasFallbackWorkspaceChildren(dir) {
245245+ for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
246246+ const base = path.join(dir, name);
247247+ let entries;
248248+ try {
249249+ entries = fs.readdirSync(base, { withFileTypes: true });
250250+ } catch {
251251+ continue;
252252+ }
253253+ if (entries.some((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))) return true;
254254+ }
255255+ return false;
256256+}
257257+258258+function discoverTargetCandidates(repoRoot) {
259259+ const roots = new Map();
260260+ const patterns = readWorkspacePatterns(repoRoot);
261261+ for (const pattern of patterns) {
262262+ for (const root of discoverRootsForPattern(repoRoot, pattern)) {
263263+ roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
264264+ }
265265+ }
266266+ if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) {
267267+ for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
268268+ const base = path.join(repoRoot, name);
269269+ let entries;
270270+ try {
271271+ entries = fs.readdirSync(base, { withFileTypes: true });
272272+ } catch {
273273+ continue;
274274+ }
275275+ for (const entry of entries) {
276276+ if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
277277+ const root = path.join(base, entry.name);
278278+ roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
279279+ }
280280+ }
281281+ }
282282+ return [...roots.entries()]
283283+ .filter(([rel]) => rel && !rel.startsWith('..'))
284284+ // Honor negated workspace patterns (e.g. "!packages/internal"). resolveWorkspaceProjectRoot
285285+ // sends an excluded package back to the repo root, so an excluded folder must not appear as a
286286+ // selectable target — choosing it would silently resolve to the root instead.
287287+ .filter(([rel]) => !isExcludedByWorkspacePattern(rel.split('/').filter(Boolean), patterns))
288288+ .sort(([a], [b]) => a.localeCompare(b))
289289+ .map(([rel, root]) => {
290290+ const targetExample = findTargetExample(repoRoot, root);
291291+ return {
292292+ name: path.basename(root),
293293+ path: rel,
294294+ targetExample,
295295+ ...resolveCandidateContextSummary(repoRoot, root, targetExample),
296296+ };
297297+ });
298298+}
299299+300300+function resolveCandidateContextSummary(repoRoot, projectRoot, targetPath) {
301301+ const ctx = resolveContext(repoRoot, { targetPath });
302302+ return {
303303+ productStatus: contextSourceStatus(ctx.productPath, repoRoot, projectRoot),
304304+ productPath: contextSourcePath(ctx.productPath, repoRoot),
305305+ designStatus: contextSourceStatus(ctx.designPath, repoRoot, projectRoot),
306306+ designPath: contextSourcePath(ctx.designPath, repoRoot),
307307+ };
308308+}
309309+310310+// Selection candidates surface one of four statuses: 'child' (a canonical
311311+// PRODUCT.md/DESIGN.md directly in the app root), 'inherited' (resolved from the
312312+// repo root in a monorepo), 'missing' (no file found), and 'fallback'. 'fallback'
313313+// intentionally covers two non-canonical locations: a file inside the project
314314+// root but in a subdirectory (FALLBACK_DIRS, e.g. `.agents/context/`), and a file
315315+// outside both the project and repo roots (IMPECCABLE_CONTEXT_DIR override).
316316+function contextSourceStatus(filePath, repoRoot, projectRoot) {
317317+ if (!filePath) return 'missing';
318318+ const absPath = path.resolve(filePath);
319319+ const absProjectRoot = path.resolve(projectRoot);
320320+ const absRepoRoot = path.resolve(repoRoot);
321321+ if (isPathInsideOrEqual(absPath, absProjectRoot)) {
322322+ return path.dirname(absPath) === absProjectRoot ? 'child' : 'fallback';
323323+ }
324324+ if (absProjectRoot !== absRepoRoot && isPathInsideOrEqual(absPath, absRepoRoot)) {
325325+ return 'inherited';
326326+ }
327327+ return 'fallback';
328328+}
329329+330330+function contextSourcePath(filePath, repoRoot) {
331331+ if (!filePath) return null;
332332+ const rel = path.relative(repoRoot, filePath);
333333+ if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
334334+ return rel.split(path.sep).join('/');
335335+ }
336336+ return filePath;
337337+}
338338+339339+function discoverRootsForPattern(repoRoot, rawPattern) {
340340+ const pattern = normalizeWorkspacePattern(rawPattern);
341341+ if (!pattern || pattern.startsWith('!')) return [];
342342+ const segments = pattern.split('/').filter(Boolean);
343343+ if (!segments.length) return [];
344344+ const firstGlobIndex = segments.findIndex((segment) => segment.includes('*'));
345345+ const literalPrefix = firstGlobIndex === -1 ? segments : segments.slice(0, firstGlobIndex);
346346+ const base = path.join(repoRoot, ...literalPrefix);
347347+ if (!fs.existsSync(base)) return [];
348348+ if (segments.includes('**')) {
349349+ const packageRoots = [];
350350+ walkDirs(base, (dir) => {
351351+ if (dir !== base && isCandidateProjectRoot(dir)) packageRoots.push(dir);
352352+ });
353353+ if (packageRoots.length) return packageRoots;
354354+ return directChildDirs(base);
355355+ }
356356+ return expandSimplePattern(repoRoot, segments);
357357+}
358358+359359+function expandSimplePattern(repoRoot, patternSegments, index = 0, current = repoRoot) {
360360+ if (index >= patternSegments.length) return fs.existsSync(current) ? [current] : [];
361361+ const segment = patternSegments[index];
362362+ if (!segment.includes('*')) {
363363+ return expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, segment));
364364+ }
365365+ let entries;
366366+ try {
367367+ entries = fs.readdirSync(current, { withFileTypes: true });
368368+ } catch {
369369+ return [];
370370+ }
371371+ const roots = [];
372372+ for (const entry of entries) {
373373+ if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
374374+ if (!segmentMatches(segment, entry.name)) continue;
375375+ roots.push(...expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, entry.name)));
376376+ }
377377+ return roots;
378378+}
379379+380380+function directChildDirs(dir) {
381381+ try {
382382+ return fs.readdirSync(dir, { withFileTypes: true })
383383+ .filter((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))
384384+ .map((entry) => path.join(dir, entry.name));
385385+ } catch {
386386+ return [];
387387+ }
388388+}
389389+390390+function walkDirs(root, visit) {
391391+ let entries;
392392+ try {
393393+ entries = fs.readdirSync(root, { withFileTypes: true });
394394+ } catch {
395395+ return;
396396+ }
397397+ for (const entry of entries) {
398398+ if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
399399+ const dir = path.join(root, entry.name);
400400+ visit(dir);
401401+ walkDirs(dir, visit);
402402+ }
403403+}
404404+405405+function isCandidateProjectRoot(dir) {
406406+ return !!(
407407+ fs.existsSync(path.join(dir, 'package.json'))
408408+ || firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
409409+ || fs.existsSync(path.join(dir, 'src'))
410410+ || fs.existsSync(path.join(dir, 'app'))
411411+ || fs.existsSync(path.join(dir, 'pages'))
412412+ || fs.existsSync(path.join(dir, 'public'))
413413+ );
414414+}
415415+416416+function isIgnoredWorkspaceDiscoveryDir(name) {
417417+ return name.startsWith('.') || WORKSPACE_DISCOVERY_IGNORED_DIRS.has(name);
418418+}
419419+420420+function findTargetExample(repoRoot, projectRoot) {
421421+ const examples = [
422422+ 'src/App.jsx',
423423+ 'src/App.tsx',
424424+ 'src/main.jsx',
425425+ 'src/main.tsx',
426426+ 'src/index.jsx',
427427+ 'src/index.ts',
428428+ 'app/page.tsx',
429429+ 'pages/index.tsx',
430430+ 'public/index.html',
431431+ ];
432432+ for (const rel of examples) {
433433+ const abs = path.join(projectRoot, rel);
434434+ if (fs.existsSync(abs)) return path.relative(repoRoot, abs).split(path.sep).join('/');
435435+ }
436436+ return path.relative(repoRoot, projectRoot).split(path.sep).join('/');
437437+}
438438+439439+function resolveWorkspaceProjectRoot(repoRoot, targetDir) {
440440+ const rel = path.relative(repoRoot, targetDir);
441441+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot;
442442+ const relSegments = rel.split(path.sep).filter(Boolean);
443443+ const patterns = readWorkspacePatterns(repoRoot);
444444+ const excluded = isExcludedByWorkspacePattern(relSegments, patterns);
445445+ if (!excluded) {
446446+ for (const pattern of patterns) {
447447+ const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern);
448448+ if (projectRoot) return projectRoot;
449449+ }
450450+ }
451451+ if (excluded) return repoRoot;
452452+ if (
453453+ relSegments.length >= 2
454454+ && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0])
455455+ ) {
456456+ return path.join(repoRoot, relSegments[0], relSegments[1]);
457457+ }
458458+ const nearest = nearestProjectLikeRoot(repoRoot, targetDir);
459459+ if (nearest) return nearest;
460460+ return repoRoot;
461461+}
462462+463463+function isExcludedByWorkspacePattern(relSegments, patterns) {
464464+ return patterns.some((rawPattern) => {
465465+ const pattern = normalizeWorkspacePattern(rawPattern);
466466+ if (!pattern.startsWith('!')) return false;
467467+ return workspacePatternMatchesRel(pattern.slice(1), relSegments);
468468+ });
469469+}
470470+471471+function nearestProjectLikeRoot(repoRoot, targetDir) {
472472+ let dir = path.resolve(targetDir);
473473+ const stop = path.resolve(repoRoot);
474474+ while (dir && dir !== stop) {
475475+ if (
476476+ firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
477477+ || fs.existsSync(path.join(dir, 'package.json'))
478478+ ) {
479479+ return dir;
480480+ }
481481+ const parent = path.dirname(dir);
482482+ if (parent === dir) break;
483483+ dir = parent;
484484+ }
485485+ return null;
486486+}
487487+488488+function nearestPackageRootBetween(repoRoot, targetDir, stopDir) {
489489+ let dir = path.resolve(targetDir);
490490+ const stop = path.resolve(stopDir || repoRoot);
491491+ const root = path.resolve(repoRoot);
492492+ while (dir && dir !== stop && isPathInsideOrEqual(dir, root)) {
493493+ if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
494494+ const parent = path.dirname(dir);
495495+ if (parent === dir) break;
496496+ dir = parent;
497497+ }
498498+ return null;
499499+}
500500+501501+function isPathInsideOrEqual(candidate, root) {
502502+ return path.resolve(candidate) === path.resolve(root) || isPathInside(candidate, root);
503503+}
504504+505505+function workspacePatternMatchesRel(pattern, relSegments) {
506506+ const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
507507+ if (!patternSegments.length) return false;
508508+ if (patternSegments.includes('**')) {
509509+ const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
510510+ const literalPrefix = firstGlobIndex === -1
511511+ ? patternSegments
512512+ : patternSegments.slice(0, firstGlobIndex);
513513+ if (relSegments.length < literalPrefix.length + 1) return false;
514514+ for (let i = 0; i < literalPrefix.length; i++) {
515515+ if (!segmentMatches(literalPrefix[i], relSegments[i])) return false;
516516+ }
517517+ return true;
518518+ }
519519+ if (relSegments.length < patternSegments.length) return false;
520520+ for (let i = 0; i < patternSegments.length; i++) {
521521+ if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
522522+ }
523523+ return true;
524524+}
525525+526526+function readWorkspacePatterns(repoRoot) {
527527+ return [
528528+ ...readPackageWorkspaces(repoRoot),
529529+ ...readPnpmWorkspaces(repoRoot),
530530+ ...readLernaWorkspaces(repoRoot),
531531+ ].filter(Boolean);
532532+}
533533+534534+function readPackageWorkspaces(repoRoot) {
535535+ const pkg = readJson(path.join(repoRoot, 'package.json'));
536536+ const workspaces = pkg?.workspaces;
537537+ if (Array.isArray(workspaces)) return workspaces;
538538+ if (Array.isArray(workspaces?.packages)) return workspaces.packages;
539539+ return [];
540540+}
541541+542542+function readLernaWorkspaces(repoRoot) {
543543+ const lerna = readJson(path.join(repoRoot, 'lerna.json'));
544544+ return Array.isArray(lerna?.packages) ? lerna.packages : [];
545545+}
546546+547547+function readPnpmWorkspaces(repoRoot) {
548548+ try {
549549+ const body = fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf-8');
550550+ const patterns = [];
551551+ let inPackages = false;
552552+ for (const line of body.split(/\r?\n/)) {
553553+ const trimmed = stripYamlInlineComment(line).trim();
554554+ if (!trimmed || trimmed.startsWith('#')) continue;
555555+ const flowMatch = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
556556+ if (flowMatch) {
557557+ patterns.push(...parseYamlFlowList(flowMatch[1]));
558558+ inPackages = false;
559559+ continue;
560560+ }
561561+ if (/^packages:\s*$/.test(trimmed)) {
562562+ inPackages = true;
563563+ continue;
564564+ }
565565+ if (inPackages && /^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
566566+ if (inPackages) {
567567+ const match = trimmed.match(/^-\s*(.+)$/);
568568+ if (match) patterns.push(unquoteYamlValue(match[1]));
569569+ }
570570+ }
571571+ return patterns;
572572+ } catch {
573573+ return [];
574574+ }
575575+}
576576+577577+function stripYamlInlineComment(line) {
578578+ let quote = null;
579579+ for (let i = 0; i < line.length; i++) {
580580+ const ch = line[i];
581581+ if ((ch === '"' || ch === "'") && line[i - 1] !== '\\') {
582582+ quote = quote === ch ? null : quote || ch;
583583+ continue;
584584+ }
585585+ if (ch === '#' && !quote) return line.slice(0, i);
586586+ }
587587+ return line;
588588+}
589589+590590+function parseYamlFlowList(body) {
591591+ const items = [];
592592+ let quote = null;
593593+ let current = '';
594594+ for (let i = 0; i < body.length; i++) {
595595+ const ch = body[i];
596596+ if ((ch === '"' || ch === "'") && body[i - 1] !== '\\') {
597597+ quote = quote === ch ? null : quote || ch;
598598+ current += ch;
599599+ continue;
600600+ }
601601+ if (ch === ',' && !quote) {
602602+ const value = unquoteYamlValue(current);
603603+ if (value) items.push(value);
604604+ current = '';
605605+ continue;
606606+ }
607607+ current += ch;
608608+ }
609609+ const value = unquoteYamlValue(current);
610610+ if (value) items.push(value);
611611+ return items;
612612+}
613613+614614+function unquoteYamlValue(value) {
615615+ return String(value || '')
616616+ .trim()
617617+ .replace(/^['"]|['"]$/g, '');
618618+}
619619+620620+function readJson(filePath) {
621621+ try {
622622+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
623623+ } catch {
624624+ return null;
625625+ }
626626+}
627627+628628+function projectRootFromWorkspacePattern(repoRoot, relSegments, rawPattern) {
629629+ const pattern = normalizeWorkspacePattern(rawPattern);
630630+ if (!pattern || pattern.startsWith('!')) return null;
631631+ const patternSegments = pattern.split('/').filter(Boolean);
632632+ if (!patternSegments.length) return null;
633633+ if (patternSegments.includes('**')) {
634634+ return projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments);
635635+ }
636636+ if (relSegments.length < patternSegments.length) return null;
637637+ for (let i = 0; i < patternSegments.length; i++) {
638638+ if (!segmentMatches(patternSegments[i], relSegments[i])) return null;
639639+ }
640640+ return path.join(repoRoot, ...relSegments.slice(0, patternSegments.length));
641641+}
642642+643643+function projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments) {
644644+ const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
645645+ const literalPrefix = firstGlobIndex === -1
646646+ ? patternSegments
647647+ : patternSegments.slice(0, firstGlobIndex);
648648+ if (relSegments.length < literalPrefix.length + 1) return null;
649649+ for (let i = 0; i < literalPrefix.length; i++) {
650650+ if (!segmentMatches(literalPrefix[i], relSegments[i])) return null;
651651+ }
652652+ const prefixDir = path.join(repoRoot, ...literalPrefix);
653653+ const targetDir = path.join(repoRoot, ...relSegments);
654654+ const packageRoot = nearestPackageRootBetween(repoRoot, targetDir, prefixDir);
655655+ if (packageRoot) return packageRoot;
656656+ return path.join(repoRoot, ...relSegments.slice(0, literalPrefix.length + 1));
657657+}
658658+659659+function normalizeWorkspacePattern(pattern) {
660660+ return String(pattern || '')
661661+ .trim()
662662+ .replace(/^['"]|['"]$/g, '')
663663+ .replace(/^\.\//, '')
664664+ .replace(/\/+$/, '');
665665+}
666666+667667+function segmentMatches(patternSegment, relSegment) {
668668+ if (patternSegment === '*') return true;
669669+ if (!patternSegment.includes('*')) return patternSegment === relSegment;
670670+ const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);
671671+ return re.test(relSegment);
672672+}
673673+674674+function firstExisting(dir, names) {
675675+ for (const name of names) {
676676+ const abs = path.join(dir, name);
677677+ if (fs.existsSync(abs)) return abs;
678678+ }
679679+ return null;
680680+}
681681+682682+function safeRead(p) {
683683+ try {
684684+ return fs.readFileSync(p, 'utf-8');
685685+ } catch {
686686+ return null;
687687+ }
688688+}
689689+690690+function escapeRegExp(value) {
691691+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
692692+}
693693+694694+/**
695695+ * Pull the register (`brand` or `product`) out of PRODUCT.md by looking
696696+ * for a `## Register` section and reading the first non-empty line that
697697+ * follows it. Returns null when the file is legacy / register-less.
698698+ */
699699+export function extractRegister(product) {
700700+ if (!product) return null;
701701+ const lines = product.split('\n');
702702+ for (let i = 0; i < lines.length; i++) {
703703+ if (/^##\s+Register\b/i.test(lines[i].trim())) {
704704+ for (let j = i + 1; j < lines.length; j++) {
705705+ const next = lines[j].trim();
706706+ if (!next) continue;
707707+ const word = next.toLowerCase();
708708+ if (word === 'brand' || word === 'product') return word;
709709+ return null;
710710+ }
711711+ }
712712+ }
713713+ return null;
714714+}
715715+716716+/**
717717+ * Read the installed skill's own version from the sibling SKILL.md frontmatter
718718+ * (this file lives at `<skill>/scripts/context.mjs`). Returns null when the
719719+ * frontmatter is missing or unreadable.
720720+ */
721721+function readLocalSkillVersion() {
722722+ try {
723723+ const here = path.dirname(fileURLToPath(import.meta.url));
724724+ const skillMd = path.join(here, '..', 'SKILL.md');
725725+ const content = fs.readFileSync(skillMd, 'utf-8');
726726+ const match = content.match(/^version:\s*(.+)$/m);
727727+ return match ? match[1].trim().replace(/^["']|["']$/g, '') : null;
728728+ } catch {
729729+ return null;
730730+ }
731731+}
732732+733733+function readUpdateCache() {
734734+ try {
735735+ return JSON.parse(fs.readFileSync(UPDATE_CACHE_PATH, 'utf-8'));
736736+ } catch {
737737+ return {};
738738+ }
739739+}
740740+741741+function writeUpdateCache(cache) {
742742+ try {
743743+ fs.mkdirSync(path.dirname(UPDATE_CACHE_PATH), { recursive: true });
744744+ fs.writeFileSync(UPDATE_CACHE_PATH, JSON.stringify(cache));
745745+ } catch {
746746+ // Best-effort: a read-only home dir just means we re-poll next session.
747747+ }
748748+}
749749+750750+/** Compare dotted numeric versions. Returns >0 when a is newer than b. */
751751+function compareSemver(a, b) {
752752+ const pa = String(a).split('.').map(n => parseInt(n, 10) || 0);
753753+ const pb = String(b).split('.').map(n => parseInt(n, 10) || 0);
754754+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
755755+ const diff = (pa[i] || 0) - (pb[i] || 0);
756756+ if (diff !== 0) return diff;
757757+ }
758758+ return 0;
759759+}
760760+761761+async function fetchLatestSkillVersion() {
762762+ try {
763763+ const res = await fetch(`${UPDATE_HOST}/api/version`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
764764+ if (!res.ok) return null;
765765+ const data = await res.json();
766766+ return typeof data?.skills === 'string' ? data.skills : null;
767767+ } catch {
768768+ return null; // offline, sandboxed, timed out, or bad JSON: all non-fatal
769769+ }
770770+}
771771+772772+function buildUpdateDirective(localVersion, latestVersion) {
773773+ return (
774774+ `UPDATE_AVAILABLE: A newer Impeccable skill is available ` +
775775+ `(installed v${localVersion}, latest v${latestVersion}). ` +
776776+ `Before continuing, ask the user once: "A newer Impeccable (v${latestVersion}) is available. ` +
777777+ `Update now? It runs \`npx impeccable update\`." ` +
778778+ `If they agree, run \`npx impeccable update\` (the update applies to the next session, not this one). ` +
779779+ `Either way, continue the current task without waiting, and do not raise this again.`
780780+ );
781781+}
782782+783783+/**
784784+ * Best-effort update directive for the boot output. Returns a string to append
785785+ * or null. Polls the version endpoint at most once per day (cached globally in
786786+ * the user's home dir) and re-surfaces a given version at most once per week so
787787+ * the agent never nags. Opt out entirely with IMPECCABLE_NO_UPDATE_CHECK=1.
788788+ */
789789+// Read the unified config's top-level `updateCheck` (local overrides shared).
790790+// Inlined rather than importing hook-lib so the boot path stays lightweight.
791791+function updateCheckDisabledByConfig(cwd = process.cwd()) {
792792+ let value;
793793+ for (const name of ['config.json', 'config.local.json']) {
794794+ try {
795795+ const raw = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', name), 'utf-8'));
796796+ if (raw && typeof raw === 'object' && typeof raw.updateCheck === 'boolean') value = raw.updateCheck;
797797+ } catch { /* missing or malformed: ignore */ }
798798+ }
799799+ return value === false;
800800+}
801801+802802+async function computeUpdateDirective(now = Date.now()) {
803803+ try {
804804+ if (process.env.IMPECCABLE_NO_UPDATE_CHECK) return null;
805805+ if (updateCheckDisabledByConfig()) return null;
806806+ const localVersion = readLocalSkillVersion();
807807+ if (!localVersion) return null;
808808+809809+ const cache = readUpdateCache();
810810+811811+ // Poll the network only when the throttle window has elapsed. Stamp
812812+ // lastCheck even on failure so an offline machine doesn't poll every boot.
813813+ if (!cache.lastCheck || now - cache.lastCheck > CHECK_INTERVAL_MS) {
814814+ const latest = await fetchLatestSkillVersion();
815815+ cache.lastCheck = now;
816816+ if (latest) cache.latestVersion = latest;
817817+ writeUpdateCache(cache);
818818+ }
819819+820820+ const latest = cache.latestVersion;
821821+ if (!latest || compareSemver(latest, localVersion) <= 0) return null;
822822+823823+ // Anti-nag: surface a given version at most once per RENOTIFY window.
824824+ if (cache.notifiedVersion === latest && cache.notifiedAt && now - cache.notifiedAt < RENOTIFY_INTERVAL_MS) {
825825+ return null;
826826+ }
827827+ cache.notifiedVersion = latest;
828828+ cache.notifiedAt = now;
829829+ writeUpdateCache(cache);
830830+831831+ return buildUpdateDirective(localVersion, latest);
832832+ } catch {
833833+ return null;
834834+ }
835835+}
836836+837837+async function cli() {
838838+ let cliOptions;
839839+ try {
840840+ cliOptions = parseCliOptions(process.argv.slice(2));
841841+ } catch (err) {
842842+ if (err?.name === 'TargetArgError') {
843843+ process.stderr.write(`${err.message}\n`);
844844+ process.exit(1);
845845+ }
846846+ throw err;
847847+ }
848848+ const targetProvided = hasTargetOption(cliOptions);
849849+ const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
850850+ const selection = resolveTargetSelection(process.cwd(), cliOptions);
851851+ if (selection) {
852852+ process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
853853+ process.exit(0);
854854+ }
855855+ const ctx = loadContext(process.cwd(), cliOptions);
856856+ const updateDirective = await computeUpdateDirective();
857857+858858+ if (!ctx.hasProduct) {
859859+ // Direct stdout message instead of relying on empty output as a signal
860860+ // — cheap models miss the empty case more often than the explicit one.
861861+ const parts = [
862862+ 'NO_PRODUCT_MD: This project has no PRODUCT.md yet. ' +
863863+ 'Stop the current task, load reference/init.md, and follow its ' +
864864+ 'instructions to write PRODUCT.md before resuming.',
865865+ ];
866866+ parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
867867+ if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
868868+ parts.push(buildMissingTargetDirective());
869869+ }
870870+ if (updateDirective) parts.push(updateDirective);
871871+ process.stdout.write(parts.join('\n\n---\n\n') + '\n');
872872+ process.exit(0);
873873+ }
874874+ const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
875875+ if (ctx.hasDesign) {
876876+ parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`);
877877+ }
878878+ parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
879879+ if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
880880+ parts.push(buildMissingTargetDirective());
881881+ }
882882+ const register = extractRegister(ctx.product);
883883+ const next = register
884884+ ? `NEXT STEP: This project's register is \`${register}\`. You MUST now read \`reference/${register}.md\` before producing any design output.`
885885+ : `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.`;
886886+ parts.push(next);
887887+ if (updateDirective) parts.push(updateDirective);
888888+ process.stdout.write(parts.join('\n\n---\n\n') + '\n');
889889+}
890890+891891+function parseCliOptions(args) {
892892+ return parseTargetOptions(args, { strict: true });
893893+}
894894+895895+function hasTargetOption(options) {
896896+ return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
897897+}
898898+899899+function pathExistsForTarget(cwd, targetPath) {
900900+ const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
901901+ return fs.existsSync(abs);
902902+}
903903+904904+function buildResolvedContextDirective(ctx, options, { targetExists = null } = {}) {
905905+ const targetPath = hasTargetOption(options) ? options.targetPath : null;
906906+ return `RESOLVED_CONTEXT:\n${JSON.stringify({
907907+ targetPath,
908908+ ...(targetPath ? { targetExists } : {}),
909909+ projectRoot: ctx.projectRoot,
910910+ repoRoot: ctx.repoRoot,
911911+ productPath: ctx.productPath,
912912+ designPath: ctx.designPath,
913913+ }, null, 2)}`;
914914+}
915915+916916+function shouldWarnMissingTarget(ctx, targetProvided, targetExists = null) {
917917+ if (ctx.isMonorepo && targetProvided && targetExists === false) return true;
918918+ return !!(
919919+ ctx.isMonorepo
920920+ && (!targetProvided || targetExists === false)
921921+ && ctx.projectRoot
922922+ && ctx.repoRoot
923923+ && path.resolve(ctx.projectRoot) === path.resolve(ctx.repoRoot)
924924+ );
925925+}
926926+927927+function buildMissingTargetDirective() {
928928+ const script = process.argv[1] || 'context.mjs';
929929+ return (
930930+ 'MONOREPO_TARGET_REQUIRED: This is a monorepo and context.mjs ran without --target. ' +
931931+ 'If the user named a file, route, or child app, do not answer from this output. ' +
932932+ `Rerun \`node ${script} --target <path>\` and answer from that run's RESOLVED_CONTEXT fields.`
933933+ );
934934+}
935935+936936+function buildTargetSelectionDirective(selection) {
937937+ return (
938938+ `TARGET_SELECTION_REQUIRED:\n${JSON.stringify(selection, null, 2)}\n\n` +
939939+ '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. ' +
940940+ 'Ask the user which app Impeccable should use, then rerun Impeccable helper commands from that child app cwd using this same scripts directory. ' +
941941+ 'Use `--target <path>` only as a fallback when changing cwd is not possible, or when the user explicitly named a file/path.'
942942+ );
943943+}
944944+945945+// Run cli() only when this module is the entry point. Compare realpaths
946946+// rather than endsWith(): a loose suffix match also fires for unrelated
947947+// scripts like `load-context.mjs`, and realpath tolerates symlinked
948948+// invocation (the test harness symlinks the skill dir).
949949+function invokedAsScript() {
950950+ const arg = process.argv[1];
951951+ if (!arg) return false;
952952+ try {
953953+ return fs.realpathSync(arg) === fs.realpathSync(fileURLToPath(import.meta.url));
954954+ } catch {
955955+ return false;
956956+ }
957957+}
958958+959959+if (invokedAsScript()) {
960960+ cli();
961961+}
···11+const ANTIPATTERNS = [
22+ // ── AI slop: tells that something was AI-generated ──
33+ {
44+ id: 'side-tab',
55+ category: 'slop',
66+ name: 'Side-tab accent border',
77+ description:
88+ '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.',
99+ skillSection: 'Visual Details',
1010+ skillGuideline: 'colored accent stripe',
1111+ },
1212+ {
1313+ id: 'border-accent-on-rounded',
1414+ category: 'slop',
1515+ name: 'Border accent on rounded element',
1616+ description:
1717+ 'Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.',
1818+ skillSection: 'Visual Details',
1919+ skillGuideline: 'colored accent stripe',
2020+ },
2121+ {
2222+ id: 'overused-font',
2323+ category: 'slop',
2424+ name: 'Overused font',
2525+ description:
2626+ '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.',
2727+ skillSection: 'Typography',
2828+ skillGuideline: 'overused fonts like Inter',
2929+ },
3030+ {
3131+ id: 'single-font',
3232+ category: 'slop',
3333+ name: 'Single font for everything',
3434+ description:
3535+ 'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.',
3636+ skillSection: 'Typography',
3737+ skillGuideline: 'only one font family for the entire page',
3838+ },
3939+ {
4040+ id: 'flat-type-hierarchy',
4141+ category: 'slop',
4242+ name: 'Flat type hierarchy',
4343+ description:
4444+ '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).',
4545+ skillSection: 'Typography',
4646+ skillGuideline: 'flat type hierarchy',
4747+ },
4848+ {
4949+ id: 'gradient-text',
5050+ category: 'slop',
5151+ name: 'Gradient text',
5252+ description:
5353+ 'Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.',
5454+ skillSection: 'Color & Contrast',
5555+ skillGuideline: 'gradient text for',
5656+ },
5757+ {
5858+ id: 'ai-color-palette',
5959+ category: 'slop',
6060+ name: 'AI color palette',
6161+ description:
6262+ 'Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.',
6363+ skillSection: 'Color & Contrast',
6464+ skillGuideline: 'AI color palette',
6565+ },
6666+ {
6767+ id: 'cream-palette',
6868+ category: 'slop',
6969+ name: 'Cream / beige palette',
7070+ description:
7171+ '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.',
7272+ skillSection: 'Color & Contrast',
7373+ skillGuideline: 'cream and beige as the default surface',
7474+ },
7575+ {
7676+ id: 'nested-cards',
7777+ category: 'slop',
7878+ name: 'Nested cards',
7979+ description:
8080+ 'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.',
8181+ skillSection: 'Layout & Space',
8282+ skillGuideline: 'Nest cards inside cards',
8383+ },
8484+ {
8585+ id: 'monotonous-spacing',
8686+ category: 'slop',
8787+ name: 'Monotonous spacing',
8888+ description:
8989+ 'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.',
9090+ skillSection: 'Layout & Space',
9191+ skillGuideline: 'same spacing everywhere',
9292+ },
9393+ {
9494+ id: 'bounce-easing',
9595+ category: 'slop',
9696+ name: 'Bounce or elastic easing',
9797+ description:
9898+ 'Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.',
9999+ skillSection: 'Motion',
100100+ skillGuideline: 'bounce or elastic easing',
101101+ },
102102+ {
103103+ id: 'dark-glow',
104104+ category: 'slop',
105105+ name: 'Dark mode with glowing accents',
106106+ description:
107107+ '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.',
108108+ skillSection: 'Color & Contrast',
109109+ skillGuideline: 'dark mode with glowing accents',
110110+ },
111111+ {
112112+ id: 'icon-tile-stack',
113113+ category: 'slop',
114114+ name: 'Icon tile stacked above heading',
115115+ description:
116116+ '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.',
117117+ skillSection: 'Typography',
118118+ skillGuideline: 'large icons with rounded corners above every heading',
119119+ },
120120+ {
121121+ id: 'italic-serif-display',
122122+ category: 'slop',
123123+ name: 'Italic serif display headline',
124124+ description:
125125+ '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.',
126126+ skillSection: 'Typography',
127127+ skillGuideline: 'oversized italic serif as the hero headline',
128128+ },
129129+ {
130130+ id: 'hero-eyebrow-chip',
131131+ category: 'slop',
132132+ name: 'Hero eyebrow / pill chip',
133133+ description:
134134+ '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.',
135135+ skillSection: 'Typography',
136136+ skillGuideline: 'tiny uppercase tracked label above the hero headline',
137137+ },
138138+ {
139139+ id: 'repeated-section-kickers',
140140+ category: 'slop',
141141+ severity: 'advisory',
142142+ name: 'Repeated section kicker labels',
143143+ description:
144144+ '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.',
145145+ skillSection: 'Typography',
146146+ skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
147147+ },
148148+ {
149149+ id: 'numbered-section-markers',
150150+ category: 'slop',
151151+ severity: 'advisory',
152152+ name: 'Numbered section markers (01 / 02 / 03)',
153153+ description:
154154+ '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.',
155155+ skillSection: 'Layout & Space',
156156+ skillGuideline: 'numbered section markers',
157157+ },
158158+ {
159159+ id: 'em-dash-overuse',
160160+ category: 'slop',
161161+ name: 'Em-dash overuse',
162162+ description:
163163+ 'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.',
164164+ skillSection: 'Copy',
165165+ skillGuideline: 'no em dashes',
166166+ },
167167+ {
168168+ id: 'marketing-buzzword',
169169+ category: 'slop',
170170+ name: 'Marketing buzzword',
171171+ description:
172172+ '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.',
173173+ skillSection: 'Copy',
174174+ skillGuideline: 'marketing buzzwords',
175175+ },
176176+ {
177177+ id: 'aphoristic-cadence',
178178+ category: 'slop',
179179+ name: 'Aphoristic-cadence copy',
180180+ description:
181181+ '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.',
182182+ skillSection: 'Copy',
183183+ skillGuideline: 'aphoristic cadence',
184184+ },
185185+ {
186186+ id: 'oversized-h1',
187187+ category: 'slop',
188188+ name: 'Oversized hero headline',
189189+ description:
190190+ '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.',
191191+ skillSection: 'Typography',
192192+ skillGuideline: 'long headline set at display size',
193193+ },
194194+ {
195195+ id: 'extreme-negative-tracking',
196196+ category: 'slop',
197197+ name: 'Crushed letter spacing',
198198+ description:
199199+ 'Letter-spacing pulled tighter than the point where characters keep their own shapes costs legibility. Tighten display type optically, not destructively.',
200200+ skillSection: 'Typography',
201201+ skillGuideline: 'letter spacing crushed past legibility',
202202+ },
203203+ {
204204+ id: 'broken-image',
205205+ category: 'quality',
206206+ name: 'Broken or placeholder image',
207207+ description:
208208+ '<img> tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.',
209209+ skillSection: 'Imagery',
210210+ skillGuideline: 'broken image references',
211211+ },
212212+213213+ // ── Quality: general design and accessibility issues ──
214214+ {
215215+ id: 'gray-on-color',
216216+ category: 'quality',
217217+ name: 'Gray text on colored background',
218218+ description:
219219+ 'Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.',
220220+ skillSection: 'Color & Contrast',
221221+ skillGuideline: 'gray text on colored backgrounds',
222222+ },
223223+ {
224224+ id: 'low-contrast',
225225+ category: 'quality',
226226+ name: 'Low contrast text',
227227+ description:
228228+ '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.',
229229+ },
230230+ {
231231+ id: 'layout-transition',
232232+ category: 'quality',
233233+ name: 'Layout property animation',
234234+ description:
235235+ 'Animating width, height, padding, or margin causes layout thrash and janky performance. Use transform and opacity instead, or grid-template-rows for height animations.',
236236+ skillSection: 'Motion',
237237+ skillGuideline: 'Animate layout properties',
238238+ },
239239+ {
240240+ id: 'line-length',
241241+ category: 'quality',
242242+ name: 'Line length too long',
243243+ description:
244244+ '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.',
245245+ skillSection: 'Layout & Space',
246246+ skillGuideline: 'wrap beyond ~80 characters',
247247+ },
248248+ {
249249+ id: 'cramped-padding',
250250+ category: 'quality',
251251+ name: 'Cramped padding',
252252+ description:
253253+ '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.',
254254+ skillSection: 'Layout & Space',
255255+ skillGuideline: 'inside bordered or colored containers',
256256+ },
257257+ {
258258+ id: 'body-text-viewport-edge',
259259+ category: 'quality',
260260+ name: 'Body text touching viewport edge',
261261+ description:
262262+ '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.',
263263+ },
264264+ {
265265+ id: 'tight-leading',
266266+ category: 'quality',
267267+ name: 'Tight line height',
268268+ description:
269269+ '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.',
270270+ },
271271+ {
272272+ id: 'skipped-heading',
273273+ category: 'quality',
274274+ name: 'Skipped heading level',
275275+ description:
276276+ '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.',
277277+ },
278278+ {
279279+ id: 'justified-text',
280280+ category: 'quality',
281281+ name: 'Justified text',
282282+ description:
283283+ '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.',
284284+ },
285285+ {
286286+ id: 'tiny-text',
287287+ category: 'quality',
288288+ name: 'Tiny body text',
289289+ description:
290290+ 'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.',
291291+ },
292292+ {
293293+ id: 'all-caps-body',
294294+ category: 'quality',
295295+ name: 'All-caps body text',
296296+ description:
297297+ '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.',
298298+ skillSection: 'Typography',
299299+ skillGuideline: 'long body passages in uppercase',
300300+ },
301301+ {
302302+ id: 'wide-tracking',
303303+ category: 'quality',
304304+ name: 'Wide letter spacing on body text',
305305+ description:
306306+ 'Letter spacing above 0.05em on body text disrupts natural character groupings and slows reading. Reserve wide tracking for short uppercase labels only.',
307307+ },
308308+ {
309309+ id: 'text-overflow',
310310+ category: 'quality',
311311+ name: 'Content overflowing its container',
312312+ description:
313313+ '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.',
314314+ skillSection: 'Layout & Space',
315315+ skillGuideline: 'content wider than its container',
316316+ },
317317+ {
318318+ id: 'clipped-overflow-container',
319319+ category: 'quality',
320320+ name: 'Positioned child clipped by overflow container',
321321+ description:
322322+ '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.',
323323+ skillSection: 'Layout & Space',
324324+ skillGuideline: 'overflow container clipping positioned children',
325325+ },
326326+ {
327327+ id: 'design-system-font',
328328+ category: 'quality',
329329+ name: 'Font outside DESIGN.md',
330330+ description:
331331+ '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.',
332332+ skillSection: 'Typography',
333333+ skillGuideline: 'font family outside the project design system',
334334+ },
335335+ {
336336+ id: 'design-system-color',
337337+ category: 'quality',
338338+ severity: 'advisory',
339339+ name: 'Color outside DESIGN.md',
340340+ description:
341341+ '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.',
342342+ skillSection: 'Color & Contrast',
343343+ skillGuideline: 'literal color outside the project design system',
344344+ },
345345+ {
346346+ id: 'design-system-radius',
347347+ category: 'quality',
348348+ severity: 'advisory',
349349+ name: 'Radius outside DESIGN.md',
350350+ description:
351351+ '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.',
352352+ skillSection: 'Visual Details',
353353+ skillGuideline: 'border radius outside the project design system',
354354+ },
355355+356356+ // ── Provider tells: opt-in via --gpt / --gemini (gated off by default) ──
357357+ {
358358+ id: 'gpt-thin-border-wide-shadow',
359359+ category: 'slop',
360360+ severity: 'advisory',
361361+ gated: 'gpt',
362362+ name: 'Hairline border with wide shadow',
363363+ description:
364364+ '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.',
365365+ skillSection: 'Visual Details',
366366+ skillGuideline: 'hairline border plus wide diffuse shadow',
367367+ },
368368+ {
369369+ id: 'repeating-stripes-gradient',
370370+ category: 'slop',
371371+ severity: 'advisory',
372372+ gated: 'gpt',
373373+ name: 'Repeating-gradient stripes',
374374+ description:
375375+ 'Repeating-gradient stripes used as surface decoration are a recurring generated-UI signature. Reach for a deliberate texture or leave the surface plain.',
376376+ skillSection: 'Visual Details',
377377+ skillGuideline: 'repeating-gradient decorative stripes',
378378+ },
379379+ {
380380+ id: 'theater-slop-phrase',
381381+ category: 'slop',
382382+ severity: 'advisory',
383383+ gated: 'gpt',
384384+ name: 'Theater framing copy',
385385+ description:
386386+ 'Dismissing something as "theater" is a recurring generated-copy tic. Say plainly what the thing does or does not do.',
387387+ skillSection: 'Copy',
388388+ skillGuideline: 'theater framing copy',
389389+ },
390390+ {
391391+ id: 'image-hover-transform',
392392+ category: 'slop',
393393+ severity: 'advisory',
394394+ gated: 'gemini',
395395+ name: 'Image hover transform',
396396+ description:
397397+ 'Scaling or rotating an image on hover is a recurring generated-UI signature. Let imagery sit still, or use a subtler, purposeful interaction.',
398398+ skillSection: 'Motion',
399399+ skillGuideline: 'image scale or rotate on hover',
400400+ },
401401+];
402402+403403+const RULE_ENGINE_SUPPORT = {
404404+ regex: new Set(['source', 'page-analyzer']),
405405+ 'static-html': new Set(['element', 'page']),
406406+ browser: new Set(['element', 'page', 'layout']),
407407+ visual: new Set(['visual-contrast']),
408408+};
409409+410410+function getAntipattern(id) {
411411+ return ANTIPATTERNS.find(rule => rule.id === id);
412412+}
413413+414414+function getRulesForCategory(category) {
415415+ return ANTIPATTERNS.filter(rule => rule.category === category);
416416+}
417417+418418+function getRuleEngineSupport(engine) {
419419+ return RULE_ENGINE_SUPPORT[engine] || new Set();
420420+}
421421+422422+// Set of provider tags that gate rules off by default (e.g. 'gpt', 'gemini').
423423+const GATED_PROVIDERS = new Set(
424424+ ANTIPATTERNS.map(rule => rule.gated).filter(Boolean),
425425+);
426426+427427+// Drop findings for rules gated behind a provider tag unless that provider
428428+// was explicitly enabled (CLI --gpt / --gemini). Non-gated findings always
429429+// pass through. `findings` carry the rule id on `.antipattern`.
430430+function filterByProviders(findings, providers = []) {
431431+ const enabled = new Set(providers || []);
432432+ if (!GATED_PROVIDERS.size) return findings;
433433+ return findings.filter(f => {
434434+ const rule = getAntipattern(f.antipattern);
435435+ if (!rule || !rule.gated) return true;
436436+ return enabled.has(rule.gated);
437437+ });
438438+}
439439+440440+export {
441441+ ANTIPATTERNS,
442442+ RULE_ENGINE_SUPPORT,
443443+ GATED_PROVIDERS,
444444+ getAntipattern,
445445+ getRulesForCategory,
446446+ getRuleEngineSupport,
447447+ filterByProviders,
448448+};
+2671
tools/design-detector/detector/rules/checks.mjs
···11+import {
22+ BORDER_SAFE_TAGS,
33+ GENERIC_FONTS,
44+ KNOWN_SERIF_FONTS,
55+ OVERUSED_FONTS,
66+ SAFE_TAGS,
77+ WCAG_LARGE_BOLD_TEXT_PX,
88+ WCAG_LARGE_TEXT_PX,
99+ isBrandFontOnOwnDomain,
1010+} from '../shared/constants.mjs';
1111+import {
1212+ colorToHex,
1313+ contrastRatio,
1414+ getHue,
1515+ hasChroma,
1616+ isNeutralColor,
1717+ parseGradientColors,
1818+ parseRgb,
1919+ relativeLuminance,
2020+} from '../shared/color.mjs';
2121+2222+const DETECTOR_IS_BROWSER = typeof window !== 'undefined';
2323+2424+// ─── Section 3: Pure Detection ──────────────────────────────────────────────
2525+2626+function checkBorders(tag, widths, colors, radius) {
2727+ if (BORDER_SAFE_TAGS.has(tag)) return [];
2828+ const findings = [];
2929+ const sides = ['Top', 'Right', 'Bottom', 'Left'];
3030+3131+ for (const side of sides) {
3232+ const w = widths[side];
3333+ if (w < 1 || isNeutralColor(colors[side])) continue;
3434+3535+ const otherSides = sides.filter(s => s !== side);
3636+ const maxOther = Math.max(...otherSides.map(s => widths[s]));
3737+ if (!(w >= 2 && (maxOther <= 1 || w >= maxOther * 2))) continue;
3838+3939+ const sn = side.toLowerCase();
4040+ const isSide = side === 'Left' || side === 'Right';
4141+4242+ if (isSide) {
4343+ if (radius > 0) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });
4444+ else if (w >= 3) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` });
4545+ } else {
4646+ if (radius > 0 && w >= 2) findings.push({ id: 'border-accent-on-rounded', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });
4747+ }
4848+ }
4949+5050+ return findings;
5151+}
5252+5353+// Returns true if the given text is composed entirely of emoji characters
5454+// (plus whitespace / variation selectors). Emojis render as multicolor glyphs
5555+// regardless of CSS `color`, so contrast checks against the element's text
5656+// color are meaningless for these nodes.
5757+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;
5858+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;
5959+function isEmojiOnlyText(text) {
6060+ if (!text) return false;
6161+ if (!EMOJI_CHAR_RE.test(text)) return false;
6262+ return text.replace(EMOJI_CHARS_GLOBAL, '').trim() === '';
6363+}
6464+6565+function checkColors(opts) {
6666+ const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, isEmojiOnly, bgClip, bgImage, classList } = opts;
6767+ if (SAFE_TAGS.has(tag)) {
6868+ // Exception for <a> and <button> elements styled as buttons. SAFE_TAGS
6969+ // exists to suppress contrast noise on inline links and unstyled controls,
7070+ // where the element has no own background and the contrast against the
7171+ // ancestor surface is already the intended visual. When the element has
7272+ // its own opaque background and direct text, it is a styled button — and
7373+ // contrast on its own surface is a real, frequent bug worth flagging.
7474+ const isStyledButton = (tag === 'a' || tag === 'button')
7575+ && hasDirectText
7676+ && bgColor && bgColor.a > 0.5;
7777+ if (!isStyledButton) return [];
7878+ }
7979+ const findings = [];
8080+8181+ if (hasDirectText && textColor && !isEmojiOnly) {
8282+ // Run background-dependent checks against either a solid bg or, if the
8383+ // ancestor is a gradient, against every gradient stop (use the worst case).
8484+ const bgs = effectiveBg ? [effectiveBg] : (effectiveBgStops && effectiveBgStops.length ? effectiveBgStops : null);
8585+ if (bgs) {
8686+ // Gray on colored background — flag if every stop is chromatic
8787+ const textLum = relativeLuminance(textColor);
8888+ const isGray = !hasChroma(textColor, 20) && textLum > 0.05 && textLum < 0.85;
8989+ if (isGray && bgs.every(b => hasChroma(b, 40))) {
9090+ const bgLabel = effectiveBg ? colorToHex(effectiveBg) : `gradient(${bgs.map(colorToHex).join(', ')})`;
9191+ findings.push({ id: 'gray-on-color', snippet: `text ${colorToHex(textColor)} on bg ${bgLabel}` });
9292+ }
9393+9494+ // Low contrast (WCAG AA) — worst case across all bg stops
9595+ const ratios = bgs.map(b => contrastRatio(textColor, b));
9696+ let worstIdx = 0;
9797+ for (let i = 1; i < ratios.length; i++) if (ratios[i] < ratios[worstIdx]) worstIdx = i;
9898+ const ratio = ratios[worstIdx];
9999+ const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
100100+ const threshold = isLargeText ? 3.0 : 4.5;
101101+ if (ratio < threshold) {
102102+ // Skip the false-positive class where text has alpha < 1 AND we
103103+ // couldn't find an opaque ancestor (effectiveBg is null, we're
104104+ // comparing against gradient-stop fallback). In jsdom mode the
105105+ // detector can't resolve `var(--X)` color tokens, so a dark
106106+ // section sitting between the text and the body's decorative
107107+ // gradient is invisible to us — we end up measuring contrast
108108+ // against the body's paper-grain noise instead of the real
109109+ // local bg. Real low-contrast bugs use alpha=1 and have a
110110+ // resolvable opaque ancestor; semi-transparent Tailwind tokens
111111+ // like `text-paper/60` on `bg-ink` sections are the FP pattern.
112112+ const isAlphaFallbackFP = !DETECTOR_IS_BROWSER && !effectiveBg && (textColor.a != null && textColor.a < 1);
113113+ if (!isAlphaFallbackFP) {
114114+ findings.push({ id: 'low-contrast', snippet: `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}` });
115115+ }
116116+ }
117117+ }
118118+119119+ // AI palette: purple/violet on headings
120120+ if (hasChroma(textColor, 50)) {
121121+ const hue = getHue(textColor);
122122+ if (hue >= 260 && hue <= 310 && (['h1', 'h2', 'h3'].includes(tag) || fontSize >= 20)) {
123123+ findings.push({ id: 'ai-color-palette', snippet: `Purple/violet text (${colorToHex(textColor)}) on heading` });
124124+ }
125125+ }
126126+ }
127127+128128+ // Gradient text
129129+ if (bgClip === 'text' && bgImage && bgImage.includes('gradient')) {
130130+ findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
131131+ }
132132+133133+ // Tailwind class checks
134134+ if (classList) {
135135+ const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
136136+137137+ const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
138138+ const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
139139+ if (grayMatch && colorBgMatch) {
140140+ findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
141141+ }
142142+143143+ if (/\bbg-clip-text\b/.test(classStr) && /\bbg-gradient-to-/.test(classStr)) {
144144+ findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
145145+ }
146146+147147+ const purpleText = classStr.match(/\btext-(?:purple|violet|indigo)-\d+\b/);
148148+ if (purpleText && (['h1', 'h2', 'h3'].includes(tag) || /\btext-(?:[2-9]xl)\b/.test(classStr))) {
149149+ findings.push({ id: 'ai-color-palette', snippet: `${purpleText[0]} on heading` });
150150+ }
151151+152152+ if (/\bfrom-(?:purple|violet|indigo)-\d+\b/.test(classStr) && /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(classStr)) {
153153+ findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient (Tailwind)' });
154154+ }
155155+ }
156156+157157+ return findings;
158158+}
159159+160160+function isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg) {
161161+ if (!hasShadow && !hasBorder) return false;
162162+ return hasRadius || hasBg;
163163+}
164164+165165+const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
166166+167167+// Pure check: given a heading and metrics about its previousElementSibling,
168168+// decide if the sibling is the canonical "icon-tile-stacked-above-heading" shape.
169169+//
170170+// Triggers when ALL of the following hold for the sibling:
171171+// • size 32–128px on both axes (not too small, not a hero image)
172172+// • aspect ratio 0.7–1.4 (squarish — excludes wide thumbnails / pill badges)
173173+// • has a non-transparent background-color, background-image, OR a visible border
174174+// (covers solid colors, white-with-border, gradients — anything that visually
175175+// defines a tile)
176176+// • border-radius < width/2 (excludes round avatars; rounded squares pass)
177177+// • contains an <svg> or icon-class <i> element that's smaller than the tile
178178+// • the tile sits above the heading (its bottom is above the heading's top)
179179+function checkIconTile(opts) {
180180+ const { headingTag, headingText, headingTop,
181181+ siblingTag, siblingWidth, siblingHeight, siblingBottom,
182182+ siblingBgColor, siblingBgImage, siblingBorderWidth, siblingBorderRadius,
183183+ hasIconChild, iconChildWidth } = opts;
184184+ if (!HEADING_TAGS.has(headingTag)) return [];
185185+ if (!siblingTag) return [];
186186+ // Don't recurse into nested headings (e.g. h2 above h3 in a section header)
187187+ if (HEADING_TAGS.has(siblingTag)) return [];
188188+189189+ // Size window: 32–128px on each axis
190190+ if (!(siblingWidth >= 32 && siblingWidth <= 128)) return [];
191191+ if (!(siblingHeight >= 32 && siblingHeight <= 128)) return [];
192192+193193+ // Squarish aspect ratio
194194+ const ratio = siblingWidth / siblingHeight;
195195+ if (ratio < 0.7 || ratio > 1.4) return [];
196196+197197+ // Must have something that visually defines the tile
198198+ const bgVisible = (siblingBgColor && siblingBgColor.a > 0.1)
199199+ || (siblingBgImage && siblingBgImage !== 'none' && siblingBgImage !== '');
200200+ const borderVisible = siblingBorderWidth > 0;
201201+ if (!bgVisible && !borderVisible) return [];
202202+203203+ // Exclude circles (avatars). Rounded squares pass.
204204+ if (siblingBorderRadius >= siblingWidth / 2) return [];
205205+206206+ // Must contain an icon element smaller than the tile
207207+ if (!hasIconChild) return [];
208208+ if (iconChildWidth && iconChildWidth >= siblingWidth * 0.95) return [];
209209+210210+ // Vertical stacking: tile must end above where the heading starts.
211211+ // (Allow the check to skip when both top/bottom are 0 — jsdom layout case.)
212212+ if (headingTop && siblingBottom && siblingBottom > headingTop + 4) return [];
213213+214214+ const text = (headingText || '').trim().slice(0, 60);
215215+ return [{
216216+ id: 'icon-tile-stack',
217217+ snippet: `${Math.round(siblingWidth)}x${Math.round(siblingHeight)}px icon tile above ${headingTag} "${text}"`,
218218+ }];
219219+}
220220+221221+// Resolve the primary (non-generic) face from a font-family string and return
222222+// whether the resolved primary is serif. Two paths:
223223+// 1. Primary face is in KNOWN_SERIF_FONTS → serif.
224224+// 2. Primary face is unknown but the stack ends in the generic `serif`
225225+// token → treat as serif. Authors who declare `font-family: 'X', serif`
226226+// almost always have a serif primary; a sans declared with a serif
227227+// fallback is a code smell, not the common case.
228228+// Returns { primary, isSerif } so the snippet can name the face.
229229+function resolveSerif(fontFamily) {
230230+ if (!fontFamily) return { primary: null, isSerif: false };
231231+ const tokens = fontFamily.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
232232+ const primary = tokens.find(f => f && !GENERIC_FONTS.has(f)) || null;
233233+ if (!primary) return { primary: null, isSerif: false };
234234+ if (KNOWN_SERIF_FONTS.has(primary)) return { primary, isSerif: true };
235235+ if (tokens.includes('serif')) return { primary, isSerif: true };
236236+ return { primary, isSerif: false };
237237+}
238238+239239+function checkItalicSerif(opts) {
240240+ const { tag, fontStyle, fontFamily, fontSize, headingText } = opts;
241241+ if (fontStyle !== 'italic') return [];
242242+ // Anchor the rule on hero-scale text. h1 is the canonical hero element;
243243+ // h2 ≥ 48px catches the cases where the design demotes the visual hero
244244+ // to an h2 but keeps the size.
245245+ if (tag !== 'h1' && !(tag === 'h2' && fontSize >= 48)) return [];
246246+ if (fontSize < 48) return [];
247247+ const { primary, isSerif } = resolveSerif(fontFamily);
248248+ if (!isSerif) return [];
249249+250250+ const text = (headingText || '').trim().slice(0, 60);
251251+ return [{
252252+ id: 'italic-serif-display',
253253+ snippet: `italic serif ${tag} (${primary || 'serif'}) at ${Math.round(fontSize)}px "${text}"`,
254254+ }];
255255+}
256256+257257+// Color saturation check. Returns true when the color has visible
258258+// chroma — i.e., it's an "accent color" rather than near-neutral.
259259+// Handles rgb()/rgba(), #hex, oklch(), and hsl(). var() refs are
260260+// expected to be pre-resolved by the caller.
261261+function isAccentColor(cssColor) {
262262+ if (!cssColor) return false;
263263+ const s = String(cssColor).trim();
264264+ // rgb / rgba — direct channel-distance check.
265265+ const rgbM = /rgba?\(\s*(\d+)\s*,?\s+|\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s.replace(/rgba?\(\s*/, 'rgb(').replace(/,/g, ', '));
266266+ const rgbStrict = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s);
267267+ if (rgbStrict) {
268268+ const r = +rgbStrict[1], g = +rgbStrict[2], b = +rgbStrict[3];
269269+ return (Math.max(r, g, b) - Math.min(r, g, b)) >= 40;
270270+ }
271271+ // #hex — 3, 4, 6, or 8 digit.
272272+ const hexM = /^#([0-9a-f]{3,8})\b/i.exec(s);
273273+ if (hexM) {
274274+ let h = hexM[1];
275275+ if (h.length === 3 || h.length === 4) h = h.split('').map((c) => c + c).join('').slice(0, 6);
276276+ else h = h.slice(0, 6);
277277+ if (h.length === 6) {
278278+ const r = parseInt(h.slice(0, 2), 16);
279279+ const g = parseInt(h.slice(2, 4), 16);
280280+ const b = parseInt(h.slice(4, 6), 16);
281281+ return (Math.max(r, g, b) - Math.min(r, g, b)) >= 40;
282282+ }
283283+ }
284284+ // oklch(L C H) — chroma C is what matters. Typical neutral grays
285285+ // have C < 0.02; visible accents are 0.05+. CSS minification can
286286+ // collapse spaces between L% and C ("oklch(43%.15 34)"), so we
287287+ // extract all numbers and take the second rather than matching a
288288+ // strict L-then-whitespace-then-C pattern.
289289+ if (/^oklch\(/i.test(s)) {
290290+ const nums = s.match(/\d*\.\d+|\d+/g);
291291+ if (nums && nums.length >= 2) {
292292+ const c = parseFloat(nums[1]);
293293+ return !Number.isNaN(c) && c >= 0.05;
294294+ }
295295+ }
296296+ // hsl(H, S%, L%) — saturation > 20% reads as accent.
297297+ const hslM = /hsla?\(\s*[\d.]+\s*,\s*([\d.]+)%/i.exec(s);
298298+ if (hslM) {
299299+ const sat = parseFloat(hslM[1]);
300300+ return !Number.isNaN(sat) && sat >= 20;
301301+ }
302302+ return false;
303303+}
304304+305305+// Sibling-relationship rule. Anchor on a hero-scale h1, look at the
306306+// previousElementSibling, and gate on EITHER the classic tracked-
307307+// uppercase eyebrow OR the modern accent-colored bold eyebrow.
308308+function checkHeroEyebrow(opts) {
309309+ const {
310310+ headingTag, headingText, headingFontSize,
311311+ siblingTag, siblingText, siblingTextTransform,
312312+ siblingFontSize, siblingLetterSpacing,
313313+ siblingFontWeight, siblingColor,
314314+ } = opts;
315315+ if (headingTag !== 'h1') return [];
316316+ // We previously gated on headingFontSize >= 48 to anchor "hero scale".
317317+ // But modern hero h1s use clamp() / vw / var(--text-*), none of which
318318+ // jsdom can resolve — the computed value comes back as "2em" or
319319+ // "var(--text-9xl)" and parseFloat returns 2 or NaN. The gate fails
320320+ // on virtually every Tailwind v4 / framework build. The other gates
321321+ // (sibling text 2-60 chars, font-size ≤ 14px, accent-bold OR
322322+ // tracked-caps) are tight enough to avoid false positives on non-
323323+ // hero h1s — a tiny tan label directly above any h1 is the
324324+ // antipattern regardless of how big the h1 ends up.
325325+ if (!siblingTag) return [];
326326+ // An h2 above an h1 is a different anti-pattern (heading hierarchy / dual
327327+ // headings) — never an eyebrow.
328328+ if (HEADING_TAGS.has(siblingTag)) return [];
329329+330330+ const text = (siblingText || '').trim();
331331+ if (text.length < 2 || text.length > 60) return [];
332332+ if (!(siblingFontSize > 0 && siblingFontSize <= 14)) return [];
333333+334334+ // Branch A: classic tracked-uppercase eyebrow.
335335+ const isUppercased = siblingTextTransform === 'uppercase'
336336+ || (/[A-Z]/.test(text) && !/[a-z]/.test(text));
337337+ const isClassicTracked = isUppercased && siblingLetterSpacing >= 1.6;
338338+339339+ // Branch B: modern accent-bold eyebrow — sentence case, low
340340+ // tracking, but bold + accent-colored. The style choices changed;
341341+ // the pattern is the same kicker-above-headline anti-pattern.
342342+ const weight = Number(siblingFontWeight) || 400;
343343+ const isAccentBold = weight >= 700 && isAccentColor(siblingColor || '');
344344+345345+ if (!isClassicTracked && !isAccentBold) return [];
346346+347347+ const headingTextSnippet = (headingText || '').trim().slice(0, 60);
348348+ const eyebrowSnippet = text.slice(0, 40);
349349+ const style = isClassicTracked ? 'tracked-caps' : 'accent-bold';
350350+ return [{
351351+ id: 'hero-eyebrow-chip',
352352+ snippet: `eyebrow chip (${style}) "${eyebrowSnippet}" above ${headingTag} "${headingTextSnippet}"`,
353353+ }];
354354+}
355355+356356+function checkRepeatedSectionKickers(opts) {
357357+ const { candidates, minCount = 3 } = opts;
358358+ if (!Array.isArray(candidates) || candidates.length < minCount) return [];
359359+ return candidates.map(candidate => ({
360360+ id: 'repeated-section-kickers',
361361+ snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
362362+ }));
363363+}
364364+365365+const LAYOUT_TRANSITION_PROPS = new Set([
366366+ 'width', 'height', 'padding', 'margin',
367367+ 'max-height', 'max-width', 'min-height', 'min-width',
368368+ 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
369369+ 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
370370+]);
371371+372372+function checkMotion(opts) {
373373+ const { tag, transitionProperty, animationName, timingFunctions, classList } = opts;
374374+ if (SAFE_TAGS.has(tag)) return [];
375375+ const findings = [];
376376+377377+ // --- Bounce/elastic easing ---
378378+ if (animationName && animationName !== 'none' && /bounce|elastic|wobble|jiggle|spring/i.test(animationName)) {
379379+ findings.push({ id: 'bounce-easing', snippet: `animation: ${animationName}` });
380380+ }
381381+ if (classList && /\banimate-bounce\b/.test(classList)) {
382382+ findings.push({ id: 'bounce-easing', snippet: 'animate-bounce (Tailwind)' });
383383+ }
384384+385385+ // Check timing functions for overshoot cubic-bezier (y values outside [0, 1])
386386+ if (timingFunctions) {
387387+ const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g;
388388+ let m;
389389+ while ((m = bezierRe.exec(timingFunctions)) !== null) {
390390+ const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
391391+ if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
392392+ findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` });
393393+ break;
394394+ }
395395+ }
396396+ }
397397+398398+ // --- Layout property transition ---
399399+ if (transitionProperty && transitionProperty !== 'all' && transitionProperty !== 'none') {
400400+ const props = transitionProperty.split(',').map(p => p.trim().toLowerCase());
401401+ const layoutFound = props.filter(p => LAYOUT_TRANSITION_PROPS.has(p));
402402+ if (layoutFound.length > 0) {
403403+ findings.push({ id: 'layout-transition', snippet: `transition: ${layoutFound.join(', ')}` });
404404+ }
405405+ }
406406+407407+ return findings;
408408+}
409409+410410+function checkGlow(opts) {
411411+ const { boxShadow, effectiveBg } = opts;
412412+ if (!boxShadow || boxShadow === 'none') return [];
413413+ if (!effectiveBg) return [];
414414+415415+ // Only flag on dark backgrounds (luminance < 0.1)
416416+ const bgLum = relativeLuminance(effectiveBg);
417417+ if (bgLum >= 0.1) return [];
418418+419419+ // Split multiple shadows (commas not inside parentheses)
420420+ const parts = boxShadow.split(/,(?![^(]*\))/);
421421+ for (const shadow of parts) {
422422+ const colorMatch = shadow.match(/rgba?\([^)]+\)/);
423423+ if (!colorMatch) continue;
424424+ const color = parseRgb(colorMatch[0]);
425425+ if (!color || !hasChroma(color, 30)) continue;
426426+427427+ // Extract px values — in computed style: "color Xpx Ypx BLURpx [SPREADpx]"
428428+ const afterColor = shadow.substring(shadow.indexOf(colorMatch[0]) + colorMatch[0].length);
429429+ const beforeColor = shadow.substring(0, shadow.indexOf(colorMatch[0]));
430430+ const pxVals = [...beforeColor.matchAll(/([\d.]+)px/g), ...afterColor.matchAll(/([\d.]+)px/g)]
431431+ .map(m => parseFloat(m[1]));
432432+433433+ // Third value is blur (offset-x, offset-y, blur, [spread])
434434+ if (pxVals.length >= 3 && pxVals[2] > 4) {
435435+ return [{ id: 'dark-glow', snippet: `Colored glow (${colorToHex(color)}) on dark background` }];
436436+ }
437437+ }
438438+439439+ return [];
440440+}
441441+442442+/**
443443+ * Regex-on-HTML checks shared between browser and Node page-level detection.
444444+ * These don't need DOM access, just the raw HTML string.
445445+ */
446446+function checkHtmlPatterns(html) {
447447+ const findings = [];
448448+449449+ // --- Color ---
450450+451451+ // AI color palette: purple/violet
452452+ const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
453453+ if (purpleHexRe.test(html)) {
454454+ const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
455455+ if (purpleTextRe.test(html)) {
456456+ findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' });
457457+ }
458458+ }
459459+460460+ // Gradient text (background-clip: text + gradient)
461461+ const gradientRe = /(?:-webkit-)?background-clip\s*:\s*text/gi;
462462+ let gm;
463463+ while ((gm = gradientRe.exec(html)) !== null) {
464464+ const start = Math.max(0, gm.index - 200);
465465+ const context = html.substring(start, gm.index + gm[0].length + 200);
466466+ if (/gradient/i.test(context)) {
467467+ findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
468468+ break;
469469+ }
470470+ }
471471+ if (/\bbg-clip-text\b/.test(html) && /\bbg-gradient-to-/.test(html)) {
472472+ findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
473473+ }
474474+475475+ // --- Layout ---
476476+477477+ // Monotonous spacing
478478+ const spacingValues = [];
479479+ const spacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;
480480+ let sm;
481481+ while ((sm = spacingRe.exec(html)) !== null) {
482482+ const v = parseInt(sm[1], 10);
483483+ if (v > 0 && v < 200) spacingValues.push(v);
484484+ }
485485+ const gapRe = /gap\s*:\s*(\d+)px/gi;
486486+ while ((sm = gapRe.exec(html)) !== null) {
487487+ spacingValues.push(parseInt(sm[1], 10));
488488+ }
489489+ const twSpaceRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;
490490+ while ((sm = twSpaceRe.exec(html)) !== null) {
491491+ spacingValues.push(parseInt(sm[1], 10) * 4);
492492+ }
493493+ const remSpacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;
494494+ while ((sm = remSpacingRe.exec(html)) !== null) {
495495+ const v = Math.round(parseFloat(sm[1]) * 16);
496496+ if (v > 0 && v < 200) spacingValues.push(v);
497497+ }
498498+ const roundedSpacing = spacingValues.map(v => Math.round(v / 4) * 4);
499499+ if (roundedSpacing.length >= 10) {
500500+ const counts = {};
501501+ for (const v of roundedSpacing) counts[v] = (counts[v] || 0) + 1;
502502+ const maxCount = Math.max(...Object.values(counts));
503503+ const dominantPct = maxCount / roundedSpacing.length;
504504+ const unique = [...new Set(roundedSpacing)].filter(v => v > 0);
505505+ if (dominantPct > 0.6 && unique.length <= 3) {
506506+ const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
507507+ findings.push({
508508+ id: 'monotonous-spacing',
509509+ snippet: `~${dominant}px used ${maxCount}/${roundedSpacing.length} times (${Math.round(dominantPct * 100)}%)`,
510510+ });
511511+ }
512512+ }
513513+514514+ // --- Motion ---
515515+516516+ // Bounce/elastic animation names
517517+ const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
518518+ const bounceMatch = bounceRe.exec(html);
519519+ if (bounceMatch) {
520520+ const animationToken = bounceMatch[1]
521521+ .split(/[,\s]+/)
522522+ .find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
523523+ findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
524524+ }
525525+526526+ // Overshoot cubic-bezier
527527+ const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g;
528528+ let bm;
529529+ while ((bm = bezierRe.exec(html)) !== null) {
530530+ const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]);
531531+ if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
532532+ findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})` });
533533+ break;
534534+ }
535535+ }
536536+537537+ // Layout property transitions
538538+ const transRe = /transition(?:-property)?\s*:\s*([^;{}]+)/gi;
539539+ let tm;
540540+ while ((tm = transRe.exec(html)) !== null) {
541541+ const val = tm[1].toLowerCase();
542542+ if (/\ball\b/.test(val)) continue;
543543+ const found = val.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
544544+ if (found) {
545545+ findings.push({ id: 'layout-transition', snippet: `transition: ${found.join(', ')}` });
546546+ break;
547547+ }
548548+ }
549549+550550+ // --- Dark glow ---
551551+552552+ 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;
553553+ const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;
554554+ if (darkBgRe.test(html) || twDarkBg.test(html)) {
555555+ const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;
556556+ let shm;
557557+ while ((shm = shadowRe.exec(html)) !== null) {
558558+ const val = shm[1];
559559+ const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
560560+ if (!colorMatch) continue;
561561+ const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];
562562+ if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue;
563563+ const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));
564564+ if (pxVals.length >= 3 && pxVals[2] > 4) {
565565+ findings.push({ id: 'dark-glow', snippet: `Colored glow (rgb(${r},${g},${b})) on dark page` });
566566+ break;
567567+ }
568568+ }
569569+ }
570570+571571+ // --- Provider tells (gated): repeating-gradient stripes (GPT) ---
572572+ if (/repeating-(?:linear|radial|conic)-gradient\s*\(/i.test(html)) {
573573+ findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes' });
574574+ }
575575+576576+ // --- Provider tells (gated): "X theater" framing copy (GPT) ---
577577+ // Lives here (regex-on-HTML) rather than in the text-content analyzers so it
578578+ // runs in the bundled browser path too, not just the CLI/static path.
579579+ {
580580+ const bodyText = html
581581+ .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
582582+ .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
583583+ .replace(/<[^>]+>/g, ' ');
584584+ const tm = /\b(\w+)\s+theater\b/i.exec(bodyText);
585585+ if (tm) findings.push({ id: 'theater-slop-phrase', snippet: `"${tm[0].trim()}"` });
586586+ }
587587+588588+ // --- Provider tells (gated): image hover transform (Gemini) ---
589589+ // A CSS `img...:hover { transform: ... }` rule, or a Tailwind hover:scale /
590590+ // hover:rotate / hover:translate utility on an <img>. Each distinct
591591+ // mechanism is its own finding.
592592+ const imgHoverCss = /\bimg\b[^,{}]*:hover\b[^{}]*\{[^}]*\btransform\s*:\s*(?:scale|rotate|translate|matrix|skew)/i;
593593+ if (imgHoverCss.test(html)) {
594594+ findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule' });
595595+ }
596596+ const imgTagRe = /<img\b[^>]*\bclass\s*=\s*"([^"]*)"/gi;
597597+ let im;
598598+ while ((im = imgTagRe.exec(html)) !== null) {
599599+ if (/\bhover:(?:scale|rotate|translate|skew)-/.test(im[1])) {
600600+ findings.push({ id: 'image-hover-transform', snippet: 'Tailwind hover transform on <img>' });
601601+ }
602602+ }
603603+604604+ return findings;
605605+}
606606+607607+// ─── Section 4: resolveBackground (unified) ─────────────────────────────────
608608+609609+// Read the element's own background color, computed-style first, with a
610610+// jsdom-friendly fallback that parses the inline `background:` shorthand
611611+// from the raw style attribute. jsdom (~v29) does not decompose the
612612+// shorthand into `backgroundColor`, so without this fallback the CLI silently
613613+// returns null for any element styled via `background: rgb(...)` or
614614+// `background: #abc`. Real browsers always decompose, so the fallback is
615615+// a no-op there.
616616+function readOwnBackgroundColor(el, computedStyle) {
617617+ const bg = parseRgb(computedStyle.backgroundColor);
618618+ if (DETECTOR_IS_BROWSER || (bg && bg.a >= 0.1)) return bg;
619619+ const rawStyle = el.getAttribute?.('style') || '';
620620+ const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
621621+ const inlineBg = bgMatch ? bgMatch[1].trim() : '';
622622+ if (!inlineBg) return bg;
623623+ if (/gradient/i.test(inlineBg) || /url\s*\(/i.test(inlineBg)) return bg;
624624+ const fromRgb = parseRgb(inlineBg);
625625+ if (fromRgb) return fromRgb;
626626+ const hexMatch = inlineBg.match(/#([0-9a-f]{6}|[0-9a-f]{3})\b/i);
627627+ if (hexMatch) {
628628+ const h = hexMatch[1];
629629+ if (h.length === 6) {
630630+ return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16), a: 1 };
631631+ }
632632+ return { r: parseInt(h[0] + h[0], 16), g: parseInt(h[1] + h[1], 16), b: parseInt(h[2] + h[2], 16), a: 1 };
633633+ }
634634+ return bg;
635635+}
636636+637637+function resolveBackground(el, win, customPropMap) {
638638+ let current = el;
639639+ while (current && current.nodeType === 1) {
640640+ const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
641641+ const bgImage = style.backgroundImage || '';
642642+ const hasGradientOrUrl = bgImage && bgImage !== 'none' && (/gradient/i.test(bgImage) || /url\s*\(/i.test(bgImage));
643643+644644+ // Try the solid bg-color FIRST. If the element has both a solid color
645645+ // and a gradient/url overlay (a common pattern: `background: var(--paper)
646646+ // radial-gradient(...)` for paper-grain texture), the solid color is the
647647+ // dominant visible surface for contrast purposes; the overlay is
648648+ // decorative. The old behavior bailed on any gradient ancestor, which
649649+ // caused massive false-positive contrast findings on grain-textured
650650+ // body backgrounds.
651651+ let bg = parseRgb(style.backgroundColor);
652652+ if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) {
653653+ // jsdom returns literal "var(--X)" / "oklch(...)" strings. Resolve
654654+ // through customPropMap so Tailwind v4 color tokens become RGB.
655655+ if (customPropMap) {
656656+ bg = parseColorResolved(style.backgroundColor, customPropMap);
657657+ }
658658+ if (!bg || bg.a < 0.1) {
659659+ // Inline-style fallback. jsdom doesn't decompose background
660660+ // shorthand, so colors set via inline style are otherwise invisible.
661661+ const rawStyle = current.getAttribute?.('style') || '';
662662+ const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
663663+ const inlineBg = bgMatch ? bgMatch[1].trim() : '';
664664+ if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) {
665665+ bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);
666666+ }
667667+ }
668668+ }
669669+670670+ if (bg && bg.a > 0.1) {
671671+ if (DETECTOR_IS_BROWSER || bg.a >= 0.5) return bg;
672672+ }
673673+ // No solid bg-color at this level. If THIS level has a gradient/url
674674+ // with no underlying solid color we can read:
675675+ // • on body/html: assume white. Body-level gradients are almost
676676+ // always decorative texture (paper grain, noise) on top of a
677677+ // solid bg-color the page set via `background: var(--paper)`
678678+ // shorthand — which jsdom can't decompose into bg-color. The
679679+ // downstream gradient-stops fallback path produces catastrophic
680680+ // false positives in this case (gradient noise stops have
681681+ // accidental browns/blacks that look like card backgrounds).
682682+ // • on other elements: bail to null and let the caller fall back
683683+ // to gradient stops (gradient buttons / hero sections are real
684684+ // bgs worth checking against).
685685+ if (hasGradientOrUrl) {
686686+ if (current.tagName === 'BODY' || current.tagName === 'HTML') {
687687+ return { r: 255, g: 255, b: 255, a: 1 };
688688+ }
689689+ return null;
690690+ }
691691+ current = current.parentElement;
692692+ }
693693+ return { r: 255, g: 255, b: 255 };
694694+}
695695+696696+// Walk parents looking for a gradient background and return its color stops.
697697+// Used as a fallback when resolveBackground() returns null because the
698698+// effective background is a gradient (no single solid color to compare against).
699699+function resolveGradientStops(el, win) {
700700+ let current = el;
701701+ while (current && current.nodeType === 1) {
702702+ const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
703703+ const bgImage = style.backgroundImage || '';
704704+ if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
705705+ const stops = parseGradientColors(bgImage);
706706+ if (stops.length > 0) return stops;
707707+ }
708708+ if (!DETECTOR_IS_BROWSER) {
709709+ // jsdom doesn't decompose `background:` shorthand — peek at the raw inline style
710710+ const rawStyle = current.getAttribute?.('style') || '';
711711+ const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
712712+ if (bgMatch && /gradient/i.test(bgMatch[1])) {
713713+ const stops = parseGradientColors(bgMatch[1]);
714714+ if (stops.length > 0) return stops;
715715+ }
716716+ }
717717+ current = current.parentElement;
718718+ }
719719+ return null;
720720+}
721721+722722+// Parse a single CSS length token to pixels. Accepts "12px", "50%", a
723723+// shorthand like "12px 4px" (uses the first value), or empty / null.
724724+// Returns the pixel value, or null when the input is unparseable.
725725+// Percentages convert against `widthPx` when one is supplied. Without a
726726+// usable width (jsdom returns "auto" for many real-world elements,
727727+// which parseFloat collapses to 0), fall back to the raw percentage
728728+// number so callers gating on `> 0` (border-accent-on-rounded,
729729+// isCardLike's hasRadius) still see a positive value, matching the
730730+// original parseFloat("50%") === 50 behavior.
731731+function parseRadiusToPx(value, widthPx) {
732732+ if (!value || typeof value !== 'string') return null;
733733+ const trimmed = value.trim();
734734+ if (!trimmed) return null;
735735+ const first = trimmed.split(/\s+/)[0];
736736+ const num = parseFloat(first);
737737+ if (Number.isNaN(num)) return null;
738738+ if (/%$/.test(first)) {
739739+ if (widthPx && widthPx > 0) return (num / 100) * widthPx;
740740+ return num;
741741+ }
742742+ return num;
743743+}
744744+745745+function resolveBorderRadiusPx(el, style, widthPx, win) {
746746+ const fromComputed = parseRadiusToPx(style.borderRadius, widthPx);
747747+ if (fromComputed !== null) return fromComputed;
748748+ return 0;
749749+}
750750+751751+// ─── Section 5: Element Adapters ────────────────────────────────────────────
752752+753753+// Browser adapters — call getComputedStyle/getBoundingClientRect on live DOM
754754+755755+function checkElementBordersDOM(el) {
756756+ const tag = el.tagName.toLowerCase();
757757+ if (BORDER_SAFE_TAGS.has(tag)) return [];
758758+ const rect = el.getBoundingClientRect();
759759+ if (rect.width < 20 || rect.height < 20) return [];
760760+ const style = getComputedStyle(el);
761761+ const sides = ['Top', 'Right', 'Bottom', 'Left'];
762762+ const widths = {}, colors = {};
763763+ for (const s of sides) {
764764+ widths[s] = parseFloat(style[`border${s}Width`]) || 0;
765765+ colors[s] = style[`border${s}Color`] || '';
766766+ }
767767+ return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0);
768768+}
769769+770770+function checkElementColorsDOM(el) {
771771+ const tag = el.tagName.toLowerCase();
772772+ // No early SAFE_TAGS bail here — checkColors() does its own gating that
773773+ // includes the styled-button exception for <a> / <button> with their own
774774+ // opaque background. Bailing here would prevent that exception from firing.
775775+ const rect = el.getBoundingClientRect();
776776+ if (rect.width < 10 || rect.height < 10) return [];
777777+ const style = getComputedStyle(el);
778778+ const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
779779+ const hasDirectText = directText.trim().length > 0;
780780+ const effectiveBg = resolveBackground(el);
781781+ return checkColors({
782782+ tag,
783783+ textColor: parseRgb(style.color),
784784+ bgColor: readOwnBackgroundColor(el, style),
785785+ effectiveBg,
786786+ effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
787787+ fontSize: parseFloat(style.fontSize) || 16,
788788+ fontWeight: parseInt(style.fontWeight) || 400,
789789+ hasDirectText,
790790+ isEmojiOnly: isEmojiOnlyText(directText),
791791+ bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
792792+ bgImage: style.backgroundImage || '',
793793+ classList: el.getAttribute('class') || '',
794794+ });
795795+}
796796+797797+function checkElementIconTileDOM(el) {
798798+ const tag = el.tagName.toLowerCase();
799799+ if (!HEADING_TAGS.has(tag)) return [];
800800+ const sibling = el.previousElementSibling;
801801+ if (!sibling) return [];
802802+803803+ const sibRect = sibling.getBoundingClientRect();
804804+ const headRect = el.getBoundingClientRect();
805805+ const sibStyle = getComputedStyle(sibling);
806806+807807+ // The tile may either contain an <svg>/<i> icon child, OR the tile itself
808808+ // may contain an emoji/symbol character directly as its only text content
809809+ // (the "card-icon" pattern from many AI-generated demos).
810810+ const iconChild = sibling.querySelector('svg, i[data-lucide], i[class*="fa-"], i[class*="icon"]');
811811+ const iconRect = iconChild?.getBoundingClientRect();
812812+ const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
813813+ const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText);
814814+815815+ return checkIconTile({
816816+ headingTag: tag,
817817+ headingText: el.textContent || '',
818818+ headingTop: headRect.top,
819819+ siblingTag: sibling.tagName.toLowerCase(),
820820+ siblingWidth: sibRect.width,
821821+ siblingHeight: sibRect.height,
822822+ siblingBottom: sibRect.bottom,
823823+ siblingBgColor: parseRgb(sibStyle.backgroundColor),
824824+ siblingBgImage: sibStyle.backgroundImage || '',
825825+ siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0,
826826+ siblingBorderRadius: parseFloat(sibStyle.borderRadius) || 0,
827827+ hasIconChild: !!iconChild || hasInlineEmojiIcon,
828828+ iconChildWidth: iconRect?.width || 0,
829829+ });
830830+}
831831+832832+function checkElementItalicSerifDOM(el) {
833833+ const tag = el.tagName.toLowerCase();
834834+ if (tag !== 'h1' && tag !== 'h2') return [];
835835+ const style = getComputedStyle(el);
836836+ return checkItalicSerif({
837837+ tag,
838838+ fontStyle: style.fontStyle || '',
839839+ fontFamily: style.fontFamily || '',
840840+ fontSize: parseFloat(style.fontSize) || 0,
841841+ headingText: el.textContent || '',
842842+ });
843843+}
844844+845845+function checkElementHeroEyebrowDOM(el) {
846846+ const tag = el.tagName.toLowerCase();
847847+ if (tag !== 'h1') return [];
848848+ const sibling = el.previousElementSibling;
849849+ if (!sibling) return [];
850850+ const headStyle = getComputedStyle(el);
851851+ const sibStyle = getComputedStyle(sibling);
852852+ return checkHeroEyebrow({
853853+ headingTag: tag,
854854+ headingText: el.textContent || '',
855855+ headingFontSize: parseFloat(headStyle.fontSize) || 0,
856856+ siblingTag: sibling.tagName.toLowerCase(),
857857+ siblingText: sibling.textContent || '',
858858+ siblingTextTransform: sibStyle.textTransform || '',
859859+ siblingFontSize: parseFloat(sibStyle.fontSize) || 0,
860860+ siblingLetterSpacing: parseFloat(sibStyle.letterSpacing) || 0,
861861+ siblingFontWeight: sibStyle.fontWeight || '',
862862+ siblingColor: sibStyle.color || '',
863863+ });
864864+}
865865+866866+// Build a map of CSS custom properties declared on :root / :host / html.
867867+// Used to resolve var(--X) refs that jsdom returns verbatim in
868868+// getComputedStyle. Tailwind v4 routes every utility class through
869869+// CSS vars (font-weight: var(--font-weight-bold), font-size:
870870+// var(--text-xs), letter-spacing: var(--tracking-widest)), so without
871871+// resolution every style-based check silently fails on Tailwind v4
872872+// builds — the values come back as literal "var(--font-weight-bold)"
873873+// strings and parseFloat returns NaN.
874874+function buildCustomPropMap(document) {
875875+ const map = new Map();
876876+ let sheets;
877877+ try { sheets = Array.from(document.styleSheets || []); }
878878+ catch { return map; }
879879+ for (const sheet of sheets) {
880880+ let rules;
881881+ try { rules = Array.from(sheet.cssRules || []); }
882882+ catch { continue; }
883883+ for (const rule of rules) {
884884+ // Style rules only (type 1). Walk @media / @supports if present.
885885+ if (rule.type === 4 /* MEDIA_RULE */ || rule.type === 12 /* SUPPORTS_RULE */) {
886886+ try { rules.push(...Array.from(rule.cssRules || [])); } catch { /* ignore */ }
887887+ continue;
888888+ }
889889+ if (rule.type !== 1 /* STYLE_RULE */) continue;
890890+ const sel = rule.selectorText || '';
891891+ if (!/(^|,\s*)(:root|html|:host)\b/i.test(sel)) continue;
892892+ const style = rule.style;
893893+ if (!style) continue;
894894+ for (let i = 0; i < style.length; i++) {
895895+ const prop = style[i];
896896+ if (!prop || !prop.startsWith('--')) continue;
897897+ const val = style.getPropertyValue(prop).trim();
898898+ if (val) map.set(prop, val);
899899+ }
900900+ }
901901+ }
902902+ return map;
903903+}
904904+905905+// Resolve var(--X[, fallback]) refs in a computed-style value string.
906906+// Recurses up to 8 levels for chained refs (--a: var(--b)). Returns
907907+// the original string when no refs are present or the chain doesn't
908908+// resolve. Safe to call on already-resolved values.
909909+function resolveVarRefs(raw, customPropMap, depth = 0) {
910910+ if (typeof raw !== 'string' || !raw.includes('var(')) return raw;
911911+ if (depth > 8) return raw;
912912+ return raw.replace(/var\(\s*(--[a-zA-Z0-9_-]+)\s*(?:,\s*([^)]+))?\)/g, (_m, name, fallback) => {
913913+ const v = customPropMap.get(name);
914914+ if (v != null) return resolveVarRefs(v, customPropMap, depth + 1);
915915+ return fallback ? resolveVarRefs(fallback.trim(), customPropMap, depth + 1) : _m;
916916+ });
917917+}
918918+919919+// OKLCH → sRGB conversion (Björn Ottosson's matrices). L in 0..1 (or %),
920920+// C in 0..~0.4 typical, H in degrees. Returns clamped {r,g,b,a:1} in 0..255.
921921+// Needed because jsdom doesn't compute oklch() values — getComputedStyle
922922+// returns the literal "oklch(...)" string. Without this, the entire
923923+// Tailwind v4 color palette (which is OKLCH-based) is invisible to the
924924+// detector's contrast / color checks.
925925+function oklchToRgb(L, C, H) {
926926+ const hRad = (H * Math.PI) / 180;
927927+ const a = C * Math.cos(hRad);
928928+ const b = C * Math.sin(hRad);
929929+ const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
930930+ const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
931931+ const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
932932+ const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
933933+ const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
934934+ const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
935935+ const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc;
936936+ const enc = (x) => {
937937+ const c = Math.max(0, Math.min(1, x));
938938+ return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
939939+ };
940940+ return {
941941+ r: Math.round(enc(rLin) * 255),
942942+ g: Math.round(enc(gLin) * 255),
943943+ b: Math.round(enc(bLin) * 255),
944944+ a: 1,
945945+ };
946946+}
947947+948948+// Extended color parser: rgb/rgba/hex/oklch. Returns null on no match.
949949+// Use this when the input might be any CSS color form; use plain parseRgb
950950+// when you only expect computed rgb() values from real browsers.
951951+function parseAnyColor(s) {
952952+ if (!s || typeof s !== 'string') return null;
953953+ const str = s.trim();
954954+ if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
955955+ let m;
956956+ m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+))?\s*\)/);
957957+ 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 };
958958+ m = str.match(/^#([0-9a-f]{3,8})$/i);
959959+ if (m) {
960960+ const h = m[1];
961961+ if (h.length === 3 || h.length === 4) {
962962+ return {
963963+ r: parseInt(h[0] + h[0], 16),
964964+ g: parseInt(h[1] + h[1], 16),
965965+ b: parseInt(h[2] + h[2], 16),
966966+ a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
967967+ };
968968+ }
969969+ if (h.length === 6 || h.length === 8) {
970970+ return {
971971+ r: parseInt(h.slice(0, 2), 16),
972972+ g: parseInt(h.slice(2, 4), 16),
973973+ b: parseInt(h.slice(4, 6), 16),
974974+ a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
975975+ };
976976+ }
977977+ }
978978+ // OKLCH parser. Tailwind v4's CSS minifier squishes the space after
979979+ // `%` ("21.5%.02 50"), so the separator between L and C may be absent.
980980+ // Match L (with optional %), then C and H separated permissively.
981981+ m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
982982+ if (m) {
983983+ const Lnum = parseFloat(m[1]);
984984+ const L = m[2] === '%' ? Lnum / 100 : Lnum;
985985+ const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
986986+ if (m[5] !== undefined) {
987987+ const alpha = parseFloat(m[5]);
988988+ rgb.a = m[6] === '%' ? alpha / 100 : alpha;
989989+ }
990990+ return rgb;
991991+ }
992992+ return null;
993993+}
994994+995995+// Resolve var() refs in a color string (via customPropMap), then parse.
996996+// Returns null on any failure. Used in jsdom-mode paths where
997997+// getComputedStyle returns literal "var(--X)" or "oklch(...)" strings.
998998+function parseColorResolved(str, customPropMap) {
999999+ if (!str) return null;
10001000+ const resolved = customPropMap ? resolveVarRefs(str, customPropMap) : str;
10011001+ return parseAnyColor(resolved);
10021002+}
10031003+10041004+const REPEATED_KICKER_SKIP_SELECTOR = [
10051005+ 'nav',
10061006+ 'form',
10071007+ 'table',
10081008+ 'thead',
10091009+ 'tbody',
10101010+ 'tfoot',
10111011+ 'figure',
10121012+ 'figcaption',
10131013+ 'ol',
10141014+ 'ul',
10151015+ 'li',
10161016+ '[role="navigation"]',
10171017+ '[aria-label*="breadcrumb" i]',
10181018+ '[class*="breadcrumb" i]',
10191019+ '[aria-hidden="true"]',
10201020+ '[data-impeccable-allow-kickers]',
10211021+].join(',');
10221022+10231023+const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
10241024+ 'article',
10251025+ 'button',
10261026+ 'a',
10271027+ 'li',
10281028+ '[role="listitem"]',
10291029+ '[role="option"]',
10301030+].join(',');
10311031+10321032+function cleanInlineText(el) {
10331033+ return [...el.childNodes]
10341034+ .filter(n => n.nodeType === 3)
10351035+ .map(n => n.textContent)
10361036+ .join(' ')
10371037+ .replace(/\s+/g, ' ')
10381038+ .trim();
10391039+}
10401040+10411041+function isRepeatedKickerCardContext(heading, kicker) {
10421042+ const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
10431043+ return Boolean(item && (!item.contains || item.contains(kicker)));
10441044+}
10451045+10461046+function isRepeatedKickerCandidate(opts) {
10471047+ const {
10481048+ headingTag,
10491049+ headingText,
10501050+ headingFontSize,
10511051+ kickerTag,
10521052+ kickerText,
10531053+ kickerTextTransform,
10541054+ kickerFontSize,
10551055+ kickerLetterSpacing,
10561056+ } = opts;
10571057+ if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
10581058+ if (!headingText || headingText.length < 3) return false;
10591059+ if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
10601060+ if (!(headingFontSize >= 20)) return false;
10611061+ if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false;
10621062+ if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
10631063+ if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
10641064+ if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
10651065+10661066+ const isUppercased = kickerTextTransform === 'uppercase'
10671067+ || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
10681068+ if (!isUppercased) return false;
10691069+ if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
10701070+ const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
10711071+ if (!(kickerLetterSpacing >= minTrackedSpacing)) return false;
10721072+ return true;
10731073+}
10741074+10751075+function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
10761076+ const candidates = [];
10771077+ for (const heading of doc.querySelectorAll('h2, h3, h4')) {
10781078+ if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
10791079+ const kicker = heading.previousElementSibling;
10801080+ if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
10811081+ if (isRepeatedKickerCardContext(heading, kicker)) continue;
10821082+10831083+ const headingStyle = getStyle(heading);
10841084+ const kickerStyle = getStyle(kicker);
10851085+ const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
10861086+ const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
10871087+ const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
10881088+ const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
10891089+ const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
10901090+10911091+ if (!isRepeatedKickerCandidate({
10921092+ headingTag: heading.tagName.toLowerCase(),
10931093+ headingText,
10941094+ headingFontSize,
10951095+ kickerTag: kicker.tagName.toLowerCase(),
10961096+ kickerText,
10971097+ kickerTextTransform: kickerStyle.textTransform || '',
10981098+ kickerFontSize,
10991099+ kickerLetterSpacing,
11001100+ })) {
11011101+ continue;
11021102+ }
11031103+11041104+ candidates.push({
11051105+ headingTag: heading.tagName.toLowerCase(),
11061106+ headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
11071107+ kickerText: kickerText.slice(0, 40),
11081108+ });
11091109+ }
11101110+ return candidates;
11111111+}
11121112+11131113+function checkRepeatedSectionKickersDOM() {
11141114+ const candidates = collectRepeatedSectionKickerCandidates(
11151115+ document,
11161116+ (el) => getComputedStyle(el),
11171117+ (value, fontSize) => resolveLengthPx(value, fontSize) || 0,
11181118+ );
11191119+ return checkRepeatedSectionKickers({ candidates });
11201120+}
11211121+11221122+function checkElementMotionDOM(el) {
11231123+ const tag = el.tagName.toLowerCase();
11241124+ if (SAFE_TAGS.has(tag)) return [];
11251125+ const style = getComputedStyle(el);
11261126+ return checkMotion({
11271127+ tag,
11281128+ transitionProperty: style.transitionProperty || '',
11291129+ animationName: style.animationName || '',
11301130+ timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '),
11311131+ classList: el.getAttribute('class') || '',
11321132+ });
11331133+}
11341134+11351135+function checkElementGlowDOM(el) {
11361136+ const tag = el.tagName.toLowerCase();
11371137+ const style = getComputedStyle(el);
11381138+ if (!style.boxShadow || style.boxShadow === 'none') return [];
11391139+ // Use parent's background — glow radiates outward, so the surrounding context matters
11401140+ // If resolveBackground returns null (gradient), try to infer from the gradient colors
11411141+ let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
11421142+ if (!parentBg) {
11431143+ // Gradient background — sample its colors to determine if it's dark
11441144+ let cur = el.parentElement;
11451145+ while (cur && cur.nodeType === 1) {
11461146+ const bgImage = getComputedStyle(cur).backgroundImage || '';
11471147+ const gradColors = parseGradientColors(bgImage);
11481148+ if (gradColors.length > 0) {
11491149+ // Average the gradient colors
11501150+ const avg = { r: 0, g: 0, b: 0 };
11511151+ for (const c of gradColors) { avg.r += c.r; avg.g += c.g; avg.b += c.b; }
11521152+ avg.r = Math.round(avg.r / gradColors.length);
11531153+ avg.g = Math.round(avg.g / gradColors.length);
11541154+ avg.b = Math.round(avg.b / gradColors.length);
11551155+ parentBg = avg;
11561156+ break;
11571157+ }
11581158+ cur = cur.parentElement;
11591159+ }
11601160+ }
11611161+ return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg: parentBg });
11621162+}
11631163+11641164+function checkElementAIPaletteDOM(el) {
11651165+ const style = getComputedStyle(el);
11661166+ const findings = [];
11671167+11681168+ // Check gradient backgrounds for purple/violet or cyan
11691169+ const bgImage = style.backgroundImage || '';
11701170+ const gradColors = parseGradientColors(bgImage);
11711171+ for (const c of gradColors) {
11721172+ if (hasChroma(c, 50)) {
11731173+ const hue = getHue(c);
11741174+ if (hue >= 260 && hue <= 310) {
11751175+ findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient background' });
11761176+ break;
11771177+ }
11781178+ if (hue >= 160 && hue <= 200) {
11791179+ findings.push({ id: 'ai-color-palette', snippet: 'Cyan gradient background' });
11801180+ break;
11811181+ }
11821182+ }
11831183+ }
11841184+11851185+ // Check for neon text (vivid cyan/purple color on dark background)
11861186+ const textColor = parseRgb(style.color);
11871187+ if (textColor && hasChroma(textColor, 80)) {
11881188+ const hue = getHue(textColor);
11891189+ const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
11901190+ if (isAIPalette) {
11911191+ const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;
11921192+ // Also check gradient parents
11931193+ let effectiveBg = parentBg;
11941194+ if (!effectiveBg) {
11951195+ let cur = el.parentElement;
11961196+ while (cur && cur.nodeType === 1) {
11971197+ const gi = getComputedStyle(cur).backgroundImage || '';
11981198+ const gc = parseGradientColors(gi);
11991199+ if (gc.length > 0) {
12001200+ const avg = { r: 0, g: 0, b: 0 };
12011201+ for (const c of gc) { avg.r += c.r; avg.g += c.g; avg.b += c.b; }
12021202+ avg.r = Math.round(avg.r / gc.length);
12031203+ avg.g = Math.round(avg.g / gc.length);
12041204+ avg.b = Math.round(avg.b / gc.length);
12051205+ effectiveBg = avg;
12061206+ break;
12071207+ }
12081208+ cur = cur.parentElement;
12091209+ }
12101210+ }
12111211+ if (effectiveBg && relativeLuminance(effectiveBg) < 0.1) {
12121212+ const label = hue >= 260 ? 'Purple/violet' : 'Cyan';
12131213+ findings.push({ id: 'ai-color-palette', snippet: `${label} neon text on dark background` });
12141214+ }
12151215+ }
12161216+ }
12171217+12181218+ return findings;
12191219+}
12201220+12211221+const QUALITY_TEXT_TAGS = new Set(['p', 'li', 'td', 'th', 'dd', 'blockquote', 'figcaption']);
12221222+12231223+// Resolve a CSS font-size value to pixels by walking up the parent chain.
12241224+// Browsers resolve em/rem/% to px in getComputedStyle, but jsdom returns the
12251225+// specified value verbatim — so for the Node path we walk parents ourselves.
12261226+function resolveFontSizePx(el, win) {
12271227+ const chain = []; // raw font-size strings, leaf → root
12281228+ let cur = el;
12291229+ while (cur && cur.nodeType === 1) {
12301230+ const fs = (win ? win.getComputedStyle(cur) : getComputedStyle(cur)).fontSize;
12311231+ chain.push(fs || '');
12321232+ cur = cur.parentElement;
12331233+ }
12341234+ // Walk root → leaf, resolving each value relative to its parent context.
12351235+ let px = 16; // root default
12361236+ for (let i = chain.length - 1; i >= 0; i--) {
12371237+ const v = chain[i];
12381238+ if (!v || v === 'inherit') continue;
12391239+ const num = parseFloat(v);
12401240+ if (isNaN(num)) continue;
12411241+ if (v.endsWith('px')) px = num;
12421242+ else if (v.endsWith('rem')) px = num * 16;
12431243+ else if (v.endsWith('em')) px = num * px;
12441244+ else if (v.endsWith('%')) px = (num / 100) * px;
12451245+ else px = num; // unitless — already resolved
12461246+ }
12471247+ return px;
12481248+}
12491249+12501250+// Resolve a CSS length value (line-height, letter-spacing, etc.) given a
12511251+// known font-size context. Returns null for "normal" / unparseable values.
12521252+function resolveLengthPx(value, fontSizePx) {
12531253+ if (!value || value === 'normal' || value === 'auto' || value === 'inherit') return null;
12541254+ const num = parseFloat(value);
12551255+ if (isNaN(num)) return null;
12561256+ if (value.endsWith('px')) return num;
12571257+ if (value.endsWith('rem')) return num * 16;
12581258+ if (value.endsWith('em')) return num * fontSizePx;
12591259+ if (value.endsWith('%')) return (num / 100) * fontSizePx;
12601260+ // Unitless line-height = multiplier, return px equivalent
12611261+ return num * fontSizePx;
12621262+}
12631263+12641264+function cssColorIsTransparent(value) {
12651265+ if (!value) return true;
12661266+ const str = String(value).trim().toLowerCase();
12671267+ if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true;
12681268+ const parsed = parseAnyColor(str);
12691269+ if (parsed) return (parsed.a ?? 1) <= 0.05;
12701270+ return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str);
12711271+}
12721272+12731273+function colorsNearlyMatch(a, b) {
12741274+ const ca = parseAnyColor(a);
12751275+ const cb = parseAnyColor(b);
12761276+ if (!ca || !cb) return false;
12771277+ const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1));
12781278+ const channelDelta = Math.max(
12791279+ Math.abs(ca.r - cb.r),
12801280+ Math.abs(ca.g - cb.g),
12811281+ Math.abs(ca.b - cb.b),
12821282+ );
12831283+ return alphaDelta <= 0.03 && channelDelta <= 3;
12841284+}
12851285+12861286+function getComputedStyleFor(win, el) {
12871287+ if (win && typeof win.getComputedStyle === 'function') {
12881288+ try { return win.getComputedStyle(el); } catch {}
12891289+ }
12901290+ if (typeof getComputedStyle === 'function') {
12911291+ try { return getComputedStyle(el); } catch {}
12921292+ }
12931293+ return null;
12941294+}
12951295+12961296+function hasVisibleBackgroundBoundary(style, el, win) {
12971297+ const bg = style?.backgroundColor || '';
12981298+ if (cssColorIsTransparent(bg)) return false;
12991299+13001300+ let parent = el?.parentElement || null;
13011301+ while (parent) {
13021302+ const parentStyle = getComputedStyleFor(win, parent);
13031303+ const parentBg = parentStyle?.backgroundColor || '';
13041304+ if (!cssColorIsTransparent(parentBg)) {
13051305+ return !colorsNearlyMatch(bg, parentBg);
13061306+ }
13071307+ parent = parent.parentElement;
13081308+ }
13091309+13101310+ return true;
13111311+}
13121312+13131313+const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']);
13141314+13151315+function hasMeaningfulDirectText(node) {
13161316+ if (!node?.childNodes) return false;
13171317+ for (const child of node.childNodes) {
13181318+ if (child.nodeType === 3 && child.textContent.trim().length > 4) return true;
13191319+ }
13201320+ return false;
13211321+}
13221322+13231323+function textDescendantsFlushSides(el, rect) {
13241324+ const flush = { top: false, right: false, bottom: false, left: false };
13251325+ if (!rect || !el?.querySelectorAll) return flush;
13261326+ const TEXT_EDGE_THRESHOLD = 4;
13271327+ const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th');
13281328+ for (const node of candidates) {
13291329+ if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue;
13301330+ let nodeRect = null;
13311331+ try { nodeRect = node.getBoundingClientRect(); } catch {}
13321332+ if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue;
13331333+ if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue;
13341334+ if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true;
13351335+ if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true;
13361336+ if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true;
13371337+ if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true;
13381338+ }
13391339+ return flush;
13401340+}
13411341+13421342+// Pure quality checks. Most run on computed CSS and DOM-only inputs (work in
13431343+// jsdom and the browser). Two checks (line-length, cramped-padding) gate on
13441344+// element rect dimensions, which jsdom can't compute — pass `rect: null` from
13451345+// the Node adapter to skip those.
13461346+//
13471347+// Both adapters resolve font-size, line-height and letter-spacing to pixels
13481348+// before calling this so the pure function only deals with numbers.
13491349+function checkQuality(opts) {
13501350+ const { el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax = 80, viewportWidth = 0, win = null } = opts;
13511351+ const findings = [];
13521352+ // Skip browser extension injected elements
13531353+ const elId = el.id || '';
13541354+ if (elId.startsWith('claude-') || elId.startsWith('cic-')) return findings;
13551355+13561356+ // --- Line length too long --- (browser-only: needs rect.width)
13571357+ if (rect && hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > lineMax) {
13581358+ const charsPerLine = rect.width / (fontSize * 0.5);
13591359+ if (charsPerLine > lineMax + 5) {
13601360+ findings.push({ id: 'line-length', snippet: `~${Math.round(charsPerLine)} chars/line (aim for <${lineMax})` });
13611361+ }
13621362+ }
13631363+13641364+ // --- Cramped padding --- (browser-only: needs rect to skip small badges/labels)
13651365+ // Vertical and horizontal thresholds are independent because line-height
13661366+ // already provides built-in vertical breathing room (the line box is taller
13671367+ // than the cap height), but horizontal has no equivalent. Both scale with
13681368+ // font-size — bigger text demands proportionally more padding.
13691369+ // vertical: max(4px, fontSize × 0.3)
13701370+ // horizontal: max(8px, fontSize × 0.5)
13711371+ const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre'));
13721372+ if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {
13731373+ const borders = {
13741374+ top: parseFloat(style.borderTopWidth) || 0,
13751375+ right: parseFloat(style.borderRightWidth) || 0,
13761376+ bottom: parseFloat(style.borderBottomWidth) || 0,
13771377+ left: parseFloat(style.borderLeftWidth) || 0,
13781378+ };
13791379+ const borderCount = Object.values(borders).filter(w => w > 0).length;
13801380+ const hasBg = hasVisibleBackgroundBoundary(style, el, win);
13811381+ if (borderCount >= 2 || hasBg) {
13821382+ const vPads = [], hPads = [];
13831383+ if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0);
13841384+ if (hasBg || borders.bottom > 0) vPads.push(parseFloat(style.paddingBottom) || 0);
13851385+ if (hasBg || borders.left > 0) hPads.push(parseFloat(style.paddingLeft) || 0);
13861386+ if (hasBg || borders.right > 0) hPads.push(parseFloat(style.paddingRight) || 0);
13871387+13881388+ const vMin = vPads.length ? Math.min(...vPads) : Infinity;
13891389+ const hMin = hPads.length ? Math.min(...hPads) : Infinity;
13901390+ const vThresh = Math.max(4, fontSize * 0.3);
13911391+ const hThresh = Math.max(8, fontSize * 0.5);
13921392+13931393+ // Emit at most one finding per element — pick whichever axis is worse.
13941394+ if (vMin < vThresh) {
13951395+ findings.push({ id: 'cramped-padding', snippet: `${vMin}px vertical padding (need ≥${vThresh.toFixed(1)}px for ${fontSize}px text)` });
13961396+ } else if (hMin < hThresh) {
13971397+ findings.push({ id: 'cramped-padding', snippet: `${hMin}px horizontal padding (need ≥${hThresh.toFixed(1)}px for ${fontSize}px text)` });
13981398+ }
13991399+ }
14001400+ }
14011401+14021402+ // --- Flush against a visible boundary ---
14031403+ // Fires when a container has a visible boundary (border, outline, OR a
14041404+ // non-transparent background) AND near-zero padding on the bounded
14051405+ // side(s) AND text-bearing children land flush against the boundary.
14061406+ //
14071407+ // Distinct from cramped-padding: that rule needs the element itself to
14081408+ // have direct text (hasDirectText). This rule targets the OPPOSITE
14091409+ // shape — a container with NO direct text, only children — which is
14101410+ // exactly what cramped-padding misses (a section wrapping a label +
14111411+ // list lands a free pass).
14121412+ //
14131413+ // The classic shape: agent writes `padding: 28px 0 0` shorthand on a
14141414+ // section that also has a border, zeroing horizontal padding so the
14151415+ // text-bearing children touch the side borders. Background and
14161416+ // outline count too: a colored card with zero padding has the same
14171417+ // visual failure mode.
14181418+ {
14191419+ 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']);
14201420+ const upperTag = tag ? tag.toUpperCase() : '';
14211421+ const elPosition = style.position || '';
14221422+ if (
14231423+ !FLUSH_SKIP_TAGS.has(upperTag) &&
14241424+ !hasDirectText &&
14251425+ !['fixed', 'absolute'].includes(elPosition) &&
14261426+ el.children && el.children.length > 0
14271427+ ) {
14281428+ const borderW = {
14291429+ top: parseFloat(style.borderTopWidth) || 0,
14301430+ right: parseFloat(style.borderRightWidth) || 0,
14311431+ bottom: parseFloat(style.borderBottomWidth) || 0,
14321432+ left: parseFloat(style.borderLeftWidth) || 0,
14331433+ };
14341434+ const borderVisible = {
14351435+ top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor),
14361436+ right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor),
14371437+ bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor),
14381438+ left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor),
14391439+ };
14401440+ // Outline detection. jsdom decomposes `border` shorthand into
14411441+ // border{Top,…}Width/Color but does NOT decompose `outline` —
14421442+ // the longhands come back empty when the value was set via the
14431443+ // shorthand. Fall back to parsing `style.outline` ourselves.
14441444+ let outlineW = parseFloat(style.outlineWidth) || 0;
14451445+ let outlineStyleVal = style.outlineStyle || '';
14461446+ let outlineColorVal = style.outlineColor || '';
14471447+ if (!outlineW && style.outline) {
14481448+ const wMatch = style.outline.match(/(\d+(?:\.\d+)?)\s*px/);
14491449+ if (wMatch) outlineW = parseFloat(wMatch[1]) || 0;
14501450+ if (!outlineStyleVal) {
14511451+ outlineStyleVal = /\b(solid|dashed|dotted|double|groove|ridge|inset|outset)\b/.test(style.outline) ? 'solid' : '';
14521452+ }
14531453+ if (!outlineColorVal) {
14541454+ const cMatch = style.outline.match(/(rgba?\([^)]+\)|#[0-9a-fA-F]{3,8}|[a-zA-Z]+)\s*$/);
14551455+ if (cMatch) outlineColorVal = cMatch[1];
14561456+ }
14571457+ }
14581458+ const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none';
14591459+ const bgVisible = hasVisibleBackgroundBoundary(style, el, win);
14601460+14611461+ const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible;
14621462+ if (anyVisible) {
14631463+ // Resolve padding to px (jsdom returns raw "1.5rem" etc., not the
14641464+ // computed px value; parseFloat would strip the unit and treat
14651465+ // 1.5rem as 1.5px, false-flagging legitimate insets).
14661466+ const pad = {
14671467+ top: resolveLengthPx(style.paddingTop, fontSize) ?? 0,
14681468+ right: resolveLengthPx(style.paddingRight, fontSize) ?? 0,
14691469+ bottom: resolveLengthPx(style.paddingBottom, fontSize) ?? 0,
14701470+ left: resolveLengthPx(style.paddingLeft, fontSize) ?? 0,
14711471+ };
14721472+ const PAD_THRESHOLD = 2;
14731473+ // Children-insulate-this-side: a side is insulated if ANY direct
14741474+ // child has its own padding ≥ 4px on that side. Rationale: in
14751475+ // typical flow, only the first/last (or leftmost/rightmost)
14761476+ // children actually sit at the parent's edges. If even one of
14771477+ // them has its own padding, the visual flush is broken on that
14781478+ // side. Classic example: a column-flow card frame where the
14791479+ // top child (header) has padding-top:12 and the bottom child
14801480+ // (footer) has padding-bottom:8 — the parent's padding:0 doesn't
14811481+ // matter; nothing is actually flush. The `any-child-insulates`
14821482+ // heuristic accepts some false negatives (a card with one heavily
14831483+ // padded middle child won't flag) for far fewer false positives.
14841484+ const CHILD_INSULATE_THRESHOLD = 4;
14851485+ const childrenInsulate = { top: false, right: false, bottom: false, left: false };
14861486+ for (const child of el.children) {
14871487+ let childStyle = getComputedStyleFor(win, child);
14881488+ if (!childStyle) continue;
14891489+ const childPad = {
14901490+ top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0,
14911491+ right: resolveLengthPx(childStyle.paddingRight, fontSize) ?? 0,
14921492+ bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0,
14931493+ left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0,
14941494+ };
14951495+ const childMargin = {
14961496+ top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0,
14971497+ right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0,
14981498+ bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0,
14991499+ left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0,
15001500+ };
15011501+ if (rect && typeof child.getBoundingClientRect === 'function') {
15021502+ try {
15031503+ const childRect = child.getBoundingClientRect();
15041504+ if (childRect && childRect.width > 0 && childRect.height > 0) {
15051505+ if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true;
15061506+ if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true;
15071507+ if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true;
15081508+ if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true;
15091509+ }
15101510+ } catch {}
15111511+ }
15121512+ for (const s of ['top', 'right', 'bottom', 'left']) {
15131513+ if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) {
15141514+ childrenInsulate[s] = true;
15151515+ }
15161516+ }
15171517+ }
15181518+15191519+ const textFlush = rect ? textDescendantsFlushSides(el, rect) : null;
15201520+ const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible;
15211521+ const flushSides = [];
15221522+ for (const side of ['top', 'right', 'bottom', 'left']) {
15231523+ const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right'));
15241524+ const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide;
15251525+ if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) {
15261526+ flushSides.push(side);
15271527+ }
15281528+ }
15291529+15301530+ if (flushSides.length > 0) {
15311531+ // Confirm at least one direct child has substantial text content
15321532+ // (> 4 chars). Without this, the flush is harmless: e.g. an
15331533+ // image-only card.
15341534+ let hasTextChild = false;
15351535+ for (const child of el.children) {
15361536+ const childText = (child.textContent || '').trim();
15371537+ if (childText.length > 4) { hasTextChild = true; break; }
15381538+ }
15391539+ if (hasTextChild) {
15401540+ const cls = (typeof el.className === 'string' && el.className.trim())
15411541+ ? el.className.trim().split(/\s+/)[0]
15421542+ : '';
15431543+ const boundaryParts = [];
15441544+ const borderSidesVisible = ['top', 'right', 'bottom', 'left'].filter(s => borderVisible[s]);
15451545+ if (borderSidesVisible.length === 4) boundaryParts.push('border');
15461546+ else if (borderSidesVisible.length > 0) boundaryParts.push(`border-${borderSidesVisible.join('/')}`);
15471547+ if (outlineVisible) boundaryParts.push('outline');
15481548+ if (bgVisible) boundaryParts.push('bg');
15491549+ const sidesLabel = flushSides.length === 4 ? 'all sides' : flushSides.join('/');
15501550+ const ident = cls
15511551+ ? `<${tag.toLowerCase()}> "${cls}"`
15521552+ : `<${tag.toLowerCase()}>`;
15531553+ findings.push({
15541554+ id: 'cramped-padding',
15551555+ snippet: `${ident}: children flush against ${boundaryParts.join('+')} on ${sidesLabel} (no inset)`,
15561556+ });
15571557+ }
15581558+ }
15591559+ }
15601560+ }
15611561+ }
15621562+15631563+ // --- Body text touching viewport edge --- (browser-only: needs rect)
15641564+ // Catches the failure mode where the agent ships body paragraphs
15651565+ // with NO container providing horizontal padding — text bleeds
15661566+ // directly to the viewport edge. Different from cramped-padding,
15671567+ // which requires a colored/bordered container. Here the failure
15681568+ // is the absence of the container entirely.
15691569+ //
15701570+ // Gate aggressively to avoid false positives:
15711571+ // - <p> or <li> only (body content; not headings, not nav, not
15721572+ // wrappers)
15731573+ // - text > 40 chars (paragraph-like, not a label)
15741574+ // - rect.width > 50% of viewport (real body, not a pull-quote)
15751575+ // - rect.left < 16 OR rect.right > viewport - 16 (actually
15761576+ // touching the edge)
15771577+ // - not inside <nav> or <header> (those legitimately bleed)
15781578+ // - element itself has no background-color (intentional full-bleed
15791579+ // sections set a bg-color and provide their own internal padding)
15801580+ if (rect && hasDirectText && textLen > 40 && ['P', 'LI'].includes(tag.toUpperCase()) && viewportWidth > 0) {
15811581+ const inNavHeader = el.closest && (el.closest('nav') || el.closest('header'));
15821582+ const hasOwnBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)' && style.backgroundColor !== 'transparent';
15831583+ const isPositioned = ['fixed', 'absolute'].includes(style.position || '');
15841584+ const widthRatio = rect.width / viewportWidth;
15851585+ const leftClose = rect.left < 16;
15861586+ const rightClose = rect.right > viewportWidth - 16;
15871587+ if (!inNavHeader && !hasOwnBg && !isPositioned && widthRatio > 0.5 && (leftClose || rightClose)) {
15881588+ const which = leftClose && rightClose
15891589+ ? `left ${Math.round(rect.left)}px / right ${Math.round(viewportWidth - rect.right)}px`
15901590+ : leftClose
15911591+ ? `left ${Math.round(rect.left)}px`
15921592+ : `right ${Math.round(viewportWidth - rect.right)}px`;
15931593+ findings.push({ id: 'body-text-viewport-edge', snippet: `<${tag.toLowerCase()}> with ${textLen}-char body bleeds to viewport edge (${which})` });
15941594+ }
15951595+ }
15961596+15971597+ // --- Tight line height ---
15981598+ if (hasDirectText && textLen > 50 && !['h1','h2','h3','h4','h5','h6'].includes(tag)) {
15991599+ if (lineHeightPx != null && fontSize > 0) {
16001600+ const ratio = lineHeightPx / fontSize;
16011601+ if (ratio > 0 && ratio < 1.3) {
16021602+ findings.push({ id: 'tight-leading', snippet: `line-height ${ratio.toFixed(2)}x (need >=1.3)` });
16031603+ }
16041604+ }
16051605+ }
16061606+16071607+ // --- Justified text (without hyphens) ---
16081608+ if (hasDirectText && style.textAlign === 'justify') {
16091609+ const hyphens = style.hyphens || style.webkitHyphens || '';
16101610+ if (hyphens !== 'auto') {
16111611+ findings.push({ id: 'justified-text', snippet: 'text-align: justify without hyphens: auto' });
16121612+ }
16131613+ }
16141614+16151615+ // --- Tiny body text ---
16161616+ // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.)
16171617+ if (hasDirectText && textLen > 20 && fontSize < 12) {
16181618+ const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption'];
16191619+ 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]');
16201620+ const isUppercase = style.textTransform === 'uppercase';
16211621+ if (!skipTags.includes(tag) && !inUIContext && !isUppercase) {
16221622+ findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` });
16231623+ }
16241624+ }
16251625+16261626+ // --- All-caps body text ---
16271627+ if (hasDirectText && textLen > 30 && style.textTransform === 'uppercase') {
16281628+ if (!['h1','h2','h3','h4','h5','h6'].includes(tag)) {
16291629+ findings.push({ id: 'all-caps-body', snippet: `text-transform: uppercase on ${textLen} chars of body text` });
16301630+ }
16311631+ }
16321632+16331633+ // --- Wide letter spacing on body text ---
16341634+ if (hasDirectText && textLen > 20 && style.textTransform !== 'uppercase') {
16351635+ if (letterSpacingPx != null && letterSpacingPx > 0 && fontSize > 0) {
16361636+ const trackingEm = letterSpacingPx / fontSize;
16371637+ if (trackingEm > 0.05) {
16381638+ findings.push({ id: 'wide-tracking', snippet: `letter-spacing: ${trackingEm.toFixed(2)}em on body text` });
16391639+ }
16401640+ }
16411641+ }
16421642+16431643+ // --- Crushed letter spacing (mirror of wide-tracking) ---
16441644+ // Tracking pulled tighter than ~-0.05em crushes characters into each other.
16451645+ // Optical tightening that display type legitimately wants (around -0.02em)
16461646+ // stays well above this floor.
16471647+ if (hasDirectText && textLen > 20 && fontSize > 0) {
16481648+ if (letterSpacingPx != null && letterSpacingPx < 0) {
16491649+ const trackingEm = letterSpacingPx / fontSize;
16501650+ if (trackingEm <= -0.05) {
16511651+ const excerpt = (el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 40);
16521652+ findings.push({ id: 'extreme-negative-tracking', snippet: `letter-spacing: ${trackingEm.toFixed(2)}em — "${excerpt}"` });
16531653+ }
16541654+ }
16551655+ }
16561656+16571657+ return findings;
16581658+}
16591659+16601660+function checkElementQualityDOM(el) {
16611661+ const tag = el.tagName.toLowerCase();
16621662+ const style = getComputedStyle(el);
16631663+ const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 10);
16641664+ const textLen = el.textContent?.trim().length || 0;
16651665+ // Browser getComputedStyle resolves everything to px — direct parseFloat
16661666+ // works.
16671667+ const fontSize = parseFloat(style.fontSize) || 16;
16681668+ const lineHeightPx = resolveLengthPx(style.lineHeight, fontSize);
16691669+ const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize);
16701670+ const rect = el.getBoundingClientRect();
16711671+ const lineMax = (typeof window !== 'undefined' && window.__IMPECCABLE_CONFIG__?.lineLengthMax) || 80;
16721672+ const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0;
16731673+ return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax, viewportWidth, win: typeof window !== 'undefined' ? window : null });
16741674+}
16751675+16761676+// Pure page-level skipped-heading walk. Takes a Document so it works in both
16771677+// the browser and jsdom.
16781678+function checkPageQualityFromDoc(doc) {
16791679+ const findings = [];
16801680+ const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6');
16811681+ let prevLevel = 0;
16821682+ let prevText = '';
16831683+ for (const h of headings) {
16841684+ const level = parseInt(h.tagName[1]);
16851685+ const text = (h.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60);
16861686+ if (prevLevel > 0 && level > prevLevel + 1) {
16871687+ findings.push({
16881688+ id: 'skipped-heading',
16891689+ snippet: `<h${prevLevel}> "${prevText}" followed by <h${level}> "${text}" (missing h${prevLevel + 1})`,
16901690+ });
16911691+ }
16921692+ prevLevel = level;
16931693+ prevText = text;
16941694+ }
16951695+ return findings;
16961696+}
16971697+16981698+// Browser adapter (returns the legacy { type, detail } shape used by the overlay loop)
16991699+function checkPageQualityDOM() {
17001700+ return checkPageQualityFromDoc(document).map(f => ({ type: f.id, detail: f.snippet }));
17011701+}
17021702+17031703+// Node adapters — take pre-extracted jsdom computed style
17041704+17051705+// jsdom doesn't lay out OR resolve em/rem/% to px — so we pre-resolve every
17061706+// CSS length the rule needs ourselves (walking the parent chain for
17071707+// font-size inheritance), and pass `rect: null` to skip the two rules that
17081708+// genuinely need element rects (line-length, cramped-padding).
17091709+function checkElementQuality(el, style, tag, window) {
17101710+ const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 10);
17111711+ const textLen = el.textContent?.trim().length || 0;
17121712+ const fontSize = resolveFontSizePx(el, window);
17131713+ const lineHeightPx = resolveLengthPx(style.lineHeight, fontSize);
17141714+ const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize);
17151715+ return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect: null, win: window });
17161716+}
17171717+17181718+function checkElementBorders(tag, style, overrides, resolvedRadius) {
17191719+ const sides = ['Top', 'Right', 'Bottom', 'Left'];
17201720+ const widths = {}, colors = {};
17211721+ for (const s of sides) {
17221722+ widths[s] = parseFloat(style[`border${s}Width`]) || 0;
17231723+ colors[s] = style[`border${s}Color`] || '';
17241724+ // jsdom silently drops any border shorthand containing var(), leaving
17251725+ // both width and color empty on the computed style. When the detectHtml
17261726+ // pre-pass pulled a resolved value off the rule, use it to fill in the
17271727+ // missing side so the side-tab check can run. Real browsers resolve
17281728+ // var() natively, so this fallback is a no-op in the browser path.
17291729+ if (widths[s] === 0 && overrides && overrides[s]) {
17301730+ widths[s] = overrides[s].width;
17311731+ colors[s] = overrides[s].color;
17321732+ } else if (colors[s] && colors[s].startsWith('var(') && overrides && overrides[s]) {
17331733+ // Longhand case: jsdom kept the width but left the color as the
17341734+ // literal `var(...)` string. Substitute the resolved color.
17351735+ colors[s] = overrides[s].color;
17361736+ }
17371737+ }
17381738+ // resolvedRadius lets the caller pre-resolve the radius via
17391739+ // resolveBorderRadiusPx so the value survives jsdom 29.1.0's broken
17401740+ // shorthand serialization. Falls back to the computed value for tests
17411741+ // and browser callers that don't pre-resolve.
17421742+ const radius = resolvedRadius != null
17431743+ ? resolvedRadius
17441744+ : (parseFloat(style.borderRadius) || 0);
17451745+ return checkBorders(tag, widths, colors, radius);
17461746+}
17471747+17481748+function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule) {
17491749+ const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
17501750+ const hasDirectText = directText.trim().length > 0;
17511751+17521752+ const effectiveBg = resolveBackground(el, window, customPropMap);
17531753+ // jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain
17541754+ // parseRgb misses Tailwind-tokenized text colors. Resolve through the
17551755+ // customPropMap first; fall back to parseRgb for vanilla rgb() pages.
17561756+ let textColor = customPropMap ? parseColorResolved(style.color, customPropMap) : null;
17571757+ if (!textColor) textColor = parseRgb(style.color);
17581758+17591759+ // Anchor-inherit FP workaround: jsdom's UA stylesheet has `:link { color:
17601760+ // blue }` at high specificity. The page's `a { color: inherit }` rule
17611761+ // (Tailwind v4 preflight) loses to jsdom even though it WINS in real
17621762+ // browsers (Chrome's UA wraps :link in :where() — zero specificity).
17631763+ // When the page declares the inherit rule AND we see jsdom's default
17641764+ // link blue on an anchor, walk to the nearest non-anchor ancestor and
17651765+ // use its color instead.
17661766+ if (
17671767+ hasAnchorInheritRule &&
17681768+ textColor &&
17691769+ textColor.r === 0 && textColor.g === 0 && textColor.b === 238 &&
17701770+ (tag === 'a' || el.closest?.('a'))
17711771+ ) {
17721772+ let cur = el.parentElement;
17731773+ while (cur && cur.tagName !== 'HTML') {
17741774+ if (cur.tagName !== 'A') {
17751775+ const ps = window.getComputedStyle(cur);
17761776+ const inh = (customPropMap ? parseColorResolved(ps.color, customPropMap) : null) || parseRgb(ps.color);
17771777+ if (inh && !(inh.r === 0 && inh.g === 0 && inh.b === 238)) {
17781778+ textColor = inh;
17791779+ break;
17801780+ }
17811781+ }
17821782+ cur = cur.parentElement;
17831783+ }
17841784+ }
17851785+17861786+ return checkColors({
17871787+ tag,
17881788+ textColor,
17891789+ bgColor: readOwnBackgroundColor(el, style),
17901790+ effectiveBg,
17911791+ effectiveBgStops: effectiveBg ? null : resolveGradientStops(el, window),
17921792+ fontSize: parseFloat(style.fontSize) || 16,
17931793+ fontWeight: parseInt(style.fontWeight) || 400,
17941794+ hasDirectText,
17951795+ isEmojiOnly: isEmojiOnlyText(directText),
17961796+ bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
17971797+ bgImage: style.backgroundImage || '',
17981798+ classList: el.getAttribute?.('class') || el.className || '',
17991799+ });
18001800+}
18011801+18021802+function checkElementIconTile(el, tag, window) {
18031803+ if (!HEADING_TAGS.has(tag)) return [];
18041804+ const sibling = el.previousElementSibling;
18051805+ if (!sibling) return [];
18061806+18071807+ const sibStyle = window.getComputedStyle(sibling);
18081808+ // jsdom doesn't lay out — read explicit pixel dimensions from CSS instead.
18091809+ const sibWidth = parseFloat(sibStyle.width) || 0;
18101810+ const sibHeight = parseFloat(sibStyle.height) || 0;
18111811+18121812+ const iconChild = sibling.querySelector('svg, i[data-lucide], i[class*="fa-"], i[class*="icon"]');
18131813+ let iconWidth = 0;
18141814+ if (iconChild) {
18151815+ const iconStyle = window.getComputedStyle(iconChild);
18161816+ iconWidth = parseFloat(iconStyle.width) || parseFloat(iconChild.getAttribute('width')) || 0;
18171817+ }
18181818+ // Or: tile contains an emoji/symbol character directly as its only content
18191819+ const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
18201820+ const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText);
18211821+18221822+ return checkIconTile({
18231823+ headingTag: tag,
18241824+ headingText: el.textContent || '',
18251825+ headingTop: 0, // jsdom: no layout, skip vertical-stacking gate
18261826+ siblingTag: sibling.tagName.toLowerCase(),
18271827+ siblingWidth: sibWidth,
18281828+ siblingHeight: sibHeight,
18291829+ siblingBottom: 0,
18301830+ siblingBgColor: parseRgb(sibStyle.backgroundColor),
18311831+ siblingBgImage: sibStyle.backgroundImage || '',
18321832+ siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0,
18331833+ siblingBorderRadius: resolveBorderRadiusPx(sibling, sibStyle, sibWidth, window),
18341834+ hasIconChild: !!iconChild || hasInlineEmojiIcon,
18351835+ iconChildWidth: iconWidth,
18361836+ });
18371837+}
18381838+18391839+function checkElementItalicSerif(el, style, tag) {
18401840+ if (tag !== 'h1' && tag !== 'h2') return [];
18411841+ return checkItalicSerif({
18421842+ tag,
18431843+ fontStyle: style.fontStyle || '',
18441844+ fontFamily: style.fontFamily || '',
18451845+ fontSize: parseFloat(style.fontSize) || 0,
18461846+ headingText: el.textContent || '',
18471847+ });
18481848+}
18491849+18501850+function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
18511851+ if (tag !== 'h1') return [];
18521852+ const sibling = el.previousElementSibling;
18531853+ if (!sibling) return [];
18541854+ const sibStyle = window.getComputedStyle(sibling);
18551855+ // Resolve Tailwind v4 CSS-variable wrappers (font-weight:var(--font-weight-bold)
18561856+ // etc.) before parsing. jsdom returns these verbatim from getComputedStyle;
18571857+ // without resolution every style-based gate fails silently on Tailwind v4 builds.
18581858+ const fontSizeRaw = customPropMap ? resolveVarRefs(sibStyle.fontSize, customPropMap) : sibStyle.fontSize;
18591859+ const fontWeightRaw = customPropMap ? resolveVarRefs(sibStyle.fontWeight, customPropMap) : sibStyle.fontWeight;
18601860+ const letterSpacingRaw = customPropMap ? resolveVarRefs(sibStyle.letterSpacing, customPropMap) : sibStyle.letterSpacing;
18611861+ const colorRaw = customPropMap ? resolveVarRefs(sibStyle.color, customPropMap) : sibStyle.color;
18621862+ const headingFontSizeRaw = customPropMap ? resolveVarRefs(style.fontSize, customPropMap) : style.fontSize;
18631863+ const siblingFontSize = parseFloat(fontSizeRaw) || 0;
18641864+ // resolveLengthPx returns null for 'normal' / 'auto'; coerce to 0 so the
18651865+ // gate falls through cleanly. jsdom returns letter-spacing verbatim
18661866+ // (e.g. '0.15em'), unlike real browsers, so this conversion is required.
18671867+ return checkHeroEyebrow({
18681868+ headingTag: tag,
18691869+ headingText: el.textContent || '',
18701870+ headingFontSize: parseFloat(headingFontSizeRaw) || 0,
18711871+ siblingTag: sibling.tagName.toLowerCase(),
18721872+ siblingText: sibling.textContent || '',
18731873+ siblingTextTransform: sibStyle.textTransform || '',
18741874+ siblingFontSize,
18751875+ siblingLetterSpacing: resolveLengthPx(letterSpacingRaw, siblingFontSize) || 0,
18761876+ siblingFontWeight: fontWeightRaw || '',
18771877+ siblingColor: colorRaw || '',
18781878+ });
18791879+}
18801880+18811881+function checkRepeatedSectionKickersFromDoc(doc, win) {
18821882+ const candidates = collectRepeatedSectionKickerCandidates(
18831883+ doc,
18841884+ (el) => win.getComputedStyle(el),
18851885+ (value, fontSize) => resolveLengthPx(value, fontSize) || 0,
18861886+ );
18871887+ return checkRepeatedSectionKickers({ candidates });
18881888+}
18891889+18901890+function checkElementMotion(tag, style) {
18911891+ return checkMotion({
18921892+ tag,
18931893+ transitionProperty: style.transitionProperty || '',
18941894+ animationName: style.animationName || '',
18951895+ timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '),
18961896+ classList: '',
18971897+ });
18981898+}
18991899+19001900+function checkElementGlow(tag, style, effectiveBg) {
19011901+ if (!style.boxShadow || style.boxShadow === 'none') return [];
19021902+ return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg });
19031903+}
19041904+19051905+// ─── Section 6: Page-Level Checks ───────────────────────────────────────────
19061906+19071907+// Browser page-level checks — use document/getComputedStyle globals
19081908+19091909+function checkTypography() {
19101910+ const findings = [];
19111911+19121912+ // Walk actual text-bearing elements and tally font usage by *computed style*.
19131913+ // This is much more accurate than scanning CSS rules — it ignores rules that
19141914+ // exist in the stylesheet but apply to nothing (e.g. demo classes showing
19151915+ // anti-patterns), and counts what the user actually sees.
19161916+ const fontUsage = new Map(); // primary font name → count of elements
19171917+ let totalTextElements = 0;
19181918+ for (const el of document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, td, th, dd, blockquote, figcaption, a, button, label, span')) {
19191919+ // Skip impeccable's own elements
19201920+ if (el.closest && el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;
19211921+ // Only count elements that actually have visible direct text
19221922+ const hasText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
19231923+ if (!hasText) continue;
19241924+ const style = getComputedStyle(el);
19251925+ const ff = style.fontFamily;
19261926+ if (!ff) continue;
19271927+ const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
19281928+ const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
19291929+ if (!primary) continue;
19301930+ fontUsage.set(primary, (fontUsage.get(primary) || 0) + 1);
19311931+ totalTextElements++;
19321932+ }
19331933+19341934+ if (totalTextElements >= 20) {
19351935+ // A font is "primary" if it's used by at least 15% of text elements
19361936+ const PRIMARY_THRESHOLD = 0.15;
19371937+ for (const [font, count] of fontUsage) {
19381938+ const share = count / totalTextElements;
19391939+ if (share < PRIMARY_THRESHOLD) continue;
19401940+ if (!OVERUSED_FONTS.has(font)) continue;
19411941+ if (isBrandFontOnOwnDomain(font)) continue;
19421942+ findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
19431943+ }
19441944+19451945+ // Single-font check: only one distinct primary font across all text
19461946+ if (fontUsage.size === 1) {
19471947+ const only = [...fontUsage.keys()][0];
19481948+ findings.push({ type: 'single-font', detail: `only font used is ${only}` });
19491949+ }
19501950+ }
19511951+19521952+ const sizes = new Set();
19531953+ for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) {
19541954+ const fs = parseFloat(getComputedStyle(el).fontSize);
19551955+ if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10);
19561956+ }
19571957+ if (sizes.size >= 3) {
19581958+ const sorted = [...sizes].sort((a, b) => a - b);
19591959+ const ratio = sorted[sorted.length - 1] / sorted[0];
19601960+ if (ratio < 2.0) {
19611961+ findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
19621962+ }
19631963+ }
19641964+19651965+ return findings;
19661966+}
19671967+19681968+function isCardLikeDOM(el) {
19691969+ const tag = el.tagName.toLowerCase();
19701970+ if (SAFE_TAGS.has(tag) || ['input','select','textarea','img','video','canvas','picture'].includes(tag)) return false;
19711971+ const style = getComputedStyle(el);
19721972+ const cls = el.getAttribute('class') || '';
19731973+ const hasShadow = (style.boxShadow && style.boxShadow !== 'none') || /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls);
19741974+ const hasBorder = /\bborder\b/.test(cls);
19751975+ const hasRadius = parseFloat(style.borderRadius) > 0 || /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls);
19761976+ const hasBg = (style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)') || /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls);
19771977+ return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);
19781978+}
19791979+19801980+function checkLayout() {
19811981+ const findings = [];
19821982+ const flaggedEls = new Set();
19831983+19841984+ for (const el of document.querySelectorAll('*')) {
19851985+ if (!isCardLikeDOM(el) || flaggedEls.has(el)) continue;
19861986+ const cls = el.getAttribute('class') || '';
19871987+ const style = getComputedStyle(el);
19881988+ if (style.position === 'absolute' || style.position === 'fixed') continue;
19891989+ if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue;
19901990+ if ((el.textContent?.trim().length || 0) < 10) continue;
19911991+ const rect = el.getBoundingClientRect();
19921992+ if (rect.width < 50 || rect.height < 30) continue;
19931993+19941994+ let parent = el.parentElement;
19951995+ while (parent) {
19961996+ if (isCardLikeDOM(parent)) { flaggedEls.add(el); break; }
19971997+ parent = parent.parentElement;
19981998+ }
19991999+ }
20002000+20012001+ for (const el of flaggedEls) {
20022002+ let isAncestor = false;
20032003+ for (const other of flaggedEls) {
20042004+ if (other !== el && el.contains(other)) { isAncestor = true; break; }
20052005+ }
20062006+ if (!isAncestor) findings.push({ type: 'nested-cards', detail: 'Card inside card', el });
20072007+ }
20082008+20092009+ return findings;
20102010+}
20112011+20122012+// Node page-level checks — take document/window as parameters
20132013+20142014+function checkPageTypography(doc, win) {
20152015+ const findings = [];
20162016+20172017+ const fonts = new Set();
20182018+ const overusedFound = new Set();
20192019+20202020+ for (const sheet of doc.styleSheets) {
20212021+ let rules;
20222022+ try { rules = sheet.cssRules || sheet.rules; } catch { continue; }
20232023+ if (!rules) continue;
20242024+ for (const rule of rules) {
20252025+ if (rule.type !== 1) continue;
20262026+ const ff = rule.style?.fontFamily;
20272027+ if (!ff) continue;
20282028+ const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
20292029+ const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
20302030+ if (primary) {
20312031+ fonts.add(primary);
20322032+ if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
20332033+ }
20342034+ }
20352035+ }
20362036+20372037+ // Check Google Fonts links in HTML
20382038+ const html = doc.documentElement?.outerHTML || '';
20392039+ const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
20402040+ let m;
20412041+ while ((m = gfRe.exec(html)) !== null) {
20422042+ const families = m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase());
20432043+ for (const f of families) {
20442044+ fonts.add(f);
20452045+ if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
20462046+ }
20472047+ }
20482048+20492049+ // Also parse raw HTML/style content for font-family (jsdom may not expose all via CSSOM)
20502050+ const ffRe = /font-family\s*:\s*([^;}]+)/gi;
20512051+ let fm;
20522052+ while ((fm = ffRe.exec(html)) !== null) {
20532053+ for (const f of fm[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
20542054+ if (f && !GENERIC_FONTS.has(f)) {
20552055+ fonts.add(f);
20562056+ if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
20572057+ }
20582058+ }
20592059+ }
20602060+20612061+ for (const font of overusedFound) {
20622062+ findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` });
20632063+ }
20642064+20652065+ // Single font
20662066+ if (fonts.size === 1) {
20672067+ const els = doc.querySelectorAll('*');
20682068+ if (els.length >= 20) {
20692069+ findings.push({ id: 'single-font', snippet: `only font used is ${[...fonts][0]}` });
20702070+ }
20712071+ }
20722072+20732073+ // Flat type hierarchy
20742074+ const sizes = new Set();
20752075+ const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div');
20762076+ for (const el of textEls) {
20772077+ const fontSize = parseFloat(win.getComputedStyle(el).fontSize);
20782078+ // Filter out sub-8px values (jsdom doesn't resolve relative units properly)
20792079+ if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10);
20802080+ }
20812081+ if (sizes.size >= 3) {
20822082+ const sorted = [...sizes].sort((a, b) => a - b);
20832083+ const ratio = sorted[sorted.length - 1] / sorted[0];
20842084+ if (ratio < 2.0) {
20852085+ findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
20862086+ }
20872087+ }
20882088+20892089+ return findings;
20902090+}
20912091+20922092+function isCardLike(el, win) {
20932093+ const tag = el.tagName.toLowerCase();
20942094+ if (SAFE_TAGS.has(tag) || ['input', 'select', 'textarea', 'img', 'video', 'canvas', 'picture'].includes(tag)) return false;
20952095+20962096+ const style = win.getComputedStyle(el);
20972097+ const rawStyle = el.getAttribute?.('style') || '';
20982098+ const cls = el.getAttribute?.('class') || '';
20992099+21002100+ const hasShadow = (style.boxShadow && style.boxShadow !== 'none') ||
21012101+ /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls) || /box-shadow/i.test(rawStyle);
21022102+ const hasBorder = /\bborder\b/.test(cls);
21032103+ const widthPx = parseFloat(style.width) || 0;
21042104+ const hasRadius = resolveBorderRadiusPx(el, style, widthPx, win) > 0 ||
21052105+ /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls) || /border-radius/i.test(rawStyle);
21062106+ const hasBg = /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls) ||
21072107+ /background(?:-color)?\s*:\s*(?!transparent)/i.test(rawStyle);
21082108+21092109+ return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);
21102110+}
21112111+21122112+function checkPageLayout(doc, win) {
21132113+ const findings = [];
21142114+21152115+ // Nested cards
21162116+ const allEls = doc.querySelectorAll('*');
21172117+ const flaggedEls = new Set();
21182118+ for (const el of allEls) {
21192119+ if (!isCardLike(el, win)) continue;
21202120+ if (flaggedEls.has(el)) continue;
21212121+21222122+ const tag = el.tagName.toLowerCase();
21232123+ const cls = el.getAttribute?.('class') || '';
21242124+ const rawStyle = el.getAttribute?.('style') || '';
21252125+21262126+ if (['pre', 'code'].includes(tag)) continue;
21272127+ if (/\b(?:absolute|fixed)\b/.test(cls) || /position\s*:\s*(?:absolute|fixed)/i.test(rawStyle)) continue;
21282128+ if ((el.textContent?.trim().length || 0) < 10) continue;
21292129+ if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue;
21302130+21312131+ // Walk up to find card-like ancestor
21322132+ let parent = el.parentElement;
21332133+ while (parent) {
21342134+ if (isCardLike(parent, win)) {
21352135+ flaggedEls.add(el);
21362136+ break;
21372137+ }
21382138+ parent = parent.parentElement;
21392139+ }
21402140+ }
21412141+21422142+ // Only report innermost nested cards
21432143+ for (const el of flaggedEls) {
21442144+ let isAncestorOfFlagged = false;
21452145+ for (const other of flaggedEls) {
21462146+ if (other !== el && el.contains(other)) {
21472147+ isAncestorOfFlagged = true;
21482148+ break;
21492149+ }
21502150+ }
21512151+ if (!isAncestorOfFlagged) {
21522152+ findings.push({ id: 'nested-cards', snippet: `Card inside card (${el.tagName.toLowerCase()})` });
21532153+ }
21542154+ }
21552155+21562156+ return findings;
21572157+}
21582158+21592159+// ─── Cream / beige palette (the default "tasteful" AI surface) ────────────────
21602160+// A warm, lightly-tinted off-white page background — light, with R≥G≥B and a
21612161+// small warm tint (not white, not a strong color). The current reflex surface.
21622162+function isCreamColor(rgb) {
21632163+ if (!rgb) return false;
21642164+ const { r, g, b } = rgb;
21652165+ if (Math.min(r, g, b) < 209) return false; // must be light
21662166+ if (!(r >= g && g >= b)) return false; // warm ordering
21672167+ const warmth = r - b;
21682168+ return warmth >= 6 && warmth <= 48; // tinted, not white, not strong
21692169+}
21702170+21712171+// Tailwind background utilities that render as a warm off-white surface. The
21722172+// static engine doesn't fetch Tailwind's CSS, so a `bg-amber-50` on <body>
21732173+// resolves to nothing in computed style — catch it from the class list
21742174+// instead. Candidate tokens map to their actual Tailwind hex and are still
21752175+// filtered through isCreamColor, so neutral grays (stone) and over-saturated
21762176+// shades drop out on their own.
21772177+const TAILWIND_BG_HEX = {
21782178+ 'bg-amber-50': '#fffbeb', 'bg-amber-100': '#fef3c7',
21792179+ 'bg-orange-50': '#fff7ed', 'bg-orange-100': '#ffedd5',
21802180+ 'bg-yellow-50': '#fefce8',
21812181+ 'bg-stone-50': '#fafaf9', 'bg-stone-100': '#f5f5f4', 'bg-stone-200': '#e7e5e4',
21822182+};
21832183+21842184+function creamFromClassList(cls) {
21852185+ if (!cls) return null;
21862186+ // Arbitrary value: bg-[#f5f0e6] / bg-[rgb(245_240_230)] (underscores = spaces).
21872187+ const arb = cls.match(/\bbg-\[([^\]]+)\]/);
21882188+ if (arb && isCreamColor(parseAnyColor(arb[1].replace(/_/g, ' ')))) return `bg-[${arb[1]}]`;
21892189+ // Named warm-light utilities.
21902190+ for (const [tok, hex] of Object.entries(TAILWIND_BG_HEX)) {
21912191+ if (new RegExp(`(^|\\s)${tok}($|\\s)`).test(cls) && isCreamColor(parseAnyColor(hex))) return tok;
21922192+ }
21932193+ return null;
21942194+}
21952195+21962196+function checkCreamPalette(doc, win) {
21972197+ const findings = [];
21982198+ const body = doc.body || (doc.querySelector ? doc.querySelector('body') : null);
21992199+ if (!body) return findings;
22002200+ const html = doc.documentElement;
22012201+ const getCS = (el) => (win ? win.getComputedStyle(el) : getComputedStyle(el));
22022202+22032203+ // 1. Computed background — covers inline / <style> / linked CSS, and Tailwind
22042204+ // once it's actually rendered (browser path).
22052205+ let bg = readOwnBackgroundColor(body, getCS(body));
22062206+ if (!bg || bg.a === 0) {
22072207+ if (html) bg = readOwnBackgroundColor(html, getCS(html));
22082208+ }
22092209+ if (isCreamColor(bg)) {
22102210+ findings.push({ id: 'cream-palette', snippet: `cream/beige page background rgb(${bg.r}, ${bg.g}, ${bg.b})` });
22112211+ return findings;
22122212+ }
22132213+22142214+ // 2. Tailwind class fallback — for the static path, where utility classes
22152215+ // never resolve to computed CSS.
22162216+ for (const el of [body, html]) {
22172217+ const tok = creamFromClassList(el && el.getAttribute ? el.getAttribute('class') : '');
22182218+ if (tok) {
22192219+ findings.push({ id: 'cream-palette', snippet: `cream/beige page background (Tailwind ${tok})` });
22202220+ break;
22212221+ }
22222222+ }
22232223+ return findings;
22242224+}
22252225+22262226+// ─── Oversized hero headline ────────────────────────────────────────────────
22272227+// Fires when a *long* headline is set at display size and actually dominates
22282228+// the viewport. A punchy one- or two-word headline at the same size is a
22292229+// legitimate stylistic choice, and a large-but-contained two-line hero should
22302230+// pass too — length and viewport share together are the tell.
22312231+const OVERSIZED_H1_FONT_PX = 72;
22322232+const OVERSIZED_H1_MIN_CHARS = 40;
22332233+const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28;
22342234+const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25;
22352235+function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) {
22362236+ if (tag !== 'h1') return [];
22372237+ const textLen = headingText.length;
22382238+ if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) {
22392239+ let viewportDetail = '';
22402240+ if (rect && viewportWidth > 0 && viewportHeight > 0) {
22412241+ const heightRatio = rect.height / viewportHeight;
22422242+ const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight);
22432243+ const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO
22442244+ || areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO;
22452245+ if (!dominatesViewport) return [];
22462246+ viewportDetail = `, ${Math.round(heightRatio * 100)}vh`;
22472247+ }
22482248+ return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }];
22492249+ }
22502250+ return [];
22512251+}
22522252+22532253+function checkElementOversizedH1(el, style, tag, window) {
22542254+ if (tag !== 'h1') return [];
22552255+ const fontSize = resolveFontSizePx(el, window);
22562256+ const headingText = (el.textContent || '').trim().replace(/\s+/g, ' ');
22572257+ return checkOversizedH1({ tag, fontSize, headingText });
22582258+}
22592259+22602260+function checkElementOversizedH1DOM(el) {
22612261+ const tag = el.tagName.toLowerCase();
22622262+ if (tag !== 'h1') return [];
22632263+ const style = getComputedStyle(el);
22642264+ const fontSize = parseFloat(style.fontSize) || 0;
22652265+ const headingText = (el.textContent || '').trim().replace(/\s+/g, ' ');
22662266+ const rect = el.getBoundingClientRect();
22672267+ const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0;
22682268+ const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0;
22692269+ return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight });
22702270+}
22712271+22722272+// ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ────────────
22732273+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;
22742274+22752275+function shadowLayerAlpha(layer) {
22762276+ CSS_COLOR_TOKEN_RE.lastIndex = 0;
22772277+ const match = CSS_COLOR_TOKEN_RE.exec(layer);
22782278+ if (!match) return 1;
22792279+ if (match[0].toLowerCase() === 'transparent') return 0;
22802280+ const parsed = parseAnyColor(match[0]);
22812281+ return parsed ? (parsed.a ?? 1) : 1;
22822282+}
22832283+22842284+function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) {
22852285+ if (!boxShadow || boxShadow === 'none') return 0;
22862286+ let maxBlur = 0;
22872287+ // Split into layers on commas not inside parentheses (rgba(...) etc.).
22882288+ for (const layer of boxShadow.split(/,(?![^()]*\))/)) {
22892289+ if (shadowLayerAlpha(layer) < minAlpha) continue;
22902290+ // Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the
22912291+ // ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps
22922292+ // unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") —
22932293+ // both reduce to the same numbers here.
22942294+ const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' ');
22952295+ const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0]));
22962296+ if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]);
22972297+ }
22982298+ return maxBlur;
22992299+}
23002300+23012301+function cssColorAlpha(value) {
23022302+ if (cssColorIsTransparent(value)) return 0;
23032303+ const parsed = parseAnyColor(value);
23042304+ return parsed ? (parsed.a ?? 1) : 1;
23052305+}
23062306+23072307+function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) {
23082308+ const visibleThinBorders = borderWidths
23092309+ .map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') }))
23102310+ .filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28);
23112311+ const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width));
23122312+ const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 });
23132313+ if (visibleThinBorders.length >= 2 && blur >= 16) {
23142314+ return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }];
23152315+ }
23162316+ return [];
23172317+}
23182318+23192319+function borderWidthsFromStyle(style) {
23202320+ return [
23212321+ parseFloat(style.borderTopWidth) || 0,
23222322+ parseFloat(style.borderRightWidth) || 0,
23232323+ parseFloat(style.borderBottomWidth) || 0,
23242324+ parseFloat(style.borderLeftWidth) || 0,
23252325+ ];
23262326+}
23272327+23282328+function borderColorsFromStyle(style) {
23292329+ return [
23302330+ style.borderTopColor || '',
23312331+ style.borderRightColor || '',
23322332+ style.borderBottomColor || '',
23332333+ style.borderLeftColor || '',
23342334+ ];
23352335+}
23362336+23372337+function checkElementGptBorderShadow(el, style) {
23382338+ return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' });
23392339+}
23402340+23412341+function checkElementGptBorderShadowDOM(el) {
23422342+ const style = getComputedStyle(el);
23432343+ return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' });
23442344+}
23452345+23462346+// ─── Clipped overflow container ───────────────────────────────────────────────
23472347+// A clipping container (overflow hidden/clip, not a scroll region) wrapping an
23482348+// absolutely/fixed-positioned descendant clips popovers/menus that must escape.
23492349+function classSelector(el) {
23502350+ const cls = (el.getAttribute ? el.getAttribute('class') : el.className) || '';
23512351+ const tokens = String(cls).trim().split(/\s+/).filter(Boolean);
23522352+ const tag = el.tagName ? el.tagName.toLowerCase() : 'el';
23532353+ return tokens.length ? `${tag}.${tokens.join('.')}` : tag;
23542354+}
23552355+23562356+function positionedChildIsDecorative(child) {
23572357+ if (!child || typeof child.getAttribute !== 'function') return false;
23582358+ if (child.closest?.('[aria-hidden="true"]')) return true;
23592359+ const role = (child.getAttribute('role') || '').toLowerCase();
23602360+ if (role === 'none' || role === 'presentation') return true;
23612361+ const tag = child.tagName ? child.tagName.toLowerCase() : '';
23622362+ if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true;
23632363+ const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`;
23642364+ if (
23652365+ /\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) &&
23662366+ !positionedChildHasSubstantiveContent(child)
23672367+ ) {
23682368+ return true;
23692369+ }
23702370+ return false;
23712371+}
23722372+23732373+const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [
23742374+ 'a[href]',
23752375+ 'button',
23762376+ 'input',
23772377+ 'select',
23782378+ 'summary',
23792379+ 'textarea',
23802380+ '[tabindex]:not([tabindex="-1"])',
23812381+ '[role="button"]',
23822382+ '[role="dialog"]',
23832383+ '[role="link"]',
23842384+ '[role="listbox"]',
23852385+ '[role="menu"]',
23862386+ '[role="menuitem"]',
23872387+ '[role="option"]',
23882388+ '[role="tooltip"]',
23892389+].join(',');
23902390+23912391+function positionedChildHasSubstantiveContent(child) {
23922392+ const text = (child.textContent || '').replace(/\s+/g, ' ').trim();
23932393+ if (text.length > 0) return true;
23942394+ if (typeof child.matches === 'function') {
23952395+ try {
23962396+ if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true;
23972397+ } catch {}
23982398+ }
23992399+ if (typeof child.querySelector === 'function') {
24002400+ try {
24012401+ if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true;
24022402+ } catch {}
24032403+ }
24042404+ return false;
24052405+}
24062406+24072407+function clippingContainerIsIntentionalViewport(el) {
24082408+ if (!el || typeof el.getAttribute !== 'function') return false;
24092409+ const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase();
24102410+ if (/\b(carousel|slider)\b/.test(roleDescription)) return true;
24112411+ const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase();
24122412+ return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) ||
24132413+ /\b(demo-area|demo-stage|demo-viewport)\b/.test(ident);
24142414+}
24152415+24162416+function elementRect(el) {
24172417+ if (!el || typeof el.getBoundingClientRect !== 'function') return null;
24182418+ try {
24192419+ const rect = el.getBoundingClientRect();
24202420+ if (!rect) return null;
24212421+ const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height];
24222422+ if (!values.every(Number.isFinite)) return null;
24232423+ if (rect.width <= 0 && rect.height <= 0) return null;
24242424+ return rect;
24252425+ } catch {
24262426+ return null;
24272427+ }
24282428+}
24292429+24302430+function positionedStyleImpliesEscape(style) {
24312431+ const values = [
24322432+ style.top,
24332433+ style.right,
24342434+ style.bottom,
24352435+ style.left,
24362436+ style.inset,
24372437+ style.insetBlock,
24382438+ style.insetInline,
24392439+ style.insetBlockStart,
24402440+ style.insetBlockEnd,
24412441+ style.insetInlineStart,
24422442+ style.insetInlineEnd,
24432443+ ].filter(Boolean).map(value => String(value).trim().toLowerCase());
24442444+ for (const value of values) {
24452445+ if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true;
24462446+ if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true;
24472447+ }
24482448+ return false;
24492449+}
24502450+24512451+function positionedChildEscapesClip(el, child, clipX, clipY) {
24522452+ const parentRect = elementRect(el);
24532453+ const childRect = elementRect(child);
24542454+ if (!parentRect || !childRect) return null;
24552455+ const threshold = 2;
24562456+ return Boolean(
24572457+ (clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) ||
24582458+ (clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold))
24592459+ );
24602460+}
24612461+24622462+function checkClippedOverflow(el, style, getStyle) {
24632463+ const clips = (v) => v === 'hidden' || v === 'clip';
24642464+ const scrolls = (v) => v === 'auto' || v === 'scroll';
24652465+ const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || '';
24662466+ const clipX = clips(ox) || clips(ov);
24672467+ const clipY = clips(oy) || clips(ov);
24682468+ const anyClip = clipX || clipY;
24692469+ const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov);
24702470+ if (!anyClip || anyScroll) return [];
24712471+ if (clippingContainerIsIntentionalViewport(el)) return [];
24722472+ if (!el.querySelectorAll) return [];
24732473+ for (const child of el.querySelectorAll('*')) {
24742474+ const childStyle = getStyle(child);
24752475+ const pos = childStyle.position || '';
24762476+ if (pos === 'absolute' || pos === 'fixed') {
24772477+ if (positionedChildIsDecorative(child)) continue;
24782478+ const escapes = positionedChildEscapesClip(el, child, clipX, clipY);
24792479+ if (escapes === false) continue;
24802480+ if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue;
24812481+ return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }];
24822482+ }
24832483+ }
24842484+ return [];
24852485+}
24862486+24872487+function checkElementClippedOverflow(el, style, tag, window) {
24882488+ return checkClippedOverflow(el, style, (n) => window.getComputedStyle(n));
24892489+}
24902490+24912491+function checkElementClippedOverflowDOM(el) {
24922492+ const style = getComputedStyle(el);
24932493+ return checkClippedOverflow(el, style, (n) => getComputedStyle(n));
24942494+}
24952495+24962496+// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
24972497+const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
24982498+24992499+function metricLengthPx(value, fontSizePx = 16) {
25002500+ if (typeof value === 'number' && Number.isFinite(value)) return value;
25012501+ if (typeof value !== 'string') return null;
25022502+ return resolveLengthPx(value, fontSizePx);
25032503+}
25042504+25052505+function firstMetricLengthPx(fontSizePx, ...values) {
25062506+ for (const value of values) {
25072507+ const parsed = metricLengthPx(value, fontSizePx);
25082508+ if (parsed !== null) return parsed;
25092509+ }
25102510+ return null;
25112511+}
25122512+25132513+function expandBoxShorthand(parts) {
25142514+ if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
25152515+ if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
25162516+ if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
25172517+ return [parts[0], parts[1], parts[2], parts[3]];
25182518+}
25192519+25202520+function clippedByInset(clipPath) {
25212521+ const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
25222522+ if (!match) return false;
25232523+ const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
25242524+ if (!beforeRound) return false;
25252525+ const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
25262526+ const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
25272527+ if (percents.some(match => !match)) return false;
25282528+ const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
25292529+ return top + bottom >= 100 || left + right >= 100;
25302530+}
25312531+25322532+function clippedByRect(clip) {
25332533+ const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
25342534+ if (!match) return false;
25352535+ const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
25362536+ if (values.length !== 4) return false;
25372537+ const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
25382538+ if ([top, right, bottom, left].some(value => value === null)) return false;
25392539+ return bottom <= top || right <= left;
25402540+}
25412541+25422542+function isScreenReaderOnlyTextStyle(style, metrics = {}) {
25432543+ if (!style) return false;
25442544+ const overflowValues = [style.overflow, style.overflowX, style.overflowY]
25452545+ .map(value => String(value || '').toLowerCase());
25462546+ const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
25472547+25482548+ const fontSize = metricLengthPx(style.fontSize, 16) || 16;
25492549+ const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
25502550+ const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
25512551+ const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
25522552+ const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
25532553+25542554+ const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
25552555+ const clip = String(style.clip || '').trim();
25562556+ return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
25572557+}
25582558+25592559+function isRenderedForBrowserRule(el) {
25602560+ for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) {
25612561+ if (cur.getAttribute?.('aria-hidden') === 'true') return false;
25622562+ const style = getComputedStyle(cur);
25632563+ const visibility = String(style.visibility || '').toLowerCase();
25642564+ if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false;
25652565+ if ((parseFloat(style.opacity) || 0) <= 0.01) return false;
25662566+ if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false;
25672567+ }
25682568+ return true;
25692569+}
25702570+25712571+function checkElementTextOverflowDOM(el) {
25722572+ const tag = el.tagName.toLowerCase();
25732573+ if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
25742574+ if (!isRenderedForBrowserRule(el)) return [];
25752575+ // Only the element that actually owns overflowing text — not its ancestors,
25762576+ // which inherit a wider scrollWidth from the spilling descendant.
25772577+ const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
25782578+ if (!hasDirectText) return [];
25792579+ const style = getComputedStyle(el);
25802580+ const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
25812581+ if (isScreenReaderOnlyTextStyle(style, {
25822582+ width: rect?.width,
25832583+ height: rect?.height,
25842584+ clientWidth: el.clientWidth,
25852585+ clientHeight: el.clientHeight,
25862586+ })) return [];
25872587+ const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
25882588+ if (isScrollRegion(style)) return [];
25892589+ // A scrollable ancestor means this overflow is intentional and scrollable.
25902590+ for (let p = el.parentElement; p; p = p.parentElement) {
25912591+ if (isScrollRegion(getComputedStyle(p))) return [];
25922592+ }
25932593+ const delta = el.scrollWidth - el.clientWidth;
25942594+ if (el.clientWidth > 0 && delta >= 16) {
25952595+ return [{ id: 'text-overflow', snippet: `${classSelector(el)} overflows its box by ${Math.round(delta)}px` }];
25962596+ }
25972597+ return [];
25982598+}
25992599+26002600+export {
26012601+ checkBorders,
26022602+ isEmojiOnlyText,
26032603+ checkColors,
26042604+ isCardLikeFromProps,
26052605+ checkIconTile,
26062606+ resolveSerif,
26072607+ checkItalicSerif,
26082608+ isAccentColor,
26092609+ checkHeroEyebrow,
26102610+ checkRepeatedSectionKickers,
26112611+ checkMotion,
26122612+ checkGlow,
26132613+ checkHtmlPatterns,
26142614+ readOwnBackgroundColor,
26152615+ resolveBackground,
26162616+ resolveGradientStops,
26172617+ parseRadiusToPx,
26182618+ resolveBorderRadiusPx,
26192619+ checkElementBordersDOM,
26202620+ checkElementColorsDOM,
26212621+ checkElementIconTileDOM,
26222622+ checkElementItalicSerifDOM,
26232623+ checkElementHeroEyebrowDOM,
26242624+ buildCustomPropMap,
26252625+ resolveVarRefs,
26262626+ oklchToRgb,
26272627+ parseAnyColor,
26282628+ parseColorResolved,
26292629+ cleanInlineText,
26302630+ isRepeatedKickerCandidate,
26312631+ collectRepeatedSectionKickerCandidates,
26322632+ checkRepeatedSectionKickersDOM,
26332633+ checkElementMotionDOM,
26342634+ checkElementGlowDOM,
26352635+ checkElementAIPaletteDOM,
26362636+ resolveFontSizePx,
26372637+ resolveLengthPx,
26382638+ checkQuality,
26392639+ checkElementQualityDOM,
26402640+ checkPageQualityFromDoc,
26412641+ checkPageQualityDOM,
26422642+ checkElementQuality,
26432643+ checkElementBorders,
26442644+ checkElementColors,
26452645+ checkElementIconTile,
26462646+ checkElementItalicSerif,
26472647+ checkElementHeroEyebrow,
26482648+ checkRepeatedSectionKickersFromDoc,
26492649+ checkElementMotion,
26502650+ checkElementGlow,
26512651+ checkTypography,
26522652+ isCardLikeDOM,
26532653+ checkLayout,
26542654+ checkPageTypography,
26552655+ isCardLike,
26562656+ checkPageLayout,
26572657+ isCreamColor,
26582658+ checkCreamPalette,
26592659+ checkOversizedH1,
26602660+ checkElementOversizedH1,
26612661+ checkElementOversizedH1DOM,
26622662+ shadowMaxBlurPx,
26632663+ checkGptThinBorderWideShadow,
26642664+ checkElementGptBorderShadow,
26652665+ checkElementGptBorderShadowDOM,
26662666+ checkClippedOverflow,
26672667+ checkElementClippedOverflow,
26682668+ checkElementClippedOverflowDOM,
26692669+ isScreenReaderOnlyTextStyle,
26702670+ checkElementTextOverflowDOM,
26712671+};
+124
tools/design-detector/detector/shared/color.mjs
···11+// ─── Section 2: Color Utilities ─────────────────────────────────────────────
22+33+function isNeutralColor(color) {
44+ if (!color || color === 'transparent') return true;
55+66+ // rgb/rgba — use channel spread. Threshold 30 ≈ 11.7% of the 0–255 range.
77+ const rgb = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
88+ if (rgb) {
99+ return (Math.max(+rgb[1], +rgb[2], +rgb[3]) - Math.min(+rgb[1], +rgb[2], +rgb[3])) < 30;
1010+ }
1111+1212+ // oklch()/lch() — chroma is the second numeric component.
1313+ // oklch chroma is ~0–0.4 in sRGB gamut; >= 0.02 reads as tinted, not gray.
1414+ // lch chroma is ~0–150; >= 3 reads as tinted. jsdom emits both formats
1515+ // literally (it does NOT convert them to rgb).
1616+ const oklch = color.match(/oklch\(\s*[\d.]+%?\s*([\d.-]+)/i);
1717+ if (oklch) return parseFloat(oklch[1]) < 0.02;
1818+ const lch = color.match(/lch\(\s*[\d.]+%?\s*([\d.-]+)/i);
1919+ if (lch) return parseFloat(lch[1]) < 3;
2020+2121+ // oklab()/lab() — a and b are signed axes; chroma = sqrt(a² + b²).
2222+ // oklab a/b are ~-0.4..0.4, threshold 0.02. lab a/b are ~-128..127, threshold 3.
2323+ const oklab = color.match(/oklab\(\s*[\d.]+%?\s*([\d.-]+)\s+([\d.-]+)/i);
2424+ if (oklab) {
2525+ const a = parseFloat(oklab[1]), b = parseFloat(oklab[2]);
2626+ return Math.hypot(a, b) < 0.02;
2727+ }
2828+ const lab = color.match(/lab\(\s*[\d.]+%?\s*([\d.-]+)\s+([\d.-]+)/i);
2929+ if (lab) {
3030+ const a = parseFloat(lab[1]), b = parseFloat(lab[2]);
3131+ return Math.hypot(a, b) < 3;
3232+ }
3333+3434+ // hsl/hsla — saturation is the second numeric component (percent).
3535+ // Modern jsdom usually converts hsl() to rgb, but handle it directly for
3636+ // safety across versions and for any engine that preserves the format.
3737+ const hsl = color.match(/hsla?\(\s*[\d.-]+\s*,?\s*([\d.]+)%/i);
3838+ if (hsl) return parseFloat(hsl[1]) < 10;
3939+4040+ // hwb(hue whiteness% blackness%) — a pixel is fully gray when
4141+ // whiteness + blackness >= 100; chroma-like saturation = 1 - (w+b)/100.
4242+ const hwb = color.match(/hwb\(\s*[\d.-]+\s+([\d.]+)%\s+([\d.]+)%/i);
4343+ if (hwb) {
4444+ const w = parseFloat(hwb[1]), b = parseFloat(hwb[2]);
4545+ return (1 - Math.min(100, w + b) / 100) < 0.1;
4646+ }
4747+4848+ // Unknown / unrecognized format — err on the side of DETECTING rather
4949+ // than silently skipping. This is the opposite of the previous default,
5050+ // which was the root cause of the oklch bug.
5151+ return false;
5252+}
5353+5454+function parseRgb(color) {
5555+ if (!color || color === 'transparent') return null;
5656+ const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
5757+ if (!m) return null;
5858+ return { r: +m[1], g: +m[2], b: +m[3], a: m[4] !== undefined ? +m[4] : 1 };
5959+}
6060+6161+function relativeLuminance({ r, g, b }) {
6262+ const [rs, gs, bs] = [r / 255, g / 255, b / 255].map(c =>
6363+ c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
6464+ );
6565+ return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
6666+}
6767+6868+function contrastRatio(c1, c2) {
6969+ const l1 = relativeLuminance(c1);
7070+ const l2 = relativeLuminance(c2);
7171+ return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
7272+}
7373+7474+function parseGradientColors(bgImage) {
7575+ if (!bgImage || !bgImage.includes('gradient')) return [];
7676+ const colors = [];
7777+ for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) {
7878+ const c = parseRgb(m[0]);
7979+ if (c) colors.push(c);
8080+ }
8181+ for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
8282+ const h = m[1];
8383+ if (h.length === 6) {
8484+ 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 });
8585+ } else {
8686+ 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 });
8787+ }
8888+ }
8989+ return colors;
9090+}
9191+9292+function hasChroma(c, threshold = 30) {
9393+ if (!c) return false;
9494+ return (Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b)) >= threshold;
9595+}
9696+9797+function getHue(c) {
9898+ if (!c) return 0;
9999+ const r = c.r / 255, g = c.g / 255, b = c.b / 255;
100100+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
101101+ if (max === min) return 0;
102102+ const d = max - min;
103103+ let h;
104104+ if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
105105+ else if (max === g) h = ((b - r) / d + 2) / 6;
106106+ else h = ((r - g) / d + 4) / 6;
107107+ return Math.round(h * 360);
108108+}
109109+110110+function colorToHex(c) {
111111+ if (!c) return '?';
112112+ return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
113113+}
114114+115115+export {
116116+ isNeutralColor,
117117+ parseRgb,
118118+ relativeLuminance,
119119+ contrastRatio,
120120+ parseGradientColors,
121121+ hasChroma,
122122+ getHue,
123123+ colorToHex,
124124+};
···11+/**
22+ * Inline, in-file ignore directives — eslint-disable-style waivers that live at
33+ * the point they apply and travel with the artifact instead of (or alongside)
44+ * an ignore in `.impeccable/config.json`.
55+ *
66+ * A config ignore is the right default for repo-wide policy. This complements it
77+ * for the one case config can't cover: a waiver that belongs to a single file and
88+ * needs to follow that file when it leaves the repo — a generated/exported
99+ * standalone document, an emailed HTML file, a snippet scanned out of context.
1010+ *
1111+ * Comment-syntax-agnostic: the directive is a raw token matched anywhere on a
1212+ * line, so the same marker works across every comment style impeccable scans —
1313+ * `//`, `/* *\/`, `<!-- -->`, `#`, `{/* *\/}`, `{# #}`. Trailing comment closers
1414+ * are stripped before the rule list is parsed.
1515+ *
1616+ * Syntax (reason optional; eslint `--` or biome `:` separator):
1717+ *
1818+ * impeccable-disable <rule>[, <rule>...] [-- reason] whole file
1919+ * impeccable-disable-line <rule>... [-- reason] the same line
2020+ * impeccable-disable-next-line <rule>... [-- reason] the following line
2121+ * impeccable-disable bare / `*` = every rule
2222+ *
2323+ * Examples:
2424+ *
2525+ * <!-- impeccable-disable overused-font -- exported brand doc, font is first-party -->
2626+ * .brand { font-family: Inter; } /* impeccable-disable-line overused-font *\/
2727+ * // impeccable-disable-next-line bounce-easing: intentional playful affordance
2828+ *
2929+ * Behavior is suppression, for parity with config ignores: a matched directive
3030+ * drops the finding. The inline reason is self-documenting in the diff; it is not
3131+ * required and is discarded at scan time (only used here to keep reason words out
3232+ * of the parsed rule list).
3333+ */
3434+3535+const DIRECTIVE_RE = /impeccable-(disable-next-line|disable-line|disable)\b[ \t]*([^\n\r]*)/gi;
3636+3737+// Trailing comment closers, so `*/`, `*/}`, `-->`, `*}`, `#}`, `%>`, `}}` don't
3838+// leak into the rule list. Anchored to end-of-line; the leading `\s*` mops up the
3939+// space before the closer. `--+>` covers `-->` and any longer dash run.
4040+const TRAILING_CLOSER_RE = /\s*(?:\*\/\}?|--+>|\*\}|#\}|%>|\}\})\s*$/;
4141+4242+function normalizeRule(token) {
4343+ return String(token || '').trim().toLowerCase();
4444+}
4545+4646+// Split the directive remainder into rule tokens, dropping any human reason that
4747+// follows an eslint-style `--` or biome-style `:` separator. Rule ids only ever
4848+// contain single hyphens (`overused-font`, `bounce-easing`), so `--` and `:`
4949+// are unambiguous separators.
5050+function parseRuleList(remainder) {
5151+ let text = String(remainder || '').replace(TRAILING_CLOSER_RE, '').trim();
5252+ // Cut off a human reason at the first `--` (eslint) or `:` (biome) separator.
5353+ const reasonSep = text.match(/\s*(?:--+|:)\s*/);
5454+ if (reasonSep) text = text.slice(0, reasonSep.index);
5555+ const tokens = text.split(/[\s,]+/).map(normalizeRule).filter(Boolean);
5656+ if (tokens.length === 0 || tokens.includes('*')) return ['*'];
5757+ return tokens;
5858+}
5959+6060+function addRules(set, rules) {
6161+ for (const rule of rules) set.add(rule);
6262+}
6363+6464+function getSet(map, key) {
6565+ let set = map.get(key);
6666+ if (!set) {
6767+ set = new Set();
6868+ map.set(key, set);
6969+ }
7070+ return set;
7171+}
7272+7373+/**
7474+ * Parse every inline ignore directive in a file's raw text.
7575+ *
7676+ * Returns sets keyed by the 1-based line the directive *targets* so matching is a
7777+ * direct lookup:
7878+ * - file: rules disabled for the whole file
7979+ * - line: line -> rules disabled on that exact line (disable-line)
8080+ * - nextLine: line -> rules disabled on that line (disable-next-line on line-1)
8181+ *
8282+ * `*` in any set means "every rule".
8383+ */
8484+function parseInlineIgnores(content) {
8585+ const result = { file: new Set(), line: new Map(), nextLine: new Map() };
8686+ const text = typeof content === 'string' ? content : '';
8787+ // Cheap bail-out: the substring must be present for any directive to exist.
8888+ // Case-insensitive to match DIRECTIVE_RE's `i` flag (e.g. `Impeccable-Disable`).
8989+ if (!/impeccable-disable/i.test(text)) return result;
9090+9191+ // Split on `\n` only, exactly as detectText numbers lines, so directive line
9292+ // keys line up with finding `line` values (incl. on `\r`-only line endings).
9393+ // The directive regex excludes `\r`, so a trailing `\r` on `\r\n` files is
9494+ // never captured into the rule list.
9595+ const lines = text.split('\n');
9696+ for (let i = 0; i < lines.length; i++) {
9797+ DIRECTIVE_RE.lastIndex = 0;
9898+ let m;
9999+ while ((m = DIRECTIVE_RE.exec(lines[i])) !== null) {
100100+ const variant = m[1].toLowerCase();
101101+ const rules = parseRuleList(m[2]);
102102+ if (variant === 'disable') {
103103+ addRules(result.file, rules);
104104+ } else if (variant === 'disable-line') {
105105+ addRules(getSet(result.line, i + 1), rules);
106106+ } else {
107107+ // disable-next-line on line i+1 targets line i+2.
108108+ addRules(getSet(result.nextLine, i + 2), rules);
109109+ }
110110+ }
111111+ }
112112+ return result;
113113+}
114114+115115+function setMatches(set, rule) {
116116+ return Boolean(set) && (set.has('*') || set.has(rule));
117117+}
118118+119119+function isInlineIgnored(finding, directives) {
120120+ const rule = normalizeRule(finding && finding.antipattern);
121121+ if (!rule) return false;
122122+ if (setMatches(directives.file, rule)) return true;
123123+ const line = Number(finding && finding.line) || 0;
124124+ if (line > 0) {
125125+ if (setMatches(directives.line.get(line), rule)) return true;
126126+ if (setMatches(directives.nextLine.get(line), rule)) return true;
127127+ }
128128+ return false;
129129+}
130130+131131+function hasDirectives(directives) {
132132+ return directives.file.size > 0 || directives.line.size > 0 || directives.nextLine.size > 0;
133133+}
134134+135135+/**
136136+ * Drop findings waived by an inline directive in the same file's source text.
137137+ * Findings without a usable line number (e.g. static-HTML page-level findings)
138138+ * are only matched by whole-file directives — which is the standalone-document
139139+ * case this primitive exists for.
140140+ */
141141+function applyInlineIgnores(findings, content) {
142142+ if (!Array.isArray(findings) || findings.length === 0) return findings;
143143+ const directives = parseInlineIgnores(content);
144144+ if (!hasDirectives(directives)) return findings;
145145+ return findings.filter((finding) => !isInlineIgnored(finding, directives));
146146+}
147147+148148+export { parseInlineIgnores, applyInlineIgnores, isInlineIgnored };
+7
tools/design-detector/detector/shared/page.mjs
···11+/** Check if content looks like a full page (not a component/partial) */
22+function isFullPage(content) {
33+ const stripped = content.replace(/<!--[\s\S]*?-->/g, '');
44+ return /<!doctype\s|<html[\s>]|<head[\s>]/i.test(stripped);
55+}
66+77+export { isFullPage };
+842
tools/design-detector/lib/design-parser.mjs
···11+// Parse a DESIGN.md (Stitch-spec format) into a structured JSON model that
22+// the live-mode design-system panel can render. Deterministic, dependency-free.
33+//
44+// Two-layer: YAML frontmatter (machine-readable tokens) + markdown body
55+// (prose with six canonical H2 sections). When frontmatter is present, it's
66+// exposed on `model.frontmatter` alongside the prose-scraped sections;
77+// consumers can prefer frontmatter values and fall back to prose.
88+99+const CANONICAL_SECTIONS = [
1010+ 'Overview',
1111+ 'Colors',
1212+ 'Typography',
1313+ 'Elevation',
1414+ 'Components',
1515+ "Do's and Don'ts",
1616+];
1717+1818+// ---------- Frontmatter (Stitch YAML subset) ----------
1919+2020+function parseFrontmatter(md) {
2121+ const lines = md.split(/\r?\n/);
2222+ if (lines[0]?.trim() !== '---') return { frontmatter: null, body: md };
2323+2424+ let end = -1;
2525+ for (let i = 1; i < lines.length; i++) {
2626+ if (lines[i].trim() === '---') { end = i; break; }
2727+ }
2828+ if (end === -1) return { frontmatter: null, body: md };
2929+3030+ const yaml = lines.slice(1, end).join('\n');
3131+ const body = lines.slice(end + 1).join('\n');
3232+ try {
3333+ return { frontmatter: parseYamlSubset(yaml), body };
3434+ } catch {
3535+ return { frontmatter: null, body: md };
3636+ }
3737+}
3838+3939+// Minimal YAML reader for the Stitch frontmatter subset: scalar maps with
4040+// one level of nested objects (typography roles, components). Indent-based,
4141+// 2-space convention. No arrays, no anchors, no multi-line scalars — Stitch's
4242+// schema doesn't need them and accepting them would require a real YAML
4343+// dependency we don't want to vendor.
4444+function parseYamlSubset(yaml) {
4545+ const lines = yaml.split(/\r?\n/);
4646+ const root = {};
4747+ const stack = [{ indent: -1, obj: root }];
4848+4949+ for (const raw of lines) {
5050+ // Skip blanks and line-only comments. Don't strip inline comments:
5151+ // unquoted hex values start with `#` and can't be safely distinguished
5252+ // from a comment after whitespace.
5353+ if (!raw.trim() || /^\s*#/.test(raw)) continue;
5454+5555+ const indent = raw.match(/^\s*/)[0].length;
5656+ const content = raw.slice(indent);
5757+5858+ const colonIdx = findTopLevelColon(content);
5959+ if (colonIdx === -1) continue;
6060+6161+ while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
6262+ stack.pop();
6363+ }
6464+6565+ const key = unquoteYamlKey(content.slice(0, colonIdx).trim());
6666+ const rest = stripInlineYamlComment(content.slice(colonIdx + 1).trim());
6767+ const parent = stack[stack.length - 1].obj;
6868+6969+ if (rest === '') {
7070+ const obj = {};
7171+ parent[key] = obj;
7272+ stack.push({ indent, obj });
7373+ } else {
7474+ parent[key] = parseScalar(rest);
7575+ }
7676+ }
7777+7878+ return root;
7979+}
8080+8181+function findTopLevelColon(s) {
8282+ let inQuote = null;
8383+ for (let i = 0; i < s.length; i++) {
8484+ const ch = s[i];
8585+ if (inQuote) {
8686+ if (ch === inQuote && s[i - 1] !== '\\') inQuote = null;
8787+ } else if (ch === '"' || ch === "'") {
8888+ inQuote = ch;
8989+ } else if (ch === ':') {
9090+ return i;
9191+ }
9292+ }
9393+ return -1;
9494+}
9595+9696+function unquoteYamlKey(key) {
9797+ if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) {
9898+ return key.slice(1, -1);
9999+ }
100100+ return key;
101101+}
102102+103103+function stripInlineYamlComment(s) {
104104+ let inQuote = null;
105105+ for (let i = 0; i < s.length; i++) {
106106+ const ch = s[i];
107107+ if (inQuote) {
108108+ if (ch === inQuote && s[i - 1] !== '\\') inQuote = null;
109109+ } else if (ch === '"' || ch === "'") {
110110+ inQuote = ch;
111111+ } else if (ch === '#' && i > 0 && /\s/.test(s[i - 1])) {
112112+ return s.slice(0, i).trimEnd();
113113+ }
114114+ }
115115+ return s;
116116+}
117117+118118+function parseScalar(raw) {
119119+ const s = raw.trim();
120120+ if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
121121+ return s.slice(1, -1);
122122+ }
123123+ if (s === 'true') return true;
124124+ if (s === 'false') return false;
125125+ if (s === 'null' || s === '~') return null;
126126+ if (/^-?\d+$/.test(s)) return Number(s);
127127+ if (/^-?\d*\.\d+$/.test(s)) return Number(s);
128128+ return s;
129129+}
130130+131131+const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
132132+const OKLCH_RE = /oklch\([^)]+\)/gi;
133133+const RGBA_RE = /rgba?\([^)]+\)/gi;
134134+const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
135135+const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
136136+137137+// ---------- Section splitting ----------
138138+139139+function splitSections(md) {
140140+ const lines = md.split(/\r?\n/);
141141+ let title = null;
142142+ const sections = {};
143143+ let current = null;
144144+145145+ for (const raw of lines) {
146146+ const line = raw.trimEnd();
147147+148148+ if (!title && line.startsWith('# ') && !line.startsWith('## ')) {
149149+ title = line.replace(/^#\s+/, '').trim();
150150+ continue;
151151+ }
152152+153153+ const h2 = line.match(/^##\s+(?:\d+\.\s*)?([^:\n]+?)(?::\s*(.+))?$/);
154154+ if (h2) {
155155+ const rawName = normalizeApostrophes(h2[1].trim());
156156+ const subtitle = h2[2] ? h2[2].trim() : null;
157157+ const canonical = matchCanonicalSection(rawName);
158158+ if (canonical) {
159159+ current = { name: canonical, subtitle, lines: [] };
160160+ sections[canonical] = current;
161161+ continue;
162162+ }
163163+ // non-canonical H2 — ignore but stop feeding into current
164164+ current = null;
165165+ continue;
166166+ }
167167+168168+ if (current) current.lines.push(raw);
169169+ }
170170+171171+ return { title, sections };
172172+}
173173+174174+function normalizeApostrophes(s) {
175175+ return s.replace(/[\u2018\u2019]/g, "'");
176176+}
177177+178178+function matchCanonicalSection(name) {
179179+ const normalized = normalizeApostrophes(name).toLowerCase();
180180+ // Exact match first
181181+ for (const c of CANONICAL_SECTIONS) {
182182+ if (normalizeApostrophes(c).toLowerCase() === normalized) return c;
183183+ }
184184+ // Keyword-contained match: "Overview & Creative North Star" -> "Overview",
185185+ // "Elevation & Depth" -> "Elevation", etc.
186186+ for (const c of CANONICAL_SECTIONS) {
187187+ const key = normalizeApostrophes(c).toLowerCase();
188188+ const pattern = new RegExp(`\\b${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
189189+ if (pattern.test(normalized)) return c;
190190+ }
191191+ return null;
192192+}
193193+194194+// ---------- Subsection splitting (inside a canonical section) ----------
195195+196196+function splitSubsections(lines) {
197197+ const subs = [];
198198+ let current = { name: null, lines: [] };
199199+ subs.push(current);
200200+201201+ for (const raw of lines) {
202202+ const h3 = raw.match(/^###\s+(.+?)\s*$/);
203203+ if (h3) {
204204+ current = { name: h3[1].trim(), lines: [] };
205205+ subs.push(current);
206206+ continue;
207207+ }
208208+ current.lines.push(raw);
209209+ }
210210+211211+ return subs;
212212+}
213213+214214+// ---------- Generic helpers ----------
215215+216216+function collectParagraphs(lines) {
217217+ const paragraphs = [];
218218+ let buf = [];
219219+ const flush = () => {
220220+ if (buf.length) {
221221+ paragraphs.push(buf.join(' ').trim());
222222+ buf = [];
223223+ }
224224+ };
225225+ for (const raw of lines) {
226226+ const trimmed = raw.trim();
227227+ if (trimmed === '') { flush(); continue; }
228228+ // Horizontal rules (---, ***) and headings/bullets end a paragraph.
229229+ if (/^(?:-{3,}|\*{3,}|_{3,})$/.test(trimmed)) { flush(); continue; }
230230+ if (raw.startsWith('#') || raw.match(/^[-*]\s/)) { flush(); continue; }
231231+ buf.push(trimmed);
232232+ }
233233+ flush();
234234+ return paragraphs.filter(Boolean);
235235+}
236236+237237+function collectBullets(lines) {
238238+ const bullets = [];
239239+ let current = null;
240240+ for (const raw of lines) {
241241+ const m = raw.match(/^\s*[-*]\s+(.+)$/);
242242+ if (m) {
243243+ if (current) bullets.push(current);
244244+ current = m[1];
245245+ continue;
246246+ }
247247+ // continuation of a bullet (indented line)
248248+ if (current && raw.match(/^\s{2,}\S/)) {
249249+ current += ' ' + raw.trim();
250250+ continue;
251251+ }
252252+ // blank line ends a bullet
253253+ if (raw.trim() === '' && current) {
254254+ bullets.push(current);
255255+ current = null;
256256+ }
257257+ }
258258+ if (current) bullets.push(current);
259259+ return bullets;
260260+}
261261+262262+function stripBold(s) {
263263+ return s.replace(/\*\*(.+?)\*\*/g, '$1');
264264+}
265265+266266+function extractNamedRules(lines) {
267267+ const rules = [];
268268+ const seen = new Set();
269269+270270+ // Style A (Impeccable): "**The X Rule.** body body body" — can span lines.
271271+ const joined = lines.join('\n');
272272+ const inlineStart = /\*\*(The [^*]+?Rule)\.\*\*/g;
273273+ const inlineMatches = [];
274274+ let m;
275275+ while ((m = inlineStart.exec(joined)) !== null) {
276276+ inlineMatches.push({ name: m[1], start: m.index, end: inlineStart.lastIndex });
277277+ }
278278+ for (let i = 0; i < inlineMatches.length; i++) {
279279+ const mm = inlineMatches[i];
280280+ const bodyEnd = i + 1 < inlineMatches.length ? inlineMatches[i + 1].start : joined.length;
281281+ const body = joined
282282+ .slice(mm.end, bodyEnd)
283283+ .replace(/\n##[^\n]*$/s, '')
284284+ .replace(/\n###[^\n]*$/s, '')
285285+ .trim();
286286+ const name = stripBold(mm.name).trim();
287287+ seen.add(name.toLowerCase());
288288+ rules.push({ name, body: stripBold(body) });
289289+ }
290290+291291+ // Style B (Stitch): `### The "X" Rule` or `### The X Fallback`, body is the
292292+ // bullets/paragraphs until the next heading. Accept Rule / Fallback / Principle.
293293+ for (let i = 0; i < lines.length; i++) {
294294+ const h3 = lines[i].match(/^###\s+(.+?)\s*$/);
295295+ if (!h3) continue;
296296+ const headerName = stripBold(h3[1]).replace(/["“”]/g, '').trim();
297297+ if (!/^The\b.*\b(Rule|Fallback|Principle)\b/i.test(headerName)) continue;
298298+ if (seen.has(headerName.toLowerCase())) continue;
299299+300300+ const bodyLines = [];
301301+ for (let j = i + 1; j < lines.length; j++) {
302302+ if (/^##\s|^###\s/.test(lines[j])) break;
303303+ bodyLines.push(lines[j]);
304304+ }
305305+ const body = stripBold(bodyLines.join('\n').replace(/\n+/g, ' ')).trim();
306306+ if (body) {
307307+ seen.add(headerName.toLowerCase());
308308+ rules.push({ name: headerName, body });
309309+ }
310310+ }
311311+312312+ // Style C (Stitch bullet form): "* **The Layering Principle:** body"
313313+ // Colon/period lives inside the bold, so match "**...**" then inspect.
314314+ for (const b of collectBullets(lines)) {
315315+ const mm = b.match(/^\*\*([^*]+?)\*\*\s*(.+)$/);
316316+ if (!mm) continue;
317317+ const nameRaw = mm[1].replace(/[.:]\s*$/, '').replace(/["“”]/g, '').trim();
318318+ if (!/^The\b.+\b(Rule|Fallback|Principle)$/i.test(nameRaw)) continue;
319319+ if (seen.has(nameRaw.toLowerCase())) continue;
320320+ seen.add(nameRaw.toLowerCase());
321321+ rules.push({ name: nameRaw, body: stripBold(mm[2]).trim() });
322322+ }
323323+324324+ return rules;
325325+}
326326+327327+// ---------- Per-section extractors ----------
328328+329329+function extractOverview(section) {
330330+ if (!section) return null;
331331+ const text = section.lines.join('\n');
332332+ const northStar = text.match(/\*\*Creative North Star:\s*"([^"]+)"\*\*/);
333333+ const keyChars = [];
334334+ const keyCharMatch = text.match(/\*\*Key Characteristics:\*\*\s*\n([\s\S]+?)(?:\n##|\n###|$)/);
335335+ if (keyCharMatch) {
336336+ for (const line of keyCharMatch[1].split('\n')) {
337337+ const m = line.match(/^\s*[-*]\s+(.+)$/);
338338+ if (m) keyChars.push(stripBold(m[1].trim()));
339339+ }
340340+ }
341341+342342+ // Philosophy paragraphs: everything that isn't a rule header or key-char block
343343+ const paragraphs = collectParagraphs(section.lines).filter(
344344+ (p) =>
345345+ !p.startsWith('**Creative North Star') &&
346346+ !p.startsWith('**Key Characteristics')
347347+ );
348348+349349+ return {
350350+ subtitle: section.subtitle,
351351+ creativeNorthStar: northStar ? northStar[1] : null,
352352+ philosophy: paragraphs,
353353+ keyCharacteristics: keyChars,
354354+ };
355355+}
356356+357357+function extractColors(section) {
358358+ if (!section) return null;
359359+ const subs = splitSubsections(section.lines);
360360+361361+ const description = collectParagraphs(subs[0].lines).join(' ');
362362+ const groups = [];
363363+ const ROLE_KEYWORDS = /^(primary|secondary|tertiary|neutral|accent)\b/i;
364364+365365+ for (const sub of subs.slice(1)) {
366366+ if (!sub.name || /Named Rules?/i.test(sub.name) || /^The\s/i.test(sub.name)) continue;
367367+368368+ const bullets = collectBullets(sub.lines);
369369+ const parsed = bullets.map((b) => parseColorBullet(b)).filter(Boolean);
370370+ if (parsed.length === 0) continue;
371371+372372+ // If every bullet starts with a role keyword (Primary/Secondary/...), promote
373373+ // each bullet to its own group. Otherwise keep the subsection as the group.
374374+ const allRoleBullets =
375375+ parsed.length > 0 && parsed.every((p) => p.name && ROLE_KEYWORDS.test(p.name));
376376+377377+ if (allRoleBullets) {
378378+ for (const p of parsed) {
379379+ groups.push({ role: p.name, colors: [p] });
380380+ }
381381+ } else {
382382+ groups.push({ role: sub.name, colors: parsed });
383383+ }
384384+ }
385385+386386+ // If the Colors section has no subsections at all (unlikely), fall back to
387387+ // scanning the whole section as a flat bullet list.
388388+ if (groups.length === 0) {
389389+ const flat = collectBullets(section.lines)
390390+ .map((b) => parseColorBullet(b))
391391+ .filter(Boolean);
392392+ if (flat.length) {
393393+ for (const p of flat) {
394394+ if (p.name && ROLE_KEYWORDS.test(p.name)) {
395395+ groups.push({ role: p.name, colors: [p] });
396396+ } else {
397397+ const fallback = groups.find((g) => g.role === 'Palette');
398398+ if (fallback) fallback.colors.push(p);
399399+ else groups.push({ role: 'Palette', colors: [p] });
400400+ }
401401+ }
402402+ }
403403+ }
404404+405405+ return {
406406+ subtitle: section.subtitle,
407407+ description: description || null,
408408+ groups,
409409+ rules: extractNamedRules(section.lines),
410410+ };
411411+}
412412+413413+function parseColorBullet(bullet) {
414414+ const text = bullet.trim();
415415+416416+ // Case 1 (Impeccable): **Name** (value-with-maybe-nested-parens): description
417417+ const bold = text.match(/^\*\*(.+?)\*\*\s*(.*)$/);
418418+ if (bold && bold[2].startsWith('(')) {
419419+ const value = extractParenGroup(bold[2]);
420420+ if (value !== null) {
421421+ const after = bold[2].slice(value.length + 2).trimStart();
422422+ if (after.startsWith(':')) {
423423+ return buildColor(bold[1], value, after.slice(1).trim());
424424+ }
425425+ }
426426+ }
427427+428428+ // Case 2 (Stitch): **Name (values):** description — value embedded in bold.
429429+ const stitch = text.match(/^\*\*([^*]+?)\s*\(([^)]+)\):\*\*\s*(.*)$/);
430430+ if (stitch) {
431431+ return buildColor(stitch[1].trim(), stitch[2], stitch[3]);
432432+ }
433433+434434+ // Case 3: bullet without bold, just hex/oklch inside.
435435+ const values = collectColorValues(text);
436436+ if (values.length) {
437437+ return buildColor(null, values.join(' to '), text);
438438+ }
439439+ return null;
440440+}
441441+442442+function extractParenGroup(s) {
443443+ if (s[0] !== '(') return null;
444444+ let depth = 0;
445445+ for (let i = 0; i < s.length; i++) {
446446+ if (s[i] === '(') depth++;
447447+ else if (s[i] === ')') {
448448+ depth--;
449449+ if (depth === 0) return s.slice(1, i);
450450+ }
451451+ }
452452+ return null;
453453+}
454454+455455+function buildColor(name, rawValue, description) {
456456+ const values = collectColorValues(rawValue);
457457+ const primary = values[0] ?? rawValue.trim();
458458+ return {
459459+ name: name ? stripBold(name).trim() : null,
460460+ value: primary,
461461+ valueRange: values.length > 1 ? values : null,
462462+ format: detectFormat(primary),
463463+ description: stripBold(description || '').trim() || null,
464464+ };
465465+}
466466+467467+function collectColorValues(s) {
468468+ const out = [];
469469+ s.replace(HEX_RE, (v) => {
470470+ out.push(v);
471471+ return v;
472472+ });
473473+ s.replace(OKLCH_RE, (v) => {
474474+ out.push(v);
475475+ return v;
476476+ });
477477+ return out;
478478+}
479479+480480+function detectFormat(v) {
481481+ if (!v) return 'unknown';
482482+ if (v.startsWith('#')) return 'hex';
483483+ if (/^oklch/i.test(v)) return 'oklch';
484484+ if (/^rgb/i.test(v)) return 'rgb';
485485+ return 'unknown';
486486+}
487487+488488+function scanInlineColors(lines) {
489489+ const out = [];
490490+ for (const line of lines) {
491491+ if (!/^\s*[-*]\s/.test(line)) continue;
492492+ const trimmed = line.replace(/^\s*[-*]\s+/, '');
493493+ const color = parseColorBullet(trimmed);
494494+ if (color) out.push(color);
495495+ }
496496+ return out;
497497+}
498498+499499+function parseStitchInlineGroups(lines) {
500500+ // Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
501501+ // Each bullet IS its own role. Group them under the spoken role name.
502502+ const out = [];
503503+ for (const line of lines) {
504504+ if (!/^\s*[-*]\s/.test(line)) continue;
505505+ const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
506506+ const m = trimmed.match(
507507+ /^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
508508+ );
509509+ if (m) {
510510+ const role = m[1];
511511+ const color = buildColor(role, m[2], m[3]);
512512+ out.push({ role, colors: [color] });
513513+ }
514514+ }
515515+ return out;
516516+}
517517+518518+function extractTypography(section) {
519519+ if (!section) return null;
520520+ const text = section.lines.join('\n');
521521+522522+ const fonts = {};
523523+ // Pattern A: **Display Font:** Family (with fallback)
524524+ const fontLineRe = /\*\*([\w\s/]+?)Font:\*\*\s*([^\n(]+?)(?:\s*\(with\s+([^)]+)\))?\s*$/gm;
525525+ let fm;
526526+ while ((fm = fontLineRe.exec(text)) !== null) {
527527+ const rawRole = fm[1].trim().toLowerCase().replace(/\s+/g, '-');
528528+ const role = normalizeFontRole(rawRole) || 'display';
529529+ fonts[role] = {
530530+ family: fm[2].trim(),
531531+ fallback: fm[3] ? fm[3].trim() : null,
532532+ };
533533+ }
534534+535535+ // Pattern B (Stitch): * **Display & Headlines (Noto Serif):** description
536536+ if (Object.keys(fonts).length === 0) {
537537+ const stitchRe = /\*\*([\w\s&/]+?)\s*\(([^)]+)\):\*\*\s*(.+)/g;
538538+ let sm;
539539+ while ((sm = stitchRe.exec(text)) !== null) {
540540+ const rawRole = sm[1]
541541+ .trim()
542542+ .toLowerCase()
543543+ .replace(/\s*&\s*/g, '-')
544544+ .replace(/\s+/g, '-');
545545+ const role = normalizeFontRole(rawRole) || rawRole;
546546+ fonts[role] = { family: sm[2].trim(), fallback: null, purpose: sm[3].trim() };
547547+ }
548548+ }
549549+550550+ // Character paragraph — either a **Character:** label, or fall back to the
551551+ // first free paragraph under the section header (Stitch style).
552552+ const characterMatch = text.match(/\*\*Character:\*\*\s*([^\n]+(?:\n[^\n]+)*?)(?=\n\n|\n###|\n##|$)/);
553553+ let character = characterMatch ? characterMatch[1].replace(/\n/g, ' ').trim() : null;
554554+ if (!character) {
555555+ const paragraphs = collectParagraphs(section.lines).filter(
556556+ (p) => !/^\*\*[\w\s/&]+Font/i.test(p) && !/^\*\*[\w\s/&]+\([^)]+\)/.test(p)
557557+ );
558558+ if (paragraphs.length) character = paragraphs[0];
559559+ }
560560+561561+ // Hierarchy bullets under ### Hierarchy
562562+ const subs = splitSubsections(section.lines);
563563+ let hierarchy = [];
564564+ const hierSub = subs.find((s) => s.name && /hierarch/i.test(s.name));
565565+ if (hierSub) {
566566+ const bullets = collectBullets(hierSub.lines);
567567+ hierarchy = bullets.map(parseTypeBullet).filter(Boolean);
568568+ }
569569+570570+ return {
571571+ subtitle: section.subtitle,
572572+ fonts,
573573+ character,
574574+ hierarchy,
575575+ rules: extractNamedRules(section.lines),
576576+ };
577577+}
578578+579579+function normalizeFontRole(raw) {
580580+ // Canonical roles the panel cares about: display, body, label, mono.
581581+ // Stitch often writes compound roles like "display-&-headlines" or "ui-&-body"
582582+ // — collapse them to the first canonical role present.
583583+ const tokens = raw.split(/[-/&\s]+/).filter(Boolean);
584584+ const priority = ['display', 'headline', 'body', 'ui', 'label', 'mono'];
585585+ const canonical = { headline: 'display', ui: 'body' };
586586+ for (const p of priority) {
587587+ if (tokens.includes(p)) return canonical[p] || p;
588588+ }
589589+ return null;
590590+}
591591+592592+function parseTypeBullet(bullet) {
593593+ // - **Display** (family, weight 300, italic, clamp(...), line-height 1): purpose
594594+ const m = bullet.match(/^\*\*(.+?)\*\*\s*\(([^)]+)\):\s*(.*)$/);
595595+ if (!m) return null;
596596+ const name = m[1].trim();
597597+ const specs = m[2].split(',').map((s) => s.trim());
598598+ return {
599599+ name,
600600+ specs,
601601+ purpose: stripBold(m[3] || '').trim() || null,
602602+ };
603603+}
604604+605605+function extractElevation(section) {
606606+ if (!section) return null;
607607+ const subs = splitSubsections(section.lines);
608608+609609+ const description = collectParagraphs(subs[0].lines).join(' ') || null;
610610+611611+ const shadows = [];
612612+ const seen = new Set();
613613+ const dedupe = (entry) => {
614614+ const key = (entry.name || '') + '::' + entry.value;
615615+ if (seen.has(key)) return;
616616+ seen.add(key);
617617+ shadows.push(entry);
618618+ };
619619+620620+ for (const b of collectBullets(section.lines)) {
621621+ const parsed = parseShadowBullet(b);
622622+ if (parsed) dedupe(parsed);
623623+ }
624624+625625+ // Fallback: extract shadows written inline in prose. Stitch style is
626626+ // "...use an extra-diffused shadow: `box-shadow: 0 12px 40px rgba(...)`."
627627+ for (const p of collectParagraphs(section.lines)) {
628628+ for (const inline of extractInlineShadows(p)) dedupe(inline);
629629+ }
630630+ for (const b of collectBullets(section.lines)) {
631631+ for (const inline of extractInlineShadows(b)) dedupe(inline);
632632+ }
633633+634634+ return {
635635+ subtitle: section.subtitle,
636636+ description,
637637+ shadows,
638638+ rules: extractNamedRules(section.lines),
639639+ };
640640+}
641641+642642+function extractInlineShadows(text) {
643643+ // Find `box-shadow: ...` anywhere in prose and capture the value. Work on the
644644+ // raw string so it handles both backtick-fenced and unfenced variants.
645645+ const out = [];
646646+ const re = /box-shadow\s*:\s*([^`;\n]+)/gi;
647647+ let m;
648648+ while ((m = re.exec(text)) !== null) {
649649+ const value = m[1].replace(/[`.)]+$/, '').trim();
650650+ if (!value) continue;
651651+ // Name heuristic: the noun immediately before the shadow phrase.
652652+ // e.g. "an extra-diffused shadow: ..." -> "extra-diffused shadow"
653653+ const before = text.slice(0, m.index);
654654+ const nameMatch = before.match(/\b([A-Za-z][A-Za-z\- ]{2,40})\s+shadow\b[^A-Za-z0-9]*$/i);
655655+ let name = null;
656656+ if (nameMatch) {
657657+ const stripped = nameMatch[1]
658658+ .replace(/^(?:use|using|apply|applying|is|are|looks? like)\s+/i, '')
659659+ .replace(/^(?:a|an|the)\s+/i, '')
660660+ .trim();
661661+ if (stripped) {
662662+ name =
663663+ stripped.charAt(0).toUpperCase() + stripped.slice(1) + ' shadow';
664664+ }
665665+ }
666666+ out.push({
667667+ name,
668668+ value,
669669+ purpose: null,
670670+ });
671671+ }
672672+ return out;
673673+}
674674+675675+function parseShadowBullet(bullet) {
676676+ // - **Name** (`box-shadow: value`): purpose
677677+ // - **Name** (`value`): purpose
678678+ // Only accept if the paren content looks like a shadow value (contains px,
679679+ // rem, rgba, or box-shadow). This filters out `**Rule Name:**` bullets.
680680+ const m = bullet.match(/^\*\*(.+?)\*\*\s*\(`?([^`]+?)`?\):\s*(.*)$/);
681681+ if (!m) return null;
682682+ const rawValue = m[2].replace(/^box-shadow:\s*/i, '').trim();
683683+ const looksLikeShadow =
684684+ /box-shadow|rgba?\(|\bpx\b|\brem\b|^-?\d+\s/i.test(rawValue) &&
685685+ /\d/.test(rawValue);
686686+ if (!looksLikeShadow) return null;
687687+ const name = stripBold(m[1]).trim();
688688+ return {
689689+ name,
690690+ value: rawValue,
691691+ purpose: stripBold(m[3] || '').trim() || null,
692692+ };
693693+}
694694+695695+function extractComponents(section) {
696696+ if (!section) return null;
697697+ const subs = splitSubsections(section.lines);
698698+ const components = [];
699699+700700+ for (const sub of subs.slice(1)) {
701701+ if (!sub.name) continue;
702702+703703+ const bullets = collectBullets(sub.lines);
704704+ const paragraphs = collectParagraphs(sub.lines);
705705+706706+ const variants = [];
707707+ const properties = {};
708708+709709+ for (const b of bullets) {
710710+ // - **Key:** value
711711+ const m = b.match(/^\*\*(.+?):?\*\*:?\s*(.+)$/);
712712+ if (m) {
713713+ const key = stripBold(m[1]).trim();
714714+ const value = stripBold(m[2]).trim();
715715+ // Heuristic: "Primary", "Secondary", "Hover", "Focus" etc are variants;
716716+ // "Shape", "Background", "Padding" are properties.
717717+ if (/^(primary|secondary|tertiary|ghost|hover|focus|active|disabled|default|error|selected|unselected|state)$/i.test(key.split(/[\s/]/)[0])) {
718718+ variants.push({ name: key, description: value });
719719+ } else {
720720+ properties[key.toLowerCase()] = value;
721721+ }
722722+ }
723723+ }
724724+725725+ components.push({
726726+ name: sub.name,
727727+ description: paragraphs.join(' ') || null,
728728+ properties,
729729+ variants,
730730+ });
731731+ }
732732+733733+ return {
734734+ subtitle: section.subtitle,
735735+ components,
736736+ };
737737+}
738738+739739+function extractDosDonts(section) {
740740+ if (!section) return null;
741741+ const subs = splitSubsections(section.lines);
742742+ const dos = [];
743743+ const donts = [];
744744+745745+ for (const sub of subs.slice(1)) {
746746+ if (!sub.name) continue;
747747+ const subName = normalizeApostrophes(sub.name);
748748+ const bullets = collectBullets(sub.lines).map((b) => stripBold(b).trim());
749749+ if (/^do'?t?:?$/i.test(subName) || /^do:?$/i.test(subName)) {
750750+ dos.push(...bullets);
751751+ } else if (/^don'?t:?$/i.test(subName)) {
752752+ donts.push(...bullets);
753753+ }
754754+ }
755755+756756+ // Classify by bullet prefix as a backup (catches loose bullets outside H3 wrappers)
757757+ for (const b of collectBullets(section.lines)) {
758758+ const stripped = normalizeApostrophes(stripBold(b).trim());
759759+ if (/^don'?t\b/i.test(stripped)) {
760760+ if (!donts.some((d) => normalizeApostrophes(d) === stripped)) donts.push(stripped);
761761+ } else if (/^do\b/i.test(stripped)) {
762762+ if (!dos.some((d) => normalizeApostrophes(d) === stripped)) dos.push(stripped);
763763+ }
764764+ }
765765+766766+ return { dos, donts };
767767+}
768768+769769+// ---------- Coverage assessment ----------
770770+771771+function assessCoverage(model) {
772772+ const report = {};
773773+774774+ report.overview = model.overview
775775+ ? {
776776+ northStar: Boolean(model.overview.creativeNorthStar),
777777+ philosophy: model.overview.philosophy.length > 0,
778778+ keyCharacteristics: model.overview.keyCharacteristics.length,
779779+ }
780780+ : 'missing';
781781+782782+ report.colors = model.colors
783783+ ? {
784784+ groups: model.colors.groups.length,
785785+ totalColors: model.colors.groups.reduce((n, g) => n + g.colors.length, 0),
786786+ rules: model.colors.rules.length,
787787+ }
788788+ : 'missing';
789789+790790+ report.typography = model.typography
791791+ ? {
792792+ fonts: Object.keys(model.typography.fonts).length,
793793+ hierarchyEntries: model.typography.hierarchy.length,
794794+ character: Boolean(model.typography.character),
795795+ rules: model.typography.rules.length,
796796+ }
797797+ : 'missing';
798798+799799+ report.elevation = model.elevation
800800+ ? {
801801+ shadows: model.elevation.shadows.length,
802802+ rules: model.elevation.rules.length,
803803+ description: Boolean(model.elevation.description),
804804+ }
805805+ : 'missing';
806806+807807+ report.components = model.components
808808+ ? {
809809+ count: model.components.components.length,
810810+ variantTotal: model.components.components.reduce((n, c) => n + c.variants.length, 0),
811811+ }
812812+ : 'missing';
813813+814814+ report.dosDonts = model.dosDonts
815815+ ? {
816816+ dos: model.dosDonts.dos.length,
817817+ donts: model.dosDonts.donts.length,
818818+ }
819819+ : 'missing';
820820+821821+ return report;
822822+}
823823+824824+// ---------- Main ----------
825825+826826+export function parseDesignMd(md) {
827827+ const { frontmatter, body } = parseFrontmatter(md);
828828+ const { title, sections } = splitSections(body);
829829+ return {
830830+ schemaVersion: 2,
831831+ title,
832832+ frontmatter,
833833+ overview: extractOverview(sections['Overview']),
834834+ colors: extractColors(sections['Colors']),
835835+ typography: extractTypography(sections['Typography']),
836836+ elevation: extractElevation(sections['Elevation']),
837837+ components: extractComponents(sections['Components']),
838838+ dosDonts: extractDosDonts(sections["Do's and Don'ts"]),
839839+ };
840840+}
841841+842842+export { assessCoverage };
+638
tools/design-detector/lib/impeccable-config.mjs
···11+/**
22+ * CLI-side reader/writer for the unified `.impeccable` config.
33+ *
44+ * The CLI (published to npm) and the skill scripts (bundled into the install)
55+ * live in separate trees and cannot share runtime code, so this duplicates a
66+ * small slice of skill/scripts/hook-lib.mjs — the config-path layout, detector
77+ * ignore semantics, and the `.git/info/exclude` handling. Keep the schema,
88+ * ignore filtering, and exclude marker in sync if either side changes.
99+ *
1010+ * Schema (config.json shared / config.local.json gitignored, per-developer):
1111+ * {
1212+ * "detector": { "ignoreRules": [], "ignoreFiles": [], "ignoreValues": [], "designSystem": { "enabled": true } },
1313+ * "hook": { "consent": "accepted" | "declined", ... },
1414+ * "updateCheck": bool
1515+ * }
1616+ */
1717+1818+import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
1919+import { join, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
2020+2121+export function getConfigPath(root) {
2222+ return join(root, '.impeccable', 'config.json');
2323+}
2424+2525+export function getLocalConfigPath(root) {
2626+ return join(root, '.impeccable', 'config.local.json');
2727+}
2828+2929+function safeReadJson(filePath) {
3030+ try {
3131+ const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
3232+ return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
3333+ } catch {
3434+ return null;
3535+ }
3636+}
3737+3838+function hookSection(raw) {
3939+ return raw && raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
4040+}
4141+4242+function detectorSection(raw) {
4343+ return raw && raw.detector && typeof raw.detector === 'object' && !Array.isArray(raw.detector) ? raw.detector : null;
4444+}
4545+4646+const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem']);
4747+4848+const DEFAULT_DETECTION_CONFIG = Object.freeze({
4949+ ignoreRules: [],
5050+ ignoreFiles: [],
5151+ ignoreValues: [],
5252+ designSystem: { enabled: true },
5353+});
5454+5555+function cloneDetectionConfig() {
5656+ return {
5757+ ignoreRules: [],
5858+ ignoreFiles: [],
5959+ ignoreValues: [],
6060+ designSystem: { ...DEFAULT_DETECTION_CONFIG.designSystem },
6161+ };
6262+}
6363+6464+function cloneRawDetectionConfig() {
6565+ return {
6666+ ignoreRules: [],
6767+ ignoreFiles: [],
6868+ ignoreValues: [],
6969+ };
7070+}
7171+7272+function applyDetectionConfigSource(config, raw) {
7373+ if (!raw || typeof raw !== 'object') return config;
7474+ if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
7575+ config.designSystem = {
7676+ ...config.designSystem,
7777+ enabled: raw.designSystem.enabled === false ? false : true,
7878+ };
7979+ }
8080+ if (Array.isArray(raw.ignoreRules)) {
8181+ config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
8282+ }
8383+ if (Array.isArray(raw.ignoreFiles)) {
8484+ config.ignoreFiles = uniqueStrings([...config.ignoreFiles, ...raw.ignoreFiles]);
8585+ }
8686+ if (Array.isArray(raw.ignoreValues)) {
8787+ config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
8888+ }
8989+ return config;
9090+}
9191+9292+function uniqueStrings(values) {
9393+ return Array.from(new Set(values.map(String)));
9494+}
9595+9696+/**
9797+ * Detector filters shared by `npx impeccable detect` and the design hook.
9898+ * `hook.enabled` remains hook lifecycle state; manual CLI scans still run when
9999+ * the hook is disabled, but they honor the same ignore rules and design-system
100100+ * toggle.
101101+ */
102102+export function readDetectionConfig(root) {
103103+ const config = cloneDetectionConfig();
104104+ for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
105105+ const raw = safeReadJson(filePath);
106106+ // Back-compat: old builds stored detector filters under hook.*.
107107+ applyDetectionConfigSource(config, hookSection(raw));
108108+ applyDetectionConfigSource(config, detectorSection(raw));
109109+ }
110110+ return config;
111111+}
112112+113113+export function readRawDetectionConfig(root, opts = {}) {
114114+ const raw = safeReadJson(opts.local ? getLocalConfigPath(root) : getConfigPath(root));
115115+ const config = cloneRawDetectionConfig();
116116+ applyDetectionConfigSource(config, hookSection(raw));
117117+ applyDetectionConfigSource(config, detectorSection(raw));
118118+ return config;
119119+}
120120+121121+export function writeDetectionConfig(root, detectorConfig, opts = {}) {
122122+ const filePath = opts.local ? getLocalConfigPath(root) : getConfigPath(root);
123123+ if (opts.local) ensureConfigGitExclude(root);
124124+ const existing = safeReadJson(filePath) || {};
125125+ const existingHook = hookSection(existing);
126126+ const nextHook = stripDetectorKeys(existingHook);
127127+ const nextDetector = {
128128+ ...(detectorSection(existing) || {}),
129129+ ...normalizeDetectionConfigForWrite(detectorConfig),
130130+ };
131131+ const next = {
132132+ ...existing,
133133+ detector: nextDetector,
134134+ };
135135+ if (nextHook && Object.keys(nextHook).length > 0) {
136136+ next.hook = nextHook;
137137+ } else {
138138+ delete next.hook;
139139+ }
140140+ mkdirSync(dirname(filePath), { recursive: true });
141141+ writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
142142+ return filePath;
143143+}
144144+145145+function normalizeDetectionConfigForWrite(config) {
146146+ const out = {};
147147+ if (Array.isArray(config?.ignoreRules)) {
148148+ out.ignoreRules = uniqueStrings(config.ignoreRules.map((rule) => normalizeIgnoreRule(rule)).filter(Boolean));
149149+ }
150150+ if (Array.isArray(config?.ignoreFiles)) {
151151+ out.ignoreFiles = uniqueStrings(config.ignoreFiles.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()));
152152+ }
153153+ out.ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
154154+ if (config?.designSystem && typeof config.designSystem === 'object' && !Array.isArray(config.designSystem)) {
155155+ out.designSystem = {
156156+ enabled: config.designSystem.enabled === false ? false : true,
157157+ };
158158+ }
159159+ return out;
160160+}
161161+162162+function stripDetectorKeys(raw) {
163163+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
164164+ const out = {};
165165+ for (const [key, value] of Object.entries(raw)) {
166166+ if (!DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
167167+ }
168168+ return out;
169169+}
170170+171171+export function normalizeIgnoreValue(value) {
172172+ return String(value || '')
173173+ .trim()
174174+ .replace(/^["']|["']$/g, '')
175175+ .replace(/\+/g, ' ')
176176+ .replace(/\s+/g, ' ')
177177+ .toLowerCase();
178178+}
179179+180180+function normalizeIgnoreRule(rule) {
181181+ return String(rule || '').trim().toLowerCase();
182182+}
183183+184184+function colorIgnoreKey(value) {
185185+ const color = parseIgnoreColor(value);
186186+ if (!color) return '';
187187+ return `${color.r},${color.g},${color.b},${Math.round(color.a * 255)}`;
188188+}
189189+190190+function parseIgnoreColor(value) {
191191+ const text = String(value || '').trim().toLowerCase();
192192+ if (!text) return null;
193193+194194+ const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
195195+ if (hex) return parseHexIgnoreColor(hex[1]);
196196+197197+ const rgb = text.match(/^rgba?\((.*)\)$/i);
198198+ if (rgb) {
199199+ const parts = splitColorArgs(rgb[1]);
200200+ if (parts.length < 3 || parts.length > 4) return null;
201201+ const r = parseRgbChannel(parts[0]);
202202+ const g = parseRgbChannel(parts[1]);
203203+ const b = parseRgbChannel(parts[2]);
204204+ const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
205205+ if ([r, g, b, a].some((v) => v === null)) return null;
206206+ return { r, g, b, a };
207207+ }
208208+209209+ const hsl = text.match(/^hsla?\((.*)\)$/i);
210210+ if (hsl) {
211211+ const parts = splitColorArgs(hsl[1]);
212212+ if (parts.length < 3 || parts.length > 4) return null;
213213+ const h = parseHueChannel(parts[0]);
214214+ const s = parsePercentChannel(parts[1]);
215215+ const l = parsePercentChannel(parts[2]);
216216+ const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
217217+ if ([h, s, l, a].some((v) => v === null)) return null;
218218+ return hslToRgb(h, s, l, a);
219219+ }
220220+221221+ return null;
222222+}
223223+224224+function parseHexIgnoreColor(hex) {
225225+ if (hex.length === 3 || hex.length === 4) {
226226+ const r = parseInt(hex[0] + hex[0], 16);
227227+ const g = parseInt(hex[1] + hex[1], 16);
228228+ const b = parseInt(hex[2] + hex[2], 16);
229229+ const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
230230+ return { r, g, b, a };
231231+ }
232232+ const r = parseInt(hex.slice(0, 2), 16);
233233+ const g = parseInt(hex.slice(2, 4), 16);
234234+ const b = parseInt(hex.slice(4, 6), 16);
235235+ const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
236236+ return { r, g, b, a };
237237+}
238238+239239+function splitColorArgs(body) {
240240+ const text = String(body || '').trim();
241241+ if (!text) return [];
242242+ if (text.includes(',')) {
243243+ const parts = text.split(',').map((part) => part.trim()).filter(Boolean);
244244+ const last = parts[parts.length - 1];
245245+ if (last && last.includes('/')) {
246246+ const split = last.split('/').map((part) => part.trim()).filter(Boolean);
247247+ return [...parts.slice(0, -1), ...split];
248248+ }
249249+ return parts;
250250+ }
251251+ return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
252252+}
253253+254254+function parseRgbChannel(raw) {
255255+ const text = String(raw || '').trim();
256256+ const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
257257+ if (!match) return null;
258258+ const value = Number.parseFloat(match[1]);
259259+ if (!Number.isFinite(value)) return null;
260260+ const scaled = match[2] ? value * 2.55 : value;
261261+ if (scaled < 0 || scaled > 255) return null;
262262+ return Math.round(scaled);
263263+}
264264+265265+function parseAlphaChannel(raw) {
266266+ const text = String(raw || '').trim();
267267+ const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
268268+ if (!match) return null;
269269+ const value = Number.parseFloat(match[1]);
270270+ if (!Number.isFinite(value)) return null;
271271+ const alpha = match[2] ? value / 100 : value;
272272+ return alpha >= 0 && alpha <= 1 ? alpha : null;
273273+}
274274+275275+function parseHueChannel(raw) {
276276+ const text = String(raw || '').trim();
277277+ const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/);
278278+ if (!match) return null;
279279+ const value = Number.parseFloat(match[1]);
280280+ if (!Number.isFinite(value)) return null;
281281+ const unit = match[2] || 'deg';
282282+ if (unit === 'turn') return value * 360;
283283+ if (unit === 'rad') return value * (180 / Math.PI);
284284+ if (unit === 'grad') return value * 0.9;
285285+ return value;
286286+}
287287+288288+function parsePercentChannel(raw) {
289289+ const text = String(raw || '').trim();
290290+ const match = text.match(/^(-?\d*\.?\d+)%$/);
291291+ if (!match) return null;
292292+ const value = Number.parseFloat(match[1]);
293293+ if (!Number.isFinite(value)) return null;
294294+ return value >= 0 && value <= 100 ? value / 100 : null;
295295+}
296296+297297+function hslToRgb(hue, saturation, lightness, alpha) {
298298+ const h = (((hue % 360) + 360) % 360) / 360;
299299+ if (saturation === 0) {
300300+ const gray = clampByte(Math.round(lightness * 255));
301301+ return { r: gray, g: gray, b: gray, a: alpha };
302302+ }
303303+ const q = lightness < 0.5
304304+ ? lightness * (1 + saturation)
305305+ : lightness + saturation - lightness * saturation;
306306+ const p = 2 * lightness - q;
307307+ const toRgb = (t) => {
308308+ let channel = t;
309309+ if (channel < 0) channel += 1;
310310+ if (channel > 1) channel -= 1;
311311+ if (channel < 1 / 6) return p + (q - p) * 6 * channel;
312312+ if (channel < 1 / 2) return q;
313313+ if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;
314314+ return p;
315315+ };
316316+ return {
317317+ r: clampByte(Math.round(toRgb(h + 1 / 3) * 255)),
318318+ g: clampByte(Math.round(toRgb(h) * 255)),
319319+ b: clampByte(Math.round(toRgb(h - 1 / 3) * 255)),
320320+ a: alpha,
321321+ };
322322+}
323323+324324+function clampByte(value) {
325325+ return Math.min(255, Math.max(0, value));
326326+}
327327+328328+function ignoreValueMatches(rule, entryValue, findingValue) {
329329+ if (entryValue === findingValue) return true;
330330+ if (rule !== 'design-system-color') return false;
331331+ const entryColor = colorIgnoreKey(entryValue);
332332+ return Boolean(entryColor && entryColor === colorIgnoreKey(findingValue));
333333+}
334334+335335+export function normalizeIgnoreValueEntries(entries) {
336336+ if (!Array.isArray(entries)) return [];
337337+ const out = [];
338338+ for (const entry of entries) {
339339+ if (!entry || typeof entry !== 'object') continue;
340340+ const rule = normalizeIgnoreRule(entry.rule);
341341+ const value = normalizeIgnoreValue(entry.value);
342342+ if (!rule || !value) continue;
343343+ const normalized = { rule, value };
344344+ const files = uniqueStrings([
345345+ ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
346346+ ...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),
347347+ ]);
348348+ if (files.length > 0) normalized.files = files;
349349+ if (typeof entry.reason === 'string' && entry.reason.trim()) {
350350+ normalized.reason = entry.reason.trim();
351351+ }
352352+ if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
353353+ normalized.createdAt = entry.createdAt.trim();
354354+ }
355355+ out.push(normalized);
356356+ }
357357+ return out;
358358+}
359359+360360+function mergeIgnoreValues(existing, incoming) {
361361+ const map = new Map();
362362+ for (const entry of normalizeIgnoreValueEntries(existing)) {
363363+ map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
364364+ }
365365+ for (const entry of normalizeIgnoreValueEntries(incoming)) {
366366+ map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
367367+ }
368368+ return Array.from(map.values());
369369+}
370370+371371+function ignoreValueFilesKey(files) {
372372+ return Array.isArray(files) && files.length > 0 ? files.join('\x1f') : '';
373373+}
374374+375375+// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
376376+function globToRegex(glob) {
377377+ let re = '^';
378378+ let i = 0;
379379+ while (i < glob.length) {
380380+ const c = glob[i];
381381+ if (c === '*') {
382382+ if (glob[i + 1] === '*') {
383383+ re += '.*';
384384+ i += 2;
385385+ if (glob[i] === '/') i += 1;
386386+ } else {
387387+ re += '[^/]*';
388388+ i += 1;
389389+ }
390390+ } else if (c === '?') {
391391+ re += '[^/]';
392392+ i += 1;
393393+ } else if (c === '{') {
394394+ const end = glob.indexOf('}', i);
395395+ if (end === -1) { re += '\\{'; i += 1; continue; }
396396+ const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
397397+ re += `(?:${parts.join('|')})`;
398398+ i = end + 1;
399399+ } else if (/[.+^$()|[\]\\]/.test(c)) {
400400+ re += `\\${c}`;
401401+ i += 1;
402402+ } else {
403403+ re += c;
404404+ i += 1;
405405+ }
406406+ }
407407+ re += '$';
408408+ return new RegExp(re);
409409+}
410410+411411+export function matchesAnyGlob(filePath, globs) {
412412+ if (!Array.isArray(globs) || globs.length === 0) return false;
413413+ const normalized = String(filePath || '').split(sep).join('/');
414414+ for (const glob of globs) {
415415+ try {
416416+ const re = globToRegex(String(glob));
417417+ if (re.test(normalized)) return true;
418418+ const base = normalized.split('/').pop();
419419+ if (re.test(base)) return true;
420420+ } catch {
421421+ /* malformed glob, skip */
422422+ }
423423+ }
424424+ return false;
425425+}
426426+427427+export function shouldIgnoreDetectionFile(filePath, root, config) {
428428+ const globs = config?.ignoreFiles || [];
429429+ if (!Array.isArray(globs) || globs.length === 0) return false;
430430+ const raw = String(filePath || '').trim();
431431+ if (!raw) return false;
432432+ if (matchesAnyGlob(raw, globs)) return true;
433433+434434+ try {
435435+ const abs = isAbsolute(raw) ? raw : resolve(root, raw);
436436+ if (matchesAnyGlob(abs, globs)) return true;
437437+ const rel = relative(root, abs);
438438+ if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {
439439+ return matchesAnyGlob(rel, globs);
440440+ }
441441+ } catch {
442442+ /* ignore */
443443+ }
444444+ return false;
445445+}
446446+447447+export function filterDetectionFindings(findings, config) {
448448+ if (!Array.isArray(findings) || findings.length === 0) return [];
449449+ const ignoreRules = new Set((config?.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
450450+ const ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
451451+ return findings.filter((finding) => {
452452+ if (!finding || typeof finding !== 'object') return false;
453453+ if (ignoreRules.has(normalizeIgnoreRule(finding.antipattern))) return false;
454454+ if (isIgnoredFindingValue(finding, ignoreValues)) return false;
455455+ return true;
456456+ });
457457+}
458458+459459+function isIgnoredFindingValue(finding, ignoreValues) {
460460+ if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
461461+ const rule = normalizeIgnoreRule(finding.antipattern);
462462+ const value = extractFindingIgnoreValue(finding);
463463+ if (!rule || !value) return false;
464464+ return ignoreValues.some((entry) => {
465465+ const wildcardValue = entry.value === '*';
466466+ if (entry.rule !== rule || (!wildcardValue && !ignoreValueMatches(rule, entry.value, value))) return false;
467467+ if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
468468+ return findingMatchesScopedIgnoreFile(finding, entry.files);
469469+ });
470470+}
471471+472472+function findingMatchesScopedIgnoreFile(finding, globs) {
473473+ const filePath = String(finding?.file || '').trim();
474474+ if (!filePath) return false;
475475+ if (matchesAnyGlob(filePath, globs)) return true;
476476+477477+ const normalized = filePath.split(sep).join('/');
478478+ const parts = normalized.split('/').filter(Boolean);
479479+ for (let i = 0; i < parts.length; i++) {
480480+ const suffix = parts.slice(i).join('/');
481481+ if (matchesAnyGlob(suffix, globs)) return true;
482482+ }
483483+ return false;
484484+}
485485+486486+export function extractFindingIgnoreValue(finding) {
487487+ if (!finding || typeof finding !== 'object') return '';
488488+ const rule = normalizeIgnoreRule(finding.antipattern);
489489+ const directValueRules = new Set([
490490+ 'overused-font',
491491+ 'bounce-easing',
492492+ 'design-system-font',
493493+ 'design-system-color',
494494+ 'design-system-radius',
495495+ ]);
496496+ if (!directValueRules.has(rule)) return '';
497497+ return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
498498+}
499499+500500+function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
501501+ const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
502502+ if (direct) return direct;
503503+504504+ const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
505505+ for (const text of candidates) {
506506+ if (rule === 'bounce-easing') {
507507+ const motion = extractMotionIgnoreValue(text);
508508+ if (motion) return motion;
509509+ continue;
510510+ }
511511+512512+ const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
513513+ if (primary) return cleanIgnoreValueDisplay(primary[1]);
514514+515515+ const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
516516+ if (family) return cleanIgnoreValueDisplay(family[1]);
517517+518518+ const google = text.match(/[?&]family=([^&:;\n]+)/i);
519519+ if (google) {
520520+ try {
521521+ return cleanIgnoreValueDisplay(decodeURIComponent(google[1]));
522522+ } catch {
523523+ return cleanIgnoreValueDisplay(google[1]);
524524+ }
525525+ }
526526+ }
527527+528528+ return '';
529529+}
530530+531531+function extractMotionIgnoreValue(text) {
532532+ const tailwind = text.match(/\banimate-bounce\b/i);
533533+ if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
534534+535535+ const bezier = text.match(/cubic-bezier\([^)]+\)/i);
536536+ if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
537537+538538+ const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
539539+ if (animation) {
540540+ const token = animation[1]
541541+ .split(/[,\s]+/)
542542+ .find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
543543+ if (token) return cleanIgnoreValueDisplay(token);
544544+ }
545545+546546+ return '';
547547+}
548548+549549+function cleanIgnoreValueDisplay(value) {
550550+ return String(value || '')
551551+ .trim()
552552+ .replace(/^["']|["']$/g, '')
553553+ .replace(/\+/g, ' ')
554554+ .replace(/\s+/g, ' ');
555555+}
556556+557557+/**
558558+ * The recorded design-hook decision: 'accepted' | 'declined' | undefined.
559559+ * config.local.json (per-developer) overrides config.json.
560560+ */
561561+export function getHookConsent(root) {
562562+ let consent;
563563+ for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
564564+ const hook = hookSection(safeReadJson(filePath));
565565+ if (hook && (hook.consent === 'accepted' || hook.consent === 'declined')) consent = hook.consent;
566566+ }
567567+ return consent;
568568+}
569569+570570+/**
571571+ * Persist the per-developer decision to config.local.json, preserving any
572572+ * sibling keys, and ensure the file is gitignored.
573573+ */
574574+export function setHookConsent(root, value) {
575575+ const filePath = getLocalConfigPath(root);
576576+ const existing = safeReadJson(filePath) || {};
577577+ const hook = hookSection(existing) || {};
578578+ const next = { ...existing, hook: { ...hook, consent: value } };
579579+ mkdirSync(dirname(filePath), { recursive: true });
580580+ writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
581581+ ensureConfigGitExclude(root);
582582+ return filePath;
583583+}
584584+585585+const EXCLUDE_OPEN = '# impeccable-config-ignore-start';
586586+const EXCLUDE_CLOSE = '# impeccable-config-ignore-end';
587587+const EXCLUDE_PATTERNS = ['.impeccable/config.local.json'];
588588+589589+/**
590590+ * Add config.local.json to `.git/info/exclude` so a developer's decision is
591591+ * never committed. Idempotent via marker comments. Best-effort; returns false
592592+ * when there is no resolvable git dir.
593593+ */
594594+export function ensureConfigGitExclude(root) {
595595+ try {
596596+ const gitDir = resolveGitDir(root);
597597+ if (!gitDir) return false;
598598+ const target = join(gitDir, 'info', 'exclude');
599599+ const existing = existsSync(target) ? readFileSync(target, 'utf-8') : '';
600600+ const block = [EXCLUDE_OPEN, ...EXCLUDE_PATTERNS, EXCLUDE_CLOSE].join('\n');
601601+ const markerRe = new RegExp(`${escapeRegExp(EXCLUDE_OPEN)}[\\s\\S]*?${escapeRegExp(EXCLUDE_CLOSE)}`);
602602+ let updated;
603603+ if (markerRe.test(existing)) {
604604+ updated = existing.replace(markerRe, block);
605605+ } else {
606606+ const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
607607+ updated = `${prefix}${block}\n`;
608608+ }
609609+ if (updated !== existing) {
610610+ mkdirSync(dirname(target), { recursive: true });
611611+ writeFileSync(target, updated);
612612+ }
613613+ return true;
614614+ } catch {
615615+ return false;
616616+ }
617617+}
618618+619619+function resolveGitDir(root) {
620620+ const dotGit = join(root, '.git');
621621+ if (!existsSync(dotGit)) return null;
622622+ try {
623623+ if (statSync(dotGit).isDirectory()) return dotGit;
624624+ // A `.git` file (worktree/submodule) points elsewhere: "gitdir: <path>".
625625+ const match = readFileSync(dotGit, 'utf-8').match(/gitdir:\s*(.+)/);
626626+ if (match) {
627627+ const resolved = match[1].trim();
628628+ return isAbsolute(resolved) ? resolved : join(root, resolved);
629629+ }
630630+ } catch {
631631+ /* fall through */
632632+ }
633633+ return null;
634634+}
635635+636636+function escapeRegExp(value) {
637637+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
638638+}