diff --git a/.gitignore b/.gitignore index f41edda..2236821 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,6 @@ node_modules/ # Local databases (SQLite projection + Hydrant fjall store). /data/ /hydrant.db/ -rustc-ice-*.txt \ No newline at end of file +rustc-ice-*.txt +# Impeccable design detector scratch (ignore lists, design sidecar, critique snapshots). +.impeccable/ diff --git a/.polytoken/skills/frontend-design/SKILL.md b/.polytoken/skills/frontend-design/SKILL.md new file mode 100644 index 0000000..f7cfe57 --- /dev/null +++ b/.polytoken/skills/frontend-design/SKILL.md @@ -0,0 +1,162 @@ +--- +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. +polytoken: + tags: [frontend, design, ui] +--- +# Polymodel front-end design + +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. + +Announce: "using the frontend-design skill for Polymodel UI work". + +**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: + +- **`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`. +- **`tools/design-detector/`** — the vendored deterministic linter (see Detector below). +- **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. + +Read `DESIGN.md` and the relevant `assets/styling/*.css` before non-trivial UI work. + +--- + +## 1. Setup: orient before you design + +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. +2. **Know the register.** Polymodel is **product UI** — a tool people browse and publish with — not a marketing/brand surface. Design *serves* the task. +3. **Preserve the blueprint identity** (§3). It is committed and distinctive. Do not "fix" it toward generic flat-SaaS or dark-terminal defaults. + +--- + +## 2. The register: product, not brand + +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. + +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. + +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. + +--- + +## 3. The blueprint identity (committed — preserve it) + +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. + +- **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. +- **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. +- **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. +- **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. +- **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. +- **Both schemes first-class.** Every token has a dark variant; verify light *and* dark. + +--- + +## 4. General rules + +### Color +- **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`. +- **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. +- **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. +- **State vocabulary, standardized:** hover, focus, active, disabled, selected, loading, error, warning, success, info. Accent is for primary action / current selection / state indicators only. + +### Typography +- **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. +- **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. +- **Prose line length 65–75ch.** Dense data/tables may run wider. +- **`text-wrap: balance`** on headings; `pretty` on long prose. + +### Layout +- **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. +- **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. +- **Vary spacing for rhythm.** Build a semantic z-index scale (dropdown → sticky → modal → toast → tooltip); never `999`/`9999`. +- **Responsive behavior is structural** (collapse nav, responsive tables, breakpoint columns), not fluid typography. + +### Motion +- **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. +- **`prefers-reduced-motion` is not optional** — every animation needs a reduce fallback (crossfade or instant). +- **Don't animate layout properties** or `` on hover; animate background/border/shadow instead. + +### Components & states +- **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`). +- **Skeletons, not center spinners,** for content loading (`.skeleton-*` exist). +- **Empty states teach** the interface ("publish your first thing" + CTA), never "nothing here." +- **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. + +### Interaction +- Dropdowns with `position: absolute` inside `overflow: hidden/auto` get clipped — use `position: fixed`, the popover/`` API, or a portal. +- Exhaust inline/progressive alternatives before a modal; modal-as-first-thought is usually laziness. + +--- + +## 5. Anti-pattern catalog (rewrite if you catch yourself writing these) + +Cross-register slop tells: + +- **Side-stripe borders** — `border-left/right` >1px as a colored accent. Use full borders or background tints. +- **Gradient text** (`background-clip: text` + gradient). Solid color; emphasis via weight/size. +- **Glassmorphism as default** decoration. +- **The hero-metric template** (big number + label + gradient) and **identical icon-card grids**. +- **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.) +- **Text that overflows its container** at any breakpoint — test thing names/titles at mobile/tablet. +- **Ghost-card**: a 1px border + a wide (≥16px) soft shadow on the same element. Pick one. +- **Over-rounding** (24px+ on cards), **sketchy/hand-drawn SVG**, **repeating-stripe backgrounds**. + +Product-specific bans: + +- **Decorative motion** that doesn't convey state. +- **Inconsistent component vocabulary** across screens. +- **Display fonts in UI** labels/buttons/data. +- **Reinvented standard affordances** for flavor (custom scrollbars beyond our thin styling, weird form controls, non-standard modals). +- **Heavy/full-saturation accents on inactive states.** + +### The slop test (two altitudes) +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. +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. + +Polymodel's answer to both is the **blueprint identity** — lean into it deliberately rather than landing on flat-SaaS or dark-terminal. + +--- + +## 6. Evaluation method (review / critique / audit) + +When reviewing a surface rather than building one, run two passes and synthesize. + +### Design critique (judgment) +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: +- **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. +- **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. +- **AI-slop verdict** against §5. + +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. + +### Technical audit (measurable) +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. + +--- + +## 7. The detector + +`tools/design-detector/` is the vendored deterministic linter (44 rules; Apache-2.0; see its README): + +```sh +node tools/design-detector/detect.mjs --json assets/styling/ # static CSS scan (plain Node) +node tools/design-detector/detect.mjs --json http://localhost:8080 # rendered route (real browser; `just serve` running) +``` + +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. + +--- + +## 8. Workflow & checklist + +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. + +- [ ] Reused existing tokens/components; no parallel system invented. +- [ ] Blueprint identity preserved (paper base, near-square corners, teal/one-orange-action, mono metadata, grid-line media). +- [ ] Body/placeholder contrast ≥ 4.5:1 in both schemes; no gray-on-tint mud. +- [ ] All interactive states present (default/hover/focus/active/disabled/loading/error); focus visible. +- [ ] Skeleton loading + teaching empty state + actionable error state. +- [ ] One UI family + mono for metadata only; no display fonts in UI; fixed rem heading scale. +- [ ] No §5 anti-patterns; motion conveys state, quick, ease-out, with reduced-motion fallback. +- [ ] Headings don't overflow at any breakpoint. +- [ ] Detector run on changed CSS / affected route; findings fixed or justified. +- [ ] Verified light/dark + mobile/desktop in-browser. diff --git a/AGENTS.md b/AGENTS.md index a574179..29382af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,6 +65,7 @@ Before review or handoff, run at least `just fix` and the narrowest relevant tes ### Front-end implementation notes +- **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). - 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. - 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. - Do not commit Playwright MCP scratch output. `.playwright-mcp/` contains local browser logs/snapshots from manual checks and should stay ignored/untracked. diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..952edbe --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,202 @@ +--- +name: Polymodel +description: Blueprint/technical-drawing design system for an AT Protocol model-sharing app. +colors: + base: "#f5f1e8" + surface: "#fffdf7" + overlay: "#e8edf0" + ink: "#17252f" + emphasis: "#0c1f2c" + subtle: "#445461" + muted: "#6a747b" + primary: "#1f6d8a" + secondary: "#5a8797" + tertiary: "#0f4159" + action: "#c5652a" + link: "#a24d1f" + border: "#a9b8bf" + highlight: "#d8e5e9" + error: "#a13f32" + warning: "#b36a16" + success: "#2d7358" +typography: + heading: + fontFamily: "Proza Libre, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" + fontWeight: 600 + lineHeight: 1.3 + body: + fontFamily: "Proza Libre, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" + fontWeight: 400 + fontSize: "16px" + lineHeight: 1.55 + mono: + fontFamily: "Ioskeley Mono, IBM Plex Mono, Consolas, monospace" + fontSize: "0.72rem" + letterSpacing: "0.04em" +rounded: + panel: "2px" + square: "0px" +spacing: + base: "16px" +components: + button: + backgroundColor: "{colors.surface}" + textColor: "{colors.ink}" + rounded: "{rounded.panel}" + padding: "0.55rem 0.8rem" + button-primary: + backgroundColor: "{colors.action}" + textColor: "{colors.base}" + rounded: "{rounded.panel}" + padding: "0.55rem 0.8rem" + button-secondary: + backgroundColor: "{colors.surface}" + textColor: "{colors.tertiary}" + rounded: "{rounded.panel}" + input: + backgroundColor: "{colors.base}" + textColor: "{colors.ink}" + rounded: "{rounded.panel}" + padding: "0.75rem 0.85rem" + card: + backgroundColor: "{colors.surface}" + textColor: "{colors.ink}" + rounded: "{rounded.panel}" + status-pill: + backgroundColor: "{colors.surface}" + textColor: "{colors.subtle}" + rounded: "{rounded.panel}" + typography: "{typography.mono}" +--- + +# Design System: Polymodel + +## 1. Overview + +**Creative North Star: "The Drafting Table"** + +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. + +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. + +**Key Characteristics:** +- Drafting-paper base, drawing-ink teal, single warm-orange action accent. +- Near-square corners (2px panels, 0px controls); crisp 1px borders over shadow. +- Monospace for all technical metadata (dimensions, formats, DIDs/handles, counts, status). +- Drafting-grid placeholders instead of gray boxes. +- First-class light **and** dark schemes; restrained, state-driven color and motion. + +## 2. Colors + +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). + +### Primary +- **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. + +### Secondary +- **Slate Teal** (#5a8797): Supporting teal for secondary controls and quieter structural accents. +- **Deep Teal** (#0f4159): High-emphasis teal for hover ink and strong labels against light surfaces. + +### Tertiary +- **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.** +- **Rust Link** (#a24d1f): Inline text links, distinct from the action button so links don't read as buttons. + +### Neutral +- **Drafting Paper** (#f5f1e8): The body background. A warm paper tone tinted toward the brand — the blueprint substrate, not a generic off-white. +- **Sheet** (#fffdf7): Raised panel/card/surface background. +- **Blueprint Overlay** (#e8edf0): Cool inset background for media wells and grid placeholders. +- **Ink** (#17252f): Body text. +- **Emphasis** (#0c1f2c) / **Subtle** (#445461) / **Muted** (#6a747b): The text-emphasis ramp; pick by importance, never drop body to muted-gray on the paper base. +- **Border** (#a9b8bf): The 1px line on every panel, control, and pill. +- **Highlight** (#d8e5e9): Selection/skeleton shimmer tint. + +### Status +- **Error** (#a13f32) / **Warning** (#b36a16) / **Success** (#2d7358): Semantic state only, paired with shape/label, never color-alone. + +### Named Rules +**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. + +**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. + +**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. + +## 3. Typography + +**Heading Font:** Proza Libre (with system-ui, -apple-system, Segoe UI, sans-serif) +**Body Font:** Proza Libre (with system-ui, -apple-system, Segoe UI, sans-serif) +**Label/Mono Font:** Ioskeley Mono (with IBM Plex Mono, Consolas, monospace) + +**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. + +### Hierarchy +- **Heading** (Proza Libre, 600, ~1rem–1.18rem, line-height 1.3): Card titles, section and page headings. Fixed rem scale, tight ratio. +- **Body** (Proza Libre, 400, 16px, line-height 1.55): Paragraphs and descriptions; cap prose at 65–75ch. +- **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. +- **Meta/Label** (Ioskeley Mono, ~0.72rem, letter-spacing 0.04–0.08em, often uppercase): Dimensions, file formats, DIDs/handles, counts, status pills, eyebrows/kickers. + +### Named Rules +**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. + +**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. + +**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. + +## 4. Elevation + +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. + +### Shadow Vocabulary +- **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. + +### Named Rules +**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). + +## 5. Components + +### Buttons +- **Shape:** Near-square (2px radius); base controls render at 0px corners. Never round to pill (except deliberate tags) or 12px+. +- **Default:** Sheet background, 1px border, ink text. Hover shifts border to teal and text to deep teal. +- **Primary:** Redline Orange fill, paper-colored text — the one action. Hover brightens slightly (`filter: brightness(1.04)`), no transform bounce. +- **Secondary / Ghost:** Teal border + deep-teal text (secondary); transparent background (ghost). + +### Chips / Tabs / Status pills +- **Style:** 1px border, near-square, mono text, ~0.72rem. Transparent at rest. +- **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. + +### Cards / Containers +- **Corner Style:** 2px (panel radius). +- **Background:** Sheet (#fffdf7) over the paper base. +- **Border:** 1px Border (#a9b8bf) — the defining line. +- **Shadow Strategy:** Paper hairline only (see Elevation). +- **Internal Padding:** ~1rem; metadata rows in mono, subordinate to title/creator. +- **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. + +### Inputs / Fields +- **Style:** Paper-base fill, 1px border, 2px radius, body sans. +- **Focus:** Border shifts to teal with a 2px translucent teal outline (`outline: 2px solid color-mix(... primary 25% ...)`). Focus is always visible. +- **Error / Disabled:** Error recolors border+text to Error and adds a faint wash; disabled drops contrast and removes the pointer affordance. + +### Navigation +- 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. + +### Signature: the blueprint preview / skeleton +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. + +## 6. Do's and Don'ts + +### Do: +- **Do** reuse the tokens in `assets/styling/theme.css` and the component vocabulary in `base.css`/`cards.css`; extend, don't fork. +- **Do** set all technical metadata (dimensions, formats, DIDs/handles, counts, status) in Ioskeley Mono. +- **Do** keep body/placeholder contrast ≥ 4.5:1 in both schemes; move text toward Ink, not Muted, when close. +- **Do** ship every interactive state — default, hover, focus, active, disabled, loading, error — with visible keyboard focus. +- **Do** use drafting-grid placeholders and skeletons for loading/empty media; write empty states that teach (publish-your-first-thing). +- **Do** keep motion to 150–250ms, ease-out, for state/feedback/loading/viewer-readiness only, with a `prefers-reduced-motion` fallback. + +### Don't: +- **Don't** swap the drafting-paper base for a flat off-white, or rename it `--paper`/`--cream`/`--sand`. +- **Don't** round cards/inputs to 12px+ or reach for full pills outside tags; the near-square corner is the identity. +- **Don't** use more than one Redline-Orange action per view, or use the action color for decoration. +- **Don't** use gradient text, glassmorphism-by-default, hero-metric templates, identical icon-card grids, or an uppercase eyebrow above every section. +- **Don't** pair a 1px border with a wide soft drop shadow (ghost-card), use `border-left` >1px as a colored stripe, or animate `` on hover. +- **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. +- **Don't** land on generic flat-SaaS or generic dark-terminal looks; the answer to "what is this" is the blueprint identity. diff --git a/tools/design-detector/README.md b/tools/design-detector/README.md new file mode 100644 index 0000000..775248b --- /dev/null +++ b/tools/design-detector/README.md @@ -0,0 +1,27 @@ +# Design detector + +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`. + +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*). + +## Usage + +```sh +# Static scan of our CSS (plain Node, no browser) +node tools/design-detector/detect.mjs --json assets/styling/ + +# Scan a rendered route (drives a real browser; needs `just serve` running) +node tools/design-detector/detect.mjs --json http://localhost:8080 +``` + +Exit code `0` = clean, `2` = findings. JSON entries carry `antipattern`, `severity` (advisory/warning), `file`, `line`, `snippet`. + +## Coverage notes + +- 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. +- `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. +- Scratch/config (`.impeccable/`) is gitignored. + +## Updating + +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. diff --git a/tools/design-detector/context.mjs b/tools/design-detector/context.mjs new file mode 100644 index 0000000..adf03e0 --- /dev/null +++ b/tools/design-detector/context.mjs @@ -0,0 +1,961 @@ +/** + * Context loader: prints PRODUCT.md (and DESIGN.md if present) as one + * markdown block on stdout, or exits with empty stdout when no PRODUCT.md + * is found anywhere. The skill keys off "empty stdout" to branch into the + * init flow. + * + * Path resolution (first match wins): + * 1. Active project root, if PRODUCT.md or DESIGN.md is there + * 2. Active project .agents/context/ then docs/ + * 3. Monorepo root context, using the same order, as a per-file fallback + * 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user + * escape hatch, only consulted when defaults are empty + * 5. Active project root as a "nothing found" default + * + * `resolveContextDir()` and `loadContext()` are also exported for the + * server-side scripts (live.mjs, live-server.mjs) that need the structured + * shape rather than the markdown block. + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseTargetOptions } from './lib/target-args.mjs'; + +const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md']; +const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md']; +const FALLBACK_DIRS = ['.agents/context', 'docs']; +const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json']; +const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages']; +const WORKSPACE_DISCOVERY_IGNORED_DIRS = new Set([ + 'node_modules', + '.git', + 'dist', + 'build', + '.next', + '.nuxt', + '.svelte-kit', + '.turbo', + '.cache', + 'coverage', +]); + +// ─── Update check ────────────────────────────────────────────────────────── +// Piggyback a lightweight skill-version check on the once-per-session boot. +// When a newer skill ships, append an UPDATE_AVAILABLE directive so the agent +// can offer `npx impeccable update`. Everything here is best-effort and +// silent on failure: a network problem, sandbox, or missing cache must never +// block context output or print an error. + +const UPDATE_HOST = (process.env.IMPECCABLE_UPDATE_HOST || 'https://impeccable.style').replace(/\/$/, ''); +const UPDATE_CACHE_PATH = + process.env.IMPECCABLE_UPDATE_CACHE || path.join(os.homedir(), '.impeccable', 'update-check.json'); +const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to once a day +const RENOTIFY_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // don't re-surface the same version for a week +const FETCH_TIMEOUT_MS = 1200; + +export function resolveContextDir(cwd = process.cwd(), options = {}) { + return resolveContext(cwd, options).contextDir; +} + +export function loadContext(cwd = process.cwd(), options = {}) { + const resolved = resolveContext(cwd, options); + const absCwd = path.resolve(cwd); + const productPath = resolved.productPath; + const designPath = resolved.designPath; + const product = productPath ? safeRead(productPath) : null; + const design = designPath ? safeRead(designPath) : null; + return { + hasProduct: !!product, + product, + productPath: productPath ? path.relative(absCwd, productPath) : null, + hasDesign: !!design, + design, + designPath: designPath ? path.relative(absCwd, designPath) : null, + contextDir: resolved.contextDir, + productContextDir: productPath ? path.dirname(productPath) : null, + designContextDir: designPath ? path.dirname(designPath) : null, + projectRoot: resolved.projectRoot, + repoRoot: resolved.repoRoot, + isMonorepo: resolved.isMonorepo, + }; +} + +function resolveContext(cwd = process.cwd(), options = {}) { + const absCwd = path.resolve(cwd); + const project = resolveProject(absCwd, options); + const projectContextDir = resolveLocalContextDir(project.projectRoot); + const rootContextDir = project.isMonorepo && project.repoRoot !== project.projectRoot + ? resolveLocalContextDir(project.repoRoot) + : null; + + let productPath = + (projectContextDir ? firstExisting(projectContextDir, PRODUCT_NAMES) : null) + || (rootContextDir ? firstExisting(rootContextDir, PRODUCT_NAMES) : null); + let designPath = + (projectContextDir ? firstExisting(projectContextDir, DESIGN_NAMES) : null) + || (rootContextDir ? firstExisting(rootContextDir, DESIGN_NAMES) : null); + + let envContextDir = null; + if (!productPath && !designPath) { + envContextDir = resolveEnvContextDir(absCwd); + if (envContextDir) { + productPath = firstExisting(envContextDir, PRODUCT_NAMES); + designPath = firstExisting(envContextDir, DESIGN_NAMES); + } + } + + return { + contextDir: productPath + ? path.dirname(productPath) + : designPath + ? path.dirname(designPath) + : envContextDir || project.projectRoot, + productPath, + designPath, + projectRoot: project.projectRoot, + repoRoot: project.repoRoot, + isMonorepo: project.isMonorepo, + targetDir: project.targetDir, + }; +} + +export function resolveProjectRoot(cwd = process.cwd(), options = {}) { + return resolveProject(cwd, options).projectRoot; +} + +export function resolveTargetSelection(cwd = process.cwd(), options = {}) { + if (hasTargetOption(options)) return null; + const project = resolveProject(cwd); + if ( + !project.isMonorepo + || !project.projectRoot + || !project.repoRoot + || path.resolve(project.projectRoot) !== path.resolve(project.repoRoot) + ) { + return null; + } + const targetCandidates = discoverTargetCandidates(project.repoRoot); + // No discoverable child apps (e.g. `workspaces: ["."]`, a root-only workspace, + // or a marker file with no apps/packages children): there is nothing to choose, + // so treat the repo root as the active project rather than blocking on an empty + // selection prompt that the user cannot answer. + if (targetCandidates.length === 0) return null; + return { + targetPath: null, + projectRoot: project.projectRoot, + repoRoot: project.repoRoot, + targetCandidates, + }; +} + +function resolveProject(cwd = process.cwd(), options = {}) { + const absCwd = path.resolve(cwd); + const targetDir = resolveTargetDir(absCwd, options); + let repoRoot = findMonorepoRoot(targetDir); + if (!repoRoot && targetDir !== absCwd) { + const cwdRepoRoot = findMonorepoRoot(absCwd); + if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) { + repoRoot = cwdRepoRoot; + } + } + if (!repoRoot) { + return { + targetDir, + projectRoot: absCwd, + repoRoot: absCwd, + isMonorepo: false, + }; + } + return { + targetDir, + projectRoot: resolveWorkspaceProjectRoot(repoRoot, targetDir) || repoRoot, + repoRoot, + isMonorepo: true, + }; +} + +function isPathInside(candidate, root) { + const rel = path.relative(root, candidate); + return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel); +} + +function resolveLocalContextDir(root) { + if (firstExisting(root, [...PRODUCT_NAMES, ...DESIGN_NAMES])) { + return root; + } + for (const rel of FALLBACK_DIRS) { + const candidate = path.resolve(root, rel); + if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) { + return candidate; + } + } + return null; +} + +function resolveEnvContextDir(cwd) { + const envDir = process.env.IMPECCABLE_CONTEXT_DIR; + if (!envDir || !envDir.trim()) return null; + const trimmed = envDir.trim(); + return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed); +} + +function resolveTargetDir(cwd, options = {}) { + const targetPath = options && typeof options === 'object' ? options.targetPath : null; + if (!targetPath || !String(targetPath).trim()) return cwd; + const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath); + try { + const stat = fs.statSync(abs); + return stat.isDirectory() ? abs : path.dirname(abs); + } catch { + return path.extname(abs) ? path.dirname(abs) : abs; + } +} + +function findMonorepoRoot(startDir) { + let dir = path.resolve(startDir); + const homeDir = path.resolve(os.homedir()); + while (true) { + if (dir === homeDir) return null; + // isMonorepoRoot is checked before hasGitBoundary on purpose: a workspace + // root that also carries its own .git is still recognized. The trade-off is + // deliberate — a directory with a monorepo *marker* but no workspace patterns + // and no apps/packages children is not a monorepo root, so its .git stops + // traversal and a further-up root is not searched. The nested .git is treated + // as an independent project boundary, which is the intended isolation. + if (isMonorepoRoot(dir)) return dir; + if (hasGitBoundary(dir)) return null; + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +function isMonorepoRoot(dir) { + if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true; + if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false; + return hasFallbackWorkspaceChildren(dir); +} + +function hasGitBoundary(dir) { + return fs.existsSync(path.join(dir, '.git')); +} + +function hasFallbackWorkspaceChildren(dir) { + for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) { + const base = path.join(dir, name); + let entries; + try { + entries = fs.readdirSync(base, { withFileTypes: true }); + } catch { + continue; + } + if (entries.some((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))) return true; + } + return false; +} + +function discoverTargetCandidates(repoRoot) { + const roots = new Map(); + const patterns = readWorkspacePatterns(repoRoot); + for (const pattern of patterns) { + for (const root of discoverRootsForPattern(repoRoot, pattern)) { + roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root); + } + } + if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) { + for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) { + const base = path.join(repoRoot, name); + let entries; + try { + entries = fs.readdirSync(base, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue; + const root = path.join(base, entry.name); + roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root); + } + } + } + return [...roots.entries()] + .filter(([rel]) => rel && !rel.startsWith('..')) + // Honor negated workspace patterns (e.g. "!packages/internal"). resolveWorkspaceProjectRoot + // sends an excluded package back to the repo root, so an excluded folder must not appear as a + // selectable target — choosing it would silently resolve to the root instead. + .filter(([rel]) => !isExcludedByWorkspacePattern(rel.split('/').filter(Boolean), patterns)) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([rel, root]) => { + const targetExample = findTargetExample(repoRoot, root); + return { + name: path.basename(root), + path: rel, + targetExample, + ...resolveCandidateContextSummary(repoRoot, root, targetExample), + }; + }); +} + +function resolveCandidateContextSummary(repoRoot, projectRoot, targetPath) { + const ctx = resolveContext(repoRoot, { targetPath }); + return { + productStatus: contextSourceStatus(ctx.productPath, repoRoot, projectRoot), + productPath: contextSourcePath(ctx.productPath, repoRoot), + designStatus: contextSourceStatus(ctx.designPath, repoRoot, projectRoot), + designPath: contextSourcePath(ctx.designPath, repoRoot), + }; +} + +// Selection candidates surface one of four statuses: 'child' (a canonical +// PRODUCT.md/DESIGN.md directly in the app root), 'inherited' (resolved from the +// repo root in a monorepo), 'missing' (no file found), and 'fallback'. 'fallback' +// intentionally covers two non-canonical locations: a file inside the project +// root but in a subdirectory (FALLBACK_DIRS, e.g. `.agents/context/`), and a file +// outside both the project and repo roots (IMPECCABLE_CONTEXT_DIR override). +function contextSourceStatus(filePath, repoRoot, projectRoot) { + if (!filePath) return 'missing'; + const absPath = path.resolve(filePath); + const absProjectRoot = path.resolve(projectRoot); + const absRepoRoot = path.resolve(repoRoot); + if (isPathInsideOrEqual(absPath, absProjectRoot)) { + return path.dirname(absPath) === absProjectRoot ? 'child' : 'fallback'; + } + if (absProjectRoot !== absRepoRoot && isPathInsideOrEqual(absPath, absRepoRoot)) { + return 'inherited'; + } + return 'fallback'; +} + +function contextSourcePath(filePath, repoRoot) { + if (!filePath) return null; + const rel = path.relative(repoRoot, filePath); + if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) { + return rel.split(path.sep).join('/'); + } + return filePath; +} + +function discoverRootsForPattern(repoRoot, rawPattern) { + const pattern = normalizeWorkspacePattern(rawPattern); + if (!pattern || pattern.startsWith('!')) return []; + const segments = pattern.split('/').filter(Boolean); + if (!segments.length) return []; + const firstGlobIndex = segments.findIndex((segment) => segment.includes('*')); + const literalPrefix = firstGlobIndex === -1 ? segments : segments.slice(0, firstGlobIndex); + const base = path.join(repoRoot, ...literalPrefix); + if (!fs.existsSync(base)) return []; + if (segments.includes('**')) { + const packageRoots = []; + walkDirs(base, (dir) => { + if (dir !== base && isCandidateProjectRoot(dir)) packageRoots.push(dir); + }); + if (packageRoots.length) return packageRoots; + return directChildDirs(base); + } + return expandSimplePattern(repoRoot, segments); +} + +function expandSimplePattern(repoRoot, patternSegments, index = 0, current = repoRoot) { + if (index >= patternSegments.length) return fs.existsSync(current) ? [current] : []; + const segment = patternSegments[index]; + if (!segment.includes('*')) { + return expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, segment)); + } + let entries; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch { + return []; + } + const roots = []; + for (const entry of entries) { + if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue; + if (!segmentMatches(segment, entry.name)) continue; + roots.push(...expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, entry.name))); + } + return roots; +} + +function directChildDirs(dir) { + try { + return fs.readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name)) + .map((entry) => path.join(dir, entry.name)); + } catch { + return []; + } +} + +function walkDirs(root, visit) { + let entries; + try { + entries = fs.readdirSync(root, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue; + const dir = path.join(root, entry.name); + visit(dir); + walkDirs(dir, visit); + } +} + +function isCandidateProjectRoot(dir) { + return !!( + fs.existsSync(path.join(dir, 'package.json')) + || firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES]) + || fs.existsSync(path.join(dir, 'src')) + || fs.existsSync(path.join(dir, 'app')) + || fs.existsSync(path.join(dir, 'pages')) + || fs.existsSync(path.join(dir, 'public')) + ); +} + +function isIgnoredWorkspaceDiscoveryDir(name) { + return name.startsWith('.') || WORKSPACE_DISCOVERY_IGNORED_DIRS.has(name); +} + +function findTargetExample(repoRoot, projectRoot) { + const examples = [ + 'src/App.jsx', + 'src/App.tsx', + 'src/main.jsx', + 'src/main.tsx', + 'src/index.jsx', + 'src/index.ts', + 'app/page.tsx', + 'pages/index.tsx', + 'public/index.html', + ]; + for (const rel of examples) { + const abs = path.join(projectRoot, rel); + if (fs.existsSync(abs)) return path.relative(repoRoot, abs).split(path.sep).join('/'); + } + return path.relative(repoRoot, projectRoot).split(path.sep).join('/'); +} + +function resolveWorkspaceProjectRoot(repoRoot, targetDir) { + const rel = path.relative(repoRoot, targetDir); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot; + const relSegments = rel.split(path.sep).filter(Boolean); + const patterns = readWorkspacePatterns(repoRoot); + const excluded = isExcludedByWorkspacePattern(relSegments, patterns); + if (!excluded) { + for (const pattern of patterns) { + const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern); + if (projectRoot) return projectRoot; + } + } + if (excluded) return repoRoot; + if ( + relSegments.length >= 2 + && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]) + ) { + return path.join(repoRoot, relSegments[0], relSegments[1]); + } + const nearest = nearestProjectLikeRoot(repoRoot, targetDir); + if (nearest) return nearest; + return repoRoot; +} + +function isExcludedByWorkspacePattern(relSegments, patterns) { + return patterns.some((rawPattern) => { + const pattern = normalizeWorkspacePattern(rawPattern); + if (!pattern.startsWith('!')) return false; + return workspacePatternMatchesRel(pattern.slice(1), relSegments); + }); +} + +function nearestProjectLikeRoot(repoRoot, targetDir) { + let dir = path.resolve(targetDir); + const stop = path.resolve(repoRoot); + while (dir && dir !== stop) { + if ( + firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES]) + || fs.existsSync(path.join(dir, 'package.json')) + ) { + return dir; + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; +} + +function nearestPackageRootBetween(repoRoot, targetDir, stopDir) { + let dir = path.resolve(targetDir); + const stop = path.resolve(stopDir || repoRoot); + const root = path.resolve(repoRoot); + while (dir && dir !== stop && isPathInsideOrEqual(dir, root)) { + if (fs.existsSync(path.join(dir, 'package.json'))) return dir; + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; +} + +function isPathInsideOrEqual(candidate, root) { + return path.resolve(candidate) === path.resolve(root) || isPathInside(candidate, root); +} + +function workspacePatternMatchesRel(pattern, relSegments) { + const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean); + if (!patternSegments.length) return false; + if (patternSegments.includes('**')) { + const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*')); + const literalPrefix = firstGlobIndex === -1 + ? patternSegments + : patternSegments.slice(0, firstGlobIndex); + if (relSegments.length < literalPrefix.length + 1) return false; + for (let i = 0; i < literalPrefix.length; i++) { + if (!segmentMatches(literalPrefix[i], relSegments[i])) return false; + } + return true; + } + if (relSegments.length < patternSegments.length) return false; + for (let i = 0; i < patternSegments.length; i++) { + if (!segmentMatches(patternSegments[i], relSegments[i])) return false; + } + return true; +} + +function readWorkspacePatterns(repoRoot) { + return [ + ...readPackageWorkspaces(repoRoot), + ...readPnpmWorkspaces(repoRoot), + ...readLernaWorkspaces(repoRoot), + ].filter(Boolean); +} + +function readPackageWorkspaces(repoRoot) { + const pkg = readJson(path.join(repoRoot, 'package.json')); + const workspaces = pkg?.workspaces; + if (Array.isArray(workspaces)) return workspaces; + if (Array.isArray(workspaces?.packages)) return workspaces.packages; + return []; +} + +function readLernaWorkspaces(repoRoot) { + const lerna = readJson(path.join(repoRoot, 'lerna.json')); + return Array.isArray(lerna?.packages) ? lerna.packages : []; +} + +function readPnpmWorkspaces(repoRoot) { + try { + const body = fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf-8'); + const patterns = []; + let inPackages = false; + for (const line of body.split(/\r?\n/)) { + const trimmed = stripYamlInlineComment(line).trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const flowMatch = trimmed.match(/^packages:\s*\[(.*)\]\s*$/); + if (flowMatch) { + patterns.push(...parseYamlFlowList(flowMatch[1])); + inPackages = false; + continue; + } + if (/^packages:\s*$/.test(trimmed)) { + inPackages = true; + continue; + } + if (inPackages && /^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break; + if (inPackages) { + const match = trimmed.match(/^-\s*(.+)$/); + if (match) patterns.push(unquoteYamlValue(match[1])); + } + } + return patterns; + } catch { + return []; + } +} + +function stripYamlInlineComment(line) { + let quote = null; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if ((ch === '"' || ch === "'") && line[i - 1] !== '\\') { + quote = quote === ch ? null : quote || ch; + continue; + } + if (ch === '#' && !quote) return line.slice(0, i); + } + return line; +} + +function parseYamlFlowList(body) { + const items = []; + let quote = null; + let current = ''; + for (let i = 0; i < body.length; i++) { + const ch = body[i]; + if ((ch === '"' || ch === "'") && body[i - 1] !== '\\') { + quote = quote === ch ? null : quote || ch; + current += ch; + continue; + } + if (ch === ',' && !quote) { + const value = unquoteYamlValue(current); + if (value) items.push(value); + current = ''; + continue; + } + current += ch; + } + const value = unquoteYamlValue(current); + if (value) items.push(value); + return items; +} + +function unquoteYamlValue(value) { + return String(value || '') + .trim() + .replace(/^['"]|['"]$/g, ''); +} + +function readJson(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } catch { + return null; + } +} + +function projectRootFromWorkspacePattern(repoRoot, relSegments, rawPattern) { + const pattern = normalizeWorkspacePattern(rawPattern); + if (!pattern || pattern.startsWith('!')) return null; + const patternSegments = pattern.split('/').filter(Boolean); + if (!patternSegments.length) return null; + if (patternSegments.includes('**')) { + return projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments); + } + if (relSegments.length < patternSegments.length) return null; + for (let i = 0; i < patternSegments.length; i++) { + if (!segmentMatches(patternSegments[i], relSegments[i])) return null; + } + return path.join(repoRoot, ...relSegments.slice(0, patternSegments.length)); +} + +function projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments) { + const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*')); + const literalPrefix = firstGlobIndex === -1 + ? patternSegments + : patternSegments.slice(0, firstGlobIndex); + if (relSegments.length < literalPrefix.length + 1) return null; + for (let i = 0; i < literalPrefix.length; i++) { + if (!segmentMatches(literalPrefix[i], relSegments[i])) return null; + } + const prefixDir = path.join(repoRoot, ...literalPrefix); + const targetDir = path.join(repoRoot, ...relSegments); + const packageRoot = nearestPackageRootBetween(repoRoot, targetDir, prefixDir); + if (packageRoot) return packageRoot; + return path.join(repoRoot, ...relSegments.slice(0, literalPrefix.length + 1)); +} + +function normalizeWorkspacePattern(pattern) { + return String(pattern || '') + .trim() + .replace(/^['"]|['"]$/g, '') + .replace(/^\.\//, '') + .replace(/\/+$/, ''); +} + +function segmentMatches(patternSegment, relSegment) { + if (patternSegment === '*') return true; + if (!patternSegment.includes('*')) return patternSegment === relSegment; + const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`); + return re.test(relSegment); +} + +function firstExisting(dir, names) { + for (const name of names) { + const abs = path.join(dir, name); + if (fs.existsSync(abs)) return abs; + } + return null; +} + +function safeRead(p) { + try { + return fs.readFileSync(p, 'utf-8'); + } catch { + return null; + } +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Pull the register (`brand` or `product`) out of PRODUCT.md by looking + * for a `## Register` section and reading the first non-empty line that + * follows it. Returns null when the file is legacy / register-less. + */ +export function extractRegister(product) { + if (!product) return null; + const lines = product.split('\n'); + for (let i = 0; i < lines.length; i++) { + if (/^##\s+Register\b/i.test(lines[i].trim())) { + for (let j = i + 1; j < lines.length; j++) { + const next = lines[j].trim(); + if (!next) continue; + const word = next.toLowerCase(); + if (word === 'brand' || word === 'product') return word; + return null; + } + } + } + return null; +} + +/** + * Read the installed skill's own version from the sibling SKILL.md frontmatter + * (this file lives at `/scripts/context.mjs`). Returns null when the + * frontmatter is missing or unreadable. + */ +function readLocalSkillVersion() { + try { + const here = path.dirname(fileURLToPath(import.meta.url)); + const skillMd = path.join(here, '..', 'SKILL.md'); + const content = fs.readFileSync(skillMd, 'utf-8'); + const match = content.match(/^version:\s*(.+)$/m); + return match ? match[1].trim().replace(/^["']|["']$/g, '') : null; + } catch { + return null; + } +} + +function readUpdateCache() { + try { + return JSON.parse(fs.readFileSync(UPDATE_CACHE_PATH, 'utf-8')); + } catch { + return {}; + } +} + +function writeUpdateCache(cache) { + try { + fs.mkdirSync(path.dirname(UPDATE_CACHE_PATH), { recursive: true }); + fs.writeFileSync(UPDATE_CACHE_PATH, JSON.stringify(cache)); + } catch { + // Best-effort: a read-only home dir just means we re-poll next session. + } +} + +/** Compare dotted numeric versions. Returns >0 when a is newer than b. */ +function compareSemver(a, b) { + const pa = String(a).split('.').map(n => parseInt(n, 10) || 0); + const pb = String(b).split('.').map(n => parseInt(n, 10) || 0); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const diff = (pa[i] || 0) - (pb[i] || 0); + if (diff !== 0) return diff; + } + return 0; +} + +async function fetchLatestSkillVersion() { + try { + const res = await fetch(`${UPDATE_HOST}/api/version`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + if (!res.ok) return null; + const data = await res.json(); + return typeof data?.skills === 'string' ? data.skills : null; + } catch { + return null; // offline, sandboxed, timed out, or bad JSON: all non-fatal + } +} + +function buildUpdateDirective(localVersion, latestVersion) { + return ( + `UPDATE_AVAILABLE: A newer Impeccable skill is available ` + + `(installed v${localVersion}, latest v${latestVersion}). ` + + `Before continuing, ask the user once: "A newer Impeccable (v${latestVersion}) is available. ` + + `Update now? It runs \`npx impeccable update\`." ` + + `If they agree, run \`npx impeccable update\` (the update applies to the next session, not this one). ` + + `Either way, continue the current task without waiting, and do not raise this again.` + ); +} + +/** + * Best-effort update directive for the boot output. Returns a string to append + * or null. Polls the version endpoint at most once per day (cached globally in + * the user's home dir) and re-surfaces a given version at most once per week so + * the agent never nags. Opt out entirely with IMPECCABLE_NO_UPDATE_CHECK=1. + */ +// Read the unified config's top-level `updateCheck` (local overrides shared). +// Inlined rather than importing hook-lib so the boot path stays lightweight. +function updateCheckDisabledByConfig(cwd = process.cwd()) { + let value; + for (const name of ['config.json', 'config.local.json']) { + try { + const raw = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', name), 'utf-8')); + if (raw && typeof raw === 'object' && typeof raw.updateCheck === 'boolean') value = raw.updateCheck; + } catch { /* missing or malformed: ignore */ } + } + return value === false; +} + +async function computeUpdateDirective(now = Date.now()) { + try { + if (process.env.IMPECCABLE_NO_UPDATE_CHECK) return null; + if (updateCheckDisabledByConfig()) return null; + const localVersion = readLocalSkillVersion(); + if (!localVersion) return null; + + const cache = readUpdateCache(); + + // Poll the network only when the throttle window has elapsed. Stamp + // lastCheck even on failure so an offline machine doesn't poll every boot. + if (!cache.lastCheck || now - cache.lastCheck > CHECK_INTERVAL_MS) { + const latest = await fetchLatestSkillVersion(); + cache.lastCheck = now; + if (latest) cache.latestVersion = latest; + writeUpdateCache(cache); + } + + const latest = cache.latestVersion; + if (!latest || compareSemver(latest, localVersion) <= 0) return null; + + // Anti-nag: surface a given version at most once per RENOTIFY window. + if (cache.notifiedVersion === latest && cache.notifiedAt && now - cache.notifiedAt < RENOTIFY_INTERVAL_MS) { + return null; + } + cache.notifiedVersion = latest; + cache.notifiedAt = now; + writeUpdateCache(cache); + + return buildUpdateDirective(localVersion, latest); + } catch { + return null; + } +} + +async function cli() { + let cliOptions; + try { + cliOptions = parseCliOptions(process.argv.slice(2)); + } catch (err) { + if (err?.name === 'TargetArgError') { + process.stderr.write(`${err.message}\n`); + process.exit(1); + } + throw err; + } + const targetProvided = hasTargetOption(cliOptions); + const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null; + const selection = resolveTargetSelection(process.cwd(), cliOptions); + if (selection) { + process.stdout.write(buildTargetSelectionDirective(selection) + '\n'); + process.exit(0); + } + const ctx = loadContext(process.cwd(), cliOptions); + const updateDirective = await computeUpdateDirective(); + + if (!ctx.hasProduct) { + // Direct stdout message instead of relying on empty output as a signal + // — cheap models miss the empty case more often than the explicit one. + const parts = [ + 'NO_PRODUCT_MD: This project has no PRODUCT.md yet. ' + + 'Stop the current task, load reference/init.md, and follow its ' + + 'instructions to write PRODUCT.md before resuming.', + ]; + parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists })); + if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) { + parts.push(buildMissingTargetDirective()); + } + if (updateDirective) parts.push(updateDirective); + process.stdout.write(parts.join('\n\n---\n\n') + '\n'); + process.exit(0); + } + const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`]; + if (ctx.hasDesign) { + parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`); + } + parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists })); + if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) { + parts.push(buildMissingTargetDirective()); + } + const register = extractRegister(ctx.product); + const next = register + ? `NEXT STEP: This project's register is \`${register}\`. You MUST now read \`reference/${register}.md\` before producing any design output.` + : `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.`; + parts.push(next); + if (updateDirective) parts.push(updateDirective); + process.stdout.write(parts.join('\n\n---\n\n') + '\n'); +} + +function parseCliOptions(args) { + return parseTargetOptions(args, { strict: true }); +} + +function hasTargetOption(options) { + return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim()); +} + +function pathExistsForTarget(cwd, targetPath) { + const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath); + return fs.existsSync(abs); +} + +function buildResolvedContextDirective(ctx, options, { targetExists = null } = {}) { + const targetPath = hasTargetOption(options) ? options.targetPath : null; + return `RESOLVED_CONTEXT:\n${JSON.stringify({ + targetPath, + ...(targetPath ? { targetExists } : {}), + projectRoot: ctx.projectRoot, + repoRoot: ctx.repoRoot, + productPath: ctx.productPath, + designPath: ctx.designPath, + }, null, 2)}`; +} + +function shouldWarnMissingTarget(ctx, targetProvided, targetExists = null) { + if (ctx.isMonorepo && targetProvided && targetExists === false) return true; + return !!( + ctx.isMonorepo + && (!targetProvided || targetExists === false) + && ctx.projectRoot + && ctx.repoRoot + && path.resolve(ctx.projectRoot) === path.resolve(ctx.repoRoot) + ); +} + +function buildMissingTargetDirective() { + const script = process.argv[1] || 'context.mjs'; + return ( + 'MONOREPO_TARGET_REQUIRED: This is a monorepo and context.mjs ran without --target. ' + + 'If the user named a file, route, or child app, do not answer from this output. ' + + `Rerun \`node ${script} --target \` and answer from that run's RESOLVED_CONTEXT fields.` + ); +} + +function buildTargetSelectionDirective(selection) { + return ( + `TARGET_SELECTION_REQUIRED:\n${JSON.stringify(selection, null, 2)}\n\n` + + '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. ' + + 'Ask the user which app Impeccable should use, then rerun Impeccable helper commands from that child app cwd using this same scripts directory. ' + + 'Use `--target ` only as a fallback when changing cwd is not possible, or when the user explicitly named a file/path.' + ); +} + +// Run cli() only when this module is the entry point. Compare realpaths +// rather than endsWith(): a loose suffix match also fires for unrelated +// scripts like `load-context.mjs`, and realpath tolerates symlinked +// invocation (the test harness symlinks the skill dir). +function invokedAsScript() { + const arg = process.argv[1]; + if (!arg) return false; + try { + return fs.realpathSync(arg) === fs.realpathSync(fileURLToPath(import.meta.url)); + } catch { + return false; + } +} + +if (invokedAsScript()) { + cli(); +} diff --git a/tools/design-detector/detect.mjs b/tools/design-detector/detect.mjs new file mode 100644 index 0000000..cbc0469 --- /dev/null +++ b/tools/design-detector/detect.mjs @@ -0,0 +1,21 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL, fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const candidates = [ + path.join(__dirname, 'detector', 'detect-antipatterns.mjs'), + path.join(__dirname, '..', '..', 'cli', 'engine', 'detect-antipatterns.mjs'), +]; +const detectorPath = candidates.find(p => fs.existsSync(p)); + +if (!detectorPath) { + process.stderr.write('Error: bundled detector not found.\n'); + process.exit(1); +} + +const { detectCli } = await import(pathToFileURL(detectorPath)); + +await detectCli(); diff --git a/tools/design-detector/detector/browser/injected/index.mjs b/tools/design-detector/detector/browser/injected/index.mjs new file mode 100644 index 0000000..fff8114 --- /dev/null +++ b/tools/design-detector/detector/browser/injected/index.mjs @@ -0,0 +1,1937 @@ +const IS_BROWSER = typeof window !== 'undefined'; + +// ─── Section 7: Browser UI (IS_BROWSER only) ──────────────────────────────── + +if (IS_BROWSER) { + // Detect extension mode via the script tag's data attribute or the document element fallback. + // currentScript is reliable for synchronously-executing scripts (which our IIFE is). + const _myScript = document.currentScript; + const EXTENSION_MODE = (_myScript && _myScript.dataset.impeccableExtension === 'true') + || document.documentElement.dataset.impeccableExtension === 'true'; + + // Kinpaku gold — pinned to the site's brand token (see + // site/styles/kinpaku-tokens.css --ks-kinpaku). Keep this in sync with + // the picker's C.brand in skill/scripts/live-browser.js and the kit's + // picker section in site/styles/kinpaku-kit.css. + // + // One color across both light and dark host pages. The outline is a + // 2px gesture pointing at an element + a labeled tag — it's a marker, + // not body text, so it doesn't need WCAG AA against the page. The + // label text inside the gold tag is dark (LABEL_INK) which has ~16:1 + // against the leaf gold, so reading the rule name is solid in both + // modes. Hover deepens the gold (preserves chroma — never drops it, + // dropping chroma washes the gold into a sand/olive tone). + const BRAND_COLOR = 'oklch(84% 0.19 80.46)'; + const BRAND_COLOR_HOVER = 'oklch(74% 0.18 80)'; + const LABEL_INK = 'oklch(4% 0.004 95)'; + const LABEL_BG = BRAND_COLOR; + const OUTLINE_COLOR = BRAND_COLOR; + + // Inject hover styles via CSS (more reliable than JS event listeners) + const styleEl = document.createElement('style'); + styleEl.textContent = ` + @keyframes impeccable-reveal { + from { opacity: 0; } + to { opacity: 1; } + } + .impeccable-overlay:not(.impeccable-banner) { + pointer-events: none; + outline: 2px solid ${OUTLINE_COLOR}; + border-radius: 4px; + transition: outline-color 0.15s ease; + animation: impeccable-reveal 0.4s cubic-bezier(0.16, 1, 0.3, 1) both; + animation-play-state: paused; + border-top-left-radius: 0; + } + .impeccable-overlay.impeccable-visible { + animation-play-state: running; + } + .impeccable-overlay.impeccable-hover { + outline-color: ${BRAND_COLOR_HOVER}; + z-index: 100001 !important; + } + .impeccable-overlay.impeccable-hover .impeccable-label { + background: ${BRAND_COLOR_HOVER}; + } + .impeccable-overlay.impeccable-spotlight { + z-index: 100002 !important; + } + .impeccable-overlay.impeccable-spotlight-dimmed { + opacity: 0.15 !important; + animation: none !important; + filter: blur(3px); + } + .impeccable-spotlight-backdrop { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + backdrop-filter: blur(3px) brightness(0.6); + -webkit-backdrop-filter: blur(3px) brightness(0.6); + pointer-events: none; + z-index: 99998; + opacity: 0; + outline: none !important; + animation: none !important; + } + .impeccable-spotlight-backdrop.impeccable-visible { + opacity: 1; + } + .impeccable-hidden .impeccable-overlay${EXTENSION_MODE ? '' : ':not(.impeccable-banner)'} { + display: none !important; + } + `; + (document.head || document.documentElement).appendChild(styleEl); + + // Spotlight backdrop element (created lazily on first use) + let spotlightBackdrop = null; + let spotlightTarget = null; + + function getSpotlightBackdrop() { + if (!spotlightBackdrop) { + spotlightBackdrop = document.createElement('div'); + spotlightBackdrop.className = 'impeccable-spotlight-backdrop'; + document.body.appendChild(spotlightBackdrop); + } + return spotlightBackdrop; + } + + function updateSpotlightClipPath() { + if (!spotlightBackdrop || !spotlightTarget) return; + const r = spotlightTarget.getBoundingClientRect(); + // Match the overlay's outer edge: element rect + 4px (2px overlay offset + 2px outline width) + const inset = 4; + const radius = 6; // outline border-radius (4) + outline width (2) + const x1 = r.left - inset; + const y1 = r.top - inset; + const x2 = r.right + inset; + const y2 = r.bottom + inset; + const vw = window.innerWidth; + const vh = window.innerHeight; + // Outer rect + rounded inner rect (evenodd creates a hole) + const path = `M0 0H${vw}V${vh}H0Z M${x1 + radius} ${y1}H${x2 - radius}A${radius} ${radius} 0 0 1 ${x2} ${y1 + radius}V${y2 - radius}A${radius} ${radius} 0 0 1 ${x2 - radius} ${y2}H${x1 + radius}A${radius} ${radius} 0 0 1 ${x1} ${y2 - radius}V${y1 + radius}A${radius} ${radius} 0 0 1 ${x1 + radius} ${y1}Z`; + spotlightBackdrop.style.clipPath = `path(evenodd, "${path}")`; + } + + function showSpotlight(target) { + if (!target || !target.getBoundingClientRect) return; + // Respect the spotlightBlur setting: if disabled, don't show the backdrop + if (window.__IMPECCABLE_CONFIG__?.spotlightBlur === false) { + spotlightTarget = target; + return; + } + spotlightTarget = target; + const bd = getSpotlightBackdrop(); + updateSpotlightClipPath(); + bd.classList.add('impeccable-visible'); + } + + function hideSpotlight() { + spotlightTarget = null; + if (spotlightBackdrop) spotlightBackdrop.classList.remove('impeccable-visible'); + } + + function isInViewport(el) { + const r = el.getBoundingClientRect(); + return r.top >= 0 && r.left >= 0 && r.bottom <= window.innerHeight && r.right <= window.innerWidth; + } + + // Reposition spotlight on scroll/resize + window.addEventListener('scroll', () => { + if (spotlightTarget) updateSpotlightClipPath(); + }, { passive: true }); + window.addEventListener('resize', () => { + if (spotlightTarget) updateSpotlightClipPath(); + }); + + const overlays = []; + const TYPE_LABELS = {}; + const RULE_CATEGORY = {}; + for (const ap of ANTIPATTERNS) { + TYPE_LABELS[ap.id] = ap.name.toLowerCase(); + RULE_CATEGORY[ap.id] = ap.category || 'quality'; + } + + function isInFixedContext(el) { + let p = el; + while (p && p !== document.body) { + if (getComputedStyle(p).position === 'fixed') return true; + p = p.parentElement; + } + return false; + } + + function positionOverlay(overlay) { + const el = overlay._targetEl; + if (!el) return; + const rect = el.getBoundingClientRect(); + if (overlay._isFixed) { + // Viewport-relative coords for fixed targets + overlay.style.top = `${rect.top - 2}px`; + overlay.style.left = `${rect.left - 2}px`; + } else { + // Document-relative coords for normal targets + overlay.style.top = `${rect.top + scrollY - 2}px`; + overlay.style.left = `${rect.left + scrollX - 2}px`; + } + overlay.style.width = `${rect.width + 4}px`; + overlay.style.height = `${rect.height + 4}px`; + } + + function repositionOverlays() { + for (const o of overlays) { + if (!o._targetEl || o.classList.contains('impeccable-banner')) continue; + // Skip overlays whose target is currently hidden (display: none on the overlay) + if (o.style.display === 'none') continue; + positionOverlay(o); + } + } + + let resizeRAF; + const onResize = () => { + cancelAnimationFrame(resizeRAF); + resizeRAF = requestAnimationFrame(repositionOverlays); + }; + window.addEventListener('resize', onResize); + // Reposition on scroll too -- catches sticky/parallax shifts + window.addEventListener('scroll', onResize, { passive: true }); + // Reposition when body resizes (lazy-loaded images, dynamic content, fonts loading) + if (typeof ResizeObserver !== 'undefined') { + const bodyResizeObserver = new ResizeObserver(onResize); + bodyResizeObserver.observe(document.body); + } + + // Track target element visibility via IntersectionObserver. + // Uses a huge rootMargin so all *rendered* elements count as intersecting, + // while display:none / closed
/ hidden modals etc. do not. + // This is event-driven -- no polling needed. + let overlayIndex = 0; + const visibilityObserver = new IntersectionObserver((entries) => { + for (const entry of entries) { + const overlay = entry.target._impeccableOverlay; + if (!overlay) continue; + if (entry.isIntersecting) { + overlay.style.display = ''; + positionOverlay(overlay); + if (!overlay._revealed) { + overlay._revealed = true; + if (firstScanDone) { + // Subsequent reveals (re-scans, scroll-into-view): instant, no animation + overlay.style.animation = 'none'; + } else { + // Initial scan: staggered cascade reveal + overlay.style.animationDelay = `${Math.min((overlay._staggerIndex || 0) * 60, 600)}ms`; + } + requestAnimationFrame(() => { + overlay.classList.add('impeccable-visible'); + if (overlay._checkLabel) overlay._checkLabel(); + }); + } + } else { + overlay.style.display = 'none'; + } + } + }, { rootMargin: '99999px' }); + + function detachOverlay(overlay) { + if (!overlay) return; + if (typeof overlay._cleanup === 'function') { + try { overlay._cleanup(); } catch { /* best effort overlay teardown */ } + } + if (overlay._targetEl && overlay._targetEl._impeccableOverlay === overlay) { + visibilityObserver.unobserve(overlay._targetEl); + delete overlay._targetEl._impeccableOverlay; + } + const idx = overlays.indexOf(overlay); + if (idx >= 0) overlays.splice(idx, 1); + overlay.remove(); + } + + // Reposition overlays after CSS transitions end (e.g. reveal animations). + // Listens at document level so it catches transitions on ancestor elements + // (the transform may be on a parent, not the flagged element itself). + document.addEventListener('transitionend', (e) => { + if (e.propertyName !== 'transform') return; + for (const o of overlays) { + if (!o._targetEl || o.classList.contains('impeccable-banner') || o.style.display === 'none') continue; + if (e.target === o._targetEl || e.target.contains(o._targetEl)) { + positionOverlay(o); + } + } + }); + + const highlight = function(el, findings) { + if (el._impeccableOverlay) detachOverlay(el._impeccableOverlay); + const hasSlop = findings.some(f => RULE_CATEGORY[f.type || f.id] === 'slop'); + + const fixed = isInFixedContext(el); + const rect = el.getBoundingClientRect(); + const outline = document.createElement('div'); + outline.className = 'impeccable-overlay'; + outline._targetEl = el; + outline._isFixed = fixed; + Object.assign(outline.style, { + position: fixed ? 'fixed' : 'absolute', + top: fixed ? `${rect.top - 2}px` : `${rect.top + scrollY - 2}px`, + left: fixed ? `${rect.left - 2}px` : `${rect.left + scrollX - 2}px`, + width: `${rect.width + 4}px`, height: `${rect.height + 4}px`, + zIndex: '99999', boxSizing: 'border-box', + }); + + // Build per-finding label entries: ✦ prefix for slop + const entries = findings.map(f => { + const name = TYPE_LABELS[f.type || f.id] || f.type || f.id; + const prefix = RULE_CATEGORY[f.type || f.id] === 'slop' ? '\u2726 ' : ''; + return { name: prefix + name, detail: f.detail || f.snippet }; + }); + const allText = entries.map(e => e.name).join(', '); + + const label = document.createElement('div'); + label.className = 'impeccable-label'; + Object.assign(label.style, { + position: 'absolute', bottom: '100%', left: '-2px', + display: 'flex', alignItems: 'center', + whiteSpace: 'nowrap', + fontSize: '11px', fontWeight: '600', letterSpacing: '0.02em', + color: LABEL_INK, lineHeight: '14px', + background: LABEL_BG, + fontFamily: 'system-ui, sans-serif', + borderRadius: '4px 4px 0 0', + }); + + const textSpan = document.createElement('span'); + textSpan.style.padding = '3px 8px'; + textSpan.textContent = allText; + label.appendChild(textSpan); + + // State for cycling mode + let cycleMode = false; + let cycleIndex = 0; + let isHovered = false; + let prevBtn, nextBtn; + + function updateCycleText() { + const e = entries[cycleIndex]; + textSpan.textContent = isHovered ? e.detail : e.name; + } + + function enableCycleMode() { + if (cycleMode || entries.length < 2) return; + cycleMode = true; + + const btnStyle = { + background: 'none', border: 'none', color: 'rgba(255,255,255,0.7)', + fontSize: '11px', cursor: 'pointer', padding: '3px 4px', + fontFamily: 'system-ui, sans-serif', lineHeight: '14px', + pointerEvents: 'auto', + }; + + const navGroup = document.createElement('span'); + Object.assign(navGroup.style, { + display: 'inline-flex', alignItems: 'center', flexShrink: '0', + }); + + prevBtn = document.createElement('button'); + prevBtn.textContent = '\u2039'; + Object.assign(prevBtn.style, btnStyle); + prevBtn.style.paddingLeft = '6px'; + prevBtn.addEventListener('click', (e) => { + e.stopPropagation(); + cycleIndex = (cycleIndex - 1 + entries.length) % entries.length; + updateCycleText(); + }); + + nextBtn = document.createElement('button'); + nextBtn.textContent = '\u203A'; + Object.assign(nextBtn.style, btnStyle); + nextBtn.style.paddingRight = '2px'; + nextBtn.addEventListener('click', (e) => { + e.stopPropagation(); + cycleIndex = (cycleIndex + 1) % entries.length; + updateCycleText(); + }); + + navGroup.appendChild(prevBtn); + navGroup.appendChild(nextBtn); + label.insertBefore(navGroup, textSpan); + textSpan.style.padding = '3px 8px 3px 4px'; + updateCycleText(); + } + + outline.appendChild(label); + + // Start hidden; the IntersectionObserver will show it once the target is rendered + outline.style.display = 'none'; + outline._staggerIndex = overlayIndex++; + el._impeccableOverlay = outline; + visibilityObserver.observe(el); + + // After first paint, check label width vs outline + outline._checkLabel = () => { + if (entries.length > 1 && label.offsetWidth > outline.offsetWidth) { + enableCycleMode(); + } + }; + + // Hover: show detail text, darken + const onMouseEnter = () => { + isHovered = true; + outline.classList.add('impeccable-hover'); + outline.style.outlineColor = BRAND_COLOR_HOVER; + label.style.background = BRAND_COLOR_HOVER; + if (cycleMode) { + updateCycleText(); + } else { + textSpan.textContent = entries.map(e => e.detail).join(' | '); + } + }; + const onMouseLeave = () => { + isHovered = false; + outline.classList.remove('impeccable-hover'); + outline.style.outlineColor = ''; + label.style.background = LABEL_BG; + if (cycleMode) { + updateCycleText(); + } else { + textSpan.textContent = allText; + } + }; + el.addEventListener('mouseenter', onMouseEnter); + el.addEventListener('mouseleave', onMouseLeave); + outline._cleanup = () => { + el.removeEventListener('mouseenter', onMouseEnter); + el.removeEventListener('mouseleave', onMouseLeave); + }; + + document.body.appendChild(outline); + overlays.push(outline); + }; + + const showPageBanner = function(findings) { + if (!findings.length) return; + const banner = document.createElement('div'); + banner.className = 'impeccable-overlay impeccable-banner'; + Object.assign(banner.style, { + position: 'fixed', top: '0', left: '0', right: '0', zIndex: '100000', + background: LABEL_BG, color: LABEL_INK, + fontFamily: 'system-ui, sans-serif', fontSize: '13px', + display: 'flex', alignItems: 'center', pointerEvents: 'auto', + height: '36px', overflow: 'hidden', maxWidth: '100vw', + transform: 'translateY(-100%)', + transition: 'transform 0.4s cubic-bezier(0.16, 1, 0.3, 1)', + }); + requestAnimationFrame(() => requestAnimationFrame(() => { + banner.style.transform = 'translateY(0)'; + })); + + // Scrollable findings area + const scrollArea = document.createElement('div'); + Object.assign(scrollArea.style, { + flex: '1', minWidth: '0', overflowX: 'auto', overflowY: 'hidden', + display: 'flex', gap: '8px', alignItems: 'center', + padding: '0 12px', scrollSnapType: 'x mandatory', + scrollbarWidth: 'none', + }); + for (const f of findings) { + const prefix = RULE_CATEGORY[f.type] === 'slop' ? '\u2726 ' : ''; + const tag = document.createElement('span'); + tag.textContent = `${prefix}${TYPE_LABELS[f.type] || f.type}: ${f.detail}`; + Object.assign(tag.style, { + background: 'rgba(255,255,255,0.15)', padding: '2px 8px', + borderRadius: '3px', fontSize: '12px', fontFamily: 'ui-monospace, monospace', + whiteSpace: 'nowrap', flexShrink: '0', scrollSnapAlign: 'start', + }); + scrollArea.appendChild(tag); + } + banner.appendChild(scrollArea); + + // Controls area (only in standalone mode, not extension) + if (!EXTENSION_MODE) { + const controls = document.createElement('div'); + Object.assign(controls.style, { + display: 'flex', alignItems: 'center', gap: '2px', + padding: '0 8px', flexShrink: '0', + }); + + // Toggle visibility button + const toggle = document.createElement('button'); + toggle.textContent = '\u25C9'; // circle with dot (visible state) + toggle.title = 'Toggle overlay visibility'; + Object.assign(toggle.style, { + background: 'none', border: 'none', + color: 'white', fontSize: '16px', cursor: 'pointer', padding: '0 4px', + opacity: '0.85', transition: 'opacity 0.15s', + }); + let overlaysVisible = true; + toggle.addEventListener('click', () => { + overlaysVisible = !overlaysVisible; + document.body.classList.toggle('impeccable-hidden', !overlaysVisible); + toggle.textContent = overlaysVisible ? '\u25C9' : '\u25CB'; // filled vs empty circle + toggle.style.opacity = overlaysVisible ? '0.85' : '0.5'; + }); + controls.appendChild(toggle); + + // Close button + const close = document.createElement('button'); + close.textContent = '\u00d7'; + close.title = 'Dismiss banner'; + Object.assign(close.style, { + background: 'none', border: 'none', + color: 'white', fontSize: '18px', cursor: 'pointer', padding: '0 4px', + }); + close.addEventListener('click', () => banner.remove()); + controls.appendChild(close); + + banner.appendChild(controls); + } + document.body.appendChild(banner); + overlays.push(banner); + }; + + // Heuristic for skipping CSS-in-JS hashed class names like "css-1a2b3c" or "_2x4hG_". + // These change between builds and produce brittle, ugly selectors. + function isLikelyHashedClass(c) { + if (!c) return true; + if (/^(css|sc|emotion|jsx|module)-[\w-]{4,}$/i.test(c)) return true; + if (/^_[\w-]{5,}$/.test(c)) return true; + if (/^[a-z0-9]{6,}$/i.test(c) && /\d/.test(c)) return true; + return false; + } + + function buildSelectorSegment(el) { + const tag = el.tagName.toLowerCase(); + let sel = tag; + + if (el.classList && el.classList.length > 0) { + const classes = [...el.classList] + .filter(c => !c.startsWith('impeccable-') && !isLikelyHashedClass(c)) + .slice(0, 2); + if (classes.length > 0) { + sel += '.' + classes.map(c => CSS.escape(c)).join('.'); + } + } + + // Disambiguate among siblings only if the parent has multiple matches + const parent = el.parentElement; + if (parent) { + try { + const matching = parent.querySelectorAll(':scope > ' + sel); + if (matching.length > 1) { + const sameType = [...parent.children].filter(c => c.tagName === el.tagName); + const idx = sameType.indexOf(el) + 1; + sel += `:nth-of-type(${idx})`; + } + } catch { + const idx = [...parent.children].indexOf(el) + 1; + sel = `${tag}:nth-child(${idx})`; + } + } + return sel; + } + + function generateSelector(el) { + if (el === document.body) return 'body'; + if (el === document.documentElement) return 'html'; + if (el.id) return '#' + CSS.escape(el.id); + + const parts = []; + let current = el; + let depth = 0; + const MAX_DEPTH = 10; + + while (current && current !== document.body && current !== document.documentElement && depth < MAX_DEPTH) { + parts.unshift(buildSelectorSegment(current)); + + // Anchor on an ancestor's ID and stop walking up + if (current.id) { + parts[0] = '#' + CSS.escape(current.id); + break; + } + + // Stop as soon as the partial selector uniquely identifies the target + const trySelector = parts.join(' > '); + try { + const matches = document.querySelectorAll(trySelector); + if (matches.length === 1 && matches[0] === el) { + return trySelector; + } + } catch { /* invalid selector — keep walking */ } + + current = current.parentElement; + depth++; + } + + return parts.join(' > '); + } + + function getDirectText(el) { + return [...el.childNodes] + .filter(n => n.nodeType === 3) + .map(n => n.textContent || '') + .join(''); + } + + function getDirectTextRect(el) { + const rects = []; + for (const node of el.childNodes) { + if (node.nodeType !== 3 || !(node.textContent || '').trim()) continue; + const range = document.createRange(); + range.selectNodeContents(node); + for (const rect of range.getClientRects()) { + if (rect.width >= 1 && rect.height >= 1) rects.push(rect); + } + range.detach?.(); + } + if (rects.length === 0) return null; + const left = Math.min(...rects.map(r => r.left)); + const top = Math.min(...rects.map(r => r.top)); + const right = Math.max(...rects.map(r => r.right)); + const bottom = Math.max(...rects.map(r => r.bottom)); + return { + left, + top, + right, + bottom, + width: right - left, + height: bottom - top, + x: left, + y: top, + }; + } + + function collectVisualContrastReasons(el, style) { + const reasons = new Set(); + const bgClip = style.webkitBackgroundClip || style.backgroundClip || ''; + const ownBgImage = style.backgroundImage || ''; + if (bgClip === 'text' && ownBgImage && ownBgImage !== 'none') { + reasons.add('background-clip text'); + } + if (style.textShadow && style.textShadow !== 'none') reasons.add('text shadow'); + + let current = el; + while (current && current.nodeType === 1) { + const tag = current.tagName?.toLowerCase(); + const currentStyle = getComputedStyle(current); + const bgImage = currentStyle.backgroundImage || ''; + const isDocumentSurface = tag === 'body' || tag === 'html'; + + if (!isDocumentSurface && bgImage && bgImage !== 'none') { + if (/url\s*\(/i.test(bgImage)) reasons.add('image background'); + if (/gradient/i.test(bgImage)) reasons.add('gradient background'); + } + if (parseFloat(currentStyle.opacity) < 0.99) reasons.add('opacity stack'); + if (currentStyle.mixBlendMode && currentStyle.mixBlendMode !== 'normal') reasons.add('blend mode'); + if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter'); + if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter'); + + const solidBg = parseRgb(currentStyle.backgroundColor); + if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break; + current = current.parentElement; + } + + const sampleRect = getDirectTextRect(el) || el.getBoundingClientRect(); + if (sampleRect && document.elementsFromPoint) { + const points = [ + [sampleRect.left + sampleRect.width / 2, sampleRect.top + sampleRect.height / 2], + [sampleRect.left + Math.min(sampleRect.width - 1, Math.max(1, sampleRect.width * 0.25)), sampleRect.top + sampleRect.height / 2], + [sampleRect.left + Math.min(sampleRect.width - 1, Math.max(1, sampleRect.width * 0.75)), sampleRect.top + sampleRect.height / 2], + ]; + for (const [x, y] of points) { + if (x < 0 || y < 0 || x > window.innerWidth || y > window.innerHeight) continue; + const stack = document.elementsFromPoint(x, y); + const selfIndex = stack.findIndex(node => node === el || el.contains(node) || node.contains?.(el)); + if (selfIndex < 0) continue; + for (const node of stack.slice(selfIndex + 1)) { + const nodeTag = node.tagName?.toLowerCase(); + if (nodeTag === 'img' || nodeTag === 'picture' || nodeTag === 'video' || nodeTag === 'canvas' || nodeTag === 'svg') { + reasons.add(`${nodeTag} underlay`); + break; + } + } + } + } + + return [...reasons]; + } + + function collectVisualContrastCandidates(options = {}) { + const maxCandidates = Number.isFinite(options.maxCandidates) ? options.maxCandidates : 12; + const candidates = []; + for (const el of document.querySelectorAll('*')) { + if (candidates.length >= maxCandidates) break; + if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; + if (el.closest('[id^="impeccable-live-"]')) continue; + if (el === document.body || el === document.documentElement) continue; + if (!isRenderedForBrowserRule(el)) continue; + + const tag = el.tagName.toLowerCase(); + const style = getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') continue; + const directText = getDirectText(el); + const hasDirectText = directText.trim().length > 0; + if (!hasDirectText || isEmojiOnlyText(directText)) continue; + + const bgColor = readOwnBackgroundColor(el, style); + const isStyledButton = (tag === 'a' || tag === 'button') + && bgColor && bgColor.a > 0.5; + if (SAFE_TAGS.has(tag) && !isStyledButton) continue; + + const rect = getDirectTextRect(el) || el.getBoundingClientRect(); + if (!rect || rect.width < 4 || rect.height < 4) continue; + + const reasons = collectVisualContrastReasons(el, style); + if (reasons.length === 0) continue; + + const textColor = parseRgb(style.color); + const fontSize = parseFloat(style.fontSize) || 16; + const fontWeight = parseInt(style.fontWeight) || 400; + const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700); + const threshold = isLargeText ? 3.0 : 4.5; + const clip = { + x: Math.max(0, Math.floor(rect.left + window.scrollX - 2)), + y: Math.max(0, Math.floor(rect.top + window.scrollY - 2)), + width: Math.max(1, Math.ceil(rect.width + 4)), + height: Math.max(1, Math.ceil(rect.height + 4)), + }; + + candidates.push({ + selector: generateSelector(el), + tagName: tag, + text: directText.trim().replace(/\s+/g, ' ').slice(0, 80), + threshold, + reasons, + clip, + textColor, + preferRenderedForeground: !textColor || textColor.a < 0.99 || reasons.some(reason => + reason === 'opacity stack' || + reason === 'blend mode' || + reason === 'filter' || + reason === 'backdrop filter' || + reason === 'background-clip text' + ), + backgroundClipText: reasons.includes('background-clip text'), + }); + } + return candidates; + } + + const visualContrastImageCache = new Map(); + const visualContrastRasterCache = new WeakMap(); + + function clampByte(value) { + return Math.max(0, Math.min(255, Math.round(value))); + } + + function blendRgba(fg, bg) { + if (!fg) return bg || null; + if (!bg || fg.a == null || fg.a >= 0.999) { + return { r: clampByte(fg.r), g: clampByte(fg.g), b: clampByte(fg.b), a: fg.a == null ? 1 : fg.a }; + } + const alpha = Math.max(0, Math.min(1, fg.a)); + return { + r: clampByte(fg.r * alpha + bg.r * (1 - alpha)), + g: clampByte(fg.g * alpha + bg.g * (1 - alpha)), + b: clampByte(fg.b * alpha + bg.b * (1 - alpha)), + a: 1, + }; + } + + function pickWorstContrastColor(textColor, colors) { + const usable = (colors || []).filter(Boolean); + if (!usable.length) return null; + let worst = usable[0]; + let worstRatio = contrastRatio(textColor, worst); + for (const color of usable.slice(1)) { + const ratio = contrastRatio(textColor, color); + if (ratio < worstRatio) { + worst = color; + worstRatio = ratio; + } + } + return worst; + } + + function firstCssUrl(value) { + const match = String(value || '').match(/url\((?:"([^"]+)"|'([^']+)'|([^)]*))\)/i); + if (!match) return ''; + return (match[1] || match[2] || match[3] || '').trim(); + } + + function getLayerValue(value, index = 0) { + return String(value || '').split(',')[index]?.trim() || ''; + } + + function parsePositionToken(token, container, painted) { + if (!token || token === 'center') return (container - painted) / 2; + if (token === 'left' || token === 'top') return 0; + if (token === 'right' || token === 'bottom') return container - painted; + if (/%$/.test(token)) { + const pct = parseFloat(token) / 100; + return (container - painted) * pct; + } + if (/px$/.test(token)) return parseFloat(token) || 0; + return (container - painted) / 2; + } + + function parsePositionPair(positionValue) { + const tokens = String(positionValue || '50% 50%').trim().split(/\s+/).filter(Boolean); + const first = tokens[0] || '50%'; + if (tokens.length < 2) { + if (first === 'top' || first === 'bottom') return ['50%', first]; + return [first, '50%']; + } + return [first, tokens[1] || '50%']; + } + + function resolvePaintedImageRect(containerRect, image, sizeValue, positionValue) { + const intrinsicWidth = image.naturalWidth || image.videoWidth || image.width || 1; + const intrinsicHeight = image.naturalHeight || image.videoHeight || image.height || 1; + let paintedWidth = intrinsicWidth; + let paintedHeight = intrinsicHeight; + const size = String(sizeValue || 'auto').trim(); + + if (size === 'cover' || size === 'contain') { + const scale = size === 'cover' + ? Math.max(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight) + : Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight); + paintedWidth = intrinsicWidth * scale; + paintedHeight = intrinsicHeight * scale; + } else if (size && size !== 'auto') { + const parts = size.split(/\s+/); + const widthToken = parts[0]; + const heightToken = parts[1] || 'auto'; + if (/%$/.test(widthToken)) paintedWidth = containerRect.width * (parseFloat(widthToken) / 100); + else if (/px$/.test(widthToken)) paintedWidth = parseFloat(widthToken) || paintedWidth; + if (heightToken === 'auto') paintedHeight = paintedWidth * (intrinsicHeight / intrinsicWidth); + else if (/%$/.test(heightToken)) paintedHeight = containerRect.height * (parseFloat(heightToken) / 100); + else if (/px$/.test(heightToken)) paintedHeight = parseFloat(heightToken) || paintedHeight; + } + + const [xToken, yToken] = parsePositionPair(positionValue); + const positionX = parsePositionToken(xToken, containerRect.width, paintedWidth); + const positionY = parsePositionToken(yToken, containerRect.height, paintedHeight); + return { + left: containerRect.left + positionX, + top: containerRect.top + positionY, + width: paintedWidth, + height: paintedHeight, + intrinsicWidth, + intrinsicHeight, + }; + } + + function parseObjectPosition(positionValue) { + return parsePositionPair(positionValue); + } + + function resolveObjectImageRect(containerRect, image, style) { + const intrinsicWidth = image.naturalWidth || image.videoWidth || image.width || 1; + const intrinsicHeight = image.naturalHeight || image.videoHeight || image.height || 1; + const fit = style.objectFit || 'fill'; + let paintedWidth = containerRect.width; + let paintedHeight = containerRect.height; + if (fit === 'contain' || fit === 'cover') { + const scale = fit === 'cover' + ? Math.max(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight) + : Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight); + paintedWidth = intrinsicWidth * scale; + paintedHeight = intrinsicHeight * scale; + } else if (fit === 'none') { + paintedWidth = intrinsicWidth; + paintedHeight = intrinsicHeight; + } else if (fit === 'scale-down') { + const containScale = Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight, 1); + paintedWidth = intrinsicWidth * containScale; + paintedHeight = intrinsicHeight * containScale; + } + const [xToken, yToken] = parseObjectPosition(style.objectPosition); + return { + left: containerRect.left + parsePositionToken(xToken, containerRect.width, paintedWidth), + top: containerRect.top + parsePositionToken(yToken, containerRect.height, paintedHeight), + width: paintedWidth, + height: paintedHeight, + intrinsicWidth, + intrinsicHeight, + }; + } + + function pointToImageSource(point, paintedRect) { + if ( + point.x < paintedRect.left || + point.y < paintedRect.top || + point.x > paintedRect.left + paintedRect.width || + point.y > paintedRect.top + paintedRect.height + ) { + return null; + } + return { + x: Math.max(0, Math.min(paintedRect.intrinsicWidth - 1, ((point.x - paintedRect.left) / paintedRect.width) * paintedRect.intrinsicWidth)), + y: Math.max(0, Math.min(paintedRect.intrinsicHeight - 1, ((point.y - paintedRect.top) / paintedRect.height) * paintedRect.intrinsicHeight)), + }; + } + + async function loadVisualContrastImage(src) { + if (!src) return null; + if (visualContrastImageCache.has(src)) return visualContrastImageCache.get(src); + const promise = new Promise(resolve => { + const img = new Image(); + let settled = false; + const finish = value => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(value); + }; + const timer = setTimeout(() => finish(null), 800); + try { + const absolute = new URL(src, location.href); + if (absolute.origin !== location.origin && absolute.protocol !== 'data:' && absolute.protocol !== 'blob:') { + img.crossOrigin = 'anonymous'; + } + } catch { + // Let the browser resolve unusual URLs itself. + } + img.onload = () => finish(img); + img.onerror = () => finish(null); + img.src = src; + }); + visualContrastImageCache.set(src, promise); + return promise; + } + + function sampleDrawablePixel(drawable, sourcePoint) { + if (visualContrastRasterCache.has(drawable)) { + const cached = visualContrastRasterCache.get(drawable); + if (!cached || !cached.ctx) return { status: 'unresolved', reason: cached?.reason || 'image sample failed' }; + try { + const x = Math.max(0, Math.min(cached.width - 1, Math.floor(sourcePoint.x * cached.scaleX))); + const y = Math.max(0, Math.min(cached.height - 1, Math.floor(sourcePoint.y * cached.scaleY))); + const data = cached.ctx.getImageData(x, y, 1, 1).data; + return { + status: 'sampled', + color: { r: data[0], g: data[1], b: data[2], a: data[3] / 255 }, + }; + } catch (err) { + return { + status: 'unresolved', + reason: /taint|cross-origin|Security/i.test(err?.message || '') ? 'tainted image' : 'image sample failed', + }; + } + } + + const canvas = document.createElement('canvas'); + const intrinsicWidth = drawable.naturalWidth || drawable.videoWidth || drawable.width || 1; + const intrinsicHeight = drawable.naturalHeight || drawable.videoHeight || drawable.height || 1; + const maxRasterSide = 640; + const scale = Math.min(1, maxRasterSide / Math.max(intrinsicWidth, intrinsicHeight)); + canvas.width = Math.max(1, Math.round(intrinsicWidth * scale)); + canvas.height = Math.max(1, Math.round(intrinsicHeight * scale)); + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) return { status: 'unresolved', reason: 'canvas unavailable' }; + try { + ctx.drawImage(drawable, 0, 0, canvas.width, canvas.height); + const cached = { + ctx, + width: canvas.width, + height: canvas.height, + scaleX: canvas.width / intrinsicWidth, + scaleY: canvas.height / intrinsicHeight, + }; + visualContrastRasterCache.set(drawable, cached); + const x = Math.max(0, Math.min(cached.width - 1, Math.floor(sourcePoint.x * cached.scaleX))); + const y = Math.max(0, Math.min(cached.height - 1, Math.floor(sourcePoint.y * cached.scaleY))); + const data = ctx.getImageData(x, y, 1, 1).data; + return { + status: 'sampled', + color: { r: data[0], g: data[1], b: data[2], a: data[3] / 255 }, + }; + } catch (err) { + const reason = /taint|cross-origin|Security/i.test(err?.message || '') ? 'tainted image' : 'image sample failed'; + visualContrastRasterCache.set(drawable, { ctx: null, reason }); + return { + status: 'unresolved', + reason, + }; + } + } + + async function sampleCssBackground(el, style, point, textColor) { + const rect = el.getBoundingClientRect(); + const bgImage = style.backgroundImage || ''; + if (bgImage && bgImage !== 'none') { + if (/gradient/i.test(bgImage)) { + const color = pickWorstContrastColor(textColor, parseGradientColors(bgImage)); + if (color) return { status: 'sampled', color, method: 'analytic-gradient' }; + } + if (/url\s*\(/i.test(bgImage)) { + const img = await loadVisualContrastImage(firstCssUrl(bgImage)); + if (!img) return { status: 'unresolved', reason: 'image unavailable' }; + const paintedRect = resolvePaintedImageRect( + rect, + img, + getLayerValue(style.backgroundSize) || 'auto', + getLayerValue(style.backgroundPosition) || '50% 50%', + ); + const sourcePoint = pointToImageSource(point, paintedRect); + if (!sourcePoint) return { status: 'unresolved', reason: 'point outside background image' }; + const sample = sampleDrawablePixel(img, sourcePoint); + if (sample.status === 'sampled') return { ...sample, method: 'canvas-background-image' }; + return sample; + } + } + const bg = parseRgb(style.backgroundColor); + if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' }; + return { status: 'unresolved', reason: 'no readable background' }; + } + + async function sampleImageElement(img, point) { + const rect = img.getBoundingClientRect(); + const style = getComputedStyle(img); + const paintedRect = resolveObjectImageRect(rect, img, style); + const sourcePoint = pointToImageSource(point, paintedRect); + if (!sourcePoint) return { status: 'unresolved', reason: 'point outside image' }; + const sample = sampleDrawablePixel(img, sourcePoint); + if (sample.status === 'sampled') return { ...sample, method: 'canvas-img-underlay' }; + + if (img.currentSrc || img.src) { + const loaded = await loadVisualContrastImage(img.currentSrc || img.src); + if (loaded) { + const loadedRect = { ...paintedRect, intrinsicWidth: loaded.naturalWidth || loaded.width || paintedRect.intrinsicWidth, intrinsicHeight: loaded.naturalHeight || loaded.height || paintedRect.intrinsicHeight }; + const loadedPoint = pointToImageSource(point, loadedRect); + if (loadedPoint) { + const loadedSample = sampleDrawablePixel(loaded, loadedPoint); + if (loadedSample.status === 'sampled') return { ...loadedSample, method: 'canvas-img-underlay' }; + } + } + } + return sample; + } + + function textSamplePoints(rect) { + const insetX = Math.min(12, Math.max(1, rect.width * 0.12)); + const insetY = Math.min(8, Math.max(1, rect.height * 0.22)); + const xs = rect.width < 28 + ? [rect.left + rect.width / 2] + : [rect.left + insetX, rect.left + rect.width / 2, rect.right - insetX]; + const ys = rect.height < 22 + ? [rect.top + rect.height / 2] + : [rect.top + insetY, rect.top + rect.height / 2, rect.bottom - insetY]; + const points = []; + for (const y of ys) { + for (const x of xs) { + if (x >= 0 && y >= 0 && x <= window.innerWidth && y <= window.innerHeight) points.push({ x, y }); + } + } + return points; + } + + async function sampleVisualBackgroundAtPoint(el, point, textColor, depth = 0) { + if (depth > 8) { + return { status: 'unresolved', reason: 'background stack too deep' }; + } + const stack = typeof document.elementsFromPoint === 'function' + ? document.elementsFromPoint(point.x, point.y) + : []; + const selfIndex = stack.findIndex(node => node === el || el.contains(node)); + const nodes = selfIndex >= 0 ? stack.slice(selfIndex) : [el, ...stack]; + const unresolved = []; + + for (const node of nodes) { + if (!node || node.nodeType !== 1) continue; + if (node.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; + const tag = node.tagName?.toLowerCase(); + if (tag === 'img') { + const sample = await sampleImageElement(node, point); + if (sample.status === 'sampled') return sample; + unresolved.push(sample.reason); + continue; + } + if (tag === 'canvas' || tag === 'video') { + const rect = node.getBoundingClientRect(); + const sourcePoint = pointToImageSource(point, { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + intrinsicWidth: node.width || node.videoWidth || rect.width, + intrinsicHeight: node.height || node.videoHeight || rect.height, + }); + if (sourcePoint) { + const sample = sampleDrawablePixel(node, sourcePoint); + if (sample.status === 'sampled') return { ...sample, method: `canvas-${tag}-underlay` }; + unresolved.push(sample.reason); + } + continue; + } + const style = getComputedStyle(node); + const sample = await sampleCssBackground(node, style, point, textColor); + if (sample.status === 'sampled') { + if (!sample.color || sample.color.a == null || sample.color.a >= 0.95) return sample; + const under = await sampleVisualBackgroundAtPoint(node.parentElement || document.body, point, textColor, depth + 1); + if (under.status === 'sampled') { + return { + status: 'sampled', + color: blendRgba(sample.color, under.color), + method: `${sample.method}+alpha`, + }; + } + return sample; + } + unresolved.push(sample.reason); + } + + return { + status: 'unresolved', + reason: [...new Set(unresolved.filter(Boolean))].slice(0, 3).join(', ') || 'no readable visual background', + }; + } + + async function analyzeVisualContrastCandidate(candidate) { + let el; + try { + el = document.querySelector(candidate.selector); + } catch { + return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' }; + } + if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' }; + if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' }; + + const blockingReason = (candidate.reasons || []).find(reason => + reason === 'background-clip text' || + reason === 'blend mode' || + reason === 'filter' || + reason === 'backdrop filter' || + reason === 'opacity stack' || + reason === 'text shadow' + ); + if (blockingReason) { + return { ...candidate, status: 'unresolved', confidence: 'none', reason: `${blockingReason} needs screenshot pixels` }; + } + + const style = getComputedStyle(el); + const textColor = parseRgb(style.color) || candidate.textColor; + if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' }; + + const rect = getDirectTextRect(el) || el.getBoundingClientRect(); + if (!rect || rect.width < 4 || rect.height < 4) { + return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing text rect' }; + } + + const points = textSamplePoints(rect); + if (points.length === 0) { + return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'text outside viewport' }; + } + + const ratios = []; + const methods = new Set(); + const unresolved = []; + for (const point of points) { + const sample = await sampleVisualBackgroundAtPoint(el, point, textColor); + if (sample.status !== 'sampled' || !sample.color) { + unresolved.push(sample.reason); + continue; + } + const fg = blendRgba(textColor, sample.color); + ratios.push(contrastRatio(fg, sample.color)); + if (sample.method) methods.add(sample.method); + } + + if (ratios.length < Math.min(3, points.length)) { + return { + ...candidate, + status: 'unresolved', + confidence: 'none', + samples: ratios.length, + reason: [...new Set(unresolved.filter(Boolean))].slice(0, 3).join(', ') || 'not enough readable samples', + }; + } + + ratios.sort((a, b) => a - b); + const pick = pct => ratios[Math.min(ratios.length - 1, Math.max(0, Math.floor((pct / 100) * ratios.length)))]; + const measuredRatio = pick(10); + const medianRatio = pick(50); + const status = measuredRatio < candidate.threshold ? 'fail' : 'pass'; + const method = [...methods].sort().join(', ') || 'browser-visual'; + const textLabel = candidate.text ? ` "${candidate.text}"` : ''; + const detail = `browser contrast ${measuredRatio.toFixed(1)}:1 median ${medianRatio.toFixed(1)}:1 (need ${candidate.threshold}:1) via ${method}${textLabel}`; + return { + ...candidate, + status, + confidence: method.includes('canvas-') ? 'high' : 'medium', + method, + ratio: measuredRatio, + medianRatio, + samples: ratios.length, + finding: status === 'fail' ? { id: 'low-contrast', snippet: detail } : null, + }; + } + + function waitForVisualPaint() { + return new Promise(resolve => { + requestAnimationFrame(() => requestAnimationFrame(resolve)); + }); + } + + async function analyzeVisualContrast(options = {}) { + const candidates = collectVisualContrastCandidates(options); + const results = []; + const shouldScrollOffscreen = options.scrollOffscreen === true; + const restoreScroll = { x: window.scrollX, y: window.scrollY }; + for (const candidate of candidates) { + if (shouldScrollOffscreen && (window.scrollX !== restoreScroll.x || window.scrollY !== restoreScroll.y)) { + window.scrollTo(restoreScroll.x, restoreScroll.y); + await waitForVisualPaint(); + } + let result = await analyzeVisualContrastCandidate(candidate); + if (shouldScrollOffscreen && result.status === 'unresolved' && result.reason === 'text outside viewport') { + let el = null; + try { + el = document.querySelector(candidate.selector); + } catch { + el = null; + } + if (el && typeof el.scrollIntoView === 'function') { + el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' }); + await waitForVisualPaint(); + result = await analyzeVisualContrastCandidate(candidate); + } + } + results.push(result); + } + if (shouldScrollOffscreen && (window.scrollX !== restoreScroll.x || window.scrollY !== restoreScroll.y)) { + window.scrollTo(restoreScroll.x, restoreScroll.y); + } + return results; + } + + function isElementHidden(el) { + if (!el || el === document.body || el === document.documentElement) return false; + if (typeof el.checkVisibility === 'function') return !el.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true }); + // Fallback: zero size or no offsetParent (covers display:none and detached subtrees) + return el.offsetWidth === 0 && el.offsetHeight === 0; + } + + function serializeFindings(allFindings) { + return allFindings.map(({ el, findings }) => ({ + selector: generateSelector(el), + tagName: el.tagName?.toLowerCase() || 'unknown', + rect: (el !== document.body && el !== document.documentElement && el.getBoundingClientRect) + ? el.getBoundingClientRect().toJSON() : null, + isPageLevel: el === document.body || el === document.documentElement, + isHidden: isElementHidden(el), + findings: findings.map(f => { + const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id)); + return { + type: f.type || f.id, + category: ap ? ap.category : 'quality', + severity: ap?.severity || 'warning', + detail: f.detail || f.snippet, + ignoreValue: f.ignoreValue || f.value || '', + name: ap ? ap.name : (f.type || f.id), + description: ap ? ap.description : '', + }; + }), + })); + } + + const printSummary = function(allFindings) { + if (allFindings.length === 0) { + console.log('%c[impeccable] No anti-patterns found.', 'color: #22c55e; font-weight: bold'); + return; + } + console.group( + `%c[impeccable] ${allFindings.length} anti-pattern${allFindings.length === 1 ? '' : 's'} found`, + 'color: oklch(84% 0.19 80.46); font-weight: bold' + ); + for (const { el, findings } of allFindings) { + for (const f of findings) { + console.log(`%c${f.type || f.id}%c ${f.detail || f.snippet}`, + 'color: oklch(84% 0.19 80.46); font-weight: bold', 'color: inherit', el); + } + } + console.groupEnd(); + }; + + function addBrowserFindings(groupMap, el, findings) { + if (!findings || findings.length === 0) return; + const existing = groupMap.get(el); + if (existing) existing.push(...findings); + else groupMap.set(el, [...findings]); + } + + function browserFindingsFromMap(groupMap) { + return [...groupMap.entries()].map(([el, findings]) => ({ el, findings })); + } + + const DESIGN_COLOR_TOLERANCE = 6; + const DESIGN_RADIUS_TOLERANCE_PX = 0.5; + const DESIGN_SKIP_TAGS = new Set(['head', 'title', 'meta', 'link', 'style', 'script', 'noscript', 'template', 'source']); + + function normalizeBrowserFontName(value) { + return String(value || '') + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + } + + function browserPrimaryFont(stack) { + if (!stack || /var\(/i.test(stack)) return ''; + return String(stack || '') + .split(',') + .map(normalizeBrowserFontName) + .find(font => font && !GENERIC_FONTS.has(font)) || ''; + } + + function browserDesignSystemConfig() { + const raw = window.__IMPECCABLE_CONFIG__?.designSystem; + if (!raw?.present) return null; + const allowedFonts = new Set((raw.allowedFonts || []).map(normalizeBrowserFontName).filter(Boolean)); + const allowedColors = (raw.allowedColors || []) + .filter(color => color && Number.isFinite(color.r) && Number.isFinite(color.g) && Number.isFinite(color.b)) + .map(color => ({ r: color.r, g: color.g, b: color.b })); + const allowedRadii = (raw.allowedRadii || []) + .map(Number) + .filter(px => Number.isFinite(px)); + return { + present: true, + hasFonts: raw.hasFonts === true && allowedFonts.size > 0, + allowedFonts, + hasColors: raw.hasColors === true && allowedColors.length > 0, + allowedColors, + hasRadii: raw.hasRadii === true && allowedRadii.length > 0, + allowedRadii, + hasPillRadius: raw.hasPillRadius === true, + }; + } + + function browserColorsClose(a, b) { + if (!a || !b) return false; + return Math.max( + Math.abs(a.r - b.r), + Math.abs(a.g - b.g), + Math.abs(a.b - b.b), + ) <= DESIGN_COLOR_TOLERANCE; + } + + function isBrowserDesignColorAllowed(raw, designSystem) { + if (!designSystem?.hasColors) return true; + const text = String(raw || '').trim().toLowerCase(); + if (!text || text === 'transparent' || text === 'currentcolor' || text === 'inherit' || text === 'initial') return true; + if (text.includes('var(')) return true; + const parsed = parseAnyColor(text); + if (!parsed) return true; + if ((parsed.a ?? 1) <= 0.05) return true; + return designSystem.allowedColors.some(color => browserColorsClose(parsed, color)); + } + + function isBrowserTransparentCss(value) { + const text = String(value || '').trim().toLowerCase(); + if (!text || text === 'transparent') return true; + const parsed = parseAnyColor(text); + return parsed ? (parsed.a ?? 1) <= 0.05 : false; + } + + function isBrowserDesignRadiusAllowed(raw, designSystem) { + if (!designSystem?.hasRadii) return true; + const text = String(raw || '').trim().toLowerCase(); + if (!text || text === '0' || text === 'none' || text === 'initial' || text === 'inherit') return true; + if (text.includes('var(') || text.includes('%')) return true; + const px = resolveLengthPx(text, 16); + if (px == null || !Number.isFinite(px) || px <= DESIGN_RADIUS_TOLERANCE_PX) return true; + if (designSystem.hasPillRadius && px >= 99) return true; + return designSystem.allowedRadii.some(allowed => Math.abs(allowed - px) <= DESIGN_RADIUS_TOLERANCE_PX); + } + + function browserRadiusTokens(value) { + return String(value || '') + .replace(/\s*\/\s*/g, ' ') + .split(/\s+/) + .map(token => token.trim()) + .filter(Boolean); + } + + function browserHasDirectText(el) { + return [...(el.childNodes || [])].some(node => node.nodeType === 3 && node.textContent.trim().length > 0); + } + + function browserSampleText(el) { + const text = String(el.textContent || '').replace(/\s+/g, ' ').trim(); + return text ? ` "${text.slice(0, 40)}"` : ''; + } + + function shouldSkipDesignElement(el) { + const tag = el.tagName?.toLowerCase?.() || ''; + return DESIGN_SKIP_TAGS.has(tag) || isElementHidden(el); + } + + function checkElementDesignSystemDOM(el, designSystem, seen) { + if (!designSystem?.present || shouldSkipDesignElement(el)) return []; + const findings = []; + const tag = el.tagName?.toLowerCase?.() || 'unknown'; + const style = getComputedStyle(el); + + if (designSystem.hasFonts && browserHasDirectText(el)) { + const font = browserPrimaryFont(style.fontFamily || ''); + if (font && !designSystem.allowedFonts.has(font) && !seen.fonts.has(font)) { + seen.fonts.add(font); + findings.push({ + type: 'design-system-font', + detail: `${tag}${browserSampleText(el)} uses ${font}; not declared in DESIGN.md typography`, + ignoreValue: font, + }); + } + } + + if (designSystem.hasColors) { + const colorChecks = []; + if (browserHasDirectText(el)) colorChecks.push(['text color', style.color]); + if (!isBrowserTransparentCss(style.backgroundColor)) colorChecks.push(['background', style.backgroundColor]); + for (const side of ['Top', 'Right', 'Bottom', 'Left']) { + if ((parseFloat(style[`border${side}Width`]) || 0) > 0) { + colorChecks.push([`border-${side.toLowerCase()}`, style[`border${side}Color`]]); + } + } + if ((parseFloat(style.outlineWidth) || 0) > 0) colorChecks.push(['outline', style.outlineColor]); + + for (const [kind, raw] of colorChecks) { + const label = String(raw || '').trim().replace(/\s+/g, ' '); + if (isBrowserDesignColorAllowed(label, designSystem)) continue; + const key = `${kind}:${label}`; + if (seen.colors.has(key)) continue; + seen.colors.add(key); + findings.push({ + type: 'design-system-color', + detail: `${kind} ${label} on ${tag}${browserSampleText(el)} is outside DESIGN.md colors`, + ignoreValue: label, + }); + } + } + + if (designSystem.hasRadii) { + for (const token of browserRadiusTokens(style.borderRadius || '')) { + if (isBrowserDesignRadiusAllowed(token, designSystem)) continue; + if (seen.radii.has(token)) continue; + seen.radii.add(token); + findings.push({ + type: 'design-system-radius', + detail: `border-radius ${token} on ${tag}${browserSampleText(el)} is outside the DESIGN.md rounded scale`, + ignoreValue: token, + }); + } + } + + return findings; + } + + function decodeBrowserGoogleFamily(value) { + const family = String(value || '').split(':')[0].replace(/\+/g, ' '); + try { + return decodeURIComponent(family); + } catch { + return family; + } + } + + function checkBrowserDesignSystemSources(designSystem, seen) { + if (!designSystem?.hasFonts) return []; + const findings = []; + for (const link of document.querySelectorAll('link[href*="fonts.googleapis.com/css"]')) { + const href = link.getAttribute('href') || ''; + for (const match of href.matchAll(/[?&]family=([^&]+)/g)) { + const display = decodeBrowserGoogleFamily(match[1]); + const font = normalizeBrowserFontName(display); + if (!font || designSystem.allowedFonts.has(font) || seen.fonts.has(font)) continue; + seen.fonts.add(font); + findings.push({ + type: 'design-system-font', + detail: `Google Fonts: ${display} is not declared in DESIGN.md typography`, + ignoreValue: display, + }); + } + } + return findings; + } + + function collectBrowserFindings() { + const groupMap = new Map(); + const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; + const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); + const designSystem = browserDesignSystemConfig(); + const designSeen = { fonts: new Set(), colors: new Set(), radii: new Set() }; + // Note: provider-gated rules (--gpt / --gemini) are NOT filtered here. In a + // real browser env (detector page, live overlay, extension) running every + // check is free, so we always surface them; the gating is purely a CLI + // output concern, applied in the Node engines' detect* return paths. + + for (const el of document.querySelectorAll('*')) { + // Skip impeccable's own elements and any descendants (overlays, labels, banner, nav buttons) + if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; + // Skip browser extension elements (Claude, etc.) + const elId = el.id || ''; + if (elId.startsWith('claude-') || elId.startsWith('cic-')) continue; + // Skip the impeccable live-mode overlay (highlight, tooltip, bar, picker, toast). + // These are inspector chrome, not part of the user's design. + if (el.closest('[id^="impeccable-live-"]')) continue; + // Skip html/body -- page-level findings go in the banner, not a full-page overlay + if (el === document.body || el === document.documentElement) continue; + + const findings = [ + ...checkElementBordersDOM(el).map(f => ({ type: f.id, detail: f.snippet })), + ...checkElementColorsDOM(el).map(f => ({ type: f.id, detail: f.snippet })), + ...checkElementMotionDOM(el).map(f => ({ type: f.id, detail: f.snippet })), + ...checkElementGlowDOM(el).map(f => ({ type: f.id, detail: f.snippet })), + ...checkElementAIPaletteDOM(el).map(f => ({ type: f.id, detail: f.snippet })), + ...checkElementIconTileDOM(el).map(f => ({ type: f.id, detail: f.snippet })), + ...checkElementItalicSerifDOM(el).map(f => ({ type: f.id, detail: f.snippet })), + ...checkElementQualityDOM(el).map(f => ({ type: f.id, detail: f.snippet })), + ...checkElementOversizedH1DOM(el).map(f => ({ type: f.id, detail: f.snippet })), + ...checkElementClippedOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })), + ...checkElementGptBorderShadowDOM(el).map(f => ({ type: f.id, detail: f.snippet })), + ...checkElementTextOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })), + ...checkElementDesignSystemDOM(el, designSystem, designSeen), + ].filter(f => _ruleOk(f.type)); + + addBrowserFindings(groupMap, el, findings); + + // Hero eyebrow: the offending element is the eyebrow above the heading, + // not the heading itself — highlight the previous sibling instead. + const eyebrowFindings = checkElementHeroEyebrowDOM(el) + .map(f => ({ type: f.id, detail: f.snippet })) + .filter(f => _ruleOk(f.type)); + if (eyebrowFindings.length > 0 && el.previousElementSibling) { + addBrowserFindings(groupMap, el.previousElementSibling, eyebrowFindings); + } + } + + const pageLevelFindings = []; + + const designSourceFindings = checkBrowserDesignSystemSources(designSystem, designSeen) + .filter(f => _ruleOk(f.type)); + if (designSourceFindings.length > 0) { + pageLevelFindings.push(...designSourceFindings); + addBrowserFindings(groupMap, document.body, designSourceFindings); + } + + const typoFindings = checkTypography().filter(f => _ruleOk(f.type)); + if (typoFindings.length > 0) { + pageLevelFindings.push(...typoFindings); + addBrowserFindings(groupMap, document.body, typoFindings); + } + + const sectionKickerFindings = checkRepeatedSectionKickersDOM() + .map(f => ({ type: f.id, detail: f.snippet })) + .filter(f => _ruleOk(f.type)); + if (sectionKickerFindings.length > 0) { + pageLevelFindings.push(...sectionKickerFindings); + addBrowserFindings(groupMap, document.body, sectionKickerFindings); + } + + const layoutFindings = checkLayout().filter(f => _ruleOk(f.type)); + for (const f of layoutFindings) { + const el = f.el || document.body; + addBrowserFindings(groupMap, el, [{ type: f.type, detail: f.detail || f.snippet }]); + } + + // Page-level quality checks (headings, etc.) + const qualityFindings = checkPageQualityDOM().filter(f => _ruleOk(f.type)); + if (qualityFindings.length > 0) { + pageLevelFindings.push(...qualityFindings); + addBrowserFindings(groupMap, document.body, qualityFindings); + } + + const creamFindings = checkCreamPalette(document) + .map(f => ({ type: f.id, detail: f.snippet })) + .filter(f => _ruleOk(f.type)); + if (creamFindings.length > 0) { + pageLevelFindings.push(...creamFindings); + addBrowserFindings(groupMap, document.body, creamFindings); + } + + // Regex-on-HTML checks (shared with Node) + // Clone the document and strip impeccable-live overlay nodes before the + // regex scan, so the inspector's own inline styles (transitions on top/ + // left/width/height, etc.) don't register as page anti-patterns. + const docClone = document.documentElement.cloneNode(true); + for (const node of docClone.querySelectorAll('[id^="impeccable-live-"]')) { + node.remove(); + } + const htmlPatternFindings = checkHtmlPatterns(docClone.outerHTML); + if (htmlPatternFindings.length > 0) { + const mapped = htmlPatternFindings.map(f => ({ type: f.id, detail: f.snippet })).filter(f => _ruleOk(f.type)); + pageLevelFindings.push(...mapped); + addBrowserFindings(groupMap, document.body, mapped); + } + + return { + groupMap, + allFindings: browserFindingsFromMap(groupMap), + pageLevelFindings, + }; + } + + function shouldRunVisualContrast(options = {}) { + return options.visualContrast === true || window.__IMPECCABLE_CONFIG__?.visualContrast === true; + } + + function visualContrastOptions(options = {}) { + const config = window.__IMPECCABLE_CONFIG__ || {}; + const scrollOffscreen = typeof options.scrollOffscreen === 'boolean' + ? options.scrollOffscreen + : typeof options.visualContrastScrollOffscreen === 'boolean' + ? options.visualContrastScrollOffscreen + : typeof config.visualContrastScrollOffscreen === 'boolean' + ? config.visualContrastScrollOffscreen + : false; + return { + ...options, + maxCandidates: Number.isFinite(options.visualContrastMaxCandidates) + ? options.visualContrastMaxCandidates + : Number.isFinite(options.maxCandidates) + ? options.maxCandidates + : Number.isFinite(config.visualContrastMaxCandidates) + ? config.visualContrastMaxCandidates + : undefined, + scrollOffscreen, + }; + } + + let lastVisualContrastAnalyses = []; + let lazyVisualContrastObserver = null; + let lazyVisualContrastPending = new WeakMap(); + const lazyVisualContrastResolving = new WeakSet(); + let scanGeneration = 0; + + function rememberVisualContrastAnalysis(result) { + if (!result?.selector) { + lastVisualContrastAnalyses.push(result); + return; + } + const idx = lastVisualContrastAnalyses.findIndex(item => item.selector === result.selector); + if (idx >= 0) lastVisualContrastAnalyses[idx] = result; + else lastVisualContrastAnalyses.push(result); + } + + function disconnectLazyVisualContrastObserver() { + if (lazyVisualContrastObserver) { + lazyVisualContrastObserver.disconnect(); + lazyVisualContrastObserver = null; + } + lazyVisualContrastPending = new WeakMap(); + } + + function addVisualContrastResult(groupMap, result, options = {}) { + if (result.status !== 'fail' || !result.finding || !result.selector) return false; + let el = null; + try { + el = document.querySelector(result.selector); + } catch { + el = null; + } + if (!el) return false; + const findingType = result.finding.type || result.finding.id || 'low-contrast'; + const existing = groupMap.get(el) || []; + if (existing.some(f => (f.type || f.id) === findingType)) return false; + addBrowserFindings(groupMap, el, [{ + type: findingType, + detail: result.finding.detail || result.finding.snippet, + }]); + if (options.decorate && el !== document.body && el !== document.documentElement) { + highlight(el, groupMap.get(el) || []); + } + return true; + } + + function scanResultMeta(options = {}) { + const scanId = options.scanId; + if (typeof scanId !== 'string' && typeof scanId !== 'number') return {}; + return { scanId: String(scanId) }; + } + + function postSerializedFindings(groupMap, options = {}) { + if (!EXTENSION_MODE) return; + const allFindings = browserFindingsFromMap(groupMap); + window.postMessage({ + source: 'impeccable-results', + findings: serializeFindings(allFindings), + count: allFindings.length, + ...scanResultMeta(options), + }, '*'); + } + + function postExtensionError(err) { + if (!EXTENSION_MODE) return; + window.postMessage({ + source: 'impeccable-error', + message: err?.message || String(err), + }, '*'); + } + + function reportVisualContrastError(err, detail = {}) { + window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-error', { + detail: { + ...detail, + message: err?.message || String(err), + }, + })); + if (EXTENSION_MODE) { + postExtensionError(err); + } else { + console.warn('[impeccable] visual contrast scan failed', err); + } + } + + function scheduleLazyVisualContrast(groupMap, analyses, options = {}, runtime = {}) { + disconnectLazyVisualContrastObserver(); + if (options.visualContrastLazy === false || options.scrollOffscreen !== false) return; + if (typeof IntersectionObserver === 'undefined') return; + const unresolved = (analyses || []).filter(result => + result?.status === 'unresolved' && + result.reason === 'text outside viewport' && + result.selector + ); + if (unresolved.length === 0) return; + const generation = runtime.generation || scanGeneration; + + lazyVisualContrastObserver = new IntersectionObserver((entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + const el = entry.target; + const candidate = lazyVisualContrastPending.get(el); + if (!candidate || lazyVisualContrastResolving.has(el)) continue; + lazyVisualContrastObserver?.unobserve(el); + lazyVisualContrastPending.delete(el); + lazyVisualContrastResolving.add(el); + waitForVisualPaint() + .then(() => analyzeVisualContrastCandidate(candidate)) + .then(result => { + if (generation !== scanGeneration) return; + rememberVisualContrastAnalysis(result); + const added = addVisualContrastResult(groupMap, result, { decorate: true }); + if (added) { + postSerializedFindings(groupMap, options); + window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-resolved', { + detail: { + selector: result.selector, + status: result.status, + finding: result.finding || null, + }, + })); + } + }) + .catch(err => { + reportVisualContrastError(err, { selector: candidate.selector }); + }) + .finally(() => { + lazyVisualContrastResolving.delete(el); + }); + } + }, { threshold: 0.5 }); + + for (const candidate of unresolved) { + let el = null; + try { + el = document.querySelector(candidate.selector); + } catch { + el = null; + } + if (!el) continue; + lazyVisualContrastPending.set(el, candidate); + lazyVisualContrastObserver.observe(el); + } + } + + async function addVisualContrastFindings(groupMap, options = {}, runtime = {}) { + if (!shouldRunVisualContrast(options)) { + lastVisualContrastAnalyses = []; + disconnectLazyVisualContrastObserver(); + return []; + } + const resolvedOptions = visualContrastOptions(options); + const analyses = await analyzeVisualContrast(resolvedOptions); + if (runtime.generation && runtime.generation !== scanGeneration) return analyses; + lastVisualContrastAnalyses = analyses; + for (const result of analyses) { + addVisualContrastResult(groupMap, result, { decorate: runtime.decorate }); + } + if (runtime.decorate || runtime.scheduleLazy) scheduleLazyVisualContrast(groupMap, analyses, resolvedOptions, runtime); + return analyses; + } + + async function collectBrowserFindingsAsync(options = {}, runtime = {}) { + const collected = collectBrowserFindings(); + await addVisualContrastFindings(collected.groupMap, options, runtime); + return { + ...collected, + allFindings: browserFindingsFromMap(collected.groupMap), + visualContrastAnalyses: lastVisualContrastAnalyses, + }; + } + + function clearOverlays() { + scanGeneration += 1; + disconnectLazyVisualContrastObserver(); + for (const o of [...overlays]) detachOverlay(o); + overlays.length = 0; + visibilityObserver.disconnect(); + overlayIndex = 0; + } + + function renderBrowserFindings(collected, options = {}) { + const { allFindings, pageLevelFindings } = collected; + + for (const { el, findings } of allFindings) { + if (el === document.body || el === document.documentElement) continue; + highlight(el, findings); + } + + if (pageLevelFindings.length > 0) { + showPageBanner(pageLevelFindings); + } + + if (!EXTENSION_MODE) printSummary(allFindings); + + // In extension mode, post serialized results for the DevTools panel + if (EXTENSION_MODE) { + window.postMessage({ + source: 'impeccable-results', + findings: serializeFindings(allFindings), + count: allFindings.length, + ...scanResultMeta(options), + }, '*'); + } + + // After this scan completes, all subsequent reveals are instant (no stagger, no animation) + setTimeout(() => { firstScanDone = true; }, 1000); + + return allFindings; + } + + let firstScanDone = false; + const scan = function(options = {}) { + clearOverlays(); + const generation = scanGeneration; + const collected = collectBrowserFindings(); + const allFindings = renderBrowserFindings(collected, options); + if (shouldRunVisualContrast(options)) { + addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) + .then(() => { + if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); + }) + .catch(err => { + reportVisualContrastError(err); + }); + } + return allFindings; + }; + + const scanAsync = async function(options = {}) { + clearOverlays(); + const generation = scanGeneration; + if (shouldRunVisualContrast(options)) { + const collected = await collectBrowserFindingsAsync(options, { generation, scheduleLazy: true }); + if (generation !== scanGeneration) return []; + return renderBrowserFindings(collected, options); + } + lastVisualContrastAnalyses = []; + return renderBrowserFindings(collectBrowserFindings(), options); + }; + + const detect = function(options = {}) { + lastVisualContrastAnalyses = []; + const { allFindings } = collectBrowserFindings(); + return options.serialize === false ? allFindings : serializeFindings(allFindings); + }; + + const detectAsync = async function(options = {}) { + if (shouldRunVisualContrast(options)) { + const { allFindings } = await collectBrowserFindingsAsync(options); + return options.serialize === false ? allFindings : serializeFindings(allFindings); + } + lastVisualContrastAnalyses = []; + const { allFindings } = collectBrowserFindings(); + return options.serialize === false ? allFindings : serializeFindings(allFindings); + }; + + if (EXTENSION_MODE) { + // Extension mode: listen for commands, don't auto-scan + window.addEventListener('message', (e) => { + if (e.source !== window || !e.data || e.data.source !== 'impeccable-command') return; + if (e.data.action === 'scan') { + if (e.data.config) window.__IMPECCABLE_CONFIG__ = e.data.config; + try { + scan(e.data.config || {}); + } catch (err) { + postExtensionError(err); + } + } + if (e.data.action === 'toggle-overlays') { + const visible = !document.body.classList.contains('impeccable-hidden'); + document.body.classList.toggle('impeccable-hidden', visible); + window.postMessage({ source: 'impeccable-overlays-toggled', visible: !visible }, '*'); + } + if (e.data.action === 'remove') { + clearOverlays(); + styleEl.remove(); + if (spotlightBackdrop) { spotlightBackdrop.remove(); spotlightBackdrop = null; } + document.body.classList.remove('impeccable-hidden'); + } + if (e.data.action === 'highlight') { + try { + const target = e.data.selector ? document.querySelector(e.data.selector) : null; + if (target) { + // Scroll first so positionOverlay reads the post-scroll rect + if (!isInViewport(target) && target.scrollIntoView) { + target.scrollIntoView({ behavior: 'instant', block: 'center' }); + } + for (const o of overlays) { + if (o.classList.contains('impeccable-banner')) continue; + const isMatch = o._targetEl === target; + o.classList.toggle('impeccable-spotlight', isMatch); + o.classList.toggle('impeccable-spotlight-dimmed', !isMatch); + if (isMatch) { + // Force the matching overlay visible immediately, don't wait for IntersectionObserver + o.style.display = ''; + o.style.animation = 'none'; + o.classList.add('impeccable-visible'); + o._revealed = true; + positionOverlay(o); + } + } + showSpotlight(target); + } + } catch { /* invalid selector */ } + } + if (e.data.action === 'unhighlight') { + hideSpotlight(); + for (const o of overlays) { + o.classList.remove('impeccable-spotlight'); + o.classList.remove('impeccable-spotlight-dimmed'); + } + } + }); + window.postMessage({ source: 'impeccable-ready' }, '*'); + } else { + if (window.__IMPECCABLE_CONFIG__?.autoScan !== false) { + const runAutoScan = () => { + try { + scan(); + } catch (err) { + console.warn('[impeccable] scan failed', err); + } + }; + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => setTimeout(runAutoScan, 100)); + } else { + setTimeout(runAutoScan, 100); + } + } + } + + window.impeccableDetect = detect; + window.impeccableDetectAsync = detectAsync; + window.impeccableScan = scan; + window.impeccableScanAsync = scanAsync; + window.impeccableCollectVisualContrastCandidates = collectVisualContrastCandidates; + window.impeccableAnalyzeVisualContrast = analyzeVisualContrast; + window.impeccableGetLastVisualContrastAnalyses = () => lastVisualContrastAnalyses.slice(); +} diff --git a/tools/design-detector/detector/cli/main.mjs b/tools/design-detector/detector/cli/main.mjs new file mode 100644 index 0000000..f70027b --- /dev/null +++ b/tools/design-detector/detector/cli/main.mjs @@ -0,0 +1,290 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { loadDesignSystemForCwd } from '../design-system.mjs'; +import { createBrowserDetector, detectUrl } from '../engines/browser/detect-url.mjs'; +import { detectHtml } from '../engines/static-html/detect-html.mjs'; +import { detectText } from '../engines/regex/detect-text.mjs'; +import { + filterDetectionFindings, + readDetectionConfig, + shouldIgnoreDetectionFile, +} from '../../lib/impeccable-config.mjs'; +import { + HTML_EXTENSIONS, + buildImportGraph, + detectFrameworkConfig, + isPortListening, + walkDir, +} from '../node/file-system.mjs'; + +// --------------------------------------------------------------------------- +// Output formatting +// --------------------------------------------------------------------------- + +function formatFindingSummary(count) { + return `${count} anti-pattern${count === 1 ? '' : 's'} found.`; +} + +function formatFindings(findings, jsonMode) { + if (jsonMode) return JSON.stringify(findings, null, 2); + + const grouped = {}; + for (const f of findings) { + if (!grouped[f.file]) grouped[f.file] = []; + grouped[f.file].push(f); + } + const out = []; + for (const [file, items] of Object.entries(grouped)) { + const importNote = items[0]?.importedBy?.length ? ` (imported by ${items[0].importedBy.join(', ')})` : ''; + out.push(`\n${file}${importNote}`); + for (const item of items) { + out.push(` ${item.line ? `line ${item.line}: ` : ''}[${item.antipattern}] ${item.snippet}`); + out.push(` → ${item.description}`); + } + } + out.push(`\n${formatFindingSummary(findings.length)}`); + return out.join('\n'); +} + +// --------------------------------------------------------------------------- +// Stdin handling +// --------------------------------------------------------------------------- + +async function handleStdin(options = {}) { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + const input = Buffer.concat(chunks).toString('utf-8'); + try { + const parsed = JSON.parse(input); + const fp = parsed?.tool_input?.file_path; + if (fp && fs.existsSync(fp)) { + return HTML_EXTENSIONS.has(path.extname(fp).toLowerCase()) + ? detectHtml(fp, options) : detectText(fs.readFileSync(fp, 'utf-8'), fp, options); + } + } catch { /* not JSON */ } + return detectText(input, '', options); +} + + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +async function confirm(question) { + const rl = (await import('node:readline')).default.createInterface({ + input: process.stdin, output: process.stderr, + }); + return new Promise((resolve) => { + rl.question(`${question} [Y/n] `, (answer) => { + rl.close(); + resolve(!answer || /^y(es)?$/i.test(answer.trim())); + }); + }); +} + +function printUsage() { + console.log(`Usage: impeccable detect [options] [file-or-dir-or-url...] + +Scan files or URLs for UI anti-patterns and design quality issues. + +Options: + --json Output results as JSON + --quiet In text mode, only print the final findings count + --gpt Also report GPT-specific provider tells (off by default) + --gemini Also report Gemini-specific provider tells (off by default) + --no-config Do not apply project config, detector ignores, inline + ignore comments, or DESIGN.md + --no-inline-ignores Do not honor in-file impeccable-disable* ignore comments + --no-design-system Do not load local DESIGN.md / .impeccable/design.json context + --help Show this help message + +Project config: + Respects .impeccable/config.json and .impeccable/config.local.json detector + settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues, + and detector.designSystem.enabled. + +Inline ignores: + In-file comments waive a finding where it lives and travel with the file: + + .brand { font-family: Inter } /* impeccable-disable-line overused-font */ + // impeccable-disable-next-line bounce-easing: intentional bounce + impeccable-disable applies to the whole file; -line / -next-line are scoped. + List one or more rule ids (comma-separated), or omit them / use * for all. + +Detection modes: + HTML files Static HTML/CSS analysis (default, catches linked CSS) + Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.) + URLs Puppeteer full browser rendering (auto-detected) + +Examples: + impeccable detect src/ + impeccable detect index.html + impeccable detect https://example.com + impeccable detect --json . + impeccable detect --no-config src/`); +} + +async function detectCli() { + let args = process.argv.slice(2).map(arg => { + if (arg === '-json') return '--json'; + if (arg === '-fast') return '--fast'; + return arg; + }); + if (args[0] === 'detect') args = args.slice(1); + const jsonMode = args.includes('--json'); + const quietMode = args.includes('--quiet'); + const helpMode = args.includes('--help'); + // --fast (regex-only) is deprecated: since the jsdom removal, the static + // HTML/CSS analysis is fast and covers every rule, so the regex-only path + // only loses coverage for no real speed win. Accept the flag for back-compat + // but ignore it and run the full scan. + if (args.includes('--fast')) { + process.stderr.write( + 'Note: --fast is deprecated and ignored. The full scan is fast now and runs every rule.\n', + ); + } + const configEnabled = !args.includes('--no-config'); + const detectionConfig = configEnabled + ? readDetectionConfig(process.cwd()) + : { ignoreRules: [], ignoreFiles: [], ignoreValues: [] }; + const providers = []; + if (args.includes('--gpt')) providers.push('gpt'); + if (args.includes('--gemini')) providers.push('gemini'); + const designSystemEnabled = configEnabled && !args.includes('--no-design-system') && detectionConfig.designSystem?.enabled !== false; + const designSystem = designSystemEnabled ? loadDesignSystemForCwd(process.cwd()) : null; + // Inline `impeccable-disable*` waivers are part of the scanned file, so they + // apply by default. `--no-config` (raw scan) and the dedicated + // `--no-inline-ignores` both turn them off. + const inlineIgnoresEnabled = configEnabled && !args.includes('--no-inline-ignores'); + const scanOptions = { providers, inlineIgnores: inlineIgnoresEnabled }; + if (designSystem) scanOptions.designSystem = designSystem; + const targets = args.filter(a => !a.startsWith('--')); + + if (helpMode) { printUsage(); process.exit(0); } + + let allFindings = []; + + if (!process.stdin.isTTY && targets.length === 0) { + allFindings = await handleStdin(scanOptions); + } else { + const paths = targets.length > 0 ? targets : [process.cwd()]; + const urlTargetCount = paths.filter(target => /^https?:\/\//i.test(target)).length; + const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null; + + try { + for (const target of paths) { + if (/^https?:\/\//i.test(target)) { + try { + const scanner = browserDetector + ? (url) => browserDetector.detectUrl(url, scanOptions) + : (url) => detectUrl(url, scanOptions); + allFindings.push(...await scanner(target)); + } catch (e) { process.stderr.write(`Error: ${e.message}\n`); } + continue; + } + + const resolved = path.resolve(target); + let stat; + try { stat = fs.statSync(resolved); } + catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; } + + if (stat.isDirectory()) { + // Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output) + if (!jsonMode && !quietMode) { + const fwConfig = detectFrameworkConfig(resolved); + if (fwConfig) { + const probe = await isPortListening(fwConfig.port, fwConfig.fingerprint); + if (probe.listening && probe.matched) { + process.stderr.write( + `\n${fwConfig.name} dev server detected on localhost:${fwConfig.port}.\n` + + `For more accurate results, scan the running site:\n` + + ` npx impeccable detect http://localhost:${fwConfig.port}\n\n` + ); + } else if (probe.listening && !probe.matched) { + process.stderr.write( + `\n${fwConfig.name} project detected (${path.basename(fwConfig.configPath)}).\n` + + `Port ${fwConfig.port} is in use by another service. Start the ${fwConfig.name} dev server and scan via URL for best results.\n\n` + ); + } else { + process.stderr.write( + `\n${fwConfig.name} project detected (${path.basename(fwConfig.configPath)}).\n` + + `Start the dev server and scan via URL for best results:\n` + + ` npx impeccable detect http://localhost:${fwConfig.port}\n\n` + ); + } + } + } + + const files = walkDir(resolved) + .filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig)); + const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length; + + // Warn and confirm if scanning many files (static HTML/CSS processes each HTML file) + if (files.length > 50 && process.stdin.isTTY && !jsonMode && !quietMode) { + process.stderr.write( + `\nFound ${files.length} files (${htmlCount} HTML) in ${target}.\n` + + `Scanning may take a while${htmlCount > 10 ? ' (static HTML/CSS processes each HTML file individually)' : ''}.\n` + + `Target a specific subdirectory to narrow scope.\n` + ); + const ok = await confirm('Continue?'); + if (!ok) { process.stderr.write('Aborted.\n'); process.exit(0); } + } + + // Build import graph for multi-file awareness + const graph = buildImportGraph(files); + // Build reverse map: file -> set of files that import it + const importedByMap = new Map(); + for (const [importer, imports] of graph) { + for (const imported of imports) { + if (!importedByMap.has(imported)) importedByMap.set(imported, new Set()); + importedByMap.get(imported).add(importer); + } + } + + for (const file of files) { + const ext = path.extname(file).toLowerCase(); + let fileFindings; + if (HTML_EXTENSIONS.has(ext)) { + fileFindings = await detectHtml(file, scanOptions); + } else { + fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file, scanOptions); + } + // Annotate findings with import context + const importers = importedByMap.get(file); + if (importers && importers.size > 0) { + const importerNames = [...importers].map(f => path.basename(f)); + for (const f of fileFindings) { + f.importedBy = importerNames; + } + } + allFindings.push(...fileFindings); + } + } else if (stat.isFile()) { + if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue; + const ext = path.extname(resolved).toLowerCase(); + if (HTML_EXTENSIONS.has(ext)) { + allFindings.push(...await detectHtml(resolved, scanOptions)); + } else { + allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved, scanOptions)); + } + } + } + } finally { + if (browserDetector) await browserDetector.close(); + } + } + + allFindings = filterDetectionFindings(allFindings, detectionConfig); + + if (allFindings.length > 0) { + if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n'); + else if (quietMode) process.stderr.write(formatFindingSummary(allFindings.length) + '\n'); + else process.stderr.write(formatFindings(allFindings, false) + '\n'); + process.exit(2); + } + if (jsonMode) process.stdout.write('[]\n'); + process.exit(0); +} + +export { formatFindings, handleStdin, confirm, printUsage, detectCli }; diff --git a/tools/design-detector/detector/design-system.mjs b/tools/design-detector/detector/design-system.mjs new file mode 100644 index 0000000..352148d --- /dev/null +++ b/tools/design-detector/detector/design-system.mjs @@ -0,0 +1,750 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { finding } from './findings.mjs'; +import { GENERIC_FONTS } from './shared/constants.mjs'; +import { parseAnyColor, resolveLengthPx } from './rules/checks.mjs'; + +const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md']; +const FALLBACK_DIRS = ['.agents/context', 'docs']; +const COLOR_CHANNEL_TOLERANCE = 6; +const RADIUS_TOLERANCE_PX = 0.5; + +const CSS_COLOR_RE = /#[0-9a-f]{3,8}\b|rgba?\([^)]+\)|oklch\([^)]+\)|hsla?\([^)]+\)/gi; +const FONT_DECL_RE = /font-family\s*:\s*([^;}\n]+)/gi; +const FONT_JS_RE = /fontFamily\s*[:=]\s*["'`]([^"'`]+)["'`]/g; +const GOOGLE_FONT_RE = /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi; +const BORDER_RADIUS_RE = /border-radius\s*:\s*([^;}\n]+)/gi; +const BORDER_RADIUS_JS_RE = /borderRadius\s*[:=]\s*["'`]([^"'`]+)["'`]/g; +const STATIC_DESIGN_SKIP_TAGS = new Set(['head', 'title', 'meta', 'link', 'style', 'script', 'noscript', 'template', 'source']); + +function firstExisting(dir, names) { + for (const name of names) { + const abs = path.join(dir, name); + if (fs.existsSync(abs)) return abs; + } + return null; +} + +function resolveDesignMdPath(cwd = process.cwd()) { + const root = firstExisting(cwd, DESIGN_NAMES); + if (root) return { path: root, contextDir: cwd }; + + for (const rel of FALLBACK_DIRS) { + const dir = path.resolve(cwd, rel); + const found = firstExisting(dir, DESIGN_NAMES); + if (found) return { path: found, contextDir: dir }; + } + + return null; +} + +function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) { + const candidates = [ + path.join(cwd, '.impeccable', 'design.json'), + path.join(cwd, 'DESIGN.json'), + path.join(contextDir, 'DESIGN.json'), + ]; + return candidates.find((candidate, index) => + candidates.indexOf(candidate) === index && fs.existsSync(candidate) + ) || null; +} + +function parseFrontmatter(md) { + const lines = String(md || '').split(/\r?\n/); + if (lines[0]?.trim() !== '---') return null; + let end = -1; + for (let i = 1; i < lines.length; i++) { + if (lines[i].trim() === '---') { end = i; break; } + } + if (end === -1) return null; + try { + return parseYamlSubset(lines.slice(1, end).join('\n')); + } catch { + return null; + } +} + +function parseYamlSubset(yaml) { + const root = {}; + const stack = [{ indent: -1, obj: root }]; + + for (const raw of String(yaml || '').split(/\r?\n/)) { + if (!raw.trim() || /^\s*#/.test(raw)) continue; + const indent = raw.match(/^\s*/)[0].length; + const content = raw.slice(indent); + const colonIdx = findTopLevelColon(content); + if (colonIdx === -1) continue; + + while (stack.length > 1 && stack[stack.length - 1].indent >= indent) stack.pop(); + + const key = unquoteYamlKey(content.slice(0, colonIdx).trim()); + const rest = stripInlineYamlComment(content.slice(colonIdx + 1).trim()); + const parent = stack[stack.length - 1].obj; + + if (rest === '') { + const obj = {}; + parent[key] = obj; + stack.push({ indent, obj }); + } else { + parent[key] = parseScalar(rest); + } + } + + return root; +} + +function findTopLevelColon(s) { + let inQuote = null; + for (let i = 0; i < s.length; i++) { + const ch = s[i]; + if (inQuote) { + if (ch === inQuote && s[i - 1] !== '\\') inQuote = null; + } else if (ch === '"' || ch === "'") { + inQuote = ch; + } else if (ch === ':') { + return i; + } + } + return -1; +} + +function unquoteYamlKey(key) { + if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) { + return key.slice(1, -1); + } + return key; +} + +function stripInlineYamlComment(s) { + let inQuote = null; + for (let i = 0; i < s.length; i++) { + const ch = s[i]; + if (inQuote) { + if (ch === inQuote && s[i - 1] !== '\\') inQuote = null; + } else if (ch === '"' || ch === "'") { + inQuote = ch; + } else if (ch === '#' && i > 0 && /\s/.test(s[i - 1])) { + return s.slice(0, i).trimEnd(); + } + } + return s; +} + +function parseScalar(raw) { + const s = raw.trim(); + if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) { + return s.slice(1, -1); + } + if (s === 'true') return true; + if (s === 'false') return false; + if (s === 'null' || s === '~') return null; + if (/^-?\d+$/.test(s)) return Number(s); + if (/^-?\d*\.\d+$/.test(s)) return Number(s); + return s; +} + +function safeReadJson(filePath) { + if (!filePath) return null; + try { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } catch { + return null; + } +} + +function normalizeFontName(value) { + return String(value || '') + .trim() + .replace(/\s*!important\s*$/i, '') + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); +} + +function splitFontStack(stack) { + return String(stack || '') + .replace(/\s*!important\s*$/i, '') + .split(',') + .map(normalizeFontName) + .filter(Boolean); +} + +function primaryFont(stack) { + if (!stack || /var\(/i.test(stack) || !isLiteralFontStack(stack)) return ''; + return splitFontStack(stack).find(font => !GENERIC_FONTS.has(font)) || ''; +} + +function isLiteralFontStack(stack) { + const text = String(stack || ''); + return !/[$`{}]|\s\+\s|\|\|/.test(text); +} + +function cssColorLabel(raw) { + return String(raw || '').trim().replace(/\s+/g, ' '); +} + +function colorKey(color) { + if (!color) return ''; + return `${color.r},${color.g},${color.b}`; +} + +function colorsClose(a, b) { + if (!a || !b) return false; + return Math.max( + Math.abs(a.r - b.r), + Math.abs(a.g - b.g), + Math.abs(a.b - b.b), + ) <= COLOR_CHANNEL_TOLERANCE; +} + +function hslToRgb(H, S, L, alpha = 1) { + const h = (((H % 360) + 360) % 360) / 360; + const s = Math.max(0, Math.min(1, S)); + const l = Math.max(0, Math.min(1, L)); + const hue2rgb = (p, q, t) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + return { + r: Math.round(hue2rgb(p, q, h + 1 / 3) * 255), + g: Math.round(hue2rgb(p, q, h) * 255), + b: Math.round(hue2rgb(p, q, h - 1 / 3) * 255), + a: alpha, + }; +} + +function parseDesignColor(value) { + const text = String(value || '').trim(); + const parsed = parseAnyColor(text); + if (parsed) return parsed; + const hsl = text.match(/hsla?\(\s*([-\d.]+)(?:deg)?\s*,?\s*([\d.]+)%\s*,?\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+))?\s*\)/i); + if (hsl) { + return hslToRgb( + parseFloat(hsl[1]), + parseFloat(hsl[2]) / 100, + parseFloat(hsl[3]) / 100, + hsl[4] !== undefined ? parseFloat(hsl[4]) : 1, + ); + } + return null; +} + +function addDesignColor(out, value, label) { + const parsed = parseDesignColor(value); + if (!parsed) return; + const key = colorKey(parsed); + if (!out.allowedColorKeys.has(key)) { + out.allowedColorKeys.set(key, { color: parsed, labels: [] }); + } + out.allowedColorKeys.get(key).labels.push(label || cssColorLabel(value)); +} + +function addColorObject(out, colors, prefix = 'colors') { + if (!colors || typeof colors !== 'object') return; + for (const [name, value] of Object.entries(colors)) { + if (typeof value === 'string') { + addDesignColor(out, value, `${prefix}.${name}`); + } + } +} + +function addSidecarColors(out, sidecar) { + const colorMeta = sidecar?.extensions?.colorMeta; + if (!colorMeta || typeof colorMeta !== 'object') return; + + for (const [name, meta] of Object.entries(colorMeta)) { + if (!meta || typeof meta !== 'object') continue; + if (typeof meta.canonical === 'string') addDesignColor(out, meta.canonical, `sidecar.${name}`); + if (Array.isArray(meta.tonalRamp)) { + for (const [index, value] of meta.tonalRamp.entries()) { + if (typeof value === 'string') addDesignColor(out, value, `sidecar.${name}.tonalRamp[${index}]`); + } + } + } +} + +function addTypographyFonts(out, typography) { + if (!typography || typeof typography !== 'object') return; + for (const role of Object.values(typography)) { + if (!role || typeof role !== 'object') continue; + if (typeof role.fontFamily !== 'string') continue; + for (const font of splitFontStack(role.fontFamily)) { + if (!GENERIC_FONTS.has(font)) out.allowedFonts.add(font); + } + } +} + +function addRoundedScale(out, rounded) { + if (!rounded || typeof rounded !== 'object') return; + for (const [rawName, value] of Object.entries(rounded)) { + const name = unquoteYamlKey(rawName).toLowerCase(); + addRoundedToken(out, name, value); + } +} + +function addRoundedToken(out, name, value) { + if (typeof value !== 'string' && typeof value !== 'number') return; + const raw = String(value).trim(); + if (!raw || /var\(/i.test(raw) || raw.includes('%')) return; + const px = resolveLengthPx(raw, 16); + if (px == null || !Number.isFinite(px)) return; + out.allowedRadii.push({ name, value: raw, px }); + if (/(^|\.)(full|pill|round|rounded-full)$/.test(name)) out.hasPillRadius = true; +} + +function addSidecarRadii(out, sidecar) { + const roundedMeta = sidecar?.extensions?.roundedMeta; + if (!roundedMeta || typeof roundedMeta !== 'object') return; + + for (const [rawName, meta] of Object.entries(roundedMeta)) { + const name = unquoteYamlKey(rawName).toLowerCase(); + if (typeof meta === 'string' || typeof meta === 'number') { + addRoundedToken(out, `sidecar.${name}`, meta); + continue; + } + if (!meta || typeof meta !== 'object') continue; + for (const key of ['canonical', 'value']) { + if (typeof meta[key] === 'string' || typeof meta[key] === 'number') { + addRoundedToken(out, `sidecar.${name}.${key}`, meta[key]); + } + } + for (const key of ['values', 'aliases']) { + if (!Array.isArray(meta[key])) continue; + for (const [index, value] of meta[key].entries()) { + addRoundedToken(out, `sidecar.${name}.${key}[${index}]`, value); + } + } + if (/^(full|pill|round|rounded-full)$/.test(name) || /^(full|pill|round)$/i.test(String(meta.role || ''))) { + out.hasPillRadius = true; + } + } +} + +function normalizeDesignSystem(input = {}) { + const frontmatter = input.frontmatter || {}; + const sidecar = input.sidecar || null; + const out = { + present: true, + sourcePath: input.sourcePath || null, + sidecarPath: input.sidecarPath || null, + mdNewerThanJson: input.mdNewerThanJson === true, + allowedFonts: new Set(), + allowedColorKeys: new Map(), + allowedRadii: [], + hasPillRadius: false, + }; + + addTypographyFonts(out, frontmatter.typography); + addColorObject(out, frontmatter.colors); + addSidecarColors(out, sidecar); + addRoundedScale(out, frontmatter.rounded); + addSidecarRadii(out, sidecar); + + out.hasFonts = out.allowedFonts.size > 0; + out.hasColors = out.allowedColorKeys.size > 0; + out.hasRadii = out.allowedRadii.length > 0; + return out; +} + +function loadDesignSystemForCwd(cwd = process.cwd()) { + const md = resolveDesignMdPath(cwd); + if (!md) return null; + + let frontmatter = null; + let mdStat = null; + try { + mdStat = fs.statSync(md.path); + frontmatter = parseFrontmatter(fs.readFileSync(md.path, 'utf-8')); + } catch { + return null; + } + if (!frontmatter || typeof frontmatter !== 'object') return null; + + const sidecarPath = resolveDesignSidecarPath(cwd, md.contextDir); + const sidecar = safeReadJson(sidecarPath); + let sidecarStat = null; + try { + if (sidecarPath) sidecarStat = fs.statSync(sidecarPath); + } catch { + sidecarStat = null; + } + + return normalizeDesignSystem({ + frontmatter, + sidecar, + sourcePath: md.path, + sidecarPath, + mdNewerThanJson: !!(mdStat && sidecarStat && mdStat.mtimeMs > sidecarStat.mtimeMs + 1000), + }); +} + +function isAllowedFont(font, designSystem) { + if (!font || GENERIC_FONTS.has(font)) return true; + if (!designSystem?.hasFonts) return true; + return designSystem.allowedFonts.has(font); +} + +function isAllowedColorRaw(raw, designSystem) { + if (!designSystem?.hasColors) return true; + const text = String(raw || '').trim().toLowerCase(); + if (!text || text === 'transparent' || text === 'currentcolor' || text === 'inherit' || text === 'initial') return true; + if (text.includes('var(')) return true; + const parsed = parseDesignColor(text); + if (!parsed) return true; + if ((parsed.a ?? 1) <= 0.05) return true; + for (const entry of designSystem.allowedColorKeys.values()) { + if (colorsClose(parsed, entry.color)) return true; + } + return false; +} + +function isAllowedRadiusRaw(raw, designSystem) { + if (!designSystem?.hasRadii) return true; + const text = String(raw || '').trim().toLowerCase(); + if (!text || text === '0' || text === 'none' || text === 'initial' || text === 'inherit') return true; + if (text.includes('var(') || text.includes('%')) return true; + const px = resolveLengthPx(text, 16); + if (px == null || !Number.isFinite(px) || px <= RADIUS_TOLERANCE_PX) return true; + if (designSystem.hasPillRadius && px >= 99) return true; + return designSystem.allowedRadii.some(entry => Math.abs(entry.px - px) <= RADIUS_TOLERANCE_PX); +} + +function lineLooksCommented(line) { + const trimmed = String(line || '').trim(); + return trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*') || trimmed.startsWith('/g, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' '); +} + +const PAGE_ANALYZER_EXTS = new Set(['.html', '.htm', '.astro', '.vue', '.svelte']); + +function extFromFilePath(filePath) { + return filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : ''; +} + +function shouldRunPageAnalyzers(content, filePath) { + if (!isFullPage(content)) return false; + const ext = extFromFilePath(filePath); + return !ext || PAGE_ANALYZER_EXTS.has(ext); +} + +function isNeutralBorderColor(str) { + const m = str.match(/solid\s+((?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color)\([^)]*\)|#[0-9a-f]{3,8}\b|[a-z]+)/i); + if (!m) return false; + const c = m[1].toLowerCase(); + if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true; + if (/^(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c); + const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/); + if (hex) { + const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)]; + return (Math.max(r, g, b) - Math.min(r, g, b)) < 30; + } + const shex = c.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/); + if (shex) { + const [r, g, b] = [parseInt(shex[1] + shex[1], 16), parseInt(shex[2] + shex[2], 16), parseInt(shex[3] + shex[3], 16)]; + return (Math.max(r, g, b) - Math.min(r, g, b)) < 30; + } + return false; +} + +const REGEX_MATCHERS = [ + // --- Side-tab --- + { id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g, + test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 2 : n >= 4; }, + fmt: (m) => m[0] }, + { id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi, + test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 2 : n >= 3; }, + fmt: (m) => m[0].replace(/\s*;?\s*$/, '') }, + { id: 'side-tab', regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi, + test: (m, line) => !isSafeElement(line) && +m[1] >= 3, + fmt: (m) => m[0] }, + { id: 'side-tab', regex: /border-inline-(?:start|end)\s*:\s*(\d+)px\s+solid/gi, + test: (m, line) => !isSafeElement(line) && +m[1] >= 3, + fmt: (m) => m[0] }, + { id: 'side-tab', regex: /border-inline-(?:start|end)-width\s*:\s*(\d+)px/gi, + test: (m, line) => !isSafeElement(line) && +m[1] >= 3, + fmt: (m) => m[0] }, + { id: 'side-tab', regex: /border(?:Left|Right)\s*[:=]\s*["'`](\d+)px\s+solid/g, + test: (m) => +m[1] >= 3, + fmt: (m) => m[0] }, + // --- Border accent on rounded --- + { id: 'border-accent-on-rounded', regex: /\bborder-[tb]-(\d+)\b/g, + test: (m, line) => hasRounded(line) && +m[1] >= 1, + fmt: (m) => m[0] }, + { id: 'border-accent-on-rounded', regex: /border-(?:top|bottom)\s*:\s*(\d+)px\s+solid/gi, + test: (m, line) => +m[1] >= 3 && hasBorderRadius(line), + fmt: (m) => m[0] }, + // --- Overused font --- + { id: 'overused-font', regex: /font-family\s*:\s*['"]?(Inter|Roboto|Open Sans|Lato|Montserrat|Arial|Helvetica|Fraunces|Geist Sans|Geist Mono|Geist|Mona Sans|Plus Jakarta Sans|Space Grotesk|Recoleta|Instrument Sans|Instrument Serif)\b/gi, + test: () => true, + fmt: (m) => m[0] }, + { id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?family=(Inter|Roboto|Open\+Sans|Lato|Montserrat|Fraunces|Plus\+Jakarta\+Sans|Space\+Grotesk|Instrument\+Sans|Instrument\+Serif|Mona\+Sans|Geist)\b/gi, + test: () => true, + fmt: (m) => `Google Fonts: ${m[1].replace(/\+/g, ' ')}` }, + // --- Gradient text --- + { id: 'gradient-text', regex: /background-clip\s*:\s*text|-webkit-background-clip\s*:\s*text/gi, + test: (m, line) => /gradient/i.test(line), + fmt: () => 'background-clip: text + gradient' }, + // --- Gradient text (Tailwind) --- + { id: 'gradient-text', regex: /\bbg-clip-text\b/g, + test: (m, line) => /\bbg-gradient-to-/i.test(line), + fmt: () => 'bg-clip-text + bg-gradient' }, + // --- Tailwind gray on colored bg --- + { id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g, + test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line), + fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } }, + // --- Tailwind AI palette --- + { id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g, + test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b| `${m[0]} on heading` }, + { id: 'ai-color-palette', regex: /\bfrom-(?:purple|violet|indigo)-(\d+)\b/g, + test: (m, line) => /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(line), + fmt: (m) => `${m[0]} gradient` }, + // --- Bounce/elastic easing --- + { id: 'bounce-easing', regex: /\banimate-bounce\b/g, + test: () => true, + fmt: () => 'animate-bounce (Tailwind)' }, + { id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi, + test: () => true, + fmt: (m) => { + const token = m[1] + .split(/[,\s]+/) + .find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part)); + return `animation: ${token || m[1].trim()}`; + } }, + { id: 'bounce-easing', regex: /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g, + test: (m) => { + const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]); + return y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1; + }, + fmt: (m) => `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` }, + // --- Layout property transition --- + { id: 'layout-transition', regex: /transition\s*:\s*([^;{}]+)/gi, + test: (m) => { + const val = m[1].toLowerCase(); + if (/\ball\b/.test(val)) return false; + return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val); + }, + fmt: (m) => { + const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi); + return `transition: ${found ? found.join(', ') : m[1].trim()}`; + } }, + { id: 'layout-transition', regex: /transition-property\s*:\s*([^;{}]+)/gi, + test: (m) => { + const val = m[1].toLowerCase(); + if (/\ball\b/.test(val)) return false; + return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val); + }, + fmt: (m) => { + const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi); + return `transition-property: ${found ? found.join(', ') : m[1].trim()}`; + } }, + // --- Broken image: src="" or src="#" or src=" " --- + { id: 'broken-image', regex: /]*?\bsrc\s*=\s*(?:""|''|"\s+"|'\s+'|"#"|'#')/gi, + test: () => true, + fmt: (m) => m[0].slice(0, 100) }, + // --- Broken image: with no src attribute at all --- + { id: 'broken-image', regex: /])*>/gi, + test: (m) => !/\bsrc\s*=/i.test(m[0]), + fmt: (m) => m[0].slice(0, 100) }, +]; + +const REGEX_ANALYZERS = [ + // Single font + (content, filePath) => { + const fontFamilyRe = /font-family\s*:\s*([^;}]+)/gi; + const fonts = new Set(); + let m; + while ((m = fontFamilyRe.exec(content)) !== null) { + for (const f of m[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) { + if (f && !GENERIC_FONTS.has(f)) fonts.add(f); + } + } + const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi; + while ((m = gfRe.exec(content)) !== null) { + for (const f of m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase())) fonts.add(f); + } + if (fonts.size !== 1 || content.split('\n').length < 20) return []; + const name = [...fonts][0]; + const lines = content.split('\n'); + let line = 1; + for (let i = 0; i < lines.length; i++) { if (lines[i].toLowerCase().includes(name)) { line = i + 1; break; } } + return [finding('single-font', filePath, `only font used is ${name}`, line)]; + }, + // Flat type hierarchy + (content, filePath) => { + const sizes = new Set(); + const REM = 16; + let m; + const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi; + while ((m = sizeRe.exec(content)) !== null) { + const px = m[2] === 'px' ? +m[1] : +m[1] * REM; + if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10); + } + const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi; + while ((m = clampRe.exec(content)) !== null) { + sizes.add(Math.round((m[2] === 'px' ? +m[1] : +m[1] * REM) * 10) / 10); + sizes.add(Math.round((m[4] === 'px' ? +m[3] : +m[3] * REM) * 10) / 10); + } + const TW = { 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, 'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, 'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128 }; + for (const [cls, px] of Object.entries(TW)) { if (new RegExp(`\\b${cls}\\b`).test(content)) sizes.add(px); } + if (sizes.size < 3) return []; + const sorted = [...sizes].sort((a, b) => a - b); + const ratio = sorted[sorted.length - 1] / sorted[0]; + if (ratio >= 2.0) return []; + const lines = content.split('\n'); + let line = 1; + for (let i = 0; i < lines.length; i++) { if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) { line = i + 1; break; } } + return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)]; + }, + // Monotonous spacing (regex) + (content, filePath) => { + const vals = []; + let m; + const pxRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi; + while ((m = pxRe.exec(content)) !== null) { const v = +m[1]; if (v > 0 && v < 200) vals.push(v); } + const remRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi; + while ((m = remRe.exec(content)) !== null) { const v = Math.round(parseFloat(m[1]) * 16); if (v > 0 && v < 200) vals.push(v); } + const gapRe = /gap\s*:\s*(\d+)px/gi; + while ((m = gapRe.exec(content)) !== null) vals.push(+m[1]); + const twRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g; + while ((m = twRe.exec(content)) !== null) vals.push(+m[1] * 4); + const rounded = vals.map(v => Math.round(v / 4) * 4); + if (rounded.length < 10) return []; + const counts = {}; + for (const v of rounded) counts[v] = (counts[v] || 0) + 1; + const maxCount = Math.max(...Object.values(counts)); + const pct = maxCount / rounded.length; + const unique = [...new Set(rounded)].filter(v => v > 0); + if (pct <= 0.6 || unique.length > 3) return []; + const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0]; + return [finding('monotonous-spacing', filePath, `~${dominant}px used ${maxCount}/${rounded.length} times (${Math.round(pct * 100)}%)`)]; + }, + // Em-dash overuse: 5+ em-dashes or "--" in body text content + // (occasional em-dash use in prose is fine; the pattern fires only + // when count crosses into AI-cadence territory). + (content, filePath) => { + const text = stripHtmlToText(content); + let count = 0; + const re = /[—]|--(?=\S)/g; + while (re.exec(text) !== null) count++; + if (count < 5) return []; + return [finding('em-dash-overuse', filePath, `${count} em-dashes in body text`)]; + }, + // Marketing buzzwords: SaaS phrase list + (content, filePath) => { + const text = stripHtmlToText(content); + const lower = text.toLowerCase(); + const BUZZWORDS = [ + 'streamline your', 'empower your', 'supercharge your', + 'unleash your', 'unleash the power', 'leverage the power', + 'built for the modern', 'trusted by leading', 'trusted by the world', + 'best-in-class', 'industry-leading', 'world-class', 'enterprise-grade', + 'next-generation', 'cutting-edge', 'transform your business', + 'revolutionize', 'game-changer', 'game changing', + 'mission-critical', 'best of breed', 'future-proof', 'future proof', + 'seamless experience', 'seamlessly integrate', + 'drive engagement', 'drive growth', 'drive results', + 'harness the power', + ]; + let count = 0; + let firstSample = ''; + for (const phrase of BUZZWORDS) { + let from = 0; + while (true) { + const idx = lower.indexOf(phrase, from); + if (idx === -1) break; + count++; + if (!firstSample) { + firstSample = text.slice(Math.max(0, idx - 12), Math.min(text.length, idx + phrase.length + 12)).trim(); + } + from = idx + phrase.length; + } + } + if (count === 0) return []; + return [finding('marketing-buzzword', filePath, `${count} buzzword phrase${count === 1 ? '' : 's'}: "${firstSample}"`)]; + }, + // Numbered section markers (01 / 02 / 03 ...) + (content, filePath) => { + const text = stripHtmlToText(content); + const re = /\b(0[1-9]|1[0-2])\b/g; + const seen = new Set(); + let m; + while ((m = re.exec(text)) !== null) seen.add(m[1]); + if (seen.size < 3) return []; + const sorted = [...seen].sort(); + let sequential = 0; + for (let i = 1; i < sorted.length; i++) { + if (parseInt(sorted[i], 10) === parseInt(sorted[i - 1], 10) + 1) sequential++; + } + if (sequential < 2) return []; + return [finding('numbered-section-markers', filePath, `Sequence: ${sorted.slice(0, 6).join(', ')}`)]; + }, + // Aphoristic cadence: manufactured-contrast + short-rebuttal + (content, filePath) => { + const text = stripHtmlToText(content); + const NOT_A_RE = /\bNot an? [a-z][^.!?]{1,40}[.!]\s+[A-Z][^.!?]{1,60}[.!]/g; + const SHORT_REBUTTAL_RE = /\b[A-Z][^.!?]{4,80}[.!]\s+(No|Just)\s+[a-z][^.!?]{2,60}[.!]/g; + let count = 0; + let firstSample = ''; + let m; + NOT_A_RE.lastIndex = 0; + while ((m = NOT_A_RE.exec(text)) !== null) { + count++; + if (!firstSample) firstSample = m[0].trim().slice(0, 80); + } + SHORT_REBUTTAL_RE.lastIndex = 0; + while ((m = SHORT_REBUTTAL_RE.exec(text)) !== null) { + count++; + if (!firstSample) firstSample = m[0].trim().slice(0, 80); + } + if (count < 3) return []; + return [finding('aphoristic-cadence', filePath, `${count} aphoristic constructions: "${firstSample}"`)]; + }, + // Dark glow (page-level: dark bg + colored box-shadow with blur) + (content, filePath) => { + // Check if page has a dark background + 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; + const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/; + const hasDarkBg = darkBgRe.test(content) || twDarkBg.test(content); + if (!hasDarkBg) return []; + + // Check for colored box-shadow with blur > 4px + const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi; + let m; + while ((m = shadowRe.exec(content)) !== null) { + const val = m[1]; + const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); + if (!colorMatch) continue; + const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]]; + if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue; // skip gray + // Check blur: look for pattern like "0 0 20px" (third number > 4) + const pxVals = [...val.matchAll(/(\d+)px|(? +(p[1] || p[2])); + if (pxVals.length >= 3 && pxVals[2] > 4) { + const lines = content.substring(0, m.index).split('\n'); + return [finding('dark-glow', filePath, `Colored glow (rgb(${r},${g},${b})) on dark page`, lines.length)]; + } + } + return []; + }, +]; + +// --------------------------------------------------------------------------- +// Style block extraction (Vue/Svelte